ralph-any 0.1.1__tar.gz → 0.2.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.
- {ralph_any-0.1.1 → ralph_any-0.2.0}/PKG-INFO +1 -1
- {ralph_any-0.1.1 → ralph_any-0.2.0}/README.md +50 -5
- {ralph_any-0.1.1 → ralph_any-0.2.0}/pyproject.toml +1 -1
- {ralph_any-0.1.1 → ralph_any-0.2.0}/src/ralph/__init__.py +3 -1
- {ralph_any-0.1.1 → ralph_any-0.2.0}/src/ralph/cli.py +68 -19
- ralph_any-0.2.0/src/ralph/config.py +55 -0
- ralph_any-0.2.0/tests/test_cli.py +48 -0
- ralph_any-0.2.0/tests/test_config.py +51 -0
- {ralph_any-0.1.1 → ralph_any-0.2.0}/.github/workflows/ci.yml +0 -0
- {ralph_any-0.1.1 → ralph_any-0.2.0}/.github/workflows/publish.yml +0 -0
- {ralph_any-0.1.1 → ralph_any-0.2.0}/.gitignore +0 -0
- {ralph_any-0.1.1 → ralph_any-0.2.0}/LICENSE +0 -0
- {ralph_any-0.1.1 → ralph_any-0.2.0}/src/ralph/__main__.py +0 -0
- {ralph_any-0.1.1 → ralph_any-0.2.0}/src/ralph/detect.py +0 -0
- {ralph_any-0.1.1 → ralph_any-0.2.0}/src/ralph/engine.py +0 -0
- {ralph_any-0.1.1 → ralph_any-0.2.0}/src/ralph/prompt.py +0 -0
- {ralph_any-0.1.1 → ralph_any-0.2.0}/tests/test_detect.py +0 -0
- {ralph_any-0.1.1 → ralph_any-0.2.0}/tests/test_engine.py +0 -0
|
@@ -23,21 +23,65 @@ uv tool install ralph-any
|
|
|
23
23
|
ralph "Refactor utils.py to use dataclasses"
|
|
24
24
|
|
|
25
25
|
# Gemini CLI
|
|
26
|
-
ralph "Fix the failing tests" --command gemini --command-args
|
|
26
|
+
ralph "Fix the failing tests" --command gemini --command-args="--experimental-acp"
|
|
27
27
|
|
|
28
28
|
# Read task from a file
|
|
29
29
|
ralph task.md -m 20
|
|
30
|
+
|
|
31
|
+
# Auto-detect: just place a ralph.md in your project and run
|
|
32
|
+
ralph
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Config File (`ralph.yml`)
|
|
36
|
+
|
|
37
|
+
Place a `ralph.yml` (or `ralph.yaml`) in your project root to avoid repeating CLI args:
|
|
38
|
+
|
|
39
|
+
```yaml
|
|
40
|
+
# ralph.yml
|
|
41
|
+
command: gemini
|
|
42
|
+
command_args: --experimental-acp
|
|
43
|
+
max_iterations: 20
|
|
44
|
+
timeout: 3600
|
|
45
|
+
promise: Done!
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Then just run:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
ralph "your task"
|
|
52
|
+
|
|
53
|
+
# CLI args override the config file
|
|
54
|
+
ralph "your task" -m 5
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**Priority**: CLI args > `ralph.yml` > defaults
|
|
58
|
+
|
|
59
|
+
## Auto-Detect Prompt
|
|
60
|
+
|
|
61
|
+
If no prompt argument is given, Ralph looks for these files in the working directory (in order):
|
|
62
|
+
|
|
63
|
+
1. `ralph.md`
|
|
64
|
+
2. `TASK.md`
|
|
65
|
+
3. `ralph.txt`
|
|
66
|
+
4. `TASK.txt`
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
# Create a prompt file in your project
|
|
70
|
+
echo "Refactor all utils to use dataclasses" > ralph.md
|
|
71
|
+
|
|
72
|
+
# Run with zero arguments
|
|
73
|
+
ralph
|
|
30
74
|
```
|
|
31
75
|
|
|
32
76
|
## CLI Usage
|
|
33
77
|
|
|
34
78
|
```
|
|
35
|
-
ralph
|
|
79
|
+
ralph [prompt] [options]
|
|
36
80
|
```
|
|
37
81
|
|
|
38
82
|
| Option | Short | Default | Description |
|
|
39
83
|
|--------|-------|---------|-------------|
|
|
40
|
-
| `prompt` | | *(
|
|
84
|
+
| `prompt` | | *(auto-detect)* | Task description, path to `.md`/`.txt` file, or auto-detected |
|
|
41
85
|
| `--max-iterations` | `-m` | `10` | Maximum loop iterations |
|
|
42
86
|
| `--timeout` | `-t` | `1800` | Maximum runtime in seconds (30 min) |
|
|
43
87
|
| `--promise` | | `任務完成!🥇` | Completion phrase the AI must output |
|
|
@@ -134,13 +178,14 @@ Environment variables are automatically inherited by the child process — no ex
|
|
|
134
178
|
|
|
135
179
|
## Architecture
|
|
136
180
|
|
|
137
|
-
|
|
181
|
+
7 core files, ported from [copilot-ralph](https://github.com/yazelin/copilot-ralph) (19+ TS files):
|
|
138
182
|
|
|
139
183
|
```
|
|
140
184
|
src/ralph/
|
|
141
185
|
├── __init__.py # Version + exports
|
|
142
186
|
├── __main__.py # python -m ralph entry
|
|
143
|
-
├── cli.py # argparse CLI
|
|
187
|
+
├── cli.py # argparse CLI + auto-detect
|
|
188
|
+
├── config.py # ralph.yml loader
|
|
144
189
|
├── engine.py # Ralph Loop engine (AcpClient)
|
|
145
190
|
├── prompt.py # System prompt template
|
|
146
191
|
└── detect.py # Promise detection (~5 lines)
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
"""Ralph Any — iterative AI dev loop via ACP."""
|
|
2
2
|
|
|
3
|
-
__version__ = "0.
|
|
3
|
+
__version__ = "0.2.0"
|
|
4
4
|
|
|
5
|
+
from ralph.config import load_config_file
|
|
5
6
|
from ralph.detect import detect_promise
|
|
6
7
|
from ralph.engine import LoopConfig, LoopResult, RalphEngine
|
|
7
8
|
|
|
8
9
|
__all__ = [
|
|
9
10
|
"__version__",
|
|
11
|
+
"load_config_file",
|
|
10
12
|
"detect_promise",
|
|
11
13
|
"LoopConfig",
|
|
12
14
|
"LoopResult",
|
|
@@ -7,7 +7,9 @@ import asyncio
|
|
|
7
7
|
import shlex
|
|
8
8
|
import sys
|
|
9
9
|
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
10
11
|
|
|
12
|
+
from ralph.config import load_config_file
|
|
11
13
|
from ralph.engine import LoopConfig, RalphEngine
|
|
12
14
|
|
|
13
15
|
EXIT_SUCCESS = 0
|
|
@@ -24,6 +26,9 @@ _STATE_TO_EXIT = {
|
|
|
24
26
|
"max_iterations": EXIT_MAX_ITERATIONS,
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
# Auto-detected prompt files, checked in order.
|
|
30
|
+
_AUTO_PROMPT_FILES = ("ralph.md", "TASK.md", "ralph.txt", "TASK.txt")
|
|
31
|
+
|
|
27
32
|
|
|
28
33
|
def _build_parser() -> argparse.ArgumentParser:
|
|
29
34
|
p = argparse.ArgumentParser(
|
|
@@ -32,38 +37,40 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
32
37
|
)
|
|
33
38
|
p.add_argument(
|
|
34
39
|
"prompt",
|
|
35
|
-
|
|
40
|
+
nargs="?",
|
|
41
|
+
default=None,
|
|
42
|
+
help="Task description, or path to a .md/.txt file (auto-detects ralph.md / TASK.md)",
|
|
36
43
|
)
|
|
37
44
|
p.add_argument(
|
|
38
45
|
"-m", "--max-iterations",
|
|
39
46
|
type=int,
|
|
40
|
-
default=
|
|
47
|
+
default=None,
|
|
41
48
|
help="Maximum loop iterations (default: 10)",
|
|
42
49
|
)
|
|
43
50
|
p.add_argument(
|
|
44
51
|
"-t", "--timeout",
|
|
45
52
|
type=int,
|
|
46
|
-
default=
|
|
53
|
+
default=None,
|
|
47
54
|
help="Maximum runtime in seconds (default: 1800 = 30m)",
|
|
48
55
|
)
|
|
49
56
|
p.add_argument(
|
|
50
57
|
"--promise",
|
|
51
|
-
default=
|
|
58
|
+
default=None,
|
|
52
59
|
help="Completion promise phrase (default: 任務完成!🥇)",
|
|
53
60
|
)
|
|
54
61
|
p.add_argument(
|
|
55
62
|
"-c", "--command",
|
|
56
|
-
default=
|
|
63
|
+
default=None,
|
|
57
64
|
help="ACP CLI command (default: claude-code-acp)",
|
|
58
65
|
)
|
|
59
66
|
p.add_argument(
|
|
60
67
|
"--command-args",
|
|
61
|
-
default=
|
|
68
|
+
default=None,
|
|
62
69
|
help="Extra arguments for the ACP CLI (e.g. '--experimental-acp')",
|
|
63
70
|
)
|
|
64
71
|
p.add_argument(
|
|
65
72
|
"-d", "--working-dir",
|
|
66
|
-
default=
|
|
73
|
+
default=None,
|
|
67
74
|
help="Working directory (default: .)",
|
|
68
75
|
)
|
|
69
76
|
p.add_argument(
|
|
@@ -82,6 +89,17 @@ def _resolve_prompt(raw: str) -> str:
|
|
|
82
89
|
return raw
|
|
83
90
|
|
|
84
91
|
|
|
92
|
+
def _auto_detect_prompt(working_dir: str) -> str | None:
|
|
93
|
+
"""Look for a default prompt file in the working directory."""
|
|
94
|
+
base = Path(working_dir)
|
|
95
|
+
for name in _AUTO_PROMPT_FILES:
|
|
96
|
+
p = base / name
|
|
97
|
+
if p.is_file():
|
|
98
|
+
print(f"📄 Auto-detected prompt file: {p}", flush=True)
|
|
99
|
+
return p.read_text(encoding="utf-8")
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
85
103
|
def _run(config: LoopConfig) -> int:
|
|
86
104
|
engine = RalphEngine(config)
|
|
87
105
|
try:
|
|
@@ -100,18 +118,49 @@ def main(argv: list[str] | None = None) -> None:
|
|
|
100
118
|
parser = _build_parser()
|
|
101
119
|
args = parser.parse_args(argv)
|
|
102
120
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
121
|
+
# Layer 1: defaults
|
|
122
|
+
cfg: dict[str, Any] = {
|
|
123
|
+
"prompt": None,
|
|
124
|
+
"promise_phrase": "任務完成!🥇",
|
|
125
|
+
"command": "claude-code-acp",
|
|
126
|
+
"command_args": [],
|
|
127
|
+
"working_dir": ".",
|
|
128
|
+
"max_iterations": 10,
|
|
129
|
+
"timeout_seconds": 1800,
|
|
130
|
+
"dry_run": False,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
# Layer 2: ralph.yml (overrides defaults)
|
|
134
|
+
file_cfg = load_config_file(args.working_dir or ".")
|
|
135
|
+
if file_cfg:
|
|
136
|
+
cfg.update({k: v for k, v in file_cfg.items() if v is not None})
|
|
137
|
+
|
|
138
|
+
# Layer 3: CLI args (overrides config file)
|
|
139
|
+
if args.prompt is not None:
|
|
140
|
+
cfg["prompt"] = _resolve_prompt(args.prompt)
|
|
141
|
+
if args.max_iterations is not None:
|
|
142
|
+
cfg["max_iterations"] = args.max_iterations
|
|
143
|
+
if args.timeout is not None:
|
|
144
|
+
cfg["timeout_seconds"] = args.timeout
|
|
145
|
+
if args.promise is not None:
|
|
146
|
+
cfg["promise_phrase"] = args.promise
|
|
147
|
+
if args.command is not None:
|
|
148
|
+
cfg["command"] = args.command
|
|
149
|
+
if args.command_args is not None:
|
|
150
|
+
cfg["command_args"] = shlex.split(args.command_args)
|
|
151
|
+
if args.working_dir is not None:
|
|
152
|
+
cfg["working_dir"] = args.working_dir
|
|
153
|
+
if args.dry_run:
|
|
154
|
+
cfg["dry_run"] = True
|
|
155
|
+
|
|
156
|
+
# Auto-detect prompt file if no prompt given
|
|
157
|
+
if cfg["prompt"] is None:
|
|
158
|
+
cfg["prompt"] = _auto_detect_prompt(cfg["working_dir"])
|
|
159
|
+
|
|
160
|
+
if cfg["prompt"] is None:
|
|
161
|
+
parser.error("prompt is required (provide as argument or create ralph.md / TASK.md)")
|
|
162
|
+
|
|
163
|
+
config = LoopConfig(**cfg)
|
|
115
164
|
|
|
116
165
|
if config.dry_run:
|
|
117
166
|
print("Dry-run config:")
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Load ralph.yml config file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shlex
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load_config_file(working_dir: str) -> dict[str, Any] | None:
|
|
11
|
+
"""Load ralph.yml / ralph.yaml from *working_dir*. Returns None if absent."""
|
|
12
|
+
base = Path(working_dir)
|
|
13
|
+
for name in ("ralph.yml", "ralph.yaml"):
|
|
14
|
+
path = base / name
|
|
15
|
+
if path.is_file():
|
|
16
|
+
return _parse(path)
|
|
17
|
+
return None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _parse(path: Path) -> dict[str, Any]:
|
|
21
|
+
"""Parse a ralph config file (simple YAML subset, no dependency needed)."""
|
|
22
|
+
raw: dict[str, str] = {}
|
|
23
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
24
|
+
line = line.strip()
|
|
25
|
+
if not line or line.startswith("#"):
|
|
26
|
+
continue
|
|
27
|
+
if ":" not in line:
|
|
28
|
+
continue
|
|
29
|
+
key, _, value = line.partition(":")
|
|
30
|
+
raw[key.strip()] = value.strip()
|
|
31
|
+
|
|
32
|
+
cfg: dict[str, Any] = {}
|
|
33
|
+
|
|
34
|
+
_map = {
|
|
35
|
+
"command": "command",
|
|
36
|
+
"command_args": "command_args",
|
|
37
|
+
"promise": "promise_phrase",
|
|
38
|
+
"max_iterations": "max_iterations",
|
|
39
|
+
"timeout": "timeout_seconds",
|
|
40
|
+
"working_dir": "working_dir",
|
|
41
|
+
"prompt": "prompt",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for yaml_key, cfg_key in _map.items():
|
|
45
|
+
if yaml_key not in raw:
|
|
46
|
+
continue
|
|
47
|
+
val = raw[yaml_key]
|
|
48
|
+
if cfg_key in ("max_iterations", "timeout_seconds"):
|
|
49
|
+
cfg[cfg_key] = int(val)
|
|
50
|
+
elif cfg_key == "command_args":
|
|
51
|
+
cfg[cfg_key] = shlex.split(val)
|
|
52
|
+
else:
|
|
53
|
+
cfg[cfg_key] = val
|
|
54
|
+
|
|
55
|
+
return cfg
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Tests for CLI prompt resolution and auto-detection."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from ralph.cli import _auto_detect_prompt, _resolve_prompt
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_resolve_prompt_plain_text():
|
|
12
|
+
assert _resolve_prompt("do something") == "do something"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_resolve_prompt_reads_md_file(tmp_path: Path):
|
|
16
|
+
f = tmp_path / "task.md"
|
|
17
|
+
f.write_text("# Task\nDo this.")
|
|
18
|
+
assert _resolve_prompt(str(f)) == "# Task\nDo this."
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_resolve_prompt_reads_txt_file(tmp_path: Path):
|
|
22
|
+
f = tmp_path / "task.txt"
|
|
23
|
+
f.write_text("hello")
|
|
24
|
+
assert _resolve_prompt(str(f)) == "hello"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_auto_detect_ralph_md(tmp_path: Path):
|
|
28
|
+
(tmp_path / "ralph.md").write_text("auto task")
|
|
29
|
+
result = _auto_detect_prompt(str(tmp_path))
|
|
30
|
+
assert result == "auto task"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_auto_detect_task_md(tmp_path: Path):
|
|
34
|
+
(tmp_path / "TASK.md").write_text("task file")
|
|
35
|
+
result = _auto_detect_prompt(str(tmp_path))
|
|
36
|
+
assert result == "task file"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_auto_detect_priority(tmp_path: Path):
|
|
40
|
+
(tmp_path / "ralph.md").write_text("ralph wins")
|
|
41
|
+
(tmp_path / "TASK.md").write_text("task loses")
|
|
42
|
+
result = _auto_detect_prompt(str(tmp_path))
|
|
43
|
+
assert result == "ralph wins"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_auto_detect_none(tmp_path: Path):
|
|
47
|
+
result = _auto_detect_prompt(str(tmp_path))
|
|
48
|
+
assert result is None
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Tests for ralph.yml config loading."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from ralph.config import load_config_file
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_load_full_config(tmp_path: Path):
|
|
9
|
+
(tmp_path / "ralph.yml").write_text(
|
|
10
|
+
"command: gemini\n"
|
|
11
|
+
"command_args: --experimental-acp\n"
|
|
12
|
+
"max_iterations: 20\n"
|
|
13
|
+
"timeout: 3600\n"
|
|
14
|
+
"promise: done!\n"
|
|
15
|
+
)
|
|
16
|
+
cfg = load_config_file(str(tmp_path))
|
|
17
|
+
assert cfg is not None
|
|
18
|
+
assert cfg["command"] == "gemini"
|
|
19
|
+
assert cfg["command_args"] == ["--experimental-acp"]
|
|
20
|
+
assert cfg["max_iterations"] == 20
|
|
21
|
+
assert cfg["timeout_seconds"] == 3600
|
|
22
|
+
assert cfg["promise_phrase"] == "done!"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_load_yaml_extension(tmp_path: Path):
|
|
26
|
+
(tmp_path / "ralph.yaml").write_text("command: gemini\n")
|
|
27
|
+
cfg = load_config_file(str(tmp_path))
|
|
28
|
+
assert cfg is not None
|
|
29
|
+
assert cfg["command"] == "gemini"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_missing_config_returns_none(tmp_path: Path):
|
|
33
|
+
cfg = load_config_file(str(tmp_path))
|
|
34
|
+
assert cfg is None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_comments_and_blanks(tmp_path: Path):
|
|
38
|
+
(tmp_path / "ralph.yml").write_text(
|
|
39
|
+
"# Ralph config\n"
|
|
40
|
+
"\n"
|
|
41
|
+
"command: gemini\n"
|
|
42
|
+
"# max_iterations: 5\n"
|
|
43
|
+
)
|
|
44
|
+
cfg = load_config_file(str(tmp_path))
|
|
45
|
+
assert cfg == {"command": "gemini"}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_partial_config(tmp_path: Path):
|
|
49
|
+
(tmp_path / "ralph.yml").write_text("max_iterations: 50\n")
|
|
50
|
+
cfg = load_config_file(str(tmp_path))
|
|
51
|
+
assert cfg == {"max_iterations": 50}
|
|
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
|