aru-code 0.27.0__tar.gz → 0.30.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 (91) hide show
  1. {aru_code-0.27.0/aru_code.egg-info → aru_code-0.30.0}/PKG-INFO +1 -1
  2. aru_code-0.30.0/aru/__init__.py +1 -0
  3. {aru_code-0.27.0 → aru_code-0.30.0}/aru/agent_factory.py +30 -3
  4. {aru_code-0.27.0 → aru_code-0.30.0}/aru/agents/catalog.py +12 -4
  5. {aru_code-0.27.0 → aru_code-0.30.0}/aru/cache_patch.py +122 -1
  6. {aru_code-0.27.0 → aru_code-0.30.0}/aru/cli.py +68 -3
  7. aru_code-0.30.0/aru/commands.py +245 -0
  8. {aru_code-0.27.0 → aru_code-0.30.0}/aru/config.py +27 -1
  9. {aru_code-0.27.0 → aru_code-0.30.0}/aru/context.py +130 -3
  10. {aru_code-0.27.0 → aru_code-0.30.0}/aru/display.py +1 -1
  11. {aru_code-0.27.0 → aru_code-0.30.0}/aru/permissions.py +7 -3
  12. aru_code-0.30.0/aru/plugin_cache.py +618 -0
  13. {aru_code-0.27.0 → aru_code-0.30.0}/aru/plugins/custom_tools.py +9 -1
  14. {aru_code-0.27.0 → aru_code-0.30.0}/aru/plugins/manager.py +9 -1
  15. {aru_code-0.27.0 → aru_code-0.30.0}/aru/providers.py +47 -12
  16. {aru_code-0.27.0 → aru_code-0.30.0}/aru/runner.py +258 -126
  17. {aru_code-0.27.0 → aru_code-0.30.0}/aru/runtime.py +5 -0
  18. {aru_code-0.27.0 → aru_code-0.30.0}/aru/session.py +82 -0
  19. {aru_code-0.27.0 → aru_code-0.30.0}/aru/tools/registry.py +7 -1
  20. aru_code-0.30.0/aru/tools/skill.py +166 -0
  21. {aru_code-0.27.0 → aru_code-0.30.0}/aru/tools/tasklist.py +13 -8
  22. {aru_code-0.27.0 → aru_code-0.30.0/aru_code.egg-info}/PKG-INFO +1 -1
  23. {aru_code-0.27.0 → aru_code-0.30.0}/aru_code.egg-info/SOURCES.txt +10 -1
  24. {aru_code-0.27.0 → aru_code-0.30.0}/pyproject.toml +1 -1
  25. aru_code-0.30.0/tests/test_cache_patch_stop_reason.py +108 -0
  26. {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_catalog.py +6 -3
  27. {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_config.py +16 -0
  28. aru_code-0.30.0/tests/test_invoke_skill.py +354 -0
  29. aru_code-0.30.0/tests/test_invoked_skills.py +321 -0
  30. {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_permissions.py +74 -0
  31. aru_code-0.30.0/tests/test_plugin_cache.py +354 -0
  32. {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_providers.py +19 -1
  33. aru_code-0.30.0/tests/test_runner_recovery.py +132 -0
  34. aru_code-0.30.0/tests/test_skill_disallowed_tools.py +78 -0
  35. aru_code-0.30.0/tests/test_tasklist.py +117 -0
  36. aru_code-0.27.0/aru/__init__.py +0 -1
  37. aru_code-0.27.0/aru/commands.py +0 -105
  38. {aru_code-0.27.0 → aru_code-0.30.0}/LICENSE +0 -0
  39. {aru_code-0.27.0 → aru_code-0.30.0}/README.md +0 -0
  40. {aru_code-0.27.0 → aru_code-0.30.0}/aru/agents/__init__.py +0 -0
  41. {aru_code-0.27.0 → aru_code-0.30.0}/aru/agents/base.py +0 -0
  42. {aru_code-0.27.0 → aru_code-0.30.0}/aru/agents/planner.py +0 -0
  43. {aru_code-0.27.0 → aru_code-0.30.0}/aru/checkpoints.py +0 -0
  44. {aru_code-0.27.0 → aru_code-0.30.0}/aru/completers.py +0 -0
  45. {aru_code-0.27.0 → aru_code-0.30.0}/aru/history_blocks.py +0 -0
  46. {aru_code-0.27.0 → aru_code-0.30.0}/aru/plugins/__init__.py +0 -0
  47. {aru_code-0.27.0 → aru_code-0.30.0}/aru/plugins/hooks.py +0 -0
  48. {aru_code-0.27.0 → aru_code-0.30.0}/aru/plugins/tool_api.py +0 -0
  49. {aru_code-0.27.0 → aru_code-0.30.0}/aru/select.py +0 -0
  50. {aru_code-0.27.0 → aru_code-0.30.0}/aru/tools/__init__.py +0 -0
  51. {aru_code-0.27.0 → aru_code-0.30.0}/aru/tools/_diff.py +0 -0
  52. {aru_code-0.27.0 → aru_code-0.30.0}/aru/tools/_shared.py +0 -0
  53. {aru_code-0.27.0 → aru_code-0.30.0}/aru/tools/ast_tools.py +0 -0
  54. {aru_code-0.27.0 → aru_code-0.30.0}/aru/tools/codebase.py +0 -0
  55. {aru_code-0.27.0 → aru_code-0.30.0}/aru/tools/delegate.py +0 -0
  56. {aru_code-0.27.0 → aru_code-0.30.0}/aru/tools/file_ops.py +0 -0
  57. {aru_code-0.27.0 → aru_code-0.30.0}/aru/tools/gitignore.py +0 -0
  58. {aru_code-0.27.0 → aru_code-0.30.0}/aru/tools/mcp_client.py +0 -0
  59. {aru_code-0.27.0 → aru_code-0.30.0}/aru/tools/plan_mode.py +0 -0
  60. {aru_code-0.27.0 → aru_code-0.30.0}/aru/tools/ranker.py +0 -0
  61. {aru_code-0.27.0 → aru_code-0.30.0}/aru/tools/search.py +0 -0
  62. {aru_code-0.27.0 → aru_code-0.30.0}/aru/tools/shell.py +0 -0
  63. {aru_code-0.27.0 → aru_code-0.30.0}/aru/tools/web.py +0 -0
  64. {aru_code-0.27.0 → aru_code-0.30.0}/aru_code.egg-info/dependency_links.txt +0 -0
  65. {aru_code-0.27.0 → aru_code-0.30.0}/aru_code.egg-info/entry_points.txt +0 -0
  66. {aru_code-0.27.0 → aru_code-0.30.0}/aru_code.egg-info/requires.txt +0 -0
  67. {aru_code-0.27.0 → aru_code-0.30.0}/aru_code.egg-info/top_level.txt +0 -0
  68. {aru_code-0.27.0 → aru_code-0.30.0}/setup.cfg +0 -0
  69. {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_agents_base.py +0 -0
  70. {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_agents_md_coverage.py +0 -0
  71. {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_cache_patch_metrics.py +0 -0
  72. {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_checkpoints.py +0 -0
  73. {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_cli.py +0 -0
  74. {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_cli_advanced.py +0 -0
  75. {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_cli_base.py +0 -0
  76. {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_cli_completers.py +0 -0
  77. {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_cli_new.py +0 -0
  78. {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_cli_run_cli.py +0 -0
  79. {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_cli_session.py +0 -0
  80. {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_cli_shell.py +0 -0
  81. {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_codebase.py +0 -0
  82. {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_confabulation_regression.py +0 -0
  83. {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_context.py +0 -0
  84. {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_gitignore.py +0 -0
  85. {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_guardrails_scenarios.py +0 -0
  86. {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_main.py +0 -0
  87. {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_mcp_client.py +0 -0
  88. {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_plan_mode_refactor.py +0 -0
  89. {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_plugins.py +0 -0
  90. {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_ranker.py +0 -0
  91. {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_select.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aru-code
3
- Version: 0.27.0
3
+ Version: 0.30.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
@@ -0,0 +1 @@
1
+ __version__ = "0.30.0"
@@ -81,6 +81,32 @@ def _wrap_tools_with_hooks(tools: list) -> list:
81
81
  f"exit_plan_mode(plan=<full plan text>) to request "
82
82
  f"approval. Do NOT retry {tool_name}."
83
83
  )
84
+ # Active-skill disallowed-tools gate — honors the `disallowed-tools`
85
+ # frontmatter field of the currently active skill. Mirrors the
86
+ # plan-mode gate pattern above; runs before plugin hooks so a skill
87
+ # can hard-block a tool regardless of permission/plugin state.
88
+ try:
89
+ from aru.runtime import get_ctx
90
+ ctx = get_ctx()
91
+ session = getattr(ctx, "session", None)
92
+ config = getattr(ctx, "config", None)
93
+ except (LookupError, AttributeError):
94
+ session = None
95
+ config = None
96
+ if session is not None and config is not None:
97
+ active = getattr(session, "active_skill", None)
98
+ skills = getattr(config, "skills", None) or {}
99
+ active_skill_obj = skills.get(active) if active else None
100
+ disallowed = getattr(active_skill_obj, "disallowed_tools", None) or []
101
+ if tool_name in disallowed:
102
+ return (
103
+ f"BLOCKED: tool `{tool_name}` is disallowed by the "
104
+ f"currently active skill `{active}`. Read the skill's "
105
+ f"SKILL.md for the prescribed path. Do NOT retry "
106
+ f"`{tool_name}`; use the alternative the skill specifies "
107
+ f"(commonly: write the output to a `.md` file via "
108
+ f"`write_file` instead of using in-session state)."
109
+ )
84
110
  # Before hook — plugins can mutate args or raise PermissionError to block
85
111
  try:
86
112
  before_data = await _fire_hook("tool.execute.before", {
@@ -112,10 +138,11 @@ def _wrap_tools_with_hooks(tools: list) -> list:
112
138
 
113
139
 
114
140
  async def _apply_chat_hooks(instructions: str, model_ref: str, agent_name: str,
115
- max_tokens: int = 8192) -> tuple[str, str, int]:
141
+ max_tokens: int | None = None) -> tuple[str, str, int | None]:
116
142
  """Apply chat.system.transform and chat.params hooks to agent creation params.
117
143
 
118
144
  Returns (instructions, model_ref, max_tokens) — possibly modified by plugins.
145
+ When max_tokens is None, providers.create_model will use the model's full cap.
119
146
  """
120
147
  # chat.system.transform — plugins can modify the system prompt
121
148
  data = await _fire_hook("chat.system.transform", {
@@ -216,9 +243,9 @@ async def create_custom_agent_instance(agent_def: CustomAgent, session: Session,
216
243
  parts.append(extra)
217
244
  instructions = "\n\n".join(parts)
218
245
 
219
- # Apply chat hooks (system.transform + params)
246
+ # Apply chat hooks (system.transform + params). max_tokens=None → provider cap.
220
247
  instructions, model_ref, max_tokens = await _apply_chat_hooks(
221
- instructions, model_ref, agent_def.name, max_tokens=8192,
248
+ instructions, model_ref, agent_def.name, max_tokens=None,
222
249
  )
223
250
 
224
251
  return Agent(
@@ -21,13 +21,18 @@ class AgentSpec:
21
21
 
22
22
  The tools_factory is a lazy callable so module load order does not force
23
23
  aru.tools.codebase to be imported before this module.
24
+
25
+ `max_tokens=None` means "use the model's full cap" (see providers.py).
26
+ An explicit int caps the agent below that ceiling — providers.py always
27
+ clamps the final value to min(requested, model_cap) so specs can never
28
+ ask for more than the model supports.
24
29
  """
25
30
 
26
31
  name: str # display name passed to Agno
27
32
  role: str # key into build_instructions(role, ...)
28
33
  mode: Literal["primary", "subagent"]
29
34
  tools_factory: Callable[[], list] # lazy resolver — invoked at agent creation
30
- max_tokens: int
35
+ max_tokens: int | None
31
36
  small_model: bool = False # if True, factory uses ctx.small_model_ref
32
37
 
33
38
 
@@ -52,12 +57,15 @@ def _explore_tools() -> list:
52
57
 
53
58
 
54
59
  AGENTS: dict[str, AgentSpec] = {
60
+ # Primary agents default to the model's full output cap (clamped by
61
+ # providers.create_model). Subagents keep a tight budget so a runaway
62
+ # explorer can't blow through the whole turn.
55
63
  "build": AgentSpec(
56
64
  name="Aru",
57
65
  role="general",
58
66
  mode="primary",
59
67
  tools_factory=_build_tools,
60
- max_tokens=8192,
68
+ max_tokens=None,
61
69
  ),
62
70
  "plan": AgentSpec(
63
71
  name="Planner",
@@ -71,14 +79,14 @@ AGENTS: dict[str, AgentSpec] = {
71
79
  role="executor",
72
80
  mode="primary",
73
81
  tools_factory=_exec_tools,
74
- max_tokens=8192,
82
+ max_tokens=None,
75
83
  ),
76
84
  "explorer": AgentSpec(
77
85
  name="Explorer",
78
86
  role="explorer",
79
87
  mode="subagent",
80
88
  tools_factory=_explore_tools,
81
- max_tokens=4096,
89
+ max_tokens=8192,
82
90
  small_model=True,
83
91
  ),
84
92
  }
@@ -1,6 +1,6 @@
1
1
  """Monkey-patch Agno's model layer to reduce token consumption.
2
2
 
3
- Three optimizations:
3
+ Four optimizations:
4
4
 
5
5
  1. **Tool result pruning** (ALL providers): After each tool execution, old tool
6
6
  results in the message list are truncated to a short summary. This prevents
@@ -12,6 +12,11 @@ Three optimizations:
12
12
  3. **Per-call metrics** (ALL providers): Captures input/output tokens of the
13
13
  last API call (context window size), exposed via get_last_call_metrics().
14
14
 
15
+ 4. **Stop-reason capture** (Anthropic + OpenAI-compatible): Captures the
16
+ `stop_reason` / `finish_reason` from the final message of the last API call,
17
+ exposed via get_last_stop_reason(). Lets the runner detect `max_tokens`
18
+ truncation and trigger the recovery loop.
19
+
15
20
  These patches intercept Agno's internal loop so they work transparently
16
21
  regardless of which provider is used.
17
22
  """
@@ -33,12 +38,36 @@ _last_call_output_tokens: int = 0
33
38
  _last_call_cache_read: int = 0
34
39
  _last_call_cache_write: int = 0
35
40
 
41
+ # Last API call stop reason (Anthropic uses "end_turn"/"tool_use"/"max_tokens"/
42
+ # "stop_sequence"/"pause_turn"; OpenAI uses "stop"/"length"/"tool_calls").
43
+ # We normalize "length" → "max_tokens" so callers can check a single value.
44
+ _last_call_stop_reason: str | None = None
45
+
36
46
 
37
47
  def get_last_call_metrics() -> tuple[int, int, int, int]:
38
48
  """Return (input, output, cache_read, cache_write) from the most recent API call."""
39
49
  return _last_call_input_tokens, _last_call_output_tokens, _last_call_cache_read, _last_call_cache_write
40
50
 
41
51
 
52
+ def get_last_stop_reason() -> str | None:
53
+ """Return the stop reason from the most recent API call, normalized.
54
+
55
+ Returns one of: `end_turn`, `tool_use`, `max_tokens`, `stop_sequence`,
56
+ `pause_turn`, or None if no call has happened yet / the provider did not
57
+ expose one. OpenAI's `length` is mapped to `max_tokens` and `stop` to
58
+ `end_turn` so callers have a single vocabulary.
59
+ """
60
+ return _last_call_stop_reason
61
+
62
+
63
+ def reset_last_stop_reason() -> None:
64
+ """Clear the cached stop reason — call before starting a new turn so a
65
+ stale value from a prior turn never leaks into the next one.
66
+ """
67
+ global _last_call_stop_reason
68
+ _last_call_stop_reason = None
69
+
70
+
42
71
  def _prune_tool_messages(messages):
43
72
  """Clear old tool result content using a token-budget approach.
44
73
 
@@ -97,6 +126,7 @@ def apply_cache_patch():
97
126
  _patch_tool_result_pruning()
98
127
  _patch_claude_cache_breakpoints()
99
128
  _patch_per_call_metrics()
129
+ _patch_stop_reason_capture()
100
130
 
101
131
 
102
132
  def _patch_tool_result_pruning():
@@ -235,3 +265,94 @@ def _patch_per_call_metrics():
235
265
  _base_module.accumulate_model_metrics = _patched_accumulate
236
266
  except (ImportError, AttributeError):
237
267
  pass
268
+
269
+
270
+ # OpenAI "length" and Anthropic "max_tokens" mean the same thing; normalize so
271
+ # runner logic can check a single value.
272
+ _STOP_REASON_NORMALIZE = {
273
+ "length": "max_tokens", # OpenAI
274
+ "stop": "end_turn", # OpenAI
275
+ "tool_calls": "tool_use", # OpenAI
276
+ "function_call": "tool_use", # legacy OpenAI
277
+ "MAX_TOKENS": "max_tokens", # Gemini (all-caps)
278
+ }
279
+
280
+
281
+ def _record_stop_reason(raw: str | None) -> None:
282
+ """Normalize and cache the provider's stop reason."""
283
+ global _last_call_stop_reason
284
+ if raw is None or raw == "":
285
+ return
286
+ _last_call_stop_reason = _STOP_REASON_NORMALIZE.get(raw, raw)
287
+
288
+
289
+ def _patch_stop_reason_capture():
290
+ """Forward `stop_reason` from Agno's provider parsers into a module-level
291
+ slot readable via `get_last_stop_reason()`.
292
+
293
+ Agno's Anthropic adapter sees `response.stop_reason` (non-streaming) and
294
+ `response.message.stop_reason` (streaming MessageStopEvent), but discards
295
+ both before anything downstream can observe them. We wrap the two parsers
296
+ and record the value as a side effect. The OpenAI-compatible adapter
297
+ already exposes `response.choices[0].finish_reason`, so we hook that too
298
+ for completeness (Qwen, DeepSeek, Groq, OpenRouter).
299
+ """
300
+ # Anthropic (native + streaming)
301
+ try:
302
+ from agno.models.anthropic import claude as _claude_mod
303
+
304
+ _original_parse = _claude_mod.Claude._parse_provider_response
305
+ _original_parse_delta = _claude_mod.Claude._parse_provider_response_delta
306
+
307
+ def _patched_parse(self, response, *args, **kwargs):
308
+ result = _original_parse(self, response, *args, **kwargs)
309
+ _record_stop_reason(getattr(response, "stop_reason", None))
310
+ return result
311
+
312
+ def _patched_parse_delta(self, response, *args, **kwargs):
313
+ result = _original_parse_delta(self, response, *args, **kwargs)
314
+ # MessageStopEvent / ParsedBetaMessageStopEvent carry the final
315
+ # stop_reason on their nested `message` object.
316
+ msg = getattr(response, "message", None)
317
+ if msg is not None:
318
+ _record_stop_reason(getattr(msg, "stop_reason", None))
319
+ return result
320
+
321
+ _claude_mod.Claude._parse_provider_response = _patched_parse
322
+ _claude_mod.Claude._parse_provider_response_delta = _patched_parse_delta
323
+ except (ImportError, AttributeError):
324
+ pass
325
+
326
+ # OpenAI-compatible (OpenAI, Qwen/DashScope, DeepSeek, Groq, OpenRouter)
327
+ try:
328
+ from agno.models.openai import chat as _openai_chat
329
+
330
+ _original_openai_parse = _openai_chat.OpenAIChat._parse_provider_response
331
+
332
+ def _patched_openai_parse(self, response, *args, **kwargs):
333
+ result = _original_openai_parse(self, response, *args, **kwargs)
334
+ try:
335
+ choice = response.choices[0]
336
+ _record_stop_reason(getattr(choice, "finish_reason", None))
337
+ except (AttributeError, IndexError, TypeError):
338
+ pass
339
+ return result
340
+
341
+ _openai_chat.OpenAIChat._parse_provider_response = _patched_openai_parse
342
+
343
+ if hasattr(_openai_chat.OpenAIChat, "_parse_provider_response_delta"):
344
+ _original_openai_delta = _openai_chat.OpenAIChat._parse_provider_response_delta
345
+
346
+ def _patched_openai_delta(self, response, *args, **kwargs):
347
+ result = _original_openai_delta(self, response, *args, **kwargs)
348
+ try:
349
+ choice = response.choices[0]
350
+ # Only the final chunk sets finish_reason.
351
+ _record_stop_reason(getattr(choice, "finish_reason", None))
352
+ except (AttributeError, IndexError, TypeError):
353
+ pass
354
+ return result
355
+
356
+ _openai_chat.OpenAIChat._parse_provider_response_delta = _patched_openai_delta
357
+ except (ImportError, AttributeError):
358
+ pass
@@ -15,6 +15,7 @@ import sys
15
15
 
16
16
  from rich.markdown import Markdown
17
17
  from rich.panel import Panel
18
+ from rich.text import Text
18
19
 
19
20
  # ── Re-exports for backward compatibility ─────────────────────────────
20
21
  # Tests and external code import these from aru.cli; keep them accessible.
@@ -92,7 +93,7 @@ _logging.getLogger("agno").setLevel(_logging.WARNING)
92
93
 
93
94
  from aru.agents.planner import review_plan
94
95
  from aru.config import load_config, render_command_template, render_skill_template
95
- from aru.permissions import get_skip_permissions
96
+ from aru.permissions import get_skip_permissions, set_permission_mode
96
97
  from aru.providers import (
97
98
  MODEL_ALIASES,
98
99
  list_providers,
@@ -100,6 +101,39 @@ from aru.providers import (
100
101
  )
101
102
 
102
103
 
104
+ def _toggle_yolo_mode(ctx) -> None:
105
+ """Toggle YOLO (dangerously-skip-permissions) mode from the REPL.
106
+
107
+ Turning YOLO *off* is unconditional — safety is not at risk.
108
+ Turning YOLO *on* requires an explicit y/n confirmation with a red warning panel.
109
+ """
110
+ if ctx.permission_mode == "yolo":
111
+ set_permission_mode("default")
112
+ console.print("[bold green]✔ YOLO disabled — safe mode restored.[/bold green]")
113
+ return
114
+
115
+ warning = Text.from_markup(
116
+ "[bold red]⚠ DANGEROUSLY SKIP PERMISSIONS (YOLO)[/bold red]\n\n"
117
+ "[red]All permission prompts will be bypassed for this session, including:[/red]\n"
118
+ " • Reading/writing [bold].env[/bold] files and other sensitive paths\n"
119
+ " • Arbitrary shell commands ([bold]rm -rf[/bold], package installs, network calls)\n"
120
+ " • Edits outside the working directory\n"
121
+ " • All sub-agents delegated during this session\n\n"
122
+ "[dim]Toggle off anytime with /yolo or shift+tab.[/dim]"
123
+ )
124
+ console.print(Panel(
125
+ warning,
126
+ title="[bold red]Enable YOLO mode?[/bold red]",
127
+ border_style="red",
128
+ padding=(1, 2),
129
+ ))
130
+ if ask_yes_no("Confirm enabling YOLO mode"):
131
+ set_permission_mode("yolo")
132
+ console.print("[bold red]🔥 YOLO MODE ACTIVE — all permissions bypassed.[/bold red]")
133
+ else:
134
+ console.print("[dim]Cancelled. Remaining in safe mode.[/dim]")
135
+
136
+
103
137
  # ── Main REPL ──────────────────────────────────────────────────────────
104
138
 
105
139
  async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
@@ -136,6 +170,11 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
136
170
 
137
171
  # Load project configuration
138
172
  config = load_config()
173
+ ctx.config = config
174
+ # Populate invoke_skill's dynamic docstring so the LLM-facing schema lists
175
+ # the skills actually available on this machine.
176
+ from aru.tools.skill import _update_invoke_skill_docstring
177
+ _update_invoke_skill_docstring(config.skills)
139
178
  if config.agents_md:
140
179
  console.print("[dim]Loaded AGENTS.md[/dim]")
141
180
  if config.commands:
@@ -283,7 +322,13 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
283
322
  f' <style fg="ansigray">│</style>'
284
323
  f' <style fg="ansigray">{ctx.mcp_loaded_msg}</style>'
285
324
  )
286
- if ctx.permission_mode == "acceptEdits":
325
+ if ctx.permission_mode == "yolo":
326
+ mode_part = (
327
+ f' <style fg="ansigray">│</style>'
328
+ f' <b><style fg="ansired">🔥 YOLO — permissions bypassed</style></b>'
329
+ f' <style fg="ansigray">(/yolo to toggle)</style>'
330
+ )
331
+ elif ctx.permission_mode == "acceptEdits":
287
332
  mode_part = (
288
333
  f' <style fg="ansigray">│</style>'
289
334
  f' <b><style fg="ansigreen">⏵⏵ auto-accept edits on</style></b>'
@@ -546,6 +591,12 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
546
591
  console.print(f" [bold cyan]{entry.name}[/bold cyan] [dim]{entry.description}[/dim]")
547
592
  continue
548
593
 
594
+ if user_input.lower() == "/plugin" or user_input.lower().startswith("/plugin "):
595
+ from aru.commands import handle_plugin_command
596
+ rest = user_input[len("/plugin"):].strip()
597
+ handle_plugin_command(rest)
598
+ continue
599
+
549
600
  if user_input.lower() == "/help":
550
601
  _show_help(config)
551
602
  continue
@@ -559,6 +610,10 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
559
610
  ))
560
611
  continue
561
612
 
613
+ if user_input.lower() in ("/yolo", "/unsafe"):
614
+ _toggle_yolo_mode(ctx)
615
+ continue
616
+
562
617
  # Begin a new checkpoint turn for undo support
563
618
  _turn_counter += 1
564
619
  ctx.checkpoint_manager.begin_turn(_turn_counter)
@@ -656,7 +711,13 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
656
711
  if not skill.user_invocable:
657
712
  console.print(f"[yellow]Skill '{cmd_name}' is not user-invocable[/yellow]")
658
713
  else:
714
+ session.active_skill = cmd_name
659
715
  prompt = render_skill_template(skill.content, cmd_args)
716
+ # Record so the skill body survives compaction — mirror of
717
+ # claude-code's addInvokedSkill. Store the rendered content
718
+ # (post-argument substitution) so post-compact restoration
719
+ # matches what the model initially read.
720
+ session.record_invoked_skill(cmd_name, prompt, skill.source_path)
660
721
  console.print(f"[bold magenta]Running skill /{cmd_name}...[/bold magenta]")
661
722
 
662
723
  agent = await create_general_agent(session, config, env_context=_build_env_ctx())
@@ -746,6 +807,10 @@ async def run_oneshot(prompt: str, print_only: bool = False, skip_permissions: b
746
807
  ctx = init_ctx(console=console, skip_permissions=skip_permissions)
747
808
 
748
809
  config = load_config()
810
+ ctx.config = config
811
+ # Populate invoke_skill's dynamic docstring (same as interactive path)
812
+ from aru.tools.skill import _update_invoke_skill_docstring
813
+ _update_invoke_skill_docstring(config.skills)
749
814
  session = Session()
750
815
  if config.default_model:
751
816
  session.model_ref = config.default_model
@@ -776,7 +841,7 @@ async def run_oneshot(prompt: str, print_only: bool = False, skip_permissions: b
776
841
 
777
842
  agent = Agent(
778
843
  name="Aru",
779
- model=create_model(session.model_ref, max_tokens=8192),
844
+ model=create_model(session.model_ref), # None → provider cap
780
845
  tools=[],
781
846
  instructions=build_instructions("general", extra_instructions),
782
847
  markdown=True,
@@ -0,0 +1,245 @@
1
+ """Slash command definitions, help display, shell execution, and user prompts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import os
7
+
8
+ from rich.console import Console
9
+ from rich.panel import Panel
10
+ from rich.syntax import Syntax
11
+ from rich.text import Text
12
+
13
+ from aru.display import console
14
+
15
+ SLASH_COMMANDS = [
16
+ ("/help", "Show help and available commands", "/help"),
17
+ ("/plan", "Create an implementation plan", "/plan <task>"),
18
+ ("/model", "Switch model/provider", "/model [provider/model]"),
19
+ ("/sessions", "List recent sessions", "/sessions"),
20
+ ("/commands", "List custom commands", "/commands"),
21
+ ("/skills", "List available skills", "/skills"),
22
+ ("/agents", "List custom agents", "/agents"),
23
+ ("/mcp", "List loaded MCP tools", "/mcp"),
24
+ ("/plugin", "Manage cached plugins (install/list/remove/update)", "/plugin <subcommand>"),
25
+ ("/undo", "Undo last turn — restore files and/or conversation", "/undo"),
26
+ ("/cost", "Show detailed token usage and cost", "/cost"),
27
+ ("/yolo", "Toggle DANGEROUSLY skip all permissions (YOLO mode)", "/yolo"),
28
+ ("/quit", "Exit aru", "/quit"),
29
+ ]
30
+
31
+
32
+ def run_shell(command: str):
33
+ """Run a shell command directly, streaming output to the terminal."""
34
+ console.print()
35
+ console.print(Panel(
36
+ Syntax(command, "bash", theme="monokai"),
37
+ title="[bold]Shell[/bold]",
38
+ border_style="dim",
39
+ expand=False,
40
+ ))
41
+ try:
42
+ process = subprocess.Popen(
43
+ command,
44
+ shell=True,
45
+ stdout=subprocess.PIPE,
46
+ stderr=subprocess.STDOUT,
47
+ text=True,
48
+ cwd=os.getcwd(),
49
+ bufsize=1,
50
+ )
51
+ for line in process.stdout:
52
+ console.print(Text(line.rstrip()))
53
+ process.wait()
54
+ if process.returncode != 0:
55
+ console.print(f"[red]Exit code: {process.returncode}[/red]")
56
+ except KeyboardInterrupt:
57
+ process.kill()
58
+ console.print("\n[yellow]Interrupted.[/yellow]")
59
+ except Exception as e:
60
+ from rich.markup import escape
61
+ console.print(f"[red]Error: {escape(str(e))}[/red]")
62
+ console.print()
63
+
64
+
65
+ def ask_yes_no(prompt: str) -> bool:
66
+ """Ask the user a yes/no question."""
67
+ try:
68
+ answer = console.input(f"[bold yellow]{prompt} (y/n):[/bold yellow] ").strip().lower()
69
+ return answer in ("y", "yes", "s", "sim")
70
+ except (EOFError, KeyboardInterrupt):
71
+ return False
72
+
73
+
74
+ def handle_plugin_command(args: str) -> None:
75
+ """Handle /plugin <subcommand> [args] — install/list/remove/update/info."""
76
+ from rich.table import Table
77
+ from rich.markup import escape
78
+
79
+ parts = args.strip().split(None, 2)
80
+ if not parts:
81
+ _show_plugin_help()
82
+ return
83
+
84
+ subcmd = parts[0].lower()
85
+
86
+ if subcmd == "list":
87
+ from aru.plugin_cache import list_installed
88
+ entries = list_installed()
89
+ if not entries:
90
+ console.print("[dim]No plugins installed. Use /plugin install <spec> to add one.[/dim]")
91
+ return
92
+ table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
93
+ table.add_column("Name", style="cyan")
94
+ table.add_column("Version", style="green")
95
+ table.add_column("Source")
96
+ table.add_column("Spec", style="dim")
97
+ for e in entries:
98
+ table.add_row(e.id, e.version or "-", e.source, e.spec)
99
+ console.print(table)
100
+ return
101
+
102
+ if subcmd == "install":
103
+ if len(parts) < 2:
104
+ console.print("[yellow]Usage: /plugin install <spec> [name][/yellow]")
105
+ return
106
+ spec = parts[1]
107
+ name = parts[2] if len(parts) >= 3 else None
108
+ from aru.plugin_cache import install
109
+ console.print(f"[dim]Installing {escape(spec)}...[/dim]")
110
+ result = install(spec, name=name)
111
+ if not result.ok:
112
+ console.print(f"[red]Install failed: {escape(result.error or 'unknown error')}[/red]")
113
+ return
114
+ provides = result.provides
115
+ provides_str = ", ".join(f"{c} {k}" for k, c in provides.items()) or "no resources"
116
+ console.print(
117
+ f"[green]Installed {escape(result.name or '')}"
118
+ f"{f'@{result.version}' if result.version else ''}[/green] "
119
+ f"([dim]{result.state}[/dim]) -> {escape(str(result.target))}"
120
+ )
121
+ console.print(f"[dim]Provides: {provides_str}[/dim]")
122
+ console.print(
123
+ "[dim]Discovery refreshes on next aru restart. "
124
+ "Skills/agents/tools from the plugin will then be available.[/dim]"
125
+ )
126
+ return
127
+
128
+ if subcmd == "remove":
129
+ if len(parts) < 2:
130
+ console.print("[yellow]Usage: /plugin remove <name>[/yellow]")
131
+ return
132
+ name = parts[1]
133
+ from aru.plugin_cache import remove
134
+ if remove(name):
135
+ console.print(f"[green]Removed plugin: {escape(name)}[/green]")
136
+ else:
137
+ console.print(f"[yellow]Plugin not found: {escape(name)}[/yellow]")
138
+ return
139
+
140
+ if subcmd == "update":
141
+ if len(parts) < 2:
142
+ console.print("[yellow]Usage: /plugin update <name>[/yellow]")
143
+ return
144
+ name = parts[1]
145
+ from aru.plugin_cache import update
146
+ console.print(f"[dim]Updating {escape(name)}...[/dim]")
147
+ result = update(name)
148
+ if not result.ok:
149
+ console.print(f"[red]Update failed: {escape(result.error or 'unknown error')}[/red]")
150
+ return
151
+ console.print(
152
+ f"[green]Updated {escape(result.name or '')}"
153
+ f"{f'@{result.version}' if result.version else ''}[/green] "
154
+ f"([dim]{result.state}[/dim])"
155
+ )
156
+ return
157
+
158
+ if subcmd == "info":
159
+ if len(parts) < 2:
160
+ console.print("[yellow]Usage: /plugin info <name>[/yellow]")
161
+ return
162
+ name = parts[1]
163
+ from aru.plugin_cache import list_installed, read_manifest
164
+ from pathlib import Path
165
+ entries = {e.id: e for e in list_installed()}
166
+ entry = entries.get(name)
167
+ if entry is None:
168
+ console.print(f"[yellow]Plugin not found: {escape(name)}[/yellow]")
169
+ return
170
+ manifest = read_manifest(Path(entry.target))
171
+ console.print(f"[bold cyan]{escape(entry.id)}[/bold cyan]")
172
+ console.print(f" [dim]version:[/dim] {entry.version or '-'}")
173
+ console.print(f" [dim]source:[/dim] {entry.source}")
174
+ console.print(f" [dim]spec:[/dim] {escape(entry.spec)}")
175
+ console.print(f" [dim]target:[/dim] {escape(entry.target)}")
176
+ console.print(f" [dim]fingerprint:[/dim] {entry.fingerprint}")
177
+ console.print(f" [dim]first_time:[/dim] {entry.first_time}")
178
+ console.print(f" [dim]last_time:[/dim] {entry.last_time}")
179
+ if manifest:
180
+ desc = manifest.get("description")
181
+ if desc:
182
+ console.print(f" [dim]description:[/dim] {escape(str(desc))}")
183
+ engines = manifest.get("engines") or {}
184
+ if isinstance(engines, dict) and engines.get("aru"):
185
+ console.print(f" [dim]engines.aru:[/dim] {escape(str(engines['aru']))}")
186
+ return
187
+
188
+ _show_plugin_help()
189
+
190
+
191
+ def _show_plugin_help() -> None:
192
+ """Print /plugin command usage."""
193
+ from rich.table import Table
194
+ table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
195
+ table.add_column("Subcommand", style="cyan")
196
+ table.add_column("Description", style="dim")
197
+ table.add_row("/plugin install <spec> [name]", "Install a plugin from git or local path")
198
+ table.add_row("/plugin list", "List installed plugins")
199
+ table.add_row("/plugin remove <name>", "Uninstall a plugin")
200
+ table.add_row("/plugin update <name>", "Update a plugin (git pull)")
201
+ table.add_row("/plugin info <name>", "Show plugin metadata")
202
+ console.print(table)
203
+ console.print()
204
+ console.print("[dim]Spec formats:[/dim]")
205
+ console.print("[dim] github:user/repo — shorthand for GitHub[/dim]")
206
+ console.print("[dim] github:user/repo@v1.0.0 — pin to tag/branch[/dim]")
207
+ console.print("[dim] git+https://host/path.git — any git URL[/dim]")
208
+ console.print("[dim] file:///abs/path or ./rel — local directory[/dim]")
209
+
210
+
211
+ def _show_help(config) -> None:
212
+ """Display help with available commands."""
213
+ from rich.table import Table
214
+
215
+ table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
216
+ table.add_column("Command", style="cyan")
217
+ table.add_column("Description", style="dim")
218
+
219
+ table.add_row("/plan <task>", "Create detailed implementation plan")
220
+ table.add_row("/model [provider/model]", "Switch models (e.g., ollama/llama3.1, openai/gpt-4o)")
221
+ table.add_row("/sessions", "List recent sessions")
222
+ table.add_row("/commands", "List custom commands")
223
+ table.add_row("/skills", "List available skills")
224
+ table.add_row("/agents", "List custom agents")
225
+ table.add_row("/mcp", "List loaded MCP tools")
226
+ table.add_row("/plugin <subcmd>", "Manage plugins (install/list/remove/update/info)")
227
+ table.add_row("/undo", "Undo last turn (restore files and/or conversation)")
228
+ table.add_row("/help", "Show this help")
229
+ table.add_row("/quit", "Exit aru")
230
+ table.add_row("! <cmd>", "Run shell command")
231
+
232
+ if config and config.commands:
233
+ table.add_row("", "")
234
+ for name, cmd_def in config.commands.items():
235
+ table.add_row(f"/{name}", cmd_def.description)
236
+
237
+ if config and config.custom_agents:
238
+ primary = {k: v for k, v in config.custom_agents.items() if v.mode == "primary"}
239
+ if primary:
240
+ table.add_row("", "")
241
+ for name, agent_def in primary.items():
242
+ table.add_row(f"/{name}", f"[agent] {agent_def.description}")
243
+
244
+ console.print(table)
245
+ console.print()