Skip to content

Commit b622044

Browse files
Kasper Jungeclaude
authored andcommitted
fix: resolve {{ args.* }} placeholders in command run strings
Args placeholders in command `run` fields were never resolved before execution, causing shlex.split to tokenize them into multiple tokens. Now resolve_args() is called on each command's run string before execution, matching the behavior already applied to the prompt body. Closes #20 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7cb286c commit b622044

9 files changed

Lines changed: 43 additions & 13 deletions

File tree

docs/changelog.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ keywords: ralphify changelog, release history, new features, version updates, br
77

88
All notable changes to ralphify are documented here.
99

10+
## 0.2.1 — 2026-03-21
11+
12+
### Fixed
13+
14+
- **`{{ args.* }}` placeholders now resolved in command `run` strings** — previously, arg placeholders were only resolved in the prompt body. Commands like `run: gh issue view {{ args.issue }}` would fail because `shlex.split` tokenized the raw placeholder into multiple arguments. Args are now resolved before command execution.
15+
16+
---
17+
1018
## 0.2.0 — 2026-03-21
1119

1220
The v2 rewrite. Ralphify is now simpler: a ralph is a directory with a `RALPH.md` file. No more `ralph.toml`, no more `.ralphify/` directory, no more `ralph init`. Everything lives in one file.

docs/how-it-works.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ Command output is captured **regardless of exit code** — a command like `pytes
3737

3838
### 3. Resolve placeholders
3939

40-
Each `{{ commands.<name> }}` placeholder in the prompt body is replaced with the corresponding command's output. Placeholders for `{{ args.<name> }}` are replaced with user argument values from the CLI.
40+
Each `{{ commands.<name> }}` placeholder in the prompt body is replaced with the corresponding command's output. Placeholders for `{{ args.<name> }}` are replaced with user argument values from the CLI — both in the prompt body and in command `run` strings.
4141

4242
Unmatched placeholders resolve to an empty string — you won't see raw `{{ }}` text in the assembled prompt.
4343

docs/quick-reference.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ Your instructions here. Use {{ args.dir }} for user arguments.
7272
| Field | Type | Description |
7373
|---|---|---|
7474
| `name` | string | Identifier for `{{ commands.<name> }}` |
75-
| `run` | string | Shell command to execute |
75+
| `run` | string | Shell command to execute (supports `{{ args.<name> }}` placeholders) |
7676

7777
## Placeholders
7878

docs/writing-prompts.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,7 @@ Rules of thumb:
304304

305305
- **Core prompt:** 20-50 lines is the sweet spot. Enough to be specific, short enough to leave room for work.
306306
- **Commands:** Pick the 2-3 most useful signals. Don't add commands whose output the agent doesn't need.
307-
- **User args:** Use `{{ args.name }}` to make ralphs reusable — pass project-specific values from the CLI instead of hardcoding them in the prompt.
307+
- **User args:** Use `{{ args.name }}` to make ralphs reusable — pass project-specific values from the CLI instead of hardcoding them in the prompt. Args also work in command `run` strings (e.g., `run: gh issue view {{ args.issue }}`).
308308
- **Command output:** Can be long. If your commands produce verbose output, consider using scripts that filter to the relevant lines.
309309

310310
## Next steps

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "ralphify"
3-
version = "0.2.0"
3+
version = "0.2.1"
44
description = "Stop stressing over not having an agent running. Ralph is always running"
55
readme = "README.md"
66
license = "MIT"

src/ralphify/engine.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ def _run_commands(
7777
commands: list[Command],
7878
ralph_dir: Path,
7979
project_root: Path,
80+
user_args: dict[str, str],
8081
) -> dict[str, str]:
8182
"""Execute all commands and return a dict of name→output.
8283
@@ -85,7 +86,7 @@ def _run_commands(
8586
"""
8687
results: dict[str, str] = {}
8788
for cmd in commands:
88-
run_str = cmd.run
89+
run_str = resolve_args(cmd.run, user_args)
8990
# Determine working directory: if the command starts with ./ it's
9091
# relative to the ralph directory, otherwise use project root.
9192
if run_str.startswith(_RELATIVE_CMD_PREFIX):
@@ -195,7 +196,7 @@ def _run_iteration(
195196
if config.commands:
196197
emit(EventType.COMMANDS_STARTED, {"iteration": iteration, "count": len(config.commands)})
197198
command_outputs = _run_commands(
198-
config.commands, config.ralph_dir, config.project_root,
199+
config.commands, config.ralph_dir, config.project_root, config.args,
199200
)
200201
emit(EventType.COMMANDS_COMPLETED, {
201202
"iteration": iteration,

src/ralphify/skills/new-ralph/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ A command is a name and something to run. The framework executes it, captures st
9696
- **Other commands run from the project root.** `run: uv run pytest` runs in the working directory where `ralph run` was invoked.
9797
- **Output is always captured** regardless of exit code.
9898
- **No shell features by default.** Commands are parsed with `shlex.split()`. For pipes, redirects, `&&` — use a script.
99+
- **`{{ args.<name> }}` placeholders work in `run` strings.** Example: `run: gh issue view {{ args.issue }}` resolves before execution.
99100

100101
### User arguments
101102

tests/test_engine.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -667,7 +667,7 @@ def test_returns_name_to_output_mapping(self, mock_run_cmd, tmp_path):
667667
mock_run_cmd.return_value = RunResult(success=True, returncode=0, output="test output")
668668
commands = [Command(name="tests", run="pytest")]
669669

670-
result = _run_commands(commands, ralph_dir=tmp_path / "ralph", project_root=tmp_path)
670+
result = _run_commands(commands, ralph_dir=tmp_path / "ralph", project_root=tmp_path, user_args={})
671671

672672
assert result == {"tests": "test output"}
673673

@@ -686,7 +686,7 @@ def per_command(**kwargs):
686686
Command(name="b", run="cmd-b"),
687687
]
688688

689-
result = _run_commands(commands, ralph_dir=tmp_path / "ralph", project_root=tmp_path)
689+
result = _run_commands(commands, ralph_dir=tmp_path / "ralph", project_root=tmp_path, user_args={})
690690

691691
assert len(result) == 2
692692
assert result["a"] == "out-1"
@@ -698,7 +698,7 @@ def test_dotslash_uses_ralph_dir(self, mock_run_cmd, tmp_path):
698698
ralph_dir = tmp_path / "my-ralph"
699699
commands = [Command(name="local", run="./check.sh")]
700700

701-
_run_commands(commands, ralph_dir=ralph_dir, project_root=tmp_path)
701+
_run_commands(commands, ralph_dir=ralph_dir, project_root=tmp_path, user_args={})
702702

703703
assert mock_run_cmd.call_args.kwargs["cwd"] == ralph_dir
704704

@@ -708,7 +708,7 @@ def test_regular_command_uses_project_root(self, mock_run_cmd, tmp_path):
708708
ralph_dir = tmp_path / "my-ralph"
709709
commands = [Command(name="tests", run="pytest")]
710710

711-
_run_commands(commands, ralph_dir=ralph_dir, project_root=tmp_path)
711+
_run_commands(commands, ralph_dir=ralph_dir, project_root=tmp_path, user_args={})
712712

713713
assert mock_run_cmd.call_args.kwargs["cwd"] == tmp_path
714714

@@ -717,15 +717,35 @@ def test_timeout_passed_to_run_command(self, mock_run_cmd, tmp_path):
717717
mock_run_cmd.return_value = RunResult(success=True, returncode=0, output="ok")
718718
commands = [Command(name="slow", run="sleep 1", timeout=300)]
719719

720-
_run_commands(commands, ralph_dir=tmp_path, project_root=tmp_path)
720+
_run_commands(commands, ralph_dir=tmp_path, project_root=tmp_path, user_args={})
721721

722722
assert mock_run_cmd.call_args.kwargs["timeout"] == 300
723723

724724
def test_empty_commands_returns_empty_dict(self, tmp_path):
725-
result = _run_commands([], ralph_dir=tmp_path, project_root=tmp_path)
725+
result = _run_commands([], ralph_dir=tmp_path, project_root=tmp_path, user_args={})
726726

727727
assert result == {}
728728

729+
@patch(MOCK_RUN_COMMAND)
730+
def test_resolves_args_in_command_run_string(self, mock_run_cmd, tmp_path):
731+
mock_run_cmd.return_value = RunResult(success=True, returncode=0, output="issue content")
732+
commands = [Command(name="issue", run="gh issue view {{ args.issue }} --json title")]
733+
734+
_run_commands(commands, ralph_dir=tmp_path, project_root=tmp_path, user_args={"issue": "42"})
735+
736+
assert mock_run_cmd.call_args.kwargs["command"] == "gh issue view 42 --json title"
737+
738+
@patch(MOCK_RUN_COMMAND)
739+
def test_dotslash_detection_after_args_resolution(self, mock_run_cmd, tmp_path):
740+
mock_run_cmd.return_value = RunResult(success=True, returncode=0, output="ok")
741+
ralph_dir = tmp_path / "my-ralph"
742+
commands = [Command(name="check", run="./{{ args.script }}")]
743+
744+
_run_commands(commands, ralph_dir=ralph_dir, project_root=tmp_path, user_args={"script": "check.sh"})
745+
746+
assert mock_run_cmd.call_args.kwargs["cwd"] == ralph_dir
747+
assert mock_run_cmd.call_args.kwargs["command"] == "./check.sh"
748+
729749

730750
class TestAssemblePrompt:
731751
"""Unit tests for _assemble_prompt — reading and resolving the prompt template."""

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)