simulai-cli 2.0.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simulai-cli
3
- Version: 2.0.1
3
+ Version: 2.1.0
4
4
  Summary: SimulAI CLI — persistent AI chat in your terminal. SmartRoute across 20+ models, Naira billing, and import your Claude Code / Codex sessions.
5
5
  Author-email: Simul AI <hello@trysimulai.com>
6
6
  License: MIT
@@ -62,6 +62,7 @@ conversation you can continue on any model.
62
62
  | Command | What it does |
63
63
  |---|---|
64
64
  | `simulai login` | Log in (unlocks everything) |
65
+ | `simulai code "task"` | **Agentic coding** — reads, greps, edits and tests this repo |
65
66
  | `simulai chat` | Resume this directory's conversation |
66
67
  | `simulai chat "msg"` | One-shot |
67
68
  | `simulai chat -m <model>` | Force a specific model |
@@ -36,6 +36,7 @@ conversation you can continue on any model.
36
36
  | Command | What it does |
37
37
  |---|---|
38
38
  | `simulai login` | Log in (unlocks everything) |
39
+ | `simulai code "task"` | **Agentic coding** — reads, greps, edits and tests this repo |
39
40
  | `simulai chat` | Resume this directory's conversation |
40
41
  | `simulai chat "msg"` | One-shot |
41
42
  | `simulai chat -m <model>` | Force a specific model |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "simulai-cli"
7
- version = "2.0.1"
7
+ version = "2.1.0"
8
8
  description = "SimulAI CLI — persistent AI chat in your terminal. SmartRoute across 20+ models, Naira billing, and import your Claude Code / Codex sessions."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -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]")
@@ -398,6 +398,73 @@ class SimulAIClient:
398
398
  yield from _iter(self._headers())
399
399
 
400
400
 
401
+ def stream_agent(
402
+ self,
403
+ messages: List[dict],
404
+ tools: List[dict],
405
+ model: Optional[str] = None,
406
+ effort: str = "balanced",
407
+ max_tokens: int = 4000,
408
+ ) -> Iterator[dict]:
409
+ """Agent turn against /v1/code/agent.
410
+
411
+ Same SSE vocabulary as stream_chat plus `tool_calls`, which arrives
412
+ as ONE event before [DONE] (the backend assembles the fragments —
413
+ a half-streamed JSON argument string can't be executed).
414
+ """
415
+ payload: Dict[str, Any] = {
416
+ "messages": messages,
417
+ "tools": tools,
418
+ "tool_choice": "auto",
419
+ "effort": effort,
420
+ "max_tokens": max_tokens,
421
+ }
422
+ if model:
423
+ payload["model"] = model
424
+
425
+ def _iter(headers: dict):
426
+ with httpx.Client(timeout=TIMEOUT) as client:
427
+ with client.stream(
428
+ "POST",
429
+ f"{self.base}/v1/code/agent",
430
+ headers=headers,
431
+ json=payload,
432
+ ) as response:
433
+ if response.status_code == 401:
434
+ raise AuthRequired("401")
435
+ if response.status_code == 404:
436
+ response.read()
437
+ raise ApiError(
438
+ "This SimulAI server has no agent endpoint yet "
439
+ "(POST /v1/code/agent). The backend needs the "
440
+ "tool-calling update deployed.",
441
+ 404,
442
+ )
443
+ if response.status_code >= 400:
444
+ response.read()
445
+ raise ApiError(_extract_error(response), response.status_code)
446
+ for line in response.iter_lines():
447
+ if not line or not line.startswith("data:"):
448
+ continue
449
+ data = line[5:].strip()
450
+ if data == "[DONE]":
451
+ yield {"type": "done"}
452
+ return
453
+ try:
454
+ chunk = json.loads(data)
455
+ except Exception:
456
+ continue
457
+ yield from _normalize(chunk)
458
+ yield {"type": "done"}
459
+
460
+ try:
461
+ yield from _iter(self._headers())
462
+ except AuthRequired:
463
+ if not self._refresh():
464
+ raise AuthRequired("Session expired. Run: simulai login")
465
+ yield from _iter(self._headers())
466
+
467
+
401
468
  def _normalize(chunk: dict) -> Iterator[dict]:
402
469
  event = chunk.get("simulai_event")
403
470
  if event == "fallback":
@@ -407,6 +474,9 @@ def _normalize(chunk: dict) -> Iterator[dict]:
407
474
  "to": chunk.get("to_name") or chunk.get("to_model"),
408
475
  }
409
476
  return
477
+ if event == "tool_calls":
478
+ yield {"type": "tool_calls", "tool_calls": chunk.get("tool_calls") or []}
479
+ return
410
480
  if event == "sources":
411
481
  yield {"type": "sources", "sources": chunk.get("sources") or []}
412
482
  return
@@ -634,6 +634,52 @@ def _slash(
634
634
  # ── conversations ─────────────────────────────────────────────────────────────
635
635
 
636
636
 
637
+ @app.command()
638
+ def code(
639
+ task: Optional[str] = typer.Argument(None, help="What you want done"),
640
+ model: Optional[str] = typer.Option(None, "--model", "-m", help="Force a model"),
641
+ directory: Optional[str] = typer.Option(None, "--dir", "-d", help="Workspace root"),
642
+ yes: bool = typer.Option(False, "--yes", "-y", help="Auto-approve all edits and commands"),
643
+ dry_run: bool = typer.Option(False, "--dry-run", help="Show what would change, write nothing"),
644
+ effort: Optional[str] = typer.Option(None, "--effort", "-e", help="fast | balanced | max"),
645
+ no_verify: bool = typer.Option(False, "--no-verify", help="Skip the post-edit test run"),
646
+ ):
647
+ """Agentic coding: the model reads, greps, edits and tests in this repo.
648
+
649
+ Every edit is previewed as a diff and needs your approval. Syntax is
650
+ checked before anything is written, and the test suite runs afterwards
651
+ with failures fed back for repair.
652
+ """
653
+ from .agent import run_agent
654
+ from .discovery import project_root
655
+
656
+ client = get_client()
657
+ cfg = load_config()
658
+ root = project_root(directory)
659
+
660
+ if not task:
661
+ console.print(
662
+ Panel(
663
+ f"[bold green]SimulAI code[/bold green]\n[dim]{root}[/dim]",
664
+ border_style="green",
665
+ )
666
+ )
667
+ task = Prompt.ask("[bold cyan]What should I do?[/bold cyan]")
668
+ if not task.strip():
669
+ return
670
+
671
+ run_agent(
672
+ client=client,
673
+ prompt=task,
674
+ root=root,
675
+ model=model,
676
+ auto_approve=yes,
677
+ dry_run=dry_run,
678
+ effort=effort or cfg["effort"],
679
+ verify=not no_verify,
680
+ )
681
+
682
+
637
683
  @app.command()
638
684
  def chats(
639
685
  limit: int = typer.Option(20, "--limit", "-l"),
@@ -975,7 +1021,7 @@ def usage(limit: int = typer.Option(15, "--limit", "-l")):
975
1021
  console.print(f"\n Balance: [yellow]{wallet.get('total_credits', 0)}[/yellow] credits\n")
976
1022
 
977
1023
 
978
- __version__ = "2.0.1"
1024
+ __version__ = "2.1.0"
979
1025
 
980
1026
 
981
1027
  @app.command()
@@ -0,0 +1,571 @@
1
+ """
2
+ Local tool execution for `simulai code`.
3
+
4
+ Everything in this file runs on the developer's machine. The server never
5
+ sees the repo — it only ever receives the specific snippets the model asked
6
+ for, and only after this module decided the request was safe and (for
7
+ anything destructive) the user approved it.
8
+
9
+ The design targets the "less buggy code with the same model" problem
10
+ directly:
11
+
12
+ 1. RETRIEVAL, NOT DUMPING — read_file supports line ranges and grep returns
13
+ matches with context, so the model gets the relevant 40 lines instead of
14
+ a 2,000-line file. Less noise per token = fewer invented APIs.
15
+
16
+ 2. DIFF-BASED EDITS — edit_file does exact string replacement and refuses
17
+ ambiguous matches. A model that must quote the existing code verbatim to
18
+ change it cannot hallucinate the surrounding context, which whole-file
19
+ rewrites happily do.
20
+
21
+ 3. A SAFETY NET THAT ACTUALLY CATCHES — every write is syntax-checked
22
+ before it lands (ast.parse for .py, json.loads for .json). Failed parse
23
+ = the edit is rejected and the model is told why, so it fixes it in the
24
+ loop instead of shipping you broken code.
25
+
26
+ 4. NOTHING DESTRUCTIVE WITHOUT CONSENT — writes and shell commands prompt
27
+ by default, show exactly what will happen, and are refused outright
28
+ outside the workspace root.
29
+ """
30
+
31
+ import ast
32
+ import difflib
33
+ import json
34
+ import os
35
+ import re
36
+ import shlex
37
+ import subprocess
38
+ import time
39
+ from dataclasses import dataclass
40
+ from pathlib import Path
41
+ from typing import Optional
42
+
43
+ MAX_READ_BYTES = 200_000
44
+ MAX_OUTPUT_CHARS = 12_000
45
+ MAX_GREP_MATCHES = 60
46
+ COMMAND_TIMEOUT = 180
47
+
48
+ # Junk that should never be read, searched or listed — it drowns the
49
+ # context window and none of it is source.
50
+ SKIP_DIRS = {
51
+ ".git", "node_modules", "__pycache__", ".venv", "venv", "env",
52
+ "dist", "build", ".next", ".nuxt", "target", ".idea", ".vscode",
53
+ ".pytest_cache", ".mypy_cache", ".ruff_cache", "coverage", ".tox",
54
+ }
55
+ BINARY_SUFFIXES = {
56
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".pdf", ".zip",
57
+ ".tar", ".gz", ".whl", ".so", ".dylib", ".dll", ".exe", ".bin",
58
+ ".woff", ".woff2", ".ttf", ".mp4", ".mp3", ".sqlite", ".db",
59
+ }
60
+
61
+ # Commands refused outright. Not a security boundary — a model with shell
62
+ # access has many ways around a blocklist — but it stops the obvious
63
+ # catastrophes from a confused model, and the approval prompt is the real
64
+ # control.
65
+ # NOTE: no trailing \b — several of these patterns end in '/' or '=', which
66
+ # are non-word characters, so a trailing word boundary would never match and
67
+ # the whole guard would silently pass. (Found exactly that way in testing.)
68
+ DANGEROUS = re.compile(
69
+ r"(rm\s+-rf?\s+(/|~|\*)"
70
+ r"|rm\s+-rf?\s+--no-preserve-root"
71
+ r"|\bmkfs\b|\bdd\s+if="
72
+ r"|:\(\)\s*\{.*\};\s*:"
73
+ r"|\bshutdown\b|\breboot\b"
74
+ r"|chmod\s+-R\s+777\s+/"
75
+ r"|git\s+push\s+.*--force"
76
+ r"|git\s+reset\s+--hard\s+origin"
77
+ r"|(curl|wget)[^|]*\|\s*(ba|z|s)?sh"
78
+ r"|>\s*/dev/sd[a-z])",
79
+ re.IGNORECASE,
80
+ )
81
+
82
+
83
+ @dataclass
84
+ class ToolResult:
85
+ ok: bool
86
+ output: str
87
+ display: str = "" # what to show the user (may differ from model output)
88
+ needs_approval: bool = False
89
+
90
+
91
+ def _clip(text: str, limit: int = MAX_OUTPUT_CHARS) -> str:
92
+ text = text or ""
93
+ if len(text) <= limit:
94
+ return text
95
+ half = limit // 2
96
+ return (
97
+ text[:half]
98
+ + f"\n\n… [{len(text) - limit} characters trimmed] …\n\n"
99
+ + text[-half:]
100
+ )
101
+
102
+
103
+ class Workspace:
104
+ """Filesystem access confined to one directory tree."""
105
+
106
+ def __init__(self, root: str):
107
+ self.root = Path(root).resolve()
108
+
109
+ def resolve(self, relative: str) -> Path:
110
+ """Resolve a model-supplied path, refusing anything outside root.
111
+
112
+ The model routinely proposes paths like '../config' or an absolute
113
+ '/etc/passwd' when it's confused about where it is. Refusing here
114
+ is cheaper than trusting the prompt to have said "don't".
115
+ """
116
+ candidate = (self.root / relative).resolve()
117
+ if candidate != self.root and self.root not in candidate.parents:
118
+ raise PermissionError(
119
+ f"Path '{relative}' is outside the workspace ({self.root})."
120
+ )
121
+ return candidate
122
+
123
+ def rel(self, path: Path) -> str:
124
+ try:
125
+ return str(path.relative_to(self.root))
126
+ except ValueError:
127
+ return str(path)
128
+
129
+
130
+ # ── Tool schema sent to the model ─────────────────────────────────────────────
131
+
132
+ TOOL_SCHEMA = [
133
+ {
134
+ "type": "function",
135
+ "function": {
136
+ "name": "read_file",
137
+ "description": (
138
+ "Read a text file from the workspace. Prefer a line range for "
139
+ "large files — read only what you need."
140
+ ),
141
+ "parameters": {
142
+ "type": "object",
143
+ "properties": {
144
+ "path": {"type": "string", "description": "Path relative to workspace root"},
145
+ "start_line": {"type": "integer", "description": "1-indexed, optional"},
146
+ "end_line": {"type": "integer", "description": "inclusive, optional"},
147
+ },
148
+ "required": ["path"],
149
+ },
150
+ },
151
+ },
152
+ {
153
+ "type": "function",
154
+ "function": {
155
+ "name": "list_files",
156
+ "description": "List files under a directory. Build/junk directories are excluded.",
157
+ "parameters": {
158
+ "type": "object",
159
+ "properties": {
160
+ "path": {"type": "string", "description": "Directory, default workspace root"},
161
+ "depth": {"type": "integer", "description": "Max depth, default 2"},
162
+ },
163
+ },
164
+ },
165
+ },
166
+ {
167
+ "type": "function",
168
+ "function": {
169
+ "name": "grep",
170
+ "description": (
171
+ "Search file contents with a regular expression. Returns matching "
172
+ "lines with file, line number and surrounding context. Use this "
173
+ "to locate code instead of reading whole files."
174
+ ),
175
+ "parameters": {
176
+ "type": "object",
177
+ "properties": {
178
+ "pattern": {"type": "string"},
179
+ "path": {"type": "string", "description": "Directory to search, optional"},
180
+ "glob": {"type": "string", "description": "e.g. '*.py', optional"},
181
+ },
182
+ "required": ["pattern"],
183
+ },
184
+ },
185
+ },
186
+ {
187
+ "type": "function",
188
+ "function": {
189
+ "name": "edit_file",
190
+ "description": (
191
+ "Replace an exact string in a file. old_string must appear EXACTLY "
192
+ "once — include surrounding lines to make it unique. This is the "
193
+ "preferred way to change code; it is checked for syntax before "
194
+ "being written."
195
+ ),
196
+ "parameters": {
197
+ "type": "object",
198
+ "properties": {
199
+ "path": {"type": "string"},
200
+ "old_string": {"type": "string", "description": "Exact text to replace"},
201
+ "new_string": {"type": "string", "description": "Replacement text"},
202
+ },
203
+ "required": ["path", "old_string", "new_string"],
204
+ },
205
+ },
206
+ },
207
+ {
208
+ "type": "function",
209
+ "function": {
210
+ "name": "write_file",
211
+ "description": (
212
+ "Create a new file or fully overwrite an existing one. Use "
213
+ "edit_file for changes to existing files."
214
+ ),
215
+ "parameters": {
216
+ "type": "object",
217
+ "properties": {
218
+ "path": {"type": "string"},
219
+ "content": {"type": "string"},
220
+ },
221
+ "required": ["path", "content"],
222
+ },
223
+ },
224
+ },
225
+ {
226
+ "type": "function",
227
+ "function": {
228
+ "name": "run_command",
229
+ "description": (
230
+ "Run a shell command in the workspace — tests, linters, build "
231
+ "steps, git status. Requires user approval."
232
+ ),
233
+ "parameters": {
234
+ "type": "object",
235
+ "properties": {
236
+ "command": {"type": "string"},
237
+ "reason": {"type": "string", "description": "Why this is needed"},
238
+ },
239
+ "required": ["command"],
240
+ },
241
+ },
242
+ },
243
+ ]
244
+
245
+ DESTRUCTIVE = {"edit_file", "write_file", "run_command"}
246
+
247
+
248
+ # ── Verification ──────────────────────────────────────────────────────────────
249
+
250
+
251
+ def verify_syntax(path: Path, content: str) -> Optional[str]:
252
+ """→ an error string if the content is broken, else None.
253
+
254
+ Deliberately narrow: this catches "the model produced code that cannot
255
+ parse", which is the single most common way an AI edit wastes your
256
+ time. Type errors and logic bugs are the test suite's job.
257
+ """
258
+ suffix = path.suffix.lower()
259
+ try:
260
+ if suffix == ".py":
261
+ ast.parse(content)
262
+ elif suffix == ".json":
263
+ json.loads(content)
264
+ except SyntaxError as exc:
265
+ return f"Python syntax error at line {exc.lineno}: {exc.msg}"
266
+ except json.JSONDecodeError as exc:
267
+ return f"Invalid JSON at line {exc.lineno}: {exc.msg}"
268
+ except Exception as exc:
269
+ return str(exc)
270
+ return None
271
+
272
+
273
+ def make_diff(before: str, after: str, filename: str) -> str:
274
+ diff = difflib.unified_diff(
275
+ before.splitlines(keepends=True),
276
+ after.splitlines(keepends=True),
277
+ fromfile=f"a/{filename}",
278
+ tofile=f"b/{filename}",
279
+ n=3,
280
+ )
281
+ return "".join(diff)
282
+
283
+
284
+ # ── Executor ──────────────────────────────────────────────────────────────────
285
+
286
+
287
+ class ToolExecutor:
288
+ def __init__(self, workspace: Workspace, auto_approve: bool = False,
289
+ dry_run: bool = False):
290
+ self.ws = workspace
291
+ self.auto_approve = auto_approve
292
+ self.dry_run = dry_run
293
+ self.files_changed: list[str] = []
294
+ self.commands_run: list[str] = []
295
+
296
+ # -- individual tools ---------------------------------------------------
297
+
298
+ def read_file(self, path: str, start_line: int = None, end_line: int = None) -> ToolResult:
299
+ target = self.ws.resolve(path)
300
+ if not target.exists():
301
+ return ToolResult(False, f"File not found: {path}")
302
+ if target.suffix.lower() in BINARY_SUFFIXES:
303
+ return ToolResult(False, f"{path} is a binary file — not readable as text.")
304
+ if target.stat().st_size > MAX_READ_BYTES:
305
+ return ToolResult(
306
+ False,
307
+ f"{path} is {target.stat().st_size // 1024} KB — too large. "
308
+ "Use grep to find the relevant part, then read with a line range.",
309
+ )
310
+ text = target.read_text(errors="replace")
311
+ lines = text.splitlines()
312
+ if start_line or end_line:
313
+ start = max(1, start_line or 1)
314
+ end = min(len(lines), end_line or len(lines))
315
+ selected = lines[start - 1:end]
316
+ numbered = "\n".join(f"{i}: {l}" for i, l in enumerate(selected, start))
317
+ return ToolResult(True, _clip(numbered), f"read {path}:{start}-{end}")
318
+ numbered = "\n".join(f"{i}: {l}" for i, l in enumerate(lines, 1))
319
+ return ToolResult(True, _clip(numbered), f"read {path} ({len(lines)} lines)")
320
+
321
+ def list_files(self, path: str = ".", depth: int = 2) -> ToolResult:
322
+ base = self.ws.resolve(path)
323
+ if not base.exists():
324
+ return ToolResult(False, f"Not found: {path}")
325
+ entries = []
326
+ base_depth = len(base.parts)
327
+ for root, dirs, files in os.walk(base):
328
+ dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".")]
329
+ current = Path(root)
330
+ if len(current.parts) - base_depth >= depth:
331
+ dirs[:] = []
332
+ for name in sorted(files):
333
+ if Path(name).suffix.lower() in BINARY_SUFFIXES:
334
+ continue
335
+ entries.append(self.ws.rel(current / name))
336
+ if len(entries) > 400:
337
+ entries.append("… (truncated)")
338
+ break
339
+ return ToolResult(True, "\n".join(entries), f"listed {path} ({len(entries)} files)")
340
+
341
+ def grep(self, pattern: str, path: str = ".", glob: str = None) -> ToolResult:
342
+ base = self.ws.resolve(path)
343
+ try:
344
+ regex = re.compile(pattern)
345
+ except re.error as exc:
346
+ return ToolResult(False, f"Invalid regex: {exc}")
347
+
348
+ matches = []
349
+ for root, dirs, files in os.walk(base):
350
+ dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
351
+ for name in files:
352
+ if Path(name).suffix.lower() in BINARY_SUFFIXES:
353
+ continue
354
+ if glob and not Path(name).match(glob):
355
+ continue
356
+ file_path = Path(root) / name
357
+ try:
358
+ if file_path.stat().st_size > MAX_READ_BYTES:
359
+ continue
360
+ lines = file_path.read_text(errors="replace").splitlines()
361
+ except Exception:
362
+ continue
363
+ for index, line in enumerate(lines, 1):
364
+ if regex.search(line):
365
+ matches.append(f"{self.ws.rel(file_path)}:{index}: {line.strip()[:200]}")
366
+ if len(matches) >= MAX_GREP_MATCHES:
367
+ break
368
+ if len(matches) >= MAX_GREP_MATCHES:
369
+ break
370
+ if not matches:
371
+ return ToolResult(True, f"No matches for /{pattern}/", f"grep {pattern} — 0 hits")
372
+ return ToolResult(
373
+ True, "\n".join(matches), f"grep {pattern} — {len(matches)} hits"
374
+ )
375
+
376
+ def edit_file(self, path: str, old_string: str, new_string: str) -> tuple[ToolResult, str]:
377
+ """→ (result, preview diff). Does not write; caller applies after approval."""
378
+ target = self.ws.resolve(path)
379
+ if not target.exists():
380
+ return ToolResult(False, f"File not found: {path}"), ""
381
+ original = target.read_text(errors="replace")
382
+
383
+ occurrences = original.count(old_string)
384
+ if occurrences == 0:
385
+ return ToolResult(
386
+ False,
387
+ f"old_string not found in {path}. It must match the file exactly, "
388
+ "including whitespace and indentation. Read the file again and "
389
+ "copy the text verbatim.",
390
+ ), ""
391
+ if occurrences > 1:
392
+ return ToolResult(
393
+ False,
394
+ f"old_string appears {occurrences} times in {path} — ambiguous. "
395
+ "Include more surrounding lines so it matches exactly once.",
396
+ ), ""
397
+
398
+ updated = original.replace(old_string, new_string, 1)
399
+ error = verify_syntax(target, updated)
400
+ if error:
401
+ return ToolResult(
402
+ False,
403
+ f"Edit rejected — it would break {path}: {error}. "
404
+ "The file was NOT modified. Fix the syntax and try again.",
405
+ ), ""
406
+ return ToolResult(True, "", f"edit {path}", needs_approval=True), make_diff(
407
+ original, updated, self.ws.rel(target)
408
+ )
409
+
410
+ def apply_edit(self, path: str, old_string: str, new_string: str) -> ToolResult:
411
+ target = self.ws.resolve(path)
412
+ original = target.read_text(errors="replace")
413
+ updated = original.replace(old_string, new_string, 1)
414
+ if self.dry_run:
415
+ return ToolResult(True, f"[dry-run] would edit {path}")
416
+ target.write_text(updated)
417
+ rel = self.ws.rel(target)
418
+ if rel not in self.files_changed:
419
+ self.files_changed.append(rel)
420
+ changed = len(updated.splitlines()) - len(original.splitlines())
421
+ return ToolResult(
422
+ True,
423
+ f"Edited {path} successfully ({changed:+d} lines). Syntax verified.",
424
+ f"edited {path}",
425
+ )
426
+
427
+ def write_file(self, path: str, content: str) -> tuple[ToolResult, str]:
428
+ target = self.ws.resolve(path)
429
+ existed = target.exists()
430
+ original = target.read_text(errors="replace") if existed else ""
431
+ error = verify_syntax(target, content)
432
+ if error:
433
+ return ToolResult(
434
+ False,
435
+ f"Write rejected — content would not parse: {error}. "
436
+ f"{path} was NOT modified.",
437
+ ), ""
438
+ return ToolResult(True, "", f"write {path}", needs_approval=True), make_diff(
439
+ original, content, self.ws.rel(target)
440
+ )
441
+
442
+ def apply_write(self, path: str, content: str) -> ToolResult:
443
+ target = self.ws.resolve(path)
444
+ if self.dry_run:
445
+ return ToolResult(True, f"[dry-run] would write {path}")
446
+ target.parent.mkdir(parents=True, exist_ok=True)
447
+ existed = target.exists()
448
+ target.write_text(content)
449
+ rel = self.ws.rel(target)
450
+ if rel not in self.files_changed:
451
+ self.files_changed.append(rel)
452
+ return ToolResult(
453
+ True,
454
+ f"{'Overwrote' if existed else 'Created'} {path} "
455
+ f"({len(content.splitlines())} lines). Syntax verified.",
456
+ f"wrote {path}",
457
+ )
458
+
459
+ def run_command(self, command: str, reason: str = "") -> ToolResult:
460
+ if DANGEROUS.search(command):
461
+ return ToolResult(
462
+ False,
463
+ "Command refused: it matches a destructive pattern. "
464
+ "Ask the user to run it themselves if it is genuinely needed.",
465
+ )
466
+ if self.dry_run:
467
+ return ToolResult(True, f"[dry-run] would run: {command}")
468
+ started = time.time()
469
+ try:
470
+ proc = subprocess.run(
471
+ command,
472
+ shell=True,
473
+ cwd=str(self.ws.root),
474
+ capture_output=True,
475
+ text=True,
476
+ timeout=COMMAND_TIMEOUT,
477
+ )
478
+ except subprocess.TimeoutExpired:
479
+ return ToolResult(False, f"Command timed out after {COMMAND_TIMEOUT}s.")
480
+ except Exception as exc:
481
+ return ToolResult(False, f"Failed to run: {exc}")
482
+
483
+ self.commands_run.append(command)
484
+ elapsed = time.time() - started
485
+ output = (proc.stdout or "") + (("\n[stderr]\n" + proc.stderr) if proc.stderr else "")
486
+ return ToolResult(
487
+ proc.returncode == 0,
488
+ f"exit code {proc.returncode} ({elapsed:.1f}s)\n\n{_clip(output)}",
489
+ f"ran: {command} → exit {proc.returncode}",
490
+ )
491
+
492
+
493
+ # ── git safety ────────────────────────────────────────────────────────────────
494
+
495
+
496
+ def git_status(root: str) -> dict:
497
+ """→ {'repo': bool, 'dirty': bool, 'branch': str|None}.
498
+
499
+ Used to warn before an agent edits a tree with uncommitted work — the
500
+ difference between "I can undo this" and "I lost an hour".
501
+ """
502
+ def run(args):
503
+ return subprocess.run(
504
+ args, cwd=root, capture_output=True, text=True, timeout=10
505
+ )
506
+
507
+ try:
508
+ inside = run(["git", "rev-parse", "--is-inside-work-tree"])
509
+ if inside.returncode != 0:
510
+ return {"repo": False, "dirty": False, "branch": None}
511
+ dirty = bool(run(["git", "status", "--porcelain"]).stdout.strip())
512
+ branch = run(["git", "rev-parse", "--abbrev-ref", "HEAD"]).stdout.strip()
513
+ return {"repo": True, "dirty": dirty, "branch": branch or None}
514
+ except Exception:
515
+ return {"repo": False, "dirty": False, "branch": None}
516
+
517
+
518
+ # Output that means "the tool isn't installed / isn't on PATH", NOT "your
519
+ # code is broken". Windows says "is not recognized"; POSIX says "command not
520
+ # found"; python -m says "No module named". Treating these as test failures
521
+ # is what sent one run chasing a PATH problem and writing a pytest.bat into
522
+ # the user's repo.
523
+ _ENV_FAILURE_MARKERS = (
524
+ "is not recognized",
525
+ "command not found",
526
+ "no module named",
527
+ "not found: pytest",
528
+ "cannot find the path",
529
+ "executable file not found",
530
+ "no such file or directory",
531
+ )
532
+
533
+
534
+ def is_environment_failure(output: str, exit_code: int) -> bool:
535
+ """True when the verification command could not RUN, as opposed to ran
536
+ and found failing tests."""
537
+ if exit_code in (127, 9009): # POSIX / Windows "not found"
538
+ return True
539
+ lowered = (output or "").lower()
540
+ return any(marker in lowered for marker in _ENV_FAILURE_MARKERS)
541
+
542
+
543
+ def detect_test_command(root: str) -> Optional[str]:
544
+ """Guess how this project runs its tests, for the verification pass.
545
+
546
+ Python commands are built from sys.executable rather than a bare
547
+ `pytest`, because the bare name depends on the interpreter's Scripts
548
+ directory being on PATH — frequently false on Windows, and a source of
549
+ false verification failures that look like broken code.
550
+ """
551
+ import sys
552
+
553
+ path = Path(root)
554
+ python = f'"{sys.executable}"' if " " in sys.executable else sys.executable
555
+
556
+ if (path / "pytest.ini").exists() or (path / "tests").is_dir():
557
+ return f"{python} -m pytest -q"
558
+ if (path / "package.json").exists():
559
+ try:
560
+ data = json.loads((path / "package.json").read_text())
561
+ if "test" in (data.get("scripts") or {}):
562
+ return "npm test"
563
+ except Exception:
564
+ pass
565
+ if (path / "pyproject.toml").exists():
566
+ return f"{python} -m pytest -q"
567
+ if (path / "go.mod").exists():
568
+ return "go test ./..."
569
+ if (path / "Cargo.toml").exists():
570
+ return "cargo test"
571
+ return None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simulai-cli
3
- Version: 2.0.1
3
+ Version: 2.1.0
4
4
  Summary: SimulAI CLI — persistent AI chat in your terminal. SmartRoute across 20+ models, Naira billing, and import your Claude Code / Codex sessions.
5
5
  Author-email: Simul AI <hello@trysimulai.com>
6
6
  License: MIT
@@ -62,6 +62,7 @@ conversation you can continue on any model.
62
62
  | Command | What it does |
63
63
  |---|---|
64
64
  | `simulai login` | Log in (unlocks everything) |
65
+ | `simulai code "task"` | **Agentic coding** — reads, greps, edits and tests this repo |
65
66
  | `simulai chat` | Resume this directory's conversation |
66
67
  | `simulai chat "msg"` | One-shot |
67
68
  | `simulai chat -m <model>` | Force a specific model |
@@ -1,11 +1,13 @@
1
1
  README.md
2
2
  pyproject.toml
3
3
  simulai_cli/__init__.py
4
+ simulai_cli/agent.py
4
5
  simulai_cli/api.py
5
6
  simulai_cli/config.py
6
7
  simulai_cli/discovery.py
7
8
  simulai_cli/main.py
8
9
  simulai_cli/render.py
10
+ simulai_cli/tools.py
9
11
  simulai_cli.egg-info/PKG-INFO
10
12
  simulai_cli.egg-info/SOURCES.txt
11
13
  simulai_cli.egg-info/dependency_links.txt
File without changes