aru-code 0.8.0__tar.gz → 0.10.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.8.0/aru_code.egg-info → aru_code-0.10.0}/PKG-INFO +27 -2
  2. {aru_code-0.8.0 → aru_code-0.10.0}/README.md +26 -1
  3. aru_code-0.10.0/aru/__init__.py +1 -0
  4. {aru_code-0.8.0 → aru_code-0.10.0}/aru/agent_factory.py +7 -2
  5. {aru_code-0.8.0 → aru_code-0.10.0}/aru/cli.py +12 -1
  6. {aru_code-0.8.0 → aru_code-0.10.0}/aru/config.py +117 -16
  7. {aru_code-0.8.0 → aru_code-0.10.0/aru_code.egg-info}/PKG-INFO +27 -2
  8. {aru_code-0.8.0 → aru_code-0.10.0}/pyproject.toml +1 -1
  9. {aru_code-0.8.0 → aru_code-0.10.0}/tests/test_config.py +231 -28
  10. aru_code-0.8.0/aru/__init__.py +0 -1
  11. {aru_code-0.8.0 → aru_code-0.10.0}/LICENSE +0 -0
  12. {aru_code-0.8.0 → aru_code-0.10.0}/aru/agents/__init__.py +0 -0
  13. {aru_code-0.8.0 → aru_code-0.10.0}/aru/agents/base.py +0 -0
  14. {aru_code-0.8.0 → aru_code-0.10.0}/aru/agents/executor.py +0 -0
  15. {aru_code-0.8.0 → aru_code-0.10.0}/aru/agents/planner.py +0 -0
  16. {aru_code-0.8.0 → aru_code-0.10.0}/aru/commands.py +0 -0
  17. {aru_code-0.8.0 → aru_code-0.10.0}/aru/completers.py +0 -0
  18. {aru_code-0.8.0 → aru_code-0.10.0}/aru/context.py +0 -0
  19. {aru_code-0.8.0 → aru_code-0.10.0}/aru/display.py +0 -0
  20. {aru_code-0.8.0 → aru_code-0.10.0}/aru/permissions.py +0 -0
  21. {aru_code-0.8.0 → aru_code-0.10.0}/aru/providers.py +0 -0
  22. {aru_code-0.8.0 → aru_code-0.10.0}/aru/runner.py +0 -0
  23. {aru_code-0.8.0 → aru_code-0.10.0}/aru/runtime.py +0 -0
  24. {aru_code-0.8.0 → aru_code-0.10.0}/aru/session.py +0 -0
  25. {aru_code-0.8.0 → aru_code-0.10.0}/aru/tools/__init__.py +0 -0
  26. {aru_code-0.8.0 → aru_code-0.10.0}/aru/tools/ast_tools.py +0 -0
  27. {aru_code-0.8.0 → aru_code-0.10.0}/aru/tools/codebase.py +0 -0
  28. {aru_code-0.8.0 → aru_code-0.10.0}/aru/tools/gitignore.py +0 -0
  29. {aru_code-0.8.0 → aru_code-0.10.0}/aru/tools/mcp_client.py +0 -0
  30. {aru_code-0.8.0 → aru_code-0.10.0}/aru/tools/ranker.py +0 -0
  31. {aru_code-0.8.0 → aru_code-0.10.0}/aru/tools/tasklist.py +0 -0
  32. {aru_code-0.8.0 → aru_code-0.10.0}/aru_code.egg-info/SOURCES.txt +0 -0
  33. {aru_code-0.8.0 → aru_code-0.10.0}/aru_code.egg-info/dependency_links.txt +0 -0
  34. {aru_code-0.8.0 → aru_code-0.10.0}/aru_code.egg-info/entry_points.txt +0 -0
  35. {aru_code-0.8.0 → aru_code-0.10.0}/aru_code.egg-info/requires.txt +0 -0
  36. {aru_code-0.8.0 → aru_code-0.10.0}/aru_code.egg-info/top_level.txt +0 -0
  37. {aru_code-0.8.0 → aru_code-0.10.0}/setup.cfg +0 -0
  38. {aru_code-0.8.0 → aru_code-0.10.0}/tests/test_agents_base.py +0 -0
  39. {aru_code-0.8.0 → aru_code-0.10.0}/tests/test_ast_tools.py +0 -0
  40. {aru_code-0.8.0 → aru_code-0.10.0}/tests/test_cli.py +0 -0
  41. {aru_code-0.8.0 → aru_code-0.10.0}/tests/test_cli_advanced.py +0 -0
  42. {aru_code-0.8.0 → aru_code-0.10.0}/tests/test_cli_base.py +0 -0
  43. {aru_code-0.8.0 → aru_code-0.10.0}/tests/test_cli_completers.py +0 -0
  44. {aru_code-0.8.0 → aru_code-0.10.0}/tests/test_cli_new.py +0 -0
  45. {aru_code-0.8.0 → aru_code-0.10.0}/tests/test_cli_run_cli.py +0 -0
  46. {aru_code-0.8.0 → aru_code-0.10.0}/tests/test_cli_session.py +0 -0
  47. {aru_code-0.8.0 → aru_code-0.10.0}/tests/test_cli_shell.py +0 -0
  48. {aru_code-0.8.0 → aru_code-0.10.0}/tests/test_codebase.py +0 -0
  49. {aru_code-0.8.0 → aru_code-0.10.0}/tests/test_context.py +0 -0
  50. {aru_code-0.8.0 → aru_code-0.10.0}/tests/test_executor.py +0 -0
  51. {aru_code-0.8.0 → aru_code-0.10.0}/tests/test_gitignore.py +0 -0
  52. {aru_code-0.8.0 → aru_code-0.10.0}/tests/test_main.py +0 -0
  53. {aru_code-0.8.0 → aru_code-0.10.0}/tests/test_mcp_client.py +0 -0
  54. {aru_code-0.8.0 → aru_code-0.10.0}/tests/test_permissions.py +0 -0
  55. {aru_code-0.8.0 → aru_code-0.10.0}/tests/test_planner.py +0 -0
  56. {aru_code-0.8.0 → aru_code-0.10.0}/tests/test_providers.py +0 -0
  57. {aru_code-0.8.0 → aru_code-0.10.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.8.0
3
+ Version: 0.10.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
@@ -302,6 +302,31 @@ Without any `aru.json` config, aru applies safe defaults:
302
302
 
303
303
  Place an `AGENTS.md` file in your project root with custom instructions that will be appended to all agent system prompts.
304
304
 
305
+ ### Instructions (Rules)
306
+
307
+ You can load additional instructions from local files, glob patterns, or remote URLs via the `instructions` field in `aru.json`:
308
+
309
+ ```json
310
+ {
311
+ "instructions": [
312
+ "CONTRIBUTING.md",
313
+ "docs/coding-standards.md",
314
+ "packages/*/AGENTS.md",
315
+ "https://raw.githubusercontent.com/my-org/shared-rules/main/style.md"
316
+ ]
317
+ }
318
+ ```
319
+
320
+ Each entry is resolved as follows:
321
+
322
+ | Format | Example | Behavior |
323
+ |--------|---------|----------|
324
+ | **Local file** | `"CONTRIBUTING.md"` | Reads the file relative to the project root |
325
+ | **Glob pattern** | `"docs/**/*.md"` | Expands the pattern, respects `.gitignore` |
326
+ | **Remote URL** | `"https://example.com/rules.md"` | Fetches via HTTP (5s timeout, cached per session) |
327
+
328
+ All resolved content is combined and appended to the agent's system prompt alongside `AGENTS.md`. Individual files are capped at 10KB, and the total combined size is capped at 50KB to prevent context bloat. Missing files and failed URL fetches are skipped with a warning.
329
+
305
330
  ### `.agents/` Directory
306
331
 
307
332
  ```
@@ -315,7 +340,7 @@ Place an `AGENTS.md` file in your project root with custom instructions that wil
315
340
  └── SKILL.md
316
341
  ```
317
342
 
318
- Command files support frontmatter with `description` and the `$INPUT` template variable for arguments.
343
+ Command files support frontmatter with `description`, `agent`, and `model` fields, plus OpenCode-style argument placeholders: `$ARGUMENTS` (full string), `$1`/`$2` (positional), and `$ARGUMENTS[N]` (0-indexed).
319
344
 
320
345
  ### Custom Agents
321
346
 
@@ -255,6 +255,31 @@ Without any `aru.json` config, aru applies safe defaults:
255
255
 
256
256
  Place an `AGENTS.md` file in your project root with custom instructions that will be appended to all agent system prompts.
257
257
 
258
+ ### Instructions (Rules)
259
+
260
+ You can load additional instructions from local files, glob patterns, or remote URLs via the `instructions` field in `aru.json`:
261
+
262
+ ```json
263
+ {
264
+ "instructions": [
265
+ "CONTRIBUTING.md",
266
+ "docs/coding-standards.md",
267
+ "packages/*/AGENTS.md",
268
+ "https://raw.githubusercontent.com/my-org/shared-rules/main/style.md"
269
+ ]
270
+ }
271
+ ```
272
+
273
+ Each entry is resolved as follows:
274
+
275
+ | Format | Example | Behavior |
276
+ |--------|---------|----------|
277
+ | **Local file** | `"CONTRIBUTING.md"` | Reads the file relative to the project root |
278
+ | **Glob pattern** | `"docs/**/*.md"` | Expands the pattern, respects `.gitignore` |
279
+ | **Remote URL** | `"https://example.com/rules.md"` | Fetches via HTTP (5s timeout, cached per session) |
280
+
281
+ All resolved content is combined and appended to the agent's system prompt alongside `AGENTS.md`. Individual files are capped at 10KB, and the total combined size is capped at 50KB to prevent context bloat. Missing files and failed URL fetches are skipped with a warning.
282
+
258
283
  ### `.agents/` Directory
259
284
 
260
285
  ```
@@ -268,7 +293,7 @@ Place an `AGENTS.md` file in your project root with custom instructions that wil
268
293
  └── SKILL.md
269
294
  ```
270
295
 
271
- Command files support frontmatter with `description` and the `$INPUT` template variable for arguments.
296
+ Command files support frontmatter with `description`, `agent`, and `model` fields, plus OpenCode-style argument placeholders: `$ARGUMENTS` (full string), `$1`/`$2` (positional), and `$ARGUMENTS[N]` (0-indexed).
272
297
 
273
298
  ### Custom Agents
274
299
 
@@ -0,0 +1 @@
1
+ __version__ = "0.10.0"
@@ -8,7 +8,11 @@ from aru.providers import create_model
8
8
  from aru.session import Session
9
9
 
10
10
 
11
- def create_general_agent(session: Session, config: AgentConfig | None = None):
11
+ def create_general_agent(
12
+ session: Session,
13
+ config: AgentConfig | None = None,
14
+ model_override: str | None = None,
15
+ ):
12
16
  """Create the general-purpose agent."""
13
17
  from agno.agent import Agent
14
18
  from agno.compression.manager import CompressionManager
@@ -17,10 +21,11 @@ def create_general_agent(session: Session, config: AgentConfig | None = None):
17
21
  from aru.runtime import get_ctx
18
22
 
19
23
  extra = config.get_extra_instructions() if config else ""
24
+ model_ref = model_override or session.model_ref
20
25
 
21
26
  return Agent(
22
27
  name="Aru",
23
- model=create_model(session.model_ref, max_tokens=8192),
28
+ model=create_model(model_ref, max_tokens=8192),
24
29
  tools=GENERAL_TOOLS,
25
30
  instructions=_build_instructions("general", extra),
26
31
  markdown=True,
@@ -137,6 +137,8 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
137
137
  console.print(f"[dim]Loaded {len(config.commands)} custom command(s): {', '.join(f'/{k}' for k in config.commands)}[/dim]")
138
138
  if config.skills:
139
139
  console.print(f"[dim]Loaded {len(config.skills)} skill(s): {', '.join(config.skills.keys())}[/dim]")
140
+ if config.rules_instructions:
141
+ console.print("[dim]Loaded custom instructions from aru.json[/dim]")
140
142
  if config.custom_agents:
141
143
  primary = [k for k, v in config.custom_agents.items() if v.mode == "primary"]
142
144
  subagents = [k for k, v in config.custom_agents.items() if v.mode == "subagent"]
@@ -428,7 +430,16 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
428
430
  prompt = render_command_template(cmd_def.template, cmd_args)
429
431
  console.print(f"[bold magenta]Running /{cmd_name}...[/bold magenta]")
430
432
 
431
- agent = create_general_agent(session, config)
433
+ if cmd_def.agent and cmd_def.agent in config.custom_agents:
434
+ agent_def = config.custom_agents[cmd_def.agent]
435
+ agent = create_custom_agent_instance(agent_def, session, config)
436
+ elif cmd_def.agent:
437
+ console.print(f"[yellow]Warning: agent '{cmd_def.agent}' not found, using default[/yellow]")
438
+ agent = create_general_agent(session, config, model_override=cmd_def.model)
439
+ elif cmd_def.model:
440
+ agent = create_general_agent(session, config, model_override=cmd_def.model)
441
+ else:
442
+ agent = create_general_agent(session, config)
432
443
  session.add_message("user", user_input)
433
444
  run_result = await run_agent_capture(agent, prompt, session)
434
445
  if run_result.content:
@@ -10,12 +10,15 @@ Supports:
10
10
  from __future__ import annotations
11
11
 
12
12
  import json
13
+ import logging
13
14
  import os
14
15
  import re
15
16
  from dataclasses import dataclass, field
16
17
  from pathlib import Path
17
18
  from typing import Any
18
19
 
20
+ logger = logging.getLogger("aru.config")
21
+
19
22
 
20
23
  @dataclass
21
24
  class CustomCommand:
@@ -24,6 +27,8 @@ class CustomCommand:
24
27
  description: str
25
28
  template: str
26
29
  source_path: str
30
+ agent: str | None = None
31
+ model: str | None = None
27
32
 
28
33
 
29
34
  @dataclass
@@ -54,6 +59,92 @@ class CustomAgent:
54
59
 
55
60
 
56
61
  MAX_README_CHARS = 2000 # Reduced from 8000 to save ~1.7K tokens per request
62
+ MAX_RULE_FILE_SIZE = 10_000 # 10KB per rule file
63
+ URL_FETCH_TIMEOUT = 5 # seconds
64
+ MAX_TOTAL_RULES_SIZE = 50_000 # 50KB combined cap
65
+
66
+ # Module-level URL cache (session-scoped, persists for process lifetime)
67
+ _url_cache: dict[str, str | None] = {}
68
+
69
+
70
+ def _resolve_instructions(entries: list[str], root: Path) -> str:
71
+ """Resolve instruction entries (local files, glob patterns, URLs) into combined text.
72
+
73
+ Each entry is classified as:
74
+ - URL: starts with http:// or https://
75
+ - Glob: contains *, ?, or [
76
+ - File path: everything else (resolved relative to root)
77
+ """
78
+ from aru.tools.gitignore import is_ignored
79
+
80
+ parts: list[str] = []
81
+ total_size = 0
82
+
83
+ def _add_content(source: str, content: str) -> None:
84
+ nonlocal total_size
85
+ if not content.strip():
86
+ return
87
+ truncated = content[:MAX_RULE_FILE_SIZE]
88
+ if total_size + len(truncated) > MAX_TOTAL_RULES_SIZE:
89
+ remaining = MAX_TOTAL_RULES_SIZE - total_size
90
+ if remaining <= 0:
91
+ logger.warning("Total rules size cap reached, skipping: %s", source)
92
+ return
93
+ truncated = truncated[:remaining]
94
+ logger.warning("Total rules size cap reached, truncating: %s", source)
95
+ parts.append(f"## Rules: {source}\n\n{truncated}")
96
+ total_size += len(truncated)
97
+
98
+ def _read_file(filepath: Path, source_label: str) -> None:
99
+ try:
100
+ content = filepath.read_text(encoding="utf-8")
101
+ _add_content(source_label, content)
102
+ except (OSError, UnicodeDecodeError) as exc:
103
+ logger.warning("Failed to read instruction file %s: %s", filepath, exc)
104
+
105
+ for entry in entries:
106
+ if entry.startswith("http://") or entry.startswith("https://"):
107
+ # Remote URL
108
+ if entry in _url_cache:
109
+ cached = _url_cache[entry]
110
+ if cached is not None:
111
+ _add_content(entry, cached)
112
+ continue
113
+ try:
114
+ import httpx
115
+ with httpx.Client(timeout=URL_FETCH_TIMEOUT, follow_redirects=True) as client:
116
+ resp = client.get(entry)
117
+ resp.raise_for_status()
118
+ text = resp.text
119
+ _url_cache[entry] = text
120
+ _add_content(entry, text)
121
+ except Exception as exc:
122
+ _url_cache[entry] = None
123
+ logger.warning("Failed to fetch instruction URL %s: %s", entry, exc)
124
+
125
+ elif any(c in entry for c in ("*", "?", "[")):
126
+ # Glob pattern
127
+ matched = sorted(root.glob(entry))
128
+ for filepath in matched:
129
+ if not filepath.is_file():
130
+ continue
131
+ try:
132
+ rel = filepath.relative_to(root)
133
+ except ValueError:
134
+ continue
135
+ if is_ignored(str(rel), str(root)):
136
+ continue
137
+ _read_file(filepath, str(rel))
138
+
139
+ else:
140
+ # Local file path
141
+ filepath = root / entry
142
+ if filepath.is_file():
143
+ _read_file(filepath, entry)
144
+ else:
145
+ logger.warning("Instruction file not found: %s", filepath)
146
+
147
+ return "\n\n".join(parts)
57
148
 
58
149
 
59
150
  @dataclass
@@ -61,6 +152,7 @@ class AgentConfig:
61
152
  """Loaded configuration from AGENTS.md, README.md, and .agents/ directory."""
62
153
  readme_md: str = ""
63
154
  agents_md: str = ""
155
+ rules_instructions: str = ""
64
156
  commands: dict[str, CustomCommand] = field(default_factory=dict)
65
157
  skills: dict[str, Skill] = field(default_factory=dict)
66
158
  permissions: dict[str, Any] = field(default_factory=dict)
@@ -71,7 +163,7 @@ class AgentConfig:
71
163
 
72
164
  @property
73
165
  def has_instructions(self) -> bool:
74
- return bool(self.agents_md) or bool(self.skills)
166
+ return bool(self.agents_md) or bool(self.skills) or bool(self.rules_instructions)
75
167
 
76
168
  def get_extra_instructions(self, active_skills: list[str] | None = None, lightweight: bool = False) -> str:
77
169
  """Build extra instructions from README.md, AGENTS.md, and active skills.
@@ -85,6 +177,8 @@ class AgentConfig:
85
177
  parts.append(f"## Project Overview (README.md)\n\n{self.readme_md}")
86
178
  if self.agents_md:
87
179
  parts.append(f"## Project Instructions (AGENTS.md)\n\n{self.agents_md}")
180
+ if self.rules_instructions:
181
+ parts.append(self.rules_instructions)
88
182
  if active_skills:
89
183
  for name in active_skills:
90
184
  if name in self.skills:
@@ -233,6 +327,8 @@ def _load_commands(agents_dir: Path) -> dict[str, CustomCommand]:
233
327
  description=description,
234
328
  template=body,
235
329
  source_path=str(filepath),
330
+ agent=metadata.get("agent") or None,
331
+ model=metadata.get("model") or None,
236
332
  )
237
333
 
238
334
  return commands
@@ -409,6 +505,10 @@ def load_config(cwd: str | None = None) -> AgentConfig:
409
505
  config.model_aliases = data["model_aliases"]
410
506
  if "plan_reviewer" in data:
411
507
  config.plan_reviewer = bool(data["plan_reviewer"])
508
+ # Resolve instructions (local files, globs, URLs)
509
+ if "instructions" in data and isinstance(data["instructions"], list):
510
+ entries = [str(e) for e in data["instructions"] if isinstance(e, str)]
511
+ config.rules_instructions = _resolve_instructions(entries, root)
412
512
  # Agent-level permission overrides from aru.json
413
513
  if "agent" in data and isinstance(data["agent"], dict):
414
514
  for agent_name, agent_data in data["agent"].items():
@@ -422,26 +522,17 @@ def load_config(cwd: str | None = None) -> AgentConfig:
422
522
  return config
423
523
 
424
524
 
425
- def render_command_template(template: str, user_input: str) -> str:
426
- """Render a command template with user input.
427
-
428
- Replaces $INPUT with the user's arguments.
429
- Also supports $SELECTION (empty if not provided) for future use.
430
- """
431
- result = template.replace("$INPUT", user_input)
432
- result = result.replace("$SELECTION", "")
433
- return result
434
-
435
-
436
- def render_skill_template(content: str, arguments: str) -> str:
437
- """Render a skill template with argument substitution (agentskills.io).
525
+ def render_template_arguments(
526
+ content: str, arguments: str, *, context_label: str = "Argument",
527
+ ) -> str:
528
+ """Render a template with $ARGUMENTS / $1 / $2 substitution.
438
529
 
439
530
  Supports:
440
531
  - $ARGUMENTS: Full argument string
441
532
  - $ARGUMENTS[N]: Nth argument (0-indexed)
442
533
  - $1, $2, ...: Nth argument (1-indexed, shell-style)
443
534
 
444
- Also prepends an explicit argument context block so the agent cannot
535
+ Also prepends an explicit context block so the agent cannot
445
536
  miss or misread the user-supplied value.
446
537
  """
447
538
  parts = arguments.split() if arguments else []
@@ -465,7 +556,17 @@ def render_skill_template(content: str, arguments: str) -> str:
465
556
 
466
557
  # Prepend an explicit context block so the agent cannot miss the argument
467
558
  if arguments and arguments.strip():
468
- header = f"> **Skill argument:** `{arguments.strip()}`\n> Use this value exactly where the skill instructions reference the argument.\n\n"
559
+ header = f"> **{context_label}:** `{arguments.strip()}`\n> Use this value exactly where the instructions reference the argument.\n\n"
469
560
  result = header + result
470
561
 
471
562
  return result
563
+
564
+
565
+ def render_command_template(template: str, user_input: str) -> str:
566
+ """Render a command template with OpenCode-style argument substitution."""
567
+ return render_template_arguments(template, user_input, context_label="Command argument")
568
+
569
+
570
+ def render_skill_template(content: str, arguments: str) -> str:
571
+ """Render a skill template with argument substitution (agentskills.io)."""
572
+ return render_template_arguments(content, arguments, context_label="Skill argument")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aru-code
3
- Version: 0.8.0
3
+ Version: 0.10.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
@@ -302,6 +302,31 @@ Without any `aru.json` config, aru applies safe defaults:
302
302
 
303
303
  Place an `AGENTS.md` file in your project root with custom instructions that will be appended to all agent system prompts.
304
304
 
305
+ ### Instructions (Rules)
306
+
307
+ You can load additional instructions from local files, glob patterns, or remote URLs via the `instructions` field in `aru.json`:
308
+
309
+ ```json
310
+ {
311
+ "instructions": [
312
+ "CONTRIBUTING.md",
313
+ "docs/coding-standards.md",
314
+ "packages/*/AGENTS.md",
315
+ "https://raw.githubusercontent.com/my-org/shared-rules/main/style.md"
316
+ ]
317
+ }
318
+ ```
319
+
320
+ Each entry is resolved as follows:
321
+
322
+ | Format | Example | Behavior |
323
+ |--------|---------|----------|
324
+ | **Local file** | `"CONTRIBUTING.md"` | Reads the file relative to the project root |
325
+ | **Glob pattern** | `"docs/**/*.md"` | Expands the pattern, respects `.gitignore` |
326
+ | **Remote URL** | `"https://example.com/rules.md"` | Fetches via HTTP (5s timeout, cached per session) |
327
+
328
+ All resolved content is combined and appended to the agent's system prompt alongside `AGENTS.md`. Individual files are capped at 10KB, and the total combined size is capped at 50KB to prevent context bloat. Missing files and failed URL fetches are skipped with a warning.
329
+
305
330
  ### `.agents/` Directory
306
331
 
307
332
  ```
@@ -315,7 +340,7 @@ Place an `AGENTS.md` file in your project root with custom instructions that wil
315
340
  └── SKILL.md
316
341
  ```
317
342
 
318
- Command files support frontmatter with `description` and the `$INPUT` template variable for arguments.
343
+ Command files support frontmatter with `description`, `agent`, and `model` fields, plus OpenCode-style argument placeholders: `$ARGUMENTS` (full string), `$1`/`$2` (positional), and `$ARGUMENTS[N]` (0-indexed).
319
344
 
320
345
  ### Custom Agents
321
346
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aru-code"
7
- version = "0.8.0"
7
+ version = "0.10.0"
8
8
  description = "A Claude Code clone built with Agno agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,18 +1,25 @@
1
1
  """Unit tests for aru.config module."""
2
2
 
3
+ import json
3
4
  import pytest
4
5
  from pathlib import Path
6
+ from unittest.mock import MagicMock, patch
5
7
  from aru.config import (
6
8
  AgentConfig,
7
9
  CustomAgent,
8
10
  CustomCommand,
11
+ MAX_RULE_FILE_SIZE,
12
+ MAX_TOTAL_RULES_SIZE,
9
13
  Skill,
10
14
  _discover_agents,
11
15
  _parse_agent_metadata,
12
16
  _parse_frontmatter,
13
17
  _parse_skill_metadata,
18
+ _resolve_instructions,
19
+ _url_cache,
14
20
  render_command_template,
15
21
  render_skill_template,
22
+ render_template_arguments,
16
23
  load_config,
17
24
  MAX_README_CHARS,
18
25
  )
@@ -25,13 +32,27 @@ class TestDataClasses:
25
32
  cmd = CustomCommand(
26
33
  name="test",
27
34
  description="Test command",
28
- template="Do $INPUT",
35
+ template="Do $ARGUMENTS",
29
36
  source_path="/path/to/test.md",
30
37
  )
31
38
  assert cmd.name == "test"
32
39
  assert cmd.description == "Test command"
33
- assert cmd.template == "Do $INPUT"
40
+ assert cmd.template == "Do $ARGUMENTS"
34
41
  assert cmd.source_path == "/path/to/test.md"
42
+ assert cmd.agent is None
43
+ assert cmd.model is None
44
+
45
+ def test_custom_command_with_agent_and_model(self):
46
+ cmd = CustomCommand(
47
+ name="review",
48
+ description="Review code",
49
+ template="Review $ARGUMENTS",
50
+ source_path="/path/to/review.md",
51
+ agent="reviewer",
52
+ model="anthropic/claude-sonnet-4-5",
53
+ )
54
+ assert cmd.agent == "reviewer"
55
+ assert cmd.model == "anthropic/claude-sonnet-4-5"
35
56
 
36
57
  def test_skill_creation(self):
37
58
  skill = Skill(
@@ -192,37 +213,57 @@ class TestParseFrontmatter:
192
213
 
193
214
 
194
215
  class TestRenderCommandTemplate:
195
- """Test command template rendering."""
216
+ """Test command template rendering with OpenCode-style arguments."""
196
217
 
197
- def test_render_with_input(self):
198
- template = "Execute $INPUT in the codebase"
199
- result = render_command_template(template, "refactoring")
200
- assert result == "Execute refactoring in the codebase"
218
+ def test_render_with_arguments(self):
219
+ result = render_command_template("Execute $ARGUMENTS in the codebase", "refactoring")
220
+ assert "Execute refactoring in the codebase" in result
201
221
 
202
- def test_render_multiple_input_placeholders(self):
203
- template = "Run $INPUT and verify $INPUT works"
204
- result = render_command_template(template, "tests")
205
- assert result == "Run tests and verify tests works"
222
+ def test_render_positional_args(self):
223
+ result = render_command_template("File: $1, Line: $2", "main.py 42")
224
+ assert "File: main.py, Line: 42" in result
206
225
 
207
- def test_render_removes_selection(self):
208
- template = "Process $INPUT and $SELECTION"
209
- result = render_command_template(template, "data")
210
- assert result == "Process data and "
226
+ def test_render_indexed_args(self):
227
+ result = render_command_template("$ARGUMENTS[0] and $ARGUMENTS[1]", "foo bar")
228
+ assert "foo and bar" in result
211
229
 
212
230
  def test_render_no_placeholders(self):
213
231
  template = "Just plain text"
214
232
  result = render_command_template(template, "input")
215
- assert result == "Just plain text"
233
+ # Header is prepended but template body is unchanged
234
+ assert "Just plain text" in result
216
235
 
217
236
  def test_render_empty_input(self):
218
- template = "Do $INPUT now"
219
- result = render_command_template(template, "")
237
+ result = render_command_template("Do $ARGUMENTS now", "")
220
238
  assert result == "Do now"
221
239
 
222
240
  def test_render_with_special_characters(self):
223
- template = "Run $INPUT"
224
- result = render_command_template(template, "pytest -v --cov")
225
- assert result == "Run pytest -v --cov"
241
+ result = render_command_template("Run $ARGUMENTS", "pytest -v --cov")
242
+ assert "Run pytest -v --cov" in result
243
+
244
+ def test_render_missing_positional(self):
245
+ result = render_command_template("$1 and $3", "first second")
246
+ assert "first and " in result
247
+
248
+ def test_command_context_header(self):
249
+ result = render_command_template("Do $ARGUMENTS", "something")
250
+ assert result.startswith("> **Command argument:**")
251
+
252
+ def test_no_header_when_empty_args(self):
253
+ result = render_command_template("Do $ARGUMENTS", "")
254
+ assert not result.startswith(">")
255
+
256
+
257
+ class TestRenderTemplateArguments:
258
+ """Test the shared render_template_arguments function."""
259
+
260
+ def test_custom_context_label(self):
261
+ result = render_template_arguments("Do $ARGUMENTS", "test", context_label="Custom label")
262
+ assert "> **Custom label:** `test`" in result
263
+
264
+ def test_delegates_correctly(self):
265
+ result = render_template_arguments("$1 + $2 = $ARGUMENTS", "a b")
266
+ assert "a + b = a b" in result
226
267
 
227
268
 
228
269
  class TestLoadConfig:
@@ -268,16 +309,32 @@ class TestLoadConfig:
268
309
  def test_load_config_with_commands(self, tmp_path):
269
310
  commands_dir = tmp_path / ".agents" / "commands"
270
311
  commands_dir.mkdir(parents=True)
271
-
312
+
272
313
  cmd_file = commands_dir / "deploy.md"
273
- cmd_file.write_text("---\ndescription: Deploy the app\n---\nDeploy $INPUT")
274
-
314
+ cmd_file.write_text("---\ndescription: Deploy the app\n---\nDeploy $ARGUMENTS")
315
+
275
316
  config = load_config(str(tmp_path))
276
317
  assert "deploy" in config.commands
277
318
  cmd = config.commands["deploy"]
278
319
  assert cmd.name == "deploy"
279
320
  assert cmd.description == "Deploy the app"
280
- assert cmd.template == "Deploy $INPUT"
321
+ assert cmd.template == "Deploy $ARGUMENTS"
322
+ assert cmd.agent is None
323
+ assert cmd.model is None
324
+
325
+ def test_load_config_command_with_agent_and_model(self, tmp_path):
326
+ commands_dir = tmp_path / ".agents" / "commands"
327
+ commands_dir.mkdir(parents=True)
328
+
329
+ cmd_file = commands_dir / "review.md"
330
+ cmd_file.write_text(
331
+ "---\ndescription: Review code\nagent: reviewer\nmodel: anthropic/claude-sonnet-4-5\n---\nReview $ARGUMENTS"
332
+ )
333
+
334
+ config = load_config(str(tmp_path))
335
+ cmd = config.commands["review"]
336
+ assert cmd.agent == "reviewer"
337
+ assert cmd.model == "anthropic/claude-sonnet-4-5"
281
338
 
282
339
  def test_load_config_command_without_frontmatter(self, tmp_path):
283
340
  commands_dir = tmp_path / ".agents" / "commands"
@@ -403,7 +460,7 @@ class TestLoadConfig:
403
460
 
404
461
  commands_dir = tmp_path / ".agents" / "commands"
405
462
  commands_dir.mkdir(parents=True)
406
- (commands_dir / "test.md").write_text("---\ndescription: Test\n---\nRun $INPUT")
463
+ (commands_dir / "test.md").write_text("---\ndescription: Test\n---\nRun $ARGUMENTS")
407
464
 
408
465
  skill_dir = tmp_path / ".agents" / "skills" / "review"
409
466
  skill_dir.mkdir(parents=True)
@@ -523,7 +580,7 @@ class TestParseSkillMetadata:
523
580
  class TestRenderSkillTemplate:
524
581
  """Test render_skill_template with argument substitution."""
525
582
 
526
- _HEADER = "> **Skill argument:** `{arg}`\n> Use this value exactly where the skill instructions reference the argument.\n\n"
583
+ _HEADER = "> **Skill argument:** `{arg}`\n> Use this value exactly where the instructions reference the argument.\n\n"
527
584
 
528
585
  def test_arguments_substitution(self):
529
586
  result = render_skill_template("Review $ARGUMENTS carefully", "src/main.py")
@@ -801,4 +858,150 @@ class TestAgentPermissions:
801
858
  }))
802
859
  config = load_config(str(tmp_path))
803
860
  # aru.json override should win
804
- assert config.custom_agents["worker"].permission == {"edit": "deny"}
861
+ assert config.custom_agents["worker"].permission == {"edit": "deny"}
862
+
863
+
864
+ class TestInstructionsLoading:
865
+ """Test the instructions field in aru.json (rules system)."""
866
+
867
+ def setup_method(self):
868
+ """Clear URL cache before each test."""
869
+ _url_cache.clear()
870
+
871
+ def test_instructions_local_file(self, tmp_path):
872
+ (tmp_path / "CONTRIBUTING.md").write_text("Follow these contributing guidelines.")
873
+ (tmp_path / "aru.json").write_text(json.dumps({
874
+ "instructions": ["CONTRIBUTING.md"]
875
+ }))
876
+ config = load_config(str(tmp_path))
877
+ assert "Follow these contributing guidelines." in config.rules_instructions
878
+ assert "## Rules: CONTRIBUTING.md" in config.rules_instructions
879
+
880
+ def test_instructions_glob_pattern(self, tmp_path):
881
+ docs = tmp_path / "docs"
882
+ docs.mkdir()
883
+ (docs / "style.md").write_text("Style guide content")
884
+ (docs / "api.md").write_text("API guidelines")
885
+ (tmp_path / "aru.json").write_text(json.dumps({
886
+ "instructions": ["docs/*.md"]
887
+ }))
888
+ config = load_config(str(tmp_path))
889
+ assert "Style guide content" in config.rules_instructions
890
+ assert "API guidelines" in config.rules_instructions
891
+
892
+ def test_instructions_missing_file(self, tmp_path):
893
+ (tmp_path / "aru.json").write_text(json.dumps({
894
+ "instructions": ["nonexistent.md"]
895
+ }))
896
+ config = load_config(str(tmp_path))
897
+ assert config.rules_instructions == ""
898
+
899
+ def test_instructions_file_size_cap(self, tmp_path):
900
+ large_content = "x" * (MAX_RULE_FILE_SIZE + 5000)
901
+ (tmp_path / "large.md").write_text(large_content)
902
+ (tmp_path / "aru.json").write_text(json.dumps({
903
+ "instructions": ["large.md"]
904
+ }))
905
+ config = load_config(str(tmp_path))
906
+ # Header + content, content should be truncated
907
+ assert len(config.rules_instructions) < MAX_RULE_FILE_SIZE + 200 # header overhead
908
+
909
+ def test_instructions_url(self, tmp_path):
910
+ mock_response = MagicMock()
911
+ mock_response.text = "Remote rule content"
912
+ mock_response.raise_for_status = MagicMock()
913
+
914
+ mock_client = MagicMock()
915
+ mock_client.__enter__ = MagicMock(return_value=mock_client)
916
+ mock_client.__exit__ = MagicMock(return_value=False)
917
+ mock_client.get.return_value = mock_response
918
+
919
+ with patch("httpx.Client", return_value=mock_client):
920
+ (tmp_path / "aru.json").write_text(json.dumps({
921
+ "instructions": ["https://example.com/rules.md"]
922
+ }))
923
+ config = load_config(str(tmp_path))
924
+ assert "Remote rule content" in config.rules_instructions
925
+
926
+ def test_instructions_url_timeout(self, tmp_path):
927
+ import httpx
928
+
929
+ mock_client = MagicMock()
930
+ mock_client.__enter__ = MagicMock(return_value=mock_client)
931
+ mock_client.__exit__ = MagicMock(return_value=False)
932
+ mock_client.get.side_effect = httpx.ConnectTimeout("timeout")
933
+
934
+ with patch("httpx.Client", return_value=mock_client):
935
+ (tmp_path / "aru.json").write_text(json.dumps({
936
+ "instructions": ["https://example.com/timeout.md"]
937
+ }))
938
+ config = load_config(str(tmp_path))
939
+ assert config.rules_instructions == ""
940
+
941
+ def test_instructions_combined_in_extra(self, tmp_path):
942
+ (tmp_path / "rules.md").write_text("Custom rule")
943
+ (tmp_path / "AGENTS.md").write_text("Agent instructions")
944
+ (tmp_path / "aru.json").write_text(json.dumps({
945
+ "instructions": ["rules.md"]
946
+ }))
947
+ config = load_config(str(tmp_path))
948
+ extra = config.get_extra_instructions()
949
+ # Both AGENTS.md and rules should be present
950
+ assert "Agent instructions" in extra
951
+ assert "Custom rule" in extra
952
+ # Rules should come after AGENTS.md
953
+ agents_pos = extra.find("Agent instructions")
954
+ rules_pos = extra.find("Custom rule")
955
+ assert agents_pos < rules_pos
956
+
957
+ def test_instructions_total_size_cap(self, tmp_path):
958
+ # Create files that together exceed MAX_TOTAL_RULES_SIZE
959
+ docs = tmp_path / "docs"
960
+ docs.mkdir()
961
+ chunk = "y" * (MAX_RULE_FILE_SIZE - 100)
962
+ num_files = (MAX_TOTAL_RULES_SIZE // MAX_RULE_FILE_SIZE) + 3
963
+ for i in range(num_files):
964
+ (docs / f"rule{i:02d}.md").write_text(chunk)
965
+ (tmp_path / "aru.json").write_text(json.dumps({
966
+ "instructions": ["docs/*.md"]
967
+ }))
968
+ config = load_config(str(tmp_path))
969
+ assert len(config.rules_instructions) <= MAX_TOTAL_RULES_SIZE + 5000 # headers overhead
970
+
971
+ def test_instructions_empty_list(self, tmp_path):
972
+ (tmp_path / "aru.json").write_text(json.dumps({
973
+ "instructions": []
974
+ }))
975
+ config = load_config(str(tmp_path))
976
+ assert config.rules_instructions == ""
977
+
978
+ def test_instructions_has_instructions_property(self):
979
+ config = AgentConfig(rules_instructions="some rules")
980
+ assert config.has_instructions is True
981
+
982
+ def test_resolve_instructions_directly(self, tmp_path):
983
+ (tmp_path / "a.md").write_text("Content A")
984
+ (tmp_path / "b.md").write_text("Content B")
985
+ result = _resolve_instructions(["a.md", "b.md"], tmp_path)
986
+ assert "Content A" in result
987
+ assert "Content B" in result
988
+
989
+ def test_instructions_url_caching(self, tmp_path):
990
+ """Second call should use cache, not fetch again."""
991
+ mock_response = MagicMock()
992
+ mock_response.text = "Cached content"
993
+ mock_response.raise_for_status = MagicMock()
994
+
995
+ mock_client = MagicMock()
996
+ mock_client.__enter__ = MagicMock(return_value=mock_client)
997
+ mock_client.__exit__ = MagicMock(return_value=False)
998
+ mock_client.get.return_value = mock_response
999
+
1000
+ url = "https://example.com/cached.md"
1001
+ with patch("httpx.Client", return_value=mock_client):
1002
+ result1 = _resolve_instructions([url], tmp_path)
1003
+ result2 = _resolve_instructions([url], tmp_path)
1004
+ assert "Cached content" in result1
1005
+ assert "Cached content" in result2
1006
+ # httpx.Client should only be called once (second uses cache)
1007
+ assert mock_client.get.call_count == 1
@@ -1 +0,0 @@
1
- __version__ = "0.8.0"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes