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.
Files changed (67) hide show
  1. {aru_code-0.20.0/aru_code.egg-info → aru_code-0.22.0}/PKG-INFO +42 -3
  2. {aru_code-0.20.0 → aru_code-0.22.0}/README.md +41 -2
  3. aru_code-0.22.0/aru/__init__.py +1 -0
  4. aru_code-0.22.0/aru/checkpoints.py +189 -0
  5. {aru_code-0.20.0 → aru_code-0.22.0}/aru/cli.py +167 -0
  6. {aru_code-0.20.0 → aru_code-0.22.0}/aru/commands.py +2 -0
  7. {aru_code-0.20.0 → aru_code-0.22.0}/aru/config.py +78 -46
  8. {aru_code-0.20.0 → aru_code-0.22.0}/aru/runner.py +5 -0
  9. {aru_code-0.20.0 → aru_code-0.22.0}/aru/runtime.py +3 -0
  10. {aru_code-0.20.0 → aru_code-0.22.0}/aru/session.py +18 -0
  11. {aru_code-0.20.0 → aru_code-0.22.0}/aru/tools/codebase.py +167 -19
  12. {aru_code-0.20.0 → aru_code-0.22.0/aru_code.egg-info}/PKG-INFO +42 -3
  13. {aru_code-0.20.0 → aru_code-0.22.0}/aru_code.egg-info/SOURCES.txt +2 -0
  14. {aru_code-0.20.0 → aru_code-0.22.0}/pyproject.toml +1 -1
  15. aru_code-0.22.0/tests/test_checkpoints.py +190 -0
  16. aru_code-0.20.0/aru/__init__.py +0 -1
  17. {aru_code-0.20.0 → aru_code-0.22.0}/LICENSE +0 -0
  18. {aru_code-0.20.0 → aru_code-0.22.0}/aru/agent_factory.py +0 -0
  19. {aru_code-0.20.0 → aru_code-0.22.0}/aru/agents/__init__.py +0 -0
  20. {aru_code-0.20.0 → aru_code-0.22.0}/aru/agents/base.py +0 -0
  21. {aru_code-0.20.0 → aru_code-0.22.0}/aru/agents/executor.py +0 -0
  22. {aru_code-0.20.0 → aru_code-0.22.0}/aru/agents/planner.py +0 -0
  23. {aru_code-0.20.0 → aru_code-0.22.0}/aru/cache_patch.py +0 -0
  24. {aru_code-0.20.0 → aru_code-0.22.0}/aru/completers.py +0 -0
  25. {aru_code-0.20.0 → aru_code-0.22.0}/aru/context.py +0 -0
  26. {aru_code-0.20.0 → aru_code-0.22.0}/aru/display.py +0 -0
  27. {aru_code-0.20.0 → aru_code-0.22.0}/aru/history_blocks.py +0 -0
  28. {aru_code-0.20.0 → aru_code-0.22.0}/aru/permissions.py +0 -0
  29. {aru_code-0.20.0 → aru_code-0.22.0}/aru/plugins/__init__.py +0 -0
  30. {aru_code-0.20.0 → aru_code-0.22.0}/aru/plugins/custom_tools.py +0 -0
  31. {aru_code-0.20.0 → aru_code-0.22.0}/aru/plugins/hooks.py +0 -0
  32. {aru_code-0.20.0 → aru_code-0.22.0}/aru/plugins/manager.py +0 -0
  33. {aru_code-0.20.0 → aru_code-0.22.0}/aru/plugins/tool_api.py +0 -0
  34. {aru_code-0.20.0 → aru_code-0.22.0}/aru/providers.py +0 -0
  35. {aru_code-0.20.0 → aru_code-0.22.0}/aru/tools/__init__.py +0 -0
  36. {aru_code-0.20.0 → aru_code-0.22.0}/aru/tools/ast_tools.py +0 -0
  37. {aru_code-0.20.0 → aru_code-0.22.0}/aru/tools/gitignore.py +0 -0
  38. {aru_code-0.20.0 → aru_code-0.22.0}/aru/tools/mcp_client.py +0 -0
  39. {aru_code-0.20.0 → aru_code-0.22.0}/aru/tools/ranker.py +0 -0
  40. {aru_code-0.20.0 → aru_code-0.22.0}/aru/tools/tasklist.py +0 -0
  41. {aru_code-0.20.0 → aru_code-0.22.0}/aru_code.egg-info/dependency_links.txt +0 -0
  42. {aru_code-0.20.0 → aru_code-0.22.0}/aru_code.egg-info/entry_points.txt +0 -0
  43. {aru_code-0.20.0 → aru_code-0.22.0}/aru_code.egg-info/requires.txt +0 -0
  44. {aru_code-0.20.0 → aru_code-0.22.0}/aru_code.egg-info/top_level.txt +0 -0
  45. {aru_code-0.20.0 → aru_code-0.22.0}/setup.cfg +0 -0
  46. {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_agents_base.py +0 -0
  47. {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_cli.py +0 -0
  48. {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_cli_advanced.py +0 -0
  49. {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_cli_base.py +0 -0
  50. {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_cli_completers.py +0 -0
  51. {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_cli_new.py +0 -0
  52. {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_cli_run_cli.py +0 -0
  53. {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_cli_session.py +0 -0
  54. {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_cli_shell.py +0 -0
  55. {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_codebase.py +0 -0
  56. {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_confabulation_regression.py +0 -0
  57. {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_config.py +0 -0
  58. {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_context.py +0 -0
  59. {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_executor.py +0 -0
  60. {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_gitignore.py +0 -0
  61. {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_main.py +0 -0
  62. {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_mcp_client.py +0 -0
  63. {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_permissions.py +0 -0
  64. {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_planner.py +0 -0
  65. {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_plugins.py +0 -0
  66. {aru_code-0.20.0 → aru_code-0.22.0}/tests/test_providers.py +0 -0
  67. {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.20.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
- > `aru.json` can also be placed at `.aru/config.json`.
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
- > `aru.json` can also be placed at `.aru/config.json`.
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 opencode-style config (aru.json or .aru/config.json)
496
- config_paths = [root / "aru.json", root / ".aru" / "config.json"]
497
- for config_path in config_paths:
498
- if config_path.is_file():
499
- try:
500
- content = config_path.read_text(encoding="utf-8")
501
- data = json.loads(content)
502
- if isinstance(data, dict):
503
- if "permission" in data:
504
- config.permissions = data["permission"]
505
- # Load provider configuration
506
- if "providers" in data:
507
- from aru.providers import load_providers_from_config
508
- load_providers_from_config(data)
509
- # Store default model and aliases for CLI
510
- if "default_model" in data:
511
- config.default_model = data["default_model"]
512
- if "model_aliases" in data and isinstance(data["model_aliases"], dict):
513
- config.model_aliases = data["model_aliases"]
514
- if "plan_reviewer" in data:
515
- config.plan_reviewer = bool(data["plan_reviewer"])
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