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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ralph-any
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Ralph Wiggum Loop — iterative AI dev loop via ACP (supports Claude, Gemini, and more)
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -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 --experimental-acp
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 <prompt> [options]
79
+ ralph [prompt] [options]
36
80
  ```
37
81
 
38
82
  | Option | Short | Default | Description |
39
83
  |--------|-------|---------|-------------|
40
- | `prompt` | | *(required)* | Task description, or path to `.md`/`.txt` file |
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
- 6 core files, ported from [copilot-ralph](https://github.com/yazelin/copilot-ralph) (19+ TS files):
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 (8 options)
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,6 +1,6 @@
1
1
  [project]
2
2
  name = "ralph-any"
3
- version = "0.1.1"
3
+ version = "0.2.0"
4
4
  description = "Ralph Wiggum Loop — iterative AI dev loop via ACP (supports Claude, Gemini, and more)"
5
5
  requires-python = ">=3.10"
6
6
  license = "MIT"
@@ -1,12 +1,14 @@
1
1
  """Ralph Any — iterative AI dev loop via ACP."""
2
2
 
3
- __version__ = "0.1.1"
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
- help="Task description, or path to a .md/.txt file",
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=10,
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=1800,
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="claude-code-acp",
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
- prompt_text = _resolve_prompt(args.prompt)
104
-
105
- config = LoopConfig(
106
- prompt=prompt_text,
107
- promise_phrase=args.promise,
108
- command=args.command,
109
- command_args=shlex.split(args.command_args) if args.command_args else [],
110
- working_dir=args.working_dir,
111
- max_iterations=args.max_iterations,
112
- timeout_seconds=args.timeout,
113
- dry_run=args.dry_run,
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