Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 50 additions & 2 deletions docs/cli.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: Ralph CLI Reference
description: Full CLI reference for ralphify — ralph run, ralph init, ralph new, all options, user arguments, and RALPH.md frontmatter format.
keywords: ralph CLI, ralph run, ralph init, ralph new, CLI reference, RALPH.md format, frontmatter options, agent arguments
description: Full CLI reference for ralphify — ralph run, ralph fleet, ralph init, ralph new, all options, user arguments, and RALPH.md frontmatter format.
keywords: ralph CLI, ralph run, ralph fleet, ralph init, ralph new, CLI reference, RALPH.md format, frontmatter options, agent arguments
---

# CLI Reference
Expand Down Expand Up @@ -116,6 +116,54 @@ Errors if `RALPH.md` already exists at the target location.

---

## `ralph fleet`

Run multiple ralphs in parallel from a single directory.

```bash
ralph fleet ralphs/ # Run all ralphs found in ralphs/
ralph fleet ralphs/ -n 3 # Run 3 iterations per ralph
ralph fleet ralphs/ --stop-on-error # Stop any ralph whose agent exits non-zero
ralph fleet ralphs/ --delay 10 # Wait 10s between iterations
ralph fleet ralphs/ --timeout 300 # Kill agent after 5 minutes per iteration
ralph fleet ralphs/ --log-dir fleet_logs # Save output to log files
```

| Argument / Option | Short | Default | Description |
|---|---|---|---|
| `PATH` | | (required) | Directory containing ralph subdirectories |
| `-n` | | unlimited | Max number of iterations per ralph |
| `--stop-on-error` | `-s` | off | Stop a ralph if its agent exits non-zero or times out |
| `--delay` | `-d` | `0` | Seconds to wait between iterations |
| `--timeout` | `-t` | none | Max seconds per iteration |
| `--log-dir` | `-l` | none | Directory for iteration log files |

### How it works

The fleet command scans the given directory for immediate subdirectories that contain a `RALPH.md` file. Each discovered ralph is started concurrently in its own thread. Terminal output is prefixed with the ralph name so you can tell which ralph produced each line.

Press Ctrl+C to gracefully stop all running ralphs.

### Directory layout

```
ralphs/
├── lint-fixer/
│ └── RALPH.md
├── test-writer/
│ └── RALPH.md
└── docs-updater/
└── RALPH.md
```

```bash
ralph fleet ralphs/ # Runs lint-fixer, test-writer, and docs-updater in parallel
```

Only immediate subdirectories are checked — the command does not recurse deeper.

---

## `ralph new`

Create a new ralph with AI-guided setup. Launches an interactive session where the agent guides you through creating a complete ralph via conversation.
Expand Down
15 changes: 13 additions & 2 deletions docs/contributing/codebase-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ docs/contributing/ # Contributor documentation (this section)

## Architecture: how the pieces connect

The CLI entry point is `cli.py:run()`, which parses options, reads the ralph directory path, and delegates to `engine.py:run_loop()` for the actual iteration cycle. The engine emits structured events via an `EventEmitter`, making the same loop reusable from both the CLI and any external orchestration layer (such as `manager.py`).
The CLI entry point is `cli.py:run()`, which parses options, reads the ralph directory path, and delegates to `engine.py:run_loop()` for the actual iteration cycle. The `fleet` command discovers ralph subdirectories and runs them concurrently via `manager.py:RunManager`. The engine emits structured events via an `EventEmitter`, making the same loop reusable from both the CLI and any external orchestration layer (such as `manager.py`).

```
ralph run my-ralph
Expand All @@ -64,6 +64,17 @@ ralph run my-ralph
├── Emit iteration events (started, completed, failed, timed_out)
├── Handle pause/resume/stop requests via RunState
└── Repeat

ralph fleet ralphs/
├── cli.py:fleet() — discover ralph subdirectories
│ ├── Scan directory for subdirectories containing RALPH.md
│ ├── Build a RunConfig for each discovered ralph
│ └── Create runs via RunManager, attach ConsoleEmitter
└── manager.py:RunManager — start each run in a daemon thread
└── Each thread runs engine.run_loop() independently
└── ConsoleEmitter prefixes output with ralph name
```

### Placeholder resolution
Expand Down Expand Up @@ -113,7 +124,7 @@ The CLI uses a `ConsoleEmitter` (defined in `_console_emitter.py`) that renders

1. **`engine.py`** — The core run loop. Uses `RunConfig` and `RunState` (from `_run_types.py`) and `EventEmitter`. This is where iteration logic lives.
2. **`_run_types.py`** — `RunConfig`, `RunState`, `RunStatus`, and `Command`. These are the shared data types used by the engine, CLI, and manager.
3. **`cli.py`** — All CLI commands. Validates frontmatter fields via extracted helpers (`_validate_agent`, `_validate_commands`, `_validate_credit`, `_validate_run_options`, `_validate_declared_args`), builds a `RunConfig`, and delegates to `engine.run_loop()` for the actual loop. Terminal event rendering lives in `_console_emitter.py`.
3. **`cli.py`** — All CLI commands (`run`, `fleet`, `init`, `new`). Validates frontmatter fields via extracted helpers (`_validate_agent`, `_validate_commands`, `_validate_credit`, `_validate_run_options`, `_validate_declared_args`), builds a `RunConfig`, and delegates to `engine.run_loop()` for the actual loop. The `fleet` command discovers ralphs in subdirectories and runs them concurrently via `RunManager`. Terminal event rendering lives in `_console_emitter.py`.
4. **`_frontmatter.py`** — YAML frontmatter parsing. Extracts `agent`, `commands`, `args` from the RALPH.md file.
5. **`_resolver.py`** — Template placeholder logic. Small file but critical.
6. **`_skills.py`** + **`skills/`** — The skill system behind `ralph new`. `_skills.py` handles agent detection, reads bundled skill definitions from `skills/`, installs them into the agent's skill directory, and builds the command to launch the agent.
Expand Down
58 changes: 39 additions & 19 deletions src/ralphify/_console_emitter.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ class ConsoleEmitter:
def __init__(self, console: Console) -> None:
self._console = console
self._live: Live | None = None
self._run_names: dict[str, str] = {}
self._handlers: dict[EventType, Callable[..., None]] = {
EventType.RUN_STARTED: self._on_run_started,
EventType.ITERATION_STARTED: self._on_iteration_started,
Expand All @@ -71,14 +72,27 @@ def __init__(self, console: Console) -> None:
EventType.RUN_STOPPED: self._on_run_stopped,
}

@property
def _multi_run(self) -> bool:
return len(self._run_names) > 1

def _prefix(self, run_id: str) -> str:
"""Return a ``[name]`` prefix for multi-run mode, or empty string."""
if not self._multi_run:
return ""
name = self._run_names.get(run_id, run_id)
return f"[bold cyan]\\[{escape_markup(name)}][/bold cyan] "

def emit(self, event: Event) -> None:
handler = self._handlers.get(event.type)
if handler is not None:
handler(event.data)
handler(event.data, run_id=event.run_id)

def _on_run_started(self, data: RunStartedData) -> None:
def _on_run_started(self, data: RunStartedData, *, run_id: str) -> None:
ralph_name = data["ralph_name"]
self._console.print(f"\n[bold #A78BF5]▶ Running:[/bold #A78BF5] [bold]{escape_markup(ralph_name)}[/bold]")
self._run_names[run_id] = ralph_name
pfx = self._prefix(run_id)
self._console.print(f"\n{pfx}[bold #A78BF5]▶ Running:[/bold #A78BF5] [bold]{escape_markup(ralph_name)}[/bold]")

info_parts: list[str] = []
timeout = data["timeout"]
Expand All @@ -91,7 +105,7 @@ def _on_run_started(self, data: RunStartedData) -> None:
if max_iter is not None:
info_parts.append(f"max {max_iter} iteration{'s' if max_iter != 1 else ''}")
if info_parts:
self._console.print(f" [dim]{' · '.join(info_parts)}[/dim]")
self._console.print(f" {pfx}[dim]{' · '.join(info_parts)}[/dim]")

def _start_live(self) -> None:
spinner = _IterationSpinner()
Expand All @@ -108,40 +122,45 @@ def _stop_live(self) -> None:
self._live.stop()
self._live = None

def _on_iteration_started(self, data: IterationStartedData) -> None:
def _on_iteration_started(self, data: IterationStartedData, *, run_id: str) -> None:
iteration = data["iteration"]
self._console.print(f"\n[bold blue]── Iteration {iteration} ──[/bold blue]")
self._start_live()
pfx = self._prefix(run_id)
self._console.print(f"\n{pfx}[bold blue]── Iteration {iteration} ──[/bold blue]")
if not self._multi_run:
self._start_live()

def _on_iteration_ended(self, data: IterationEndedData, color: str, icon: str) -> None:
def _on_iteration_ended(self, data: IterationEndedData, color: str, icon: str, *, run_id: str) -> None:
self._stop_live()
iteration = data["iteration"]
detail = data["detail"]
self._console.print(f"[{color}]{icon} Iteration {iteration} {detail}[/{color}]")
pfx = self._prefix(run_id)
self._console.print(f"{pfx}[{color}]{icon} Iteration {iteration} {detail}[/{color}]")
log_file = data["log_file"]
if log_file:
self._console.print(f" [dim]{_ICON_ARROW} {escape_markup(log_file)}[/dim]")
self._console.print(f" {pfx}[dim]{_ICON_ARROW} {escape_markup(log_file)}[/dim]")
result_text = data["result_text"]
if result_text:
self._console.print(Markdown(result_text))

def _on_commands_completed(self, data: CommandsCompletedData) -> None:
def _on_commands_completed(self, data: CommandsCompletedData, *, run_id: str) -> None:
count = data["count"]
if count:
self._console.print(f" [bold]Commands:[/bold] {count} ran")
pfx = self._prefix(run_id)
self._console.print(f" {pfx}[bold]Commands:[/bold] {count} ran")

def _on_log_message(self, data: LogMessageData) -> None:
def _on_log_message(self, data: LogMessageData, *, run_id: str) -> None:
msg = escape_markup(data["message"])
level = data["level"]
pfx = self._prefix(run_id)
if level == LOG_ERROR:
self._console.print(f"[red]{msg}[/red]")
self._console.print(f"{pfx}[red]{msg}[/red]")
tb = data.get("traceback")
if tb:
self._console.print(f"[dim]{escape_markup(tb)}[/dim]")
self._console.print(f"{pfx}[dim]{escape_markup(tb)}[/dim]")
else:
self._console.print(f"[dim]{msg}[/dim]")
self._console.print(f"{pfx}[dim]{msg}[/dim]")

def _on_run_stopped(self, data: RunStoppedData) -> None:
def _on_run_stopped(self, data: RunStoppedData, *, run_id: str) -> None:
self._stop_live()
if data["reason"] != STOP_COMPLETED:
return
Expand All @@ -160,5 +179,6 @@ def _on_run_stopped(self, data: RunStoppedData) -> None:
if timed_out_count:
parts.append(f"{timed_out_count} timed out")
detail = ", ".join(parts)
self._console.print(f"\n[bold blue]──────────────────────[/bold blue]")
self._console.print(f"[bold green]Done:[/bold green] {total} iteration(s) {_ICON_DASH} {detail}")
pfx = self._prefix(run_id)
self._console.print(f"\n{pfx}[bold blue]──────────────────────[/bold blue]")
self._console.print(f"{pfx}[bold green]Done:[/bold green] {total} iteration(s) {_ICON_DASH} {detail}")
72 changes: 72 additions & 0 deletions src/ralphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
)
from ralphify._run_types import Command, DEFAULT_COMMAND_TIMEOUT, RunConfig, RunState, generate_run_id
from ralphify.engine import run_loop
from ralphify.manager import RunManager

if sys.platform == "win32":
sys.stdout.reconfigure(encoding="utf-8")
Expand Down Expand Up @@ -466,3 +467,74 @@ def run(
emitter = ConsoleEmitter(_console)

run_loop(config, state, emitter)


def _discover_ralphs(directory: Path) -> list[Path]:
"""Find all subdirectories of *directory* that contain a RALPH.md file.

Returns sorted paths for deterministic ordering. Does not recurse
deeper than one level — only immediate children are checked.
"""
ralphs: list[Path] = []
if not directory.is_dir():
return ralphs
for child in sorted(directory.iterdir()):
if child.is_dir() and (child / RALPH_MARKER).is_file():
ralphs.append(child)
return ralphs


@app.command()
def fleet(
path: str = typer.Argument(..., help="Directory containing ralph subdirectories."),
n: int | None = typer.Option(None, "-n", help="Max number of iterations per ralph. Infinite if not set."),
stop_on_error: bool = typer.Option(False, "--stop-on-error", "-s", help="Stop a ralph if its agent exits non-zero or times out."),
delay: float = typer.Option(0, "--delay", "-d", help="Seconds to wait between iterations."),
log_dir: str | None = typer.Option(None, "--log-dir", "-l", help="Save iteration output to log files in this directory."),
timeout: float | None = typer.Option(None, "--timeout", "-t", help="Max seconds per iteration. Kill agent if exceeded."),
) -> None:
"""Run multiple ralphs in parallel.

Discovers all subdirectories of PATH that contain a RALPH.md file
and runs them concurrently. Ctrl+C stops all running ralphs.
"""
_validate_run_options(n, delay, timeout)

fleet_dir = Path(path)
if not fleet_dir.is_dir():
_exit_error(f"'{path}' is not a directory.")

ralph_dirs = _discover_ralphs(fleet_dir)
if not ralph_dirs:
_exit_error(f"No ralphs found in '{path}'. Each subdirectory needs a {RALPH_MARKER} file.")

_console.print(f"\n[bold #A78BF5]Fleet:[/bold #A78BF5] Found {len(ralph_dirs)} ralph(s) in [bold]{path}[/bold]")
for d in ralph_dirs:
_console.print(f" [dim]• {d.name}[/dim]")

configs: list[RunConfig] = []
for ralph_dir in ralph_dirs:
config = _build_run_config(
str(ralph_dir), n, stop_on_error, delay, log_dir, timeout,
)
configs.append(config)

manager = RunManager()
emitter = ConsoleEmitter(_console)

for config in configs:
managed = manager.create_run(config)
managed.add_listener(emitter)
manager.start_run(managed.state.run_id)

try:
for managed in manager.list_runs():
if managed.thread is not None:
managed.thread.join()
except KeyboardInterrupt:
_console.print("\n[yellow]Stopping all ralphs…[/yellow]")
for managed in manager.list_runs():
managed.state.request_stop()
for managed in manager.list_runs():
if managed.thread is not None:
managed.thread.join(timeout=10)
Loading
Loading