aru-code 0.20.0__tar.gz → 0.22.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.
- {aru_code-0.20.0/aru_code.egg-info → aru_code-0.22.0}/PKG-INFO +42 -3
- {aru_code-0.20.0 → aru_code-0.22.0}/README.md +41 -2
- aru_code-0.22.0/aru/__init__.py +1 -0
- aru_code-0.22.0/aru/checkpoints.py +189 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/aru/cli.py +167 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/aru/commands.py +2 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/aru/config.py +78 -46
- {aru_code-0.20.0 → aru_code-0.22.0}/aru/runner.py +5 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/aru/runtime.py +3 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/aru/session.py +18 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/aru/tools/codebase.py +167 -19
- {aru_code-0.20.0 → aru_code-0.22.0/aru_code.egg-info}/PKG-INFO +42 -3
- {aru_code-0.20.0 → aru_code-0.22.0}/aru_code.egg-info/SOURCES.txt +2 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/pyproject.toml +1 -1
- aru_code-0.22.0/tests/test_checkpoints.py +190 -0
- aru_code-0.20.0/aru/__init__.py +0 -1
- {aru_code-0.20.0 → aru_code-0.22.0}/LICENSE +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/aru/agent_factory.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/aru/agents/__init__.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/aru/agents/base.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/aru/agents/executor.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/aru/agents/planner.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/aru/cache_patch.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/aru/completers.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/aru/context.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/aru/display.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/aru/history_blocks.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/aru/permissions.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/aru/plugins/__init__.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/aru/plugins/custom_tools.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/aru/plugins/hooks.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/aru/plugins/manager.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/aru/plugins/tool_api.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/aru/providers.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/aru/tools/__init__.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/aru/tools/ast_tools.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/aru/tools/gitignore.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/aru/tools/mcp_client.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/aru/tools/ranker.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/aru/tools/tasklist.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/aru_code.egg-info/dependency_links.txt +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/aru_code.egg-info/entry_points.txt +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/aru_code.egg-info/requires.txt +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/aru_code.egg-info/top_level.txt +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/setup.cfg +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_agents_base.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_cli.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_cli_advanced.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_cli_base.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_cli_completers.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_cli_new.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_cli_run_cli.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_cli_session.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_cli_shell.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_codebase.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_confabulation_regression.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_config.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_context.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_executor.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_gitignore.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_main.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_mcp_client.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_permissions.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_planner.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_plugins.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_providers.py +0 -0
- {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_ranker.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aru-code
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.22.0
|
|
4
4
|
Summary: A Claude Code clone built with Agno agents
|
|
5
5
|
Author-email: Estevao <estevaofon@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -312,8 +312,47 @@ Without any `aru.json` config, aru applies safe defaults:
|
|
|
312
312
|
- Bash → ~40 safe command prefixes auto-allowed (`ls`, `git status`, `grep`, etc.), rest → `ask`
|
|
313
313
|
- Sensitive files (`*.env`, `*.env.*`) → `deny` for read/edit/write (except `*.env.example`)
|
|
314
314
|
|
|
315
|
-
|
|
316
|
-
|
|
315
|
+
#### Config file locations
|
|
316
|
+
|
|
317
|
+
Aru loads configuration from two levels, with project settings overriding global ones:
|
|
318
|
+
|
|
319
|
+
| Level | Path | Purpose |
|
|
320
|
+
|-------|------|---------|
|
|
321
|
+
| **Global (user)** | `~/.aru/config.json` | Defaults that apply to all projects (model, aliases, permissions, providers) |
|
|
322
|
+
| **Project** | `aru.json` or `.aru/config.json` | Project-specific overrides |
|
|
323
|
+
|
|
324
|
+
Global config is loaded first, then the project config is **deep-merged** on top — scalar values and lists are replaced, nested objects (like `permission`, `providers`, `model_aliases`) are merged recursively. This means you can set your preferred model and aliases globally and only override what's different per project.
|
|
325
|
+
|
|
326
|
+
**Example `~/.aru/config.json`:**
|
|
327
|
+
|
|
328
|
+
```json
|
|
329
|
+
{
|
|
330
|
+
"default_model": "anthropic/claude-sonnet-4-6",
|
|
331
|
+
"model_aliases": {
|
|
332
|
+
"sonnet": "anthropic/claude-sonnet-4-6",
|
|
333
|
+
"opus": "anthropic/claude-opus-4-6"
|
|
334
|
+
},
|
|
335
|
+
"permission": {
|
|
336
|
+
"read": "allow",
|
|
337
|
+
"glob": "allow",
|
|
338
|
+
"grep": "allow"
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
Then a project `aru.json` only needs project-specific settings:
|
|
344
|
+
|
|
345
|
+
```json
|
|
346
|
+
{
|
|
347
|
+
"default_model": "ollama/codellama",
|
|
348
|
+
"permission": {
|
|
349
|
+
"bash": { "pytest *": "allow" }
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
The result: `default_model` becomes `ollama/codellama`, `model_aliases` come from global, and `permission` merges both levels (`read`, `glob`, `grep` from global + `bash` from project).
|
|
355
|
+
|
|
317
356
|
> A full `aru.json` config reference here: [`aru.json`](./aru.json)
|
|
318
357
|
|
|
319
358
|
### AGENTS.md
|
|
@@ -265,8 +265,47 @@ Without any `aru.json` config, aru applies safe defaults:
|
|
|
265
265
|
- Bash → ~40 safe command prefixes auto-allowed (`ls`, `git status`, `grep`, etc.), rest → `ask`
|
|
266
266
|
- Sensitive files (`*.env`, `*.env.*`) → `deny` for read/edit/write (except `*.env.example`)
|
|
267
267
|
|
|
268
|
-
|
|
269
|
-
|
|
268
|
+
#### Config file locations
|
|
269
|
+
|
|
270
|
+
Aru loads configuration from two levels, with project settings overriding global ones:
|
|
271
|
+
|
|
272
|
+
| Level | Path | Purpose |
|
|
273
|
+
|-------|------|---------|
|
|
274
|
+
| **Global (user)** | `~/.aru/config.json` | Defaults that apply to all projects (model, aliases, permissions, providers) |
|
|
275
|
+
| **Project** | `aru.json` or `.aru/config.json` | Project-specific overrides |
|
|
276
|
+
|
|
277
|
+
Global config is loaded first, then the project config is **deep-merged** on top — scalar values and lists are replaced, nested objects (like `permission`, `providers`, `model_aliases`) are merged recursively. This means you can set your preferred model and aliases globally and only override what's different per project.
|
|
278
|
+
|
|
279
|
+
**Example `~/.aru/config.json`:**
|
|
280
|
+
|
|
281
|
+
```json
|
|
282
|
+
{
|
|
283
|
+
"default_model": "anthropic/claude-sonnet-4-6",
|
|
284
|
+
"model_aliases": {
|
|
285
|
+
"sonnet": "anthropic/claude-sonnet-4-6",
|
|
286
|
+
"opus": "anthropic/claude-opus-4-6"
|
|
287
|
+
},
|
|
288
|
+
"permission": {
|
|
289
|
+
"read": "allow",
|
|
290
|
+
"glob": "allow",
|
|
291
|
+
"grep": "allow"
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Then a project `aru.json` only needs project-specific settings:
|
|
297
|
+
|
|
298
|
+
```json
|
|
299
|
+
{
|
|
300
|
+
"default_model": "ollama/codellama",
|
|
301
|
+
"permission": {
|
|
302
|
+
"bash": { "pytest *": "allow" }
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
The result: `default_model` becomes `ollama/codellama`, `model_aliases` come from global, and `permission` merges both levels (`read`, `glob`, `grep` from global + `bash` from project).
|
|
308
|
+
|
|
270
309
|
> A full `aru.json` config reference here: [`aru.json`](./aru.json)
|
|
271
310
|
|
|
272
311
|
### AGENTS.md
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.22.0"
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""File checkpoint system for undo/rewind support.
|
|
2
|
+
|
|
3
|
+
Tracks file state before tool mutations so changes can be reverted.
|
|
4
|
+
Inspired by Claude Code's fileHistory system.
|
|
5
|
+
|
|
6
|
+
Architecture:
|
|
7
|
+
- Each user message creates a "snapshot" identified by a turn index.
|
|
8
|
+
- Before any file mutation (write_file, edit_file, bash), the pre-edit
|
|
9
|
+
content is saved as a versioned backup in .aru/file-history/{session_id}/.
|
|
10
|
+
- On /undo, the most recent snapshot is applied: files are restored to
|
|
11
|
+
their pre-turn state and the conversation is rewound.
|
|
12
|
+
|
|
13
|
+
Backup naming: {sha256(path)[:16]}@v{version}
|
|
14
|
+
Snapshot: {turn_index: {file_path: BackupEntry}}
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import os
|
|
21
|
+
import shutil
|
|
22
|
+
import threading
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class BackupEntry:
|
|
28
|
+
"""A single file backup."""
|
|
29
|
+
backup_path: str | None # None = file didn't exist before this turn
|
|
30
|
+
version: int
|
|
31
|
+
original_path: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Snapshot:
|
|
36
|
+
"""Checkpoint at a specific conversation turn."""
|
|
37
|
+
turn_index: int
|
|
38
|
+
backups: dict[str, BackupEntry] = field(default_factory=dict) # abs_path → BackupEntry
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
MAX_SNAPSHOTS = 100
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CheckpointManager:
|
|
45
|
+
"""Manages file checkpoints for a session.
|
|
46
|
+
|
|
47
|
+
Thread-safe: multiple tools may run in parallel within a turn.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, session_id: str, base_dir: str | None = None):
|
|
51
|
+
self._session_id = session_id
|
|
52
|
+
self._base_dir = base_dir or os.path.join(os.getcwd(), ".aru", "file-history", session_id)
|
|
53
|
+
self._lock = threading.Lock()
|
|
54
|
+
self._snapshots: list[Snapshot] = []
|
|
55
|
+
self._current_turn: int = 0
|
|
56
|
+
self._tracked_files: set[str] = set()
|
|
57
|
+
# Per-file version counter (monotonic)
|
|
58
|
+
self._file_versions: dict[str, int] = {}
|
|
59
|
+
self._dir_created = False
|
|
60
|
+
|
|
61
|
+
def _ensure_dir(self):
|
|
62
|
+
if not self._dir_created:
|
|
63
|
+
os.makedirs(self._base_dir, exist_ok=True)
|
|
64
|
+
self._dir_created = True
|
|
65
|
+
|
|
66
|
+
def _backup_filename(self, file_path: str, version: int) -> str:
|
|
67
|
+
path_hash = hashlib.sha256(file_path.encode("utf-8")).hexdigest()[:16]
|
|
68
|
+
return f"{path_hash}@v{version}"
|
|
69
|
+
|
|
70
|
+
def begin_turn(self, turn_index: int):
|
|
71
|
+
"""Start a new turn — creates a fresh snapshot for this turn."""
|
|
72
|
+
with self._lock:
|
|
73
|
+
self._current_turn = turn_index
|
|
74
|
+
# Create snapshot for this turn (backups added lazily as files are edited)
|
|
75
|
+
snapshot = Snapshot(turn_index=turn_index)
|
|
76
|
+
self._snapshots.append(snapshot)
|
|
77
|
+
# Enforce cap
|
|
78
|
+
if len(self._snapshots) > MAX_SNAPSHOTS:
|
|
79
|
+
evicted = self._snapshots.pop(0)
|
|
80
|
+
self._cleanup_snapshot_backups(evicted)
|
|
81
|
+
|
|
82
|
+
def track_edit(self, file_path: str):
|
|
83
|
+
"""Capture pre-edit state of a file before mutation.
|
|
84
|
+
|
|
85
|
+
Call this BEFORE writing/editing a file. If the file was already
|
|
86
|
+
captured in the current turn's snapshot, this is a no-op.
|
|
87
|
+
"""
|
|
88
|
+
abs_path = os.path.abspath(file_path)
|
|
89
|
+
|
|
90
|
+
with self._lock:
|
|
91
|
+
if not self._snapshots:
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
current_snapshot = self._snapshots[-1]
|
|
95
|
+
|
|
96
|
+
# Already tracked in this turn
|
|
97
|
+
if abs_path in current_snapshot.backups:
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
# Increment version
|
|
101
|
+
version = self._file_versions.get(abs_path, 0) + 1
|
|
102
|
+
self._file_versions[abs_path] = version
|
|
103
|
+
self._tracked_files.add(abs_path)
|
|
104
|
+
|
|
105
|
+
# Read file outside lock (IO)
|
|
106
|
+
backup_path = None
|
|
107
|
+
if os.path.isfile(abs_path):
|
|
108
|
+
self._ensure_dir()
|
|
109
|
+
backup_name = self._backup_filename(abs_path, version)
|
|
110
|
+
backup_path = os.path.join(self._base_dir, backup_name)
|
|
111
|
+
try:
|
|
112
|
+
shutil.copy2(abs_path, backup_path)
|
|
113
|
+
except OSError:
|
|
114
|
+
backup_path = None
|
|
115
|
+
|
|
116
|
+
# Commit to snapshot
|
|
117
|
+
with self._lock:
|
|
118
|
+
if not self._snapshots:
|
|
119
|
+
return
|
|
120
|
+
entry = BackupEntry(
|
|
121
|
+
backup_path=backup_path,
|
|
122
|
+
version=version,
|
|
123
|
+
original_path=abs_path,
|
|
124
|
+
)
|
|
125
|
+
self._snapshots[-1].backups[abs_path] = entry
|
|
126
|
+
|
|
127
|
+
def undo_last_turn(self) -> tuple[list[str], int]:
|
|
128
|
+
"""Revert files changed in the most recent snapshot.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
(list of restored file paths, turn_index that was undone)
|
|
132
|
+
"""
|
|
133
|
+
with self._lock:
|
|
134
|
+
if not self._snapshots:
|
|
135
|
+
return [], 0
|
|
136
|
+
snapshot = self._snapshots.pop()
|
|
137
|
+
|
|
138
|
+
restored = []
|
|
139
|
+
for abs_path, entry in snapshot.backups.items():
|
|
140
|
+
try:
|
|
141
|
+
if entry.backup_path is None:
|
|
142
|
+
# File didn't exist before — delete it
|
|
143
|
+
if os.path.isfile(abs_path):
|
|
144
|
+
os.unlink(abs_path)
|
|
145
|
+
restored.append(abs_path)
|
|
146
|
+
elif os.path.isfile(entry.backup_path):
|
|
147
|
+
# Restore from backup
|
|
148
|
+
shutil.copy2(entry.backup_path, abs_path)
|
|
149
|
+
restored.append(abs_path)
|
|
150
|
+
except OSError:
|
|
151
|
+
pass # best effort
|
|
152
|
+
|
|
153
|
+
return restored, snapshot.turn_index
|
|
154
|
+
|
|
155
|
+
def get_snapshot_count(self) -> int:
|
|
156
|
+
with self._lock:
|
|
157
|
+
return len(self._snapshots)
|
|
158
|
+
|
|
159
|
+
def get_last_snapshot_files(self) -> list[str]:
|
|
160
|
+
"""Return files that would be affected by undo."""
|
|
161
|
+
with self._lock:
|
|
162
|
+
if not self._snapshots:
|
|
163
|
+
return []
|
|
164
|
+
return list(self._snapshots[-1].backups.keys())
|
|
165
|
+
|
|
166
|
+
def _cleanup_snapshot_backups(self, snapshot: Snapshot):
|
|
167
|
+
"""Remove backup files for an evicted snapshot (if not referenced by others)."""
|
|
168
|
+
# Collect all backup paths still referenced
|
|
169
|
+
referenced = set()
|
|
170
|
+
for s in self._snapshots:
|
|
171
|
+
for entry in s.backups.values():
|
|
172
|
+
if entry.backup_path:
|
|
173
|
+
referenced.add(entry.backup_path)
|
|
174
|
+
|
|
175
|
+
# Delete unreferenced backups
|
|
176
|
+
for entry in snapshot.backups.values():
|
|
177
|
+
if entry.backup_path and entry.backup_path not in referenced:
|
|
178
|
+
try:
|
|
179
|
+
os.unlink(entry.backup_path)
|
|
180
|
+
except OSError:
|
|
181
|
+
pass
|
|
182
|
+
|
|
183
|
+
def cleanup(self):
|
|
184
|
+
"""Remove all backup files for this session."""
|
|
185
|
+
try:
|
|
186
|
+
if os.path.isdir(self._base_dir):
|
|
187
|
+
shutil.rmtree(self._base_dir, ignore_errors=True)
|
|
188
|
+
except OSError:
|
|
189
|
+
pass
|
|
@@ -203,6 +203,11 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
|
|
|
203
203
|
ctx.on_file_mutation = session.invalidate_context_cache
|
|
204
204
|
atexit.register(lambda: cleanup_processes(ctx.tracked_processes))
|
|
205
205
|
|
|
206
|
+
# Initialize checkpoint manager for undo/rewind support
|
|
207
|
+
from aru.checkpoints import CheckpointManager
|
|
208
|
+
ctx.checkpoint_manager = CheckpointManager(session.session_id)
|
|
209
|
+
_turn_counter = 0
|
|
210
|
+
|
|
206
211
|
planner = None
|
|
207
212
|
executor = None
|
|
208
213
|
paste_state = PasteState()
|
|
@@ -329,6 +334,64 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
|
|
|
329
334
|
# Reset "allow all" approvals for each new user message
|
|
330
335
|
perm_reset_session()
|
|
331
336
|
|
|
337
|
+
if user_input.lower() == "/undo":
|
|
338
|
+
affected_files = ctx.checkpoint_manager.get_last_snapshot_files()
|
|
339
|
+
if not affected_files and not session.history:
|
|
340
|
+
console.print("[dim]Nothing to undo.[/dim]")
|
|
341
|
+
continue
|
|
342
|
+
|
|
343
|
+
# Show what will be reverted
|
|
344
|
+
if affected_files:
|
|
345
|
+
cwd = os.getcwd()
|
|
346
|
+
console.print("[bold]Files that will be restored:[/bold]")
|
|
347
|
+
for f in affected_files:
|
|
348
|
+
rel = os.path.relpath(f, cwd) if f.startswith(cwd) else f
|
|
349
|
+
console.print(f" [cyan]{rel}[/cyan]")
|
|
350
|
+
|
|
351
|
+
console.print()
|
|
352
|
+
console.print("[bold]Restore options:[/bold]")
|
|
353
|
+
console.print(" [cyan](b)[/cyan] Restore code and conversation (both)")
|
|
354
|
+
console.print(" [cyan](c)[/cyan] Restore only code (keep conversation)")
|
|
355
|
+
console.print(" [cyan](v)[/cyan] Restore only conversation (keep code)")
|
|
356
|
+
console.print(" [cyan](n)[/cyan] Cancel")
|
|
357
|
+
try:
|
|
358
|
+
choice = console.input("[bold yellow]Choice (b/c/v/n):[/bold yellow] ").strip().lower()
|
|
359
|
+
except (EOFError, KeyboardInterrupt):
|
|
360
|
+
choice = "n"
|
|
361
|
+
|
|
362
|
+
if choice in ("n", ""):
|
|
363
|
+
console.print("[dim]Cancelled.[/dim]")
|
|
364
|
+
continue
|
|
365
|
+
|
|
366
|
+
restored_files = []
|
|
367
|
+
msgs_removed = 0
|
|
368
|
+
|
|
369
|
+
if choice in ("b", "c"):
|
|
370
|
+
# Restore files from checkpoint
|
|
371
|
+
restored_files, _ = ctx.checkpoint_manager.undo_last_turn()
|
|
372
|
+
|
|
373
|
+
if choice in ("b", "v"):
|
|
374
|
+
# Remove last turn from conversation
|
|
375
|
+
msgs_removed = session.undo_last_turn()
|
|
376
|
+
|
|
377
|
+
parts = []
|
|
378
|
+
if restored_files:
|
|
379
|
+
cwd = os.getcwd()
|
|
380
|
+
for f in restored_files:
|
|
381
|
+
rel = os.path.relpath(f, cwd) if f.startswith(cwd) else f
|
|
382
|
+
parts.append(f" [cyan]{rel}[/cyan]")
|
|
383
|
+
console.print(f"[green]Restored {len(restored_files)} file(s):[/green]")
|
|
384
|
+
for p in parts:
|
|
385
|
+
console.print(p)
|
|
386
|
+
session.invalidate_context_cache()
|
|
387
|
+
if msgs_removed:
|
|
388
|
+
console.print(f"[green]Removed {msgs_removed} message(s) from conversation.[/green]")
|
|
389
|
+
if not restored_files and not msgs_removed:
|
|
390
|
+
console.print("[dim]Nothing was changed.[/dim]")
|
|
391
|
+
else:
|
|
392
|
+
store.save(session)
|
|
393
|
+
continue
|
|
394
|
+
|
|
332
395
|
if user_input.lower() in ("/quit", "/exit", "quit", "exit"):
|
|
333
396
|
store.save(session)
|
|
334
397
|
console.print(f"\n[dim]Session saved: {session.session_id}[/dim]")
|
|
@@ -455,6 +518,10 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
|
|
|
455
518
|
))
|
|
456
519
|
continue
|
|
457
520
|
|
|
521
|
+
# Begin a new checkpoint turn for undo support
|
|
522
|
+
_turn_counter += 1
|
|
523
|
+
ctx.checkpoint_manager.begin_turn(_turn_counter)
|
|
524
|
+
|
|
458
525
|
if user_input.startswith("! "):
|
|
459
526
|
cmd = user_input[2:].strip()
|
|
460
527
|
if not cmd:
|
|
@@ -609,6 +676,72 @@ def _list_sessions_and_exit():
|
|
|
609
676
|
console.print(f"\n[dim]Resume with: aru --resume <id>[/dim]")
|
|
610
677
|
|
|
611
678
|
|
|
679
|
+
async def run_oneshot(prompt: str, print_only: bool = False, skip_permissions: bool = False):
|
|
680
|
+
"""Run a single prompt non-interactively and exit.
|
|
681
|
+
|
|
682
|
+
Args:
|
|
683
|
+
prompt: The user prompt to execute.
|
|
684
|
+
print_only: If True, run without tools (text-only response).
|
|
685
|
+
skip_permissions: If True, skip all permission checks.
|
|
686
|
+
"""
|
|
687
|
+
from aru.runtime import init_ctx
|
|
688
|
+
from aru.config import load_config
|
|
689
|
+
from aru.cache_patch import apply_cache_patch
|
|
690
|
+
|
|
691
|
+
apply_cache_patch()
|
|
692
|
+
ctx = init_ctx(console=console, skip_permissions=skip_permissions)
|
|
693
|
+
|
|
694
|
+
config = load_config()
|
|
695
|
+
session = Session()
|
|
696
|
+
if config.default_model:
|
|
697
|
+
session.model_ref = config.default_model
|
|
698
|
+
|
|
699
|
+
ctx.model_id = session.model_id
|
|
700
|
+
small_ref = config.model_aliases.get("small") if config else None
|
|
701
|
+
if not small_ref:
|
|
702
|
+
from aru.providers import resolve_model_ref
|
|
703
|
+
provider_key, _ = resolve_model_ref(session.model_ref)
|
|
704
|
+
_small_defaults = {
|
|
705
|
+
"anthropic": "anthropic/claude-haiku-4-5",
|
|
706
|
+
"openai": "openai/gpt-4o-mini",
|
|
707
|
+
"groq": "groq/llama-3.1-8b-instant",
|
|
708
|
+
"deepseek": "deepseek/deepseek-chat",
|
|
709
|
+
"ollama": "ollama/llama3.1",
|
|
710
|
+
}
|
|
711
|
+
small_ref = _small_defaults.get(provider_key, session.model_ref)
|
|
712
|
+
ctx.small_model_ref = small_ref
|
|
713
|
+
|
|
714
|
+
extra_instructions = config.get_extra_instructions()
|
|
715
|
+
|
|
716
|
+
if print_only:
|
|
717
|
+
# Text-only mode: no tools, just a direct LLM call
|
|
718
|
+
from agno.agent import Agent
|
|
719
|
+
from aru.providers import create_model
|
|
720
|
+
from aru.agents.base import build_instructions
|
|
721
|
+
|
|
722
|
+
agent = Agent(
|
|
723
|
+
name="Aru",
|
|
724
|
+
model=create_model(session.model_ref, max_tokens=8192),
|
|
725
|
+
tools=[],
|
|
726
|
+
instructions=build_instructions("general", extra_instructions),
|
|
727
|
+
markdown=True,
|
|
728
|
+
)
|
|
729
|
+
response = await agent.arun(prompt)
|
|
730
|
+
if response and response.content:
|
|
731
|
+
# Print raw text to stdout for piping
|
|
732
|
+
print(response.content)
|
|
733
|
+
else:
|
|
734
|
+
# Full mode with tools
|
|
735
|
+
from aru.runner import build_env_context
|
|
736
|
+
env_ctx = build_env_context(session)
|
|
737
|
+
agent = create_general_agent(session, config, env_context=env_ctx)
|
|
738
|
+
session.add_message("user", prompt)
|
|
739
|
+
await run_agent_capture(agent, prompt, session)
|
|
740
|
+
|
|
741
|
+
if session.token_summary:
|
|
742
|
+
console.print(f"[dim]{session.token_summary}[/dim]")
|
|
743
|
+
|
|
744
|
+
|
|
612
745
|
def main():
|
|
613
746
|
"""Entry point for the aru CLI."""
|
|
614
747
|
from dotenv import load_dotenv
|
|
@@ -616,6 +749,7 @@ def main():
|
|
|
616
749
|
load_dotenv()
|
|
617
750
|
args = sys.argv[1:]
|
|
618
751
|
skip_permissions = "--dangerously-skip-permissions" in args
|
|
752
|
+
print_only = "--print" in args or "-p" in args
|
|
619
753
|
|
|
620
754
|
if "--list" in args:
|
|
621
755
|
_list_sessions_and_exit()
|
|
@@ -629,6 +763,39 @@ def main():
|
|
|
629
763
|
else:
|
|
630
764
|
resume_id = "last"
|
|
631
765
|
|
|
766
|
+
# Collect positional arguments (non-flag, non-flag-value)
|
|
767
|
+
flags_with_value = {"--resume"}
|
|
768
|
+
positional = []
|
|
769
|
+
skip_next = False
|
|
770
|
+
for i, arg in enumerate(args):
|
|
771
|
+
if skip_next:
|
|
772
|
+
skip_next = False
|
|
773
|
+
continue
|
|
774
|
+
if arg.startswith("--") or arg.startswith("-"):
|
|
775
|
+
if arg in flags_with_value:
|
|
776
|
+
skip_next = True
|
|
777
|
+
continue
|
|
778
|
+
positional.append(arg)
|
|
779
|
+
|
|
780
|
+
# Piped stdin: echo "fix bug" | aru
|
|
781
|
+
if not sys.stdin.isatty() and not positional:
|
|
782
|
+
piped_input = sys.stdin.read().strip()
|
|
783
|
+
if piped_input:
|
|
784
|
+
positional = [piped_input]
|
|
785
|
+
|
|
786
|
+
# One-shot mode: aru "fix the bug" or aru --print "explain this"
|
|
787
|
+
if positional:
|
|
788
|
+
prompt = " ".join(positional)
|
|
789
|
+
try:
|
|
790
|
+
asyncio.run(run_oneshot(prompt, print_only=print_only, skip_permissions=skip_permissions))
|
|
791
|
+
except (KeyboardInterrupt, asyncio.CancelledError, SystemExit):
|
|
792
|
+
pass
|
|
793
|
+
except Exception as e:
|
|
794
|
+
from rich.markup import escape
|
|
795
|
+
console.print(f"\n[bold red]Fatal error: {escape(str(e))}[/bold red]")
|
|
796
|
+
return
|
|
797
|
+
|
|
798
|
+
# Interactive REPL mode
|
|
632
799
|
try:
|
|
633
800
|
asyncio.run(run_cli(skip_permissions=skip_permissions, resume_id=resume_id))
|
|
634
801
|
except (KeyboardInterrupt, asyncio.CancelledError, SystemExit):
|
|
@@ -21,6 +21,7 @@ SLASH_COMMANDS = [
|
|
|
21
21
|
("/skills", "List available skills", "/skills"),
|
|
22
22
|
("/agents", "List custom agents", "/agents"),
|
|
23
23
|
("/mcp", "List loaded MCP tools", "/mcp"),
|
|
24
|
+
("/undo", "Undo last turn — restore files and/or conversation", "/undo"),
|
|
24
25
|
("/cost", "Show detailed token usage and cost", "/cost"),
|
|
25
26
|
("/quit", "Exit aru", "/quit"),
|
|
26
27
|
]
|
|
@@ -83,6 +84,7 @@ def _show_help(config) -> None:
|
|
|
83
84
|
table.add_row("/skills", "List available skills")
|
|
84
85
|
table.add_row("/agents", "List custom agents")
|
|
85
86
|
table.add_row("/mcp", "List loaded MCP tools")
|
|
87
|
+
table.add_row("/undo", "Undo last turn (restore files and/or conversation)")
|
|
86
88
|
table.add_row("/help", "Show this help")
|
|
87
89
|
table.add_row("/quit", "Exit aru")
|
|
88
90
|
table.add_row("! <cmd>", "Run shell command")
|
|
@@ -436,6 +436,63 @@ def _discover_agents(search_roots: list[Path]) -> dict[str, CustomAgent]:
|
|
|
436
436
|
return agents
|
|
437
437
|
|
|
438
438
|
|
|
439
|
+
def _load_json_file(path: Path) -> dict | None:
|
|
440
|
+
"""Read and parse a JSON file, returning None on any error."""
|
|
441
|
+
if not path.is_file():
|
|
442
|
+
return None
|
|
443
|
+
try:
|
|
444
|
+
content = path.read_text(encoding="utf-8")
|
|
445
|
+
data = json.loads(content)
|
|
446
|
+
return data if isinstance(data, dict) else None
|
|
447
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
448
|
+
return None
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _deep_merge(base: dict, override: dict) -> dict:
|
|
452
|
+
"""Recursively merge override into base. Override values win for scalars;
|
|
453
|
+
dicts are merged recursively; lists are replaced (not concatenated)."""
|
|
454
|
+
result = base.copy()
|
|
455
|
+
for key, value in override.items():
|
|
456
|
+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
457
|
+
result[key] = _deep_merge(result[key], value)
|
|
458
|
+
else:
|
|
459
|
+
result[key] = value
|
|
460
|
+
return result
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _apply_config_data(config: AgentConfig, data: dict, root: Path) -> None:
|
|
464
|
+
"""Apply a merged config dict to an AgentConfig object."""
|
|
465
|
+
if "permission" in data:
|
|
466
|
+
config.permissions = data["permission"]
|
|
467
|
+
if "providers" in data:
|
|
468
|
+
from aru.providers import load_providers_from_config
|
|
469
|
+
load_providers_from_config(data)
|
|
470
|
+
if "default_model" in data:
|
|
471
|
+
config.default_model = data["default_model"]
|
|
472
|
+
if "model_aliases" in data and isinstance(data["model_aliases"], dict):
|
|
473
|
+
config.model_aliases = data["model_aliases"]
|
|
474
|
+
if "plan_reviewer" in data:
|
|
475
|
+
config.plan_reviewer = bool(data["plan_reviewer"])
|
|
476
|
+
if "tree_depth" in data:
|
|
477
|
+
td = data["tree_depth"]
|
|
478
|
+
if isinstance(td, int) and 0 <= td <= 5:
|
|
479
|
+
config.tree_depth = td
|
|
480
|
+
if "plugins" in data and isinstance(data["plugins"], list):
|
|
481
|
+
config.plugin_specs = data["plugins"]
|
|
482
|
+
if "tools" in data and isinstance(data["tools"], dict):
|
|
483
|
+
tools_cfg = data["tools"]
|
|
484
|
+
if "disabled" in tools_cfg and isinstance(tools_cfg["disabled"], list):
|
|
485
|
+
config.disabled_tools = [str(t) for t in tools_cfg["disabled"]]
|
|
486
|
+
if "instructions" in data and isinstance(data["instructions"], list):
|
|
487
|
+
entries = [str(e) for e in data["instructions"] if isinstance(e, str)]
|
|
488
|
+
config.rules_instructions = _resolve_instructions(entries, root)
|
|
489
|
+
if "agent" in data and isinstance(data["agent"], dict):
|
|
490
|
+
for agent_name, agent_data in data["agent"].items():
|
|
491
|
+
if agent_name in config.custom_agents and isinstance(agent_data, dict):
|
|
492
|
+
if "permission" in agent_data:
|
|
493
|
+
config.custom_agents[agent_name].permission = agent_data["permission"]
|
|
494
|
+
|
|
495
|
+
|
|
439
496
|
def load_config(cwd: str | None = None) -> AgentConfig:
|
|
440
497
|
"""Load agent configuration from AGENTS.md and .agents/ directory.
|
|
441
498
|
|
|
@@ -492,52 +549,27 @@ def load_config(cwd: str | None = None) -> AgentConfig:
|
|
|
492
549
|
config.skills = _discover_skills(skill_roots)
|
|
493
550
|
config.custom_agents = _discover_agents(skill_roots)
|
|
494
551
|
|
|
495
|
-
# Load
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
if "tree_depth" in data:
|
|
517
|
-
td = data["tree_depth"]
|
|
518
|
-
if isinstance(td, int) and 0 <= td <= 5:
|
|
519
|
-
config.tree_depth = td
|
|
520
|
-
# Plugin specs
|
|
521
|
-
if "plugins" in data and isinstance(data["plugins"], list):
|
|
522
|
-
config.plugin_specs = data["plugins"]
|
|
523
|
-
# Custom tools config
|
|
524
|
-
if "tools" in data and isinstance(data["tools"], dict):
|
|
525
|
-
tools_cfg = data["tools"]
|
|
526
|
-
if "disabled" in tools_cfg and isinstance(tools_cfg["disabled"], list):
|
|
527
|
-
config.disabled_tools = [str(t) for t in tools_cfg["disabled"]]
|
|
528
|
-
# Resolve instructions (local files, globs, URLs)
|
|
529
|
-
if "instructions" in data and isinstance(data["instructions"], list):
|
|
530
|
-
entries = [str(e) for e in data["instructions"] if isinstance(e, str)]
|
|
531
|
-
config.rules_instructions = _resolve_instructions(entries, root)
|
|
532
|
-
# Agent-level permission overrides from aru.json
|
|
533
|
-
if "agent" in data and isinstance(data["agent"], dict):
|
|
534
|
-
for agent_name, agent_data in data["agent"].items():
|
|
535
|
-
if agent_name in config.custom_agents and isinstance(agent_data, dict):
|
|
536
|
-
if "permission" in agent_data:
|
|
537
|
-
config.custom_agents[agent_name].permission = agent_data["permission"]
|
|
538
|
-
break
|
|
539
|
-
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
540
|
-
pass
|
|
552
|
+
# Load config: global (~/.aru/config.json) first, then project-level on top.
|
|
553
|
+
# Project values override global values via deep merge.
|
|
554
|
+
home = Path.home()
|
|
555
|
+
global_config_paths = [home / ".aru" / "config.json"]
|
|
556
|
+
project_config_paths = [root / "aru.json", root / ".aru" / "config.json"]
|
|
557
|
+
|
|
558
|
+
merged_data: dict = {}
|
|
559
|
+
for config_path in global_config_paths:
|
|
560
|
+
data = _load_json_file(config_path)
|
|
561
|
+
if data is not None:
|
|
562
|
+
merged_data = data
|
|
563
|
+
break
|
|
564
|
+
|
|
565
|
+
for config_path in project_config_paths:
|
|
566
|
+
data = _load_json_file(config_path)
|
|
567
|
+
if data is not None:
|
|
568
|
+
merged_data = _deep_merge(merged_data, data)
|
|
569
|
+
break
|
|
570
|
+
|
|
571
|
+
if merged_data:
|
|
572
|
+
_apply_config_data(config, merged_data, root)
|
|
541
573
|
|
|
542
574
|
return config
|
|
543
575
|
|
|
@@ -195,6 +195,7 @@ async def run_agent_capture(agent, message: str, session=None, lightweight: bool
|
|
|
195
195
|
)
|
|
196
196
|
pending_tool_uses[tool_id] = assistant_blocks[-1]
|
|
197
197
|
if accumulated[display._flushed_len:]:
|
|
198
|
+
display.content = None
|
|
198
199
|
live.stop()
|
|
199
200
|
display.flush()
|
|
200
201
|
live.start()
|
|
@@ -280,6 +281,10 @@ async def run_agent_capture(agent, message: str, session=None, lightweight: bool
|
|
|
280
281
|
)
|
|
281
282
|
break
|
|
282
283
|
|
|
284
|
+
# Clear live content before the Live context exits so its final
|
|
285
|
+
# render doesn't duplicate text that we print explicitly below.
|
|
286
|
+
display.content = None
|
|
287
|
+
|
|
283
288
|
ctx.live = None
|
|
284
289
|
ctx.display = None
|
|
285
290
|
|