aru-code 0.5.0__tar.gz → 0.6.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 (57) hide show
  1. {aru_code-0.5.0 → aru_code-0.6.0}/PKG-INFO +9 -2
  2. {aru_code-0.5.0 → aru_code-0.6.0}/README.md +8 -1
  3. aru_code-0.6.0/aru/__init__.py +1 -0
  4. aru_code-0.6.0/aru/agent_factory.py +66 -0
  5. aru_code-0.6.0/aru/cli.py +568 -0
  6. aru_code-0.6.0/aru/commands.py +102 -0
  7. aru_code-0.6.0/aru/completers.py +300 -0
  8. {aru_code-0.5.0 → aru_code-0.6.0}/aru/config.py +8 -0
  9. aru_code-0.6.0/aru/display.py +334 -0
  10. aru_code-0.6.0/aru/runner.py +448 -0
  11. aru_code-0.6.0/aru/session.py +475 -0
  12. {aru_code-0.5.0 → aru_code-0.6.0}/aru_code.egg-info/PKG-INFO +9 -2
  13. {aru_code-0.5.0 → aru_code-0.6.0}/aru_code.egg-info/SOURCES.txt +6 -0
  14. {aru_code-0.5.0 → aru_code-0.6.0}/pyproject.toml +1 -1
  15. {aru_code-0.5.0 → aru_code-0.6.0}/tests/test_cli_advanced.py +3 -3
  16. {aru_code-0.5.0 → aru_code-0.6.0}/tests/test_cli_shell.py +17 -17
  17. {aru_code-0.5.0 → aru_code-0.6.0}/tests/test_config.py +24 -7
  18. aru_code-0.5.0/aru/__init__.py +0 -1
  19. aru_code-0.5.0/aru/cli.py +0 -2270
  20. {aru_code-0.5.0 → aru_code-0.6.0}/LICENSE +0 -0
  21. {aru_code-0.5.0 → aru_code-0.6.0}/aru/agents/__init__.py +0 -0
  22. {aru_code-0.5.0 → aru_code-0.6.0}/aru/agents/base.py +0 -0
  23. {aru_code-0.5.0 → aru_code-0.6.0}/aru/agents/executor.py +0 -0
  24. {aru_code-0.5.0 → aru_code-0.6.0}/aru/agents/planner.py +0 -0
  25. {aru_code-0.5.0 → aru_code-0.6.0}/aru/context.py +0 -0
  26. {aru_code-0.5.0 → aru_code-0.6.0}/aru/permissions.py +0 -0
  27. {aru_code-0.5.0 → aru_code-0.6.0}/aru/providers.py +0 -0
  28. {aru_code-0.5.0 → aru_code-0.6.0}/aru/tools/__init__.py +0 -0
  29. {aru_code-0.5.0 → aru_code-0.6.0}/aru/tools/ast_tools.py +0 -0
  30. {aru_code-0.5.0 → aru_code-0.6.0}/aru/tools/codebase.py +0 -0
  31. {aru_code-0.5.0 → aru_code-0.6.0}/aru/tools/gitignore.py +0 -0
  32. {aru_code-0.5.0 → aru_code-0.6.0}/aru/tools/mcp_client.py +0 -0
  33. {aru_code-0.5.0 → aru_code-0.6.0}/aru/tools/ranker.py +0 -0
  34. {aru_code-0.5.0 → aru_code-0.6.0}/aru/tools/tasklist.py +0 -0
  35. {aru_code-0.5.0 → aru_code-0.6.0}/aru_code.egg-info/dependency_links.txt +0 -0
  36. {aru_code-0.5.0 → aru_code-0.6.0}/aru_code.egg-info/entry_points.txt +0 -0
  37. {aru_code-0.5.0 → aru_code-0.6.0}/aru_code.egg-info/requires.txt +0 -0
  38. {aru_code-0.5.0 → aru_code-0.6.0}/aru_code.egg-info/top_level.txt +0 -0
  39. {aru_code-0.5.0 → aru_code-0.6.0}/setup.cfg +0 -0
  40. {aru_code-0.5.0 → aru_code-0.6.0}/tests/test_agents_base.py +0 -0
  41. {aru_code-0.5.0 → aru_code-0.6.0}/tests/test_ast_tools.py +0 -0
  42. {aru_code-0.5.0 → aru_code-0.6.0}/tests/test_cli.py +0 -0
  43. {aru_code-0.5.0 → aru_code-0.6.0}/tests/test_cli_base.py +0 -0
  44. {aru_code-0.5.0 → aru_code-0.6.0}/tests/test_cli_completers.py +0 -0
  45. {aru_code-0.5.0 → aru_code-0.6.0}/tests/test_cli_new.py +0 -0
  46. {aru_code-0.5.0 → aru_code-0.6.0}/tests/test_cli_run_cli.py +0 -0
  47. {aru_code-0.5.0 → aru_code-0.6.0}/tests/test_cli_session.py +0 -0
  48. {aru_code-0.5.0 → aru_code-0.6.0}/tests/test_codebase.py +0 -0
  49. {aru_code-0.5.0 → aru_code-0.6.0}/tests/test_context.py +0 -0
  50. {aru_code-0.5.0 → aru_code-0.6.0}/tests/test_executor.py +0 -0
  51. {aru_code-0.5.0 → aru_code-0.6.0}/tests/test_gitignore.py +0 -0
  52. {aru_code-0.5.0 → aru_code-0.6.0}/tests/test_main.py +0 -0
  53. {aru_code-0.5.0 → aru_code-0.6.0}/tests/test_mcp_client.py +0 -0
  54. {aru_code-0.5.0 → aru_code-0.6.0}/tests/test_permissions.py +0 -0
  55. {aru_code-0.5.0 → aru_code-0.6.0}/tests/test_planner.py +0 -0
  56. {aru_code-0.5.0 → aru_code-0.6.0}/tests/test_providers.py +0 -0
  57. {aru_code-0.5.0 → aru_code-0.6.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.5.0
3
+ Version: 0.6.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
@@ -452,7 +452,14 @@ Aru can load tools from MCP servers. Configure in `.aru/mcp_config.json`:
452
452
  ```
453
453
  aru-code/
454
454
  ├── aru/
455
- │ ├── cli.py # Interactive CLI with streaming display
455
+ │ ├── cli.py # Main REPL loop, argument parsing, and entry point
456
+ │ ├── agent_factory.py # Agent instantiation (general and custom agents)
457
+ │ ├── commands.py # Slash commands, help display, shell execution
458
+ │ ├── completers.py # Input completions, paste detection, @file mentions
459
+ │ ├── context.py # Token optimization (pruning, truncation, compaction)
460
+ │ ├── display.py # Terminal display (logo, status bar, streaming output)
461
+ │ ├── runner.py # Agent execution orchestration with streaming
462
+ │ ├── session.py # Session state, persistence, plan tracking
456
463
  │ ├── config.py # Configuration loader (AGENTS.md, .agents/)
457
464
  │ ├── providers.py # Multi-provider LLM abstraction
458
465
  │ ├── permissions.py # Granular permission system (allow/ask/deny)
@@ -405,7 +405,14 @@ Aru can load tools from MCP servers. Configure in `.aru/mcp_config.json`:
405
405
  ```
406
406
  aru-code/
407
407
  ├── aru/
408
- │ ├── cli.py # Interactive CLI with streaming display
408
+ │ ├── cli.py # Main REPL loop, argument parsing, and entry point
409
+ │ ├── agent_factory.py # Agent instantiation (general and custom agents)
410
+ │ ├── commands.py # Slash commands, help display, shell execution
411
+ │ ├── completers.py # Input completions, paste detection, @file mentions
412
+ │ ├── context.py # Token optimization (pruning, truncation, compaction)
413
+ │ ├── display.py # Terminal display (logo, status bar, streaming output)
414
+ │ ├── runner.py # Agent execution orchestration with streaming
415
+ │ ├── session.py # Session state, persistence, plan tracking
409
416
  │ ├── config.py # Configuration loader (AGENTS.md, .agents/)
410
417
  │ ├── providers.py # Multi-provider LLM abstraction
411
418
  │ ├── permissions.py # Granular permission system (allow/ask/deny)
@@ -0,0 +1 @@
1
+ __version__ = "0.6.0"
@@ -0,0 +1,66 @@
1
+ """Agent creation: general-purpose and custom agent instantiation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from aru.agents.base import build_instructions as _build_instructions
6
+ from aru.config import AgentConfig, CustomAgent
7
+ from aru.providers import create_model
8
+ from aru.session import Session
9
+
10
+
11
+ def create_general_agent(session: Session, config: AgentConfig | None = None):
12
+ """Create the general-purpose agent."""
13
+ from agno.agent import Agent
14
+ from agno.compression.manager import CompressionManager
15
+
16
+ from aru.tools.codebase import GENERAL_TOOLS, _get_small_model_ref
17
+
18
+ extra = config.get_extra_instructions() if config else ""
19
+
20
+ return Agent(
21
+ name="Aru",
22
+ model=create_model(session.model_ref, max_tokens=8192),
23
+ tools=GENERAL_TOOLS,
24
+ instructions=_build_instructions("general", extra),
25
+ markdown=True,
26
+ compress_tool_results=True,
27
+ compression_manager=CompressionManager(
28
+ model=create_model(_get_small_model_ref(), max_tokens=1024),
29
+ compress_tool_results=True,
30
+ compress_tool_results_limit=7,
31
+ ),
32
+ tool_call_limit=20,
33
+ )
34
+
35
+
36
+ def create_custom_agent_instance(agent_def: CustomAgent, session: Session,
37
+ config: AgentConfig | None = None):
38
+ """Create an Agno Agent from a CustomAgent definition."""
39
+ from agno.agent import Agent
40
+ from agno.compression.manager import CompressionManager
41
+ from aru.agents.base import BASE_INSTRUCTIONS
42
+ from aru.tools.codebase import resolve_tools, _get_small_model_ref
43
+
44
+ model_ref = agent_def.model or session.model_ref
45
+ tools = resolve_tools(agent_def.tools)
46
+
47
+ extra = config.get_extra_instructions() if config else ""
48
+ parts = [agent_def.system_prompt, BASE_INSTRUCTIONS]
49
+ if extra:
50
+ parts.append(extra)
51
+ instructions = "\n\n".join(parts)
52
+
53
+ return Agent(
54
+ name=agent_def.name,
55
+ model=create_model(model_ref, max_tokens=8192),
56
+ tools=tools,
57
+ instructions=instructions,
58
+ markdown=True,
59
+ compress_tool_results=True,
60
+ compression_manager=CompressionManager(
61
+ model=create_model(_get_small_model_ref(), max_tokens=1024),
62
+ compress_tool_results=True,
63
+ compress_tool_results_limit=7,
64
+ ),
65
+ tool_call_limit=agent_def.max_turns or 20,
66
+ )
@@ -0,0 +1,568 @@
1
+ """Interactive CLI for aru - a Claude Code clone.
2
+
3
+ This module is the slim orchestrator: REPL loop, arg parsing, and entrypoint.
4
+ All domain logic lives in dedicated modules; public names are re-exported here
5
+ for backward compatibility.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import io as _io
12
+ import logging as _logging
13
+ import os
14
+ import sys
15
+
16
+ from rich.markdown import Markdown
17
+
18
+ # ── Re-exports for backward compatibility ─────────────────────────────
19
+ # Tests and external code import these from aru.cli; keep them accessible.
20
+
21
+ from aru.session import ( # noqa: F401
22
+ DEFAULT_MODEL,
23
+ PlanStep,
24
+ Session,
25
+ SessionStore,
26
+ SESSIONS_DIR,
27
+ _generate_session_id,
28
+ parse_plan_steps,
29
+ )
30
+
31
+ from aru.display import ( # noqa: F401
32
+ StatusBar,
33
+ StreamingDisplay,
34
+ ToolTracker,
35
+ THINKING_PHRASES,
36
+ TOOL_DISPLAY_NAMES,
37
+ TOOL_PRIMARY_ARG,
38
+ _build_logo_with_shadow,
39
+ _format_tool_label,
40
+ _render_home,
41
+ _render_input_separator,
42
+ _sanitize_input,
43
+ aru_logo,
44
+ console,
45
+ format_duration,
46
+ neon_green,
47
+ shadow_green,
48
+ )
49
+
50
+ from aru.completers import ( # noqa: F401
51
+ AruCompleter,
52
+ FileMentionCompleter,
53
+ PasteState,
54
+ SlashCommandCompleter,
55
+ TIPS,
56
+ _MENTION_RE,
57
+ _create_prompt_session,
58
+ _extract_agent_mention,
59
+ _resolve_mentions,
60
+ )
61
+
62
+ from aru.commands import ( # noqa: F401
63
+ SLASH_COMMANDS,
64
+ _show_help,
65
+ ask_yes_no,
66
+ run_shell,
67
+ )
68
+
69
+ from aru.runner import ( # noqa: F401
70
+ AgentRunResult,
71
+ _MUTATION_TOOLS,
72
+ _build_file_context,
73
+ _extract_plan_file_paths,
74
+ execute_plan_steps,
75
+ run_agent_capture,
76
+ )
77
+
78
+ from aru.agent_factory import ( # noqa: F401
79
+ create_custom_agent_instance,
80
+ create_general_agent,
81
+ )
82
+
83
+ # ── Platform setup ─────────────────────────────────────────────────────
84
+
85
+ if sys.platform == "win32" and not hasattr(sys, "_called_from_test"):
86
+ sys.stdout = _io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
87
+ sys.stderr = _io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8")
88
+
89
+ _logging.getLogger("agno").setLevel(_logging.WARNING)
90
+
91
+ # ── Imports used only in this module ───────────────────────────────────
92
+
93
+ from aru.agents.planner import create_planner, review_plan
94
+ from aru.config import load_config, render_command_template, render_skill_template
95
+ from aru.permissions import get_skip_permissions
96
+ from aru.providers import (
97
+ MODEL_ALIASES,
98
+ list_providers,
99
+ resolve_model_ref,
100
+ )
101
+
102
+
103
+ # ── Main REPL ──────────────────────────────────────────────────────────
104
+
105
+ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
106
+ """Main REPL loop."""
107
+ from aru.tools.codebase import set_model_id, set_small_model_ref, set_on_file_mutation
108
+ from aru.permissions import (
109
+ set_config as set_perm_config,
110
+ set_skip_permissions,
111
+ set_console as perm_set_console,
112
+ reset_session as perm_reset_session,
113
+ parse_permission_config,
114
+ )
115
+ from aru.tools.codebase import set_console
116
+ set_console(console)
117
+ perm_set_console(console)
118
+ set_skip_permissions(skip_permissions)
119
+
120
+ store = SessionStore()
121
+
122
+ def _sync_model(sess: Session):
123
+ """Sync the model IDs to the tools module from the session's model_ref."""
124
+ set_model_id(sess.model_id)
125
+ small_ref = config.model_aliases.get("small") if config else None
126
+ if not small_ref:
127
+ provider_key, _ = resolve_model_ref(sess.model_ref)
128
+ _small_defaults = {
129
+ "anthropic": "anthropic/claude-haiku-4-5",
130
+ "openai": "openai/gpt-4o-mini",
131
+ "groq": "groq/llama-3.1-8b-instant",
132
+ "deepseek": "deepseek/deepseek-chat",
133
+ "ollama": "ollama/llama3.1",
134
+ }
135
+ small_ref = _small_defaults.get(provider_key, sess.model_ref)
136
+ set_small_model_ref(small_ref)
137
+
138
+ # Load project configuration
139
+ config = load_config()
140
+ if config.agents_md:
141
+ console.print("[dim]Loaded AGENTS.md[/dim]")
142
+ if config.commands:
143
+ console.print(f"[dim]Loaded {len(config.commands)} custom command(s): {', '.join(f'/{k}' for k in config.commands)}[/dim]")
144
+ if config.skills:
145
+ console.print(f"[dim]Loaded {len(config.skills)} skill(s): {', '.join(config.skills.keys())}[/dim]")
146
+ if config.custom_agents:
147
+ primary = [k for k, v in config.custom_agents.items() if v.mode == "primary"]
148
+ subagents = [k for k, v in config.custom_agents.items() if v.mode == "subagent"]
149
+ parts = []
150
+ if primary:
151
+ parts.append(", ".join(f"/{k}" for k in primary))
152
+ if subagents:
153
+ parts.append(f"{len(subagents)} subagent(s)")
154
+ console.print(f"[dim]Loaded {len(config.custom_agents)} custom agent(s): {', '.join(parts)}[/dim]")
155
+ from aru.tools.codebase import set_custom_agents
156
+ set_custom_agents(config.custom_agents)
157
+ if config.permissions:
158
+ perm_config = parse_permission_config(config.permissions)
159
+ set_perm_config(perm_config)
160
+ console.print("[dim]Loaded permission config[/dim]")
161
+
162
+ extra_instructions = config.get_extra_instructions()
163
+
164
+ # Resume or create session
165
+ if resume_id:
166
+ if resume_id == "last":
167
+ session = store.load_last()
168
+ else:
169
+ session = store.load(resume_id)
170
+ if session is None:
171
+ console.print(f"[red]Session not found: {resume_id}[/red]")
172
+ return
173
+ console.print(Markdown(f"# aru - Resuming session `{session.session_id}`"))
174
+ console.print(f"[dim]Title: {session.title}[/dim]")
175
+ console.print(f"[dim]Messages: {len(session.history)} | Created: {session.created_at}[/dim]")
176
+ if session.history:
177
+ console.print(f"[green]Session loaded — {len(session.history)} messages restored.[/green]")
178
+ if session.current_plan:
179
+ console.print(f"[dim]Active plan: {session.plan_task}[/dim]")
180
+ if session.plan_steps:
181
+ completed = sum(1 for s in session.plan_steps if s.status == "completed")
182
+ console.print(f"[dim]Steps: {completed}/{len(session.plan_steps)} completed[/dim]")
183
+ _sync_model(session)
184
+ else:
185
+ session = Session()
186
+ if config.default_model:
187
+ session.model_ref = config.default_model
188
+ _sync_model(session)
189
+ _render_home(session, skip_permissions)
190
+
191
+ # Wire file-mutation callback
192
+ set_on_file_mutation(session.invalidate_context_cache)
193
+
194
+ planner = None
195
+ executor = None
196
+ paste_state = PasteState()
197
+ prompt_session = _create_prompt_session(paste_state, config)
198
+
199
+ # Startup: load MCP tools
200
+ from aru.tools.codebase import load_mcp_tools
201
+ await load_mcp_tools()
202
+
203
+ while True:
204
+ try:
205
+ paste_state.clear()
206
+ _render_input_separator()
207
+ model_tb = session.model_display
208
+ from prompt_toolkit.formatted_text import HTML
209
+ user_text = (
210
+ await asyncio.to_thread(
211
+ prompt_session.prompt,
212
+ HTML('<b><ansigreen>❯</ansigreen></b> '),
213
+ multiline=False,
214
+ bottom_toolbar=HTML(
215
+ f' <style fg="ansigray">{model_tb}</style>'
216
+ f' <style fg="ansigray">│</style>'
217
+ f' <style fg="ansigray">/help</style>'
218
+ f' <style fg="ansigray">│</style>'
219
+ f' <style fg="ansigray">Esc+Enter newline</style>'
220
+ ),
221
+ )
222
+ ).strip()
223
+ _render_input_separator()
224
+ except (EOFError, KeyboardInterrupt, asyncio.CancelledError):
225
+ store.save(session)
226
+ console.print(f"\n[dim]Session saved: {session.session_id}[/dim]")
227
+ console.print(f"[dim]Resume with:[/dim] [bold cyan]aru --resume {session.session_id}[/bold cyan]")
228
+ console.print("[dim]Bye![/dim]")
229
+ from aru.tools.mcp_client import cleanup_mcp
230
+ await cleanup_mcp()
231
+ break
232
+
233
+ user_input = _sanitize_input(paste_state.build_message(user_text))
234
+
235
+ # Resolve @file mentions (skip known agent names)
236
+ _agent_names = set(config.custom_agents.keys()) if config.custom_agents else set()
237
+ resolved, injected = _resolve_mentions(user_input, os.getcwd(), _agent_names)
238
+ if resolved != user_input:
239
+ console.print(f"[dim]Attached {injected} file(s) from @ mentions[/dim]")
240
+ user_input = resolved
241
+
242
+ if paste_state.pasted_content and user_text:
243
+ console.print(
244
+ f"[dim] {paste_state.line_count} lines pasted[/dim] [cyan]{user_text}[/cyan]"
245
+ )
246
+ elif paste_state.pasted_content:
247
+ console.print(
248
+ f"[dim] {paste_state.line_count} lines pasted[/dim]"
249
+ )
250
+
251
+ if not user_input:
252
+ continue
253
+
254
+ # Reset "allow all" approvals for each new user message
255
+ perm_reset_session()
256
+
257
+ if user_input.lower() in ("/quit", "/exit", "quit", "exit"):
258
+ store.save(session)
259
+ console.print(f"\n[dim]Session saved: {session.session_id}[/dim]")
260
+ console.print(f"[dim]Resume with:[/dim] [bold cyan]aru --resume {session.session_id}[/bold cyan]")
261
+ console.print("[dim]Bye![/dim]")
262
+ from aru.tools.mcp_client import cleanup_mcp
263
+ await cleanup_mcp()
264
+ break
265
+
266
+ if user_input == "/model" or user_input.startswith("/model "):
267
+ arg = user_input[6:].strip()
268
+ if not arg:
269
+ console.print(f"[bold]Current model:[/bold] {session.model_display} ({session.model_id})")
270
+ console.print()
271
+ if config.model_aliases:
272
+ console.print("[bold]Model aliases (aru.json):[/bold]")
273
+ for alias, ref in config.model_aliases.items():
274
+ console.print(f" [cyan]{alias}[/cyan] → {ref}")
275
+ console.print()
276
+ console.print("[bold]Aliases:[/bold]")
277
+ for alias, ref in MODEL_ALIASES.items():
278
+ console.print(f" [cyan]{alias}[/cyan] → {ref}")
279
+ console.print()
280
+ console.print("[bold]Providers:[/bold]")
281
+ for pkey, pconfig in list_providers().items():
282
+ dflt = pconfig.default_model or "—"
283
+ console.print(f" [cyan]{pkey}[/cyan] ({pconfig.name}) — default: {dflt}")
284
+ console.print()
285
+ console.print("[dim]Usage: /model <provider/model> (e.g., /model ollama/llama3.1, /model openai/gpt-4o)[/dim]")
286
+ else:
287
+ arg_lower = arg.lower()
288
+ try:
289
+ resolved_ref = config.model_aliases.get(arg_lower, arg_lower) if config.model_aliases else arg_lower
290
+ provider_key, model_name = resolve_model_ref(resolved_ref)
291
+ from aru.providers import get_provider
292
+ provider = get_provider(provider_key)
293
+ if provider is None:
294
+ available = ", ".join(sorted(list_providers().keys()))
295
+ console.print(f"[yellow]Unknown provider '{provider_key}'. Available: {available}[/yellow]")
296
+ else:
297
+ session.model_ref = resolved_ref if "/" in resolved_ref else (
298
+ MODEL_ALIASES.get(resolved_ref, resolved_ref)
299
+ )
300
+ _sync_model(session)
301
+ planner = None
302
+ executor = None
303
+ console.print(f"[bold green]Switched to {session.model_display}[/bold green] ({session.model_id})")
304
+ except Exception as e:
305
+ console.print(f"[yellow]Error: {e}[/yellow]")
306
+ continue
307
+
308
+ if user_input.lower() in ("/sessions", "/list"):
309
+ sessions = store.list_sessions()
310
+ if not sessions:
311
+ console.print("[dim]No saved sessions.[/dim]")
312
+ else:
313
+ console.print("[bold]Recent sessions:[/bold]\n")
314
+ for s in sessions:
315
+ sid = s["session_id"]
316
+ title = s["title"][:50]
317
+ msgs = s["messages"]
318
+ updated = s["updated_at"]
319
+ model = s["model"]
320
+ is_current = " [green](current)[/green]" if sid == session.session_id else ""
321
+ console.print(f" [bold cyan]{sid}[/bold cyan] {title} [dim]({msgs} msgs, {model}, {updated})[/dim]{is_current}")
322
+ console.print(f"\n[dim]Resume with: aru --resume <id>[/dim]")
323
+ continue
324
+
325
+ if user_input.lower() == "/commands":
326
+ if not config.commands:
327
+ console.print("[dim]No custom commands found. Add .md files to .agents/commands/[/dim]")
328
+ else:
329
+ console.print("[bold]Custom commands:[/bold]\n")
330
+ for name, cmd_def in config.commands.items():
331
+ console.print(f" [bold cyan]/{name}[/bold cyan] [dim]{cmd_def.description}[/dim]")
332
+ console.print(f"\n[dim]Source: .agents/commands/[/dim]")
333
+ continue
334
+
335
+ if user_input.lower() == "/skills":
336
+ if not config.skills:
337
+ console.print("[dim]No skills found. Create skills/<name>/SKILL.md in .agents/ or .claude/[/dim]")
338
+ else:
339
+ console.print("[bold]Available skills:[/bold]\n")
340
+ for name, skill in config.skills.items():
341
+ invocable = "" if skill.user_invocable else " [dim](model-only)[/dim]"
342
+ hint = f" [dim]{skill.argument_hint}[/dim]" if skill.argument_hint else ""
343
+ console.print(f" [bold cyan]/{name}[/bold cyan]{hint} {skill.description}{invocable}")
344
+ console.print(f"\n[dim]Invoke with: /skill-name <arguments>[/dim]")
345
+ continue
346
+
347
+ if user_input.lower() == "/agents":
348
+ if not config.custom_agents:
349
+ console.print("[dim]No custom agents found. Add .md files to .agents/agents/[/dim]")
350
+ else:
351
+ console.print("[bold]Custom agents:[/bold]\n")
352
+ for name, agent_def in config.custom_agents.items():
353
+ mode_tag = " [dim](subagent)[/dim]" if agent_def.mode == "subagent" else ""
354
+ model_tag = f" [dim]({agent_def.model})[/dim]" if agent_def.model else ""
355
+ console.print(f" [bold cyan]/{name}[/bold cyan] {agent_def.description}{mode_tag}{model_tag}")
356
+ console.print(f"\n[dim]Source: .agents/agents/*.md[/dim]")
357
+ continue
358
+
359
+ if user_input.lower() == "/mcp":
360
+ from aru.tools.codebase import ALL_TOOLS
361
+ from agno.tools import Function
362
+ mcp_tools = [t for t in ALL_TOOLS if isinstance(t, Function) and getattr(t, "name", "").count("__") > 0]
363
+ if not mcp_tools:
364
+ console.print("[dim]No MCP tools loaded. Check aru.mcp.json config.[/dim]")
365
+ else:
366
+ console.print(f"[bold]Loaded MCP Tools ({len(mcp_tools)}):[/bold]\n")
367
+ for t in mcp_tools:
368
+ console.print(f" [bold cyan]{t.name}[/bold cyan] [dim]{t.description}[/dim]")
369
+ continue
370
+
371
+ if user_input.lower() == "/help":
372
+ _show_help(config)
373
+ continue
374
+
375
+ if user_input.startswith("! "):
376
+ cmd = user_input[2:].strip()
377
+ if not cmd:
378
+ console.print("[yellow]Usage: ! <command>[/yellow]")
379
+ continue
380
+ run_shell(cmd)
381
+
382
+ elif user_input.startswith("/plan "):
383
+ task = user_input[6:].strip()
384
+ if not task:
385
+ console.print("[yellow]Usage: /plan <task description>[/yellow]")
386
+ continue
387
+
388
+ console.print("[bold magenta]Planning...[/bold magenta]")
389
+ if planner is None:
390
+ planner = create_planner(session.model_ref, extra_instructions)
391
+
392
+ prompt = task
393
+
394
+ plan_result = await run_agent_capture(planner, prompt, session, lightweight=True)
395
+ plan_content = plan_result.content
396
+
397
+ if plan_content and config and config.plan_reviewer:
398
+ console.print("[dim]Reviewing scope...[/dim]")
399
+ reviewed = await review_plan(task, plan_content)
400
+ if reviewed != plan_content:
401
+ plan_content = reviewed
402
+ console.print(Markdown(plan_content))
403
+
404
+ if plan_content:
405
+ session.set_plan(task, plan_content)
406
+ session.add_message("user", f"/plan {task}")
407
+ session.add_message("assistant", f"[Plan]\n{plan_content}")
408
+
409
+ if session.plan_steps:
410
+ console.print(f"\n[bold]{len(session.plan_steps)} steps detected.[/bold]")
411
+
412
+ if get_skip_permissions() or ask_yes_no("Execute this plan?"):
413
+ console.print("[bold green]Executing plan...[/bold green]")
414
+
415
+ from aru.agents.executor import create_executor
416
+ light_instructions = config.get_extra_instructions(lightweight=True) if config else ""
417
+
418
+ def make_executor():
419
+ return create_executor(session.model_ref, light_instructions)
420
+
421
+ result = await execute_plan_steps(session, make_executor)
422
+ if result:
423
+ session.add_message("assistant", f"[Execution]\n{result}")
424
+
425
+ session.clear_plan()
426
+
427
+ elif user_input.startswith("/") and not user_input.startswith("//"):
428
+ parts = user_input[1:].split(None, 1)
429
+ cmd_name = parts[0].lower()
430
+ cmd_args = parts[1] if len(parts) > 1 else ""
431
+
432
+ if cmd_name in config.commands:
433
+ cmd_def = config.commands[cmd_name]
434
+ prompt = render_command_template(cmd_def.template, cmd_args)
435
+ console.print(f"[bold magenta]Running /{cmd_name}...[/bold magenta]")
436
+
437
+ agent = create_general_agent(session, config)
438
+ session.add_message("user", user_input)
439
+ run_result = await run_agent_capture(agent, prompt, session)
440
+ if run_result.content:
441
+ session.add_message("assistant", run_result.with_tools_summary())
442
+ elif cmd_name in config.skills:
443
+ skill = config.skills[cmd_name]
444
+ if not skill.user_invocable:
445
+ console.print(f"[yellow]Skill '{cmd_name}' is not user-invocable[/yellow]")
446
+ else:
447
+ prompt = render_skill_template(skill.content, cmd_args)
448
+ console.print(f"[bold magenta]Running skill /{cmd_name}...[/bold magenta]")
449
+
450
+ agent = create_general_agent(session, config)
451
+ session.add_message("user", user_input)
452
+ run_result = await run_agent_capture(agent, prompt, session)
453
+ if run_result.content:
454
+ session.add_message("assistant", run_result.with_tools_summary())
455
+ elif cmd_name in config.custom_agents:
456
+ agent_def = config.custom_agents[cmd_name]
457
+ if agent_def.mode == "subagent":
458
+ console.print(f"[yellow]Agent '{cmd_name}' is a subagent — invoke via delegate_task only[/yellow]")
459
+ else:
460
+ from aru.permissions import permission_scope
461
+ console.print(f"[bold magenta]Running agent /{cmd_name}...[/bold magenta]")
462
+ agent = create_custom_agent_instance(agent_def, session, config)
463
+ session.add_message("user", user_input)
464
+ with permission_scope(agent_def.permission):
465
+ run_result = await run_agent_capture(agent, cmd_args or user_input, session)
466
+ if run_result.content:
467
+ session.add_message("assistant", run_result.with_tools_summary())
468
+ else:
469
+ console.print(f"[yellow]Unknown command: /{cmd_name}[/yellow]")
470
+ console.print(f"[dim]Built-in: /plan, /model, /sessions, /commands, /skills, /agents, /quit[/dim]")
471
+ if config.commands:
472
+ console.print(f"[dim]Custom: {', '.join(f'/{k}' for k in config.commands)}[/dim]")
473
+ if config.skills:
474
+ invocable = [k for k, v in config.skills.items() if v.user_invocable]
475
+ if invocable:
476
+ console.print(f"[dim]Skills: {', '.join(f'/{k}' for k in invocable)}[/dim]")
477
+ if config.custom_agents:
478
+ primary = [k for k, v in config.custom_agents.items() if v.mode == "primary"]
479
+ if primary:
480
+ console.print(f"[dim]Agents: {', '.join(f'/{k}' for k in primary)}[/dim]")
481
+
482
+ else:
483
+ # Check for @agent mention anywhere in message
484
+ agent_mention = _extract_agent_mention(user_input, config.custom_agents)
485
+ if agent_mention:
486
+ agent_name, message_text = agent_mention
487
+ agent_def = config.custom_agents[agent_name]
488
+ from aru.permissions import permission_scope
489
+ console.print(f"[bold magenta]Routing to @{agent_name}...[/bold magenta]")
490
+ agent = create_custom_agent_instance(agent_def, session, config)
491
+ session.add_message("user", user_input)
492
+ with permission_scope(agent_def.permission):
493
+ run_result = await run_agent_capture(agent, message_text, session)
494
+ if run_result.content:
495
+ session.add_message("assistant", run_result.with_tools_summary())
496
+ else:
497
+ agent = create_general_agent(session, config)
498
+ session.add_message("user", user_input)
499
+ run_result = await run_agent_capture(agent, user_input, session)
500
+ if run_result.content:
501
+ session.add_message("assistant", run_result.with_tools_summary())
502
+
503
+ # Show token usage and auto-save
504
+ if session.token_summary:
505
+ console.print(f"[dim]{session.token_summary}[/dim]")
506
+ store.save(session)
507
+
508
+
509
+ # ── CLI entrypoint ─────────────────────────────────────────────────────
510
+
511
+ def _list_sessions_and_exit():
512
+ """Print saved sessions and exit."""
513
+ store = SessionStore()
514
+ sessions = store.list_sessions()
515
+ if not sessions:
516
+ console.print("[dim]No saved sessions.[/dim]")
517
+ return
518
+ console.print("[bold]Recent sessions:[/bold]\n")
519
+ for s in sessions:
520
+ sid = s["session_id"]
521
+ title = s["title"][:50]
522
+ msgs = s["messages"]
523
+ updated = s["updated_at"]
524
+ model = s["model"]
525
+ console.print(f" [bold cyan]{sid}[/bold cyan] {title} [dim]({msgs} msgs, {model}, {updated})[/dim]")
526
+ console.print(f"\n[dim]Resume with: aru --resume <id>[/dim]")
527
+
528
+
529
+ def main():
530
+ """Entry point for the aru CLI."""
531
+ from dotenv import load_dotenv
532
+
533
+ load_dotenv()
534
+ args = sys.argv[1:]
535
+ skip_permissions = "--dangerously-skip-permissions" in args
536
+
537
+ if "--list" in args:
538
+ _list_sessions_and_exit()
539
+ return
540
+
541
+ resume_id = None
542
+ if "--resume" in args:
543
+ idx = args.index("--resume")
544
+ if idx + 1 < len(args) and not args[idx + 1].startswith("--"):
545
+ resume_id = args[idx + 1]
546
+ else:
547
+ resume_id = "last"
548
+
549
+ try:
550
+ asyncio.run(run_cli(skip_permissions=skip_permissions, resume_id=resume_id))
551
+ except (KeyboardInterrupt, asyncio.CancelledError, SystemExit):
552
+ _graceful_exit()
553
+ except Exception as e:
554
+ from rich.markup import escape
555
+ console.print(f"\n[bold red]Fatal error: {escape(str(e))}[/bold red]")
556
+ _graceful_exit()
557
+
558
+
559
+ def _graceful_exit():
560
+ """Save session and show resume hint on exit."""
561
+ try:
562
+ store = SessionStore()
563
+ last = store.load_last()
564
+ if last:
565
+ console.print(f"\n[dim]Session saved: {last.session_id}[/dim]")
566
+ console.print(f"[dim]Resume with:[/dim] [bold cyan]aru --resume {last.session_id}[/bold cyan]")
567
+ except Exception:
568
+ pass