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.
- {simulai_cli-2.0.1 → simulai_cli-2.1.0}/PKG-INFO +2 -1
- {simulai_cli-2.0.1 → simulai_cli-2.1.0}/README.md +1 -0
- {simulai_cli-2.0.1 → simulai_cli-2.1.0}/pyproject.toml +1 -1
- simulai_cli-2.1.0/simulai_cli/agent.py +403 -0
- {simulai_cli-2.0.1 → simulai_cli-2.1.0}/simulai_cli/api.py +70 -0
- {simulai_cli-2.0.1 → simulai_cli-2.1.0}/simulai_cli/main.py +47 -1
- simulai_cli-2.1.0/simulai_cli/tools.py +571 -0
- {simulai_cli-2.0.1 → simulai_cli-2.1.0}/simulai_cli.egg-info/PKG-INFO +2 -1
- {simulai_cli-2.0.1 → simulai_cli-2.1.0}/simulai_cli.egg-info/SOURCES.txt +2 -0
- {simulai_cli-2.0.1 → simulai_cli-2.1.0}/setup.cfg +0 -0
- {simulai_cli-2.0.1 → simulai_cli-2.1.0}/simulai_cli/__init__.py +0 -0
- {simulai_cli-2.0.1 → simulai_cli-2.1.0}/simulai_cli/config.py +0 -0
- {simulai_cli-2.0.1 → simulai_cli-2.1.0}/simulai_cli/discovery.py +0 -0
- {simulai_cli-2.0.1 → simulai_cli-2.1.0}/simulai_cli/render.py +0 -0
- {simulai_cli-2.0.1 → simulai_cli-2.1.0}/simulai_cli.egg-info/dependency_links.txt +0 -0
- {simulai_cli-2.0.1 → simulai_cli-2.1.0}/simulai_cli.egg-info/entry_points.txt +0 -0
- {simulai_cli-2.0.1 → simulai_cli-2.1.0}/simulai_cli.egg-info/requires.txt +0 -0
- {simulai_cli-2.0.1 → simulai_cli-2.1.0}/simulai_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: simulai-cli
|
|
3
|
-
Version: 2.0
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|