diff --git a/.gitignore b/.gitignore index 95352c8..d2d6de3 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,6 @@ ralph_logs/ node_modules/ .playwright-cli/ mess/ -.ralphify/ +.agents/ .DS_Store .coverage \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index ba03526..5166d2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ uv run mkdocs serve # Preview docs at http://127.0.0.1:8000 All source code is in `src/ralphify/`. The main file is `cli.py` — it contains the CLI commands and delegates to the engine for the core loop. Key modules: -- `cli.py` — CLI commands (`run`, `new`, `init`); delegates to `_console_emitter.py` for terminal event rendering +- `cli.py` — CLI commands (`run`, `scaffold`); delegates to `_console_emitter.py` for terminal event rendering - `engine.py` — Core run loop orchestration with structured event emission - `manager.py` — Multi-run orchestration (concurrent runs via threads) - `_frontmatter.py` — YAML frontmatter parsing (uses PyYAML) and the `RALPH_MARKER` constant @@ -33,15 +33,12 @@ Key modules: - `_console_emitter.py` — Rich terminal rendering of events - `_output.py` — `ProcessResult` base class, combine stdout+stderr, format durations - `_brand.py` — Brand color constants shared across CLI and console rendering -- `_source.py` — GitHub source parsing and git-based ralph fetching for `ralph add` -- `_skills.py` — Skill installation, agent detection, and command building for `ralph new` -- `skills/new-ralph/SKILL.md` — AI-guided ralph creation skill (bundled, installed into agent skill dir) Tests are in `tests/` with one file per module. Docs are in `docs/` using MkDocs with Material theme. ## Core concepts -A **ralph** is a directory containing a `RALPH.md` file. That's it. No project-level config, no `.ralphify/` directory, no `ralph init`. +A **ralph** is a directory containing a `RALPH.md` file. That's it. No project-level config, no special directories. **RALPH.md** has YAML frontmatter + a prompt body: - `agent` (required) — the agent command to run @@ -60,14 +57,12 @@ A **ralph** is a directory containing a `RALPH.md` file. That's it. No project-l - `docs/` (MkDocs) — user-facing docs: CLI reference, quick reference, writing prompts guide, cookbook. Only include what's relevant for users. - `docs/contributing/` — contributor/agent docs: codebase map, architecture. Only include what's relevant for contributors and coding agents. - `README.md` — keep short and high-level. Update only when the change affects the quickstart, install, or core concepts. - - `src/ralphify/skills/new-ralph/SKILL.md` — the skill that powers `ralph new`. Update when new frontmatter fields or features are added. - `CHANGELOG.md` — add an entry for every release. ## Boundaries ### Always Do - Run `uv run pytest` and `uv run ruff check .` before committing -- Save all skills in `skills/` directory (not `.claude/skills/` which is gitignored) ## Traps diff --git a/CLAUDE.md b/CLAUDE.md index ba03526..5166d2a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ uv run mkdocs serve # Preview docs at http://127.0.0.1:8000 All source code is in `src/ralphify/`. The main file is `cli.py` — it contains the CLI commands and delegates to the engine for the core loop. Key modules: -- `cli.py` — CLI commands (`run`, `new`, `init`); delegates to `_console_emitter.py` for terminal event rendering +- `cli.py` — CLI commands (`run`, `scaffold`); delegates to `_console_emitter.py` for terminal event rendering - `engine.py` — Core run loop orchestration with structured event emission - `manager.py` — Multi-run orchestration (concurrent runs via threads) - `_frontmatter.py` — YAML frontmatter parsing (uses PyYAML) and the `RALPH_MARKER` constant @@ -33,15 +33,12 @@ Key modules: - `_console_emitter.py` — Rich terminal rendering of events - `_output.py` — `ProcessResult` base class, combine stdout+stderr, format durations - `_brand.py` — Brand color constants shared across CLI and console rendering -- `_source.py` — GitHub source parsing and git-based ralph fetching for `ralph add` -- `_skills.py` — Skill installation, agent detection, and command building for `ralph new` -- `skills/new-ralph/SKILL.md` — AI-guided ralph creation skill (bundled, installed into agent skill dir) Tests are in `tests/` with one file per module. Docs are in `docs/` using MkDocs with Material theme. ## Core concepts -A **ralph** is a directory containing a `RALPH.md` file. That's it. No project-level config, no `.ralphify/` directory, no `ralph init`. +A **ralph** is a directory containing a `RALPH.md` file. That's it. No project-level config, no special directories. **RALPH.md** has YAML frontmatter + a prompt body: - `agent` (required) — the agent command to run @@ -60,14 +57,12 @@ A **ralph** is a directory containing a `RALPH.md` file. That's it. No project-l - `docs/` (MkDocs) — user-facing docs: CLI reference, quick reference, writing prompts guide, cookbook. Only include what's relevant for users. - `docs/contributing/` — contributor/agent docs: codebase map, architecture. Only include what's relevant for contributors and coding agents. - `README.md` — keep short and high-level. Update only when the change affects the quickstart, install, or core concepts. - - `src/ralphify/skills/new-ralph/SKILL.md` — the skill that powers `ralph new`. Update when new frontmatter fields or features are added. - `CHANGELOG.md` — add an entry for every release. ## Boundaries ### Always Do - Run `uv run pytest` and `uv run ruff check .` before committing -- Save all skills in `skills/` directory (not `.claude/skills/` which is gitignored) ## Traps diff --git a/README.md b/README.md index 119402f..4cd96be 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Any of these gives you the `ralph` command. A ralph is a directory with a `RALPH.md` file. Scaffold one: ```bash -ralph init my-ralph +ralph scaffold my-ralph ``` Then edit `my-ralph/RALPH.md`: @@ -140,22 +140,16 @@ my-ralph/ **No project-level configuration.** No `ralph.toml`. No config files. A ralph is fully self-contained. -## AI-guided setup - -```bash -ralph new my-task -``` - -Launches an interactive agent conversation to scaffold a new ralph with the right commands and prompt for your project. - ## Install ralphs from GitHub +Use [agr](https://github.com/computerlovetech/agr) to install shared ralphs: + ```bash -ralph add owner/repo # Install all ralphs from a repo -ralph add owner/repo/my-ralph # Install a specific ralph by name +agr add owner/repo/my-ralph # Install a ralph from GitHub +ralph run my-ralph # Run it by name ``` -Installs ralphs to `.ralphify/ralphs/` so you can run them by name with `ralph run`. +Ralphs installed by agr go to `.agents/ralphs/` and are automatically discovered by `ralph run`. ## Documentation diff --git a/docs/agents.md b/docs/agents.md index 14c42c1..efdb1c3 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -15,12 +15,12 @@ This page shows how to configure the [`agent` frontmatter field](quick-reference ## Agent comparison -| Agent | Stdin support | Streaming | `ralph new` support | Wrapper needed | -|---|---|---|---|---| -| [Claude Code](#claude-code) | Native (`-p`) | Yes — real-time activity tracking | Yes | No | -| [Aider](#aider) | Via bash wrapper | No | No | Yes (`bash -c`) | -| [Codex CLI](#codex-cli) | Native (`exec`) | No | Yes | No | -| [Custom](#custom-wrapper-script) | You implement it | No | No | Yes (script) | +| Agent | Stdin support | Streaming | Wrapper needed | +|---|---|---|---| +| [Claude Code](#claude-code) | Native (`-p`) | Yes — real-time activity tracking | No | +| [Aider](#aider) | Via bash wrapper | No | Yes (`bash -c`) | +| [Codex CLI](#codex-cli) | Native (`exec`) | No | No | +| [Custom](#custom-wrapper-script) | You implement it | No | Yes (script) | If you're not sure which to pick: **start with Claude Code.** It has the deepest integration, the best autonomous coding capabilities, and is the default. diff --git a/docs/blog/posts/the-ralph-standard.md b/docs/blog/posts/the-ralph-standard.md index dae6a5f..8648a15 100644 --- a/docs/blog/posts/the-ralph-standard.md +++ b/docs/blog/posts/the-ralph-standard.md @@ -150,14 +150,14 @@ ralph run ./ralphs/bug-hunter Declare `args: [focus]` and you get `--focus` on the CLI. The value fills `{{ args.focus }}` in the prompt. One ralph, many use cases. -Because ralphs are just directories in a git repo, anyone can share them. If a repo contains a directory with a `RALPH.md`, you can install it with `ralph add`: +Because ralphs are just directories in a git repo, anyone can share them. If a repo contains a directory with a `RALPH.md`, you can install it with [agr](https://github.com/computerlovetech/agr): ```bash # install a specific ralph from any GitHub repo -ralph add owner/repo/ralph-name +agr add owner/repo/ralph-name # install all ralphs in a repo -ralph add owner/repo +agr add owner/repo ``` The [ralphify examples](https://github.com/computerlovetech/ralphify/tree/main/examples) are a good place to start — and the [cookbook](https://ralphify.co/docs/cookbook/) has more. diff --git a/docs/cli.md b/docs/cli.md index 898c77f..5f6f25b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,13 +1,13 @@ --- title: "CLI Reference: Run AI Coding Agents in Autonomous Loops" -description: "Complete CLI reference for the ralph command — run autonomous AI coding loops, scaffold new agent prompts, install shared ralphs from GitHub, and configure RALPH.md frontmatter options." -keywords: run AI agent in loop CLI, autonomous coding agent command line, ralph run command, ralph init, ralph add GitHub, RALPH.md frontmatter format, AI coding loop options, agent timeout iterations, user arguments CLI, ralphify CLI reference +description: "Complete CLI reference for the ralph command — run autonomous AI coding loops, scaffold new agent prompts, and configure RALPH.md frontmatter options." +keywords: run AI agent in loop CLI, autonomous coding agent command line, ralph run command, ralph scaffold, RALPH.md frontmatter format, AI coding loop options, agent timeout iterations, user arguments CLI, ralphify CLI reference --- # CLI Reference !!! tldr "TL;DR" - **`ralph run -n 5`** runs the loop. **`ralph init `** scaffolds a ralph from a template. **`ralph new`** creates one with AI guidance. **`ralph add owner/repo`** installs from GitHub. Pass user args as `--name value` flags. Everything is configured in a single [`RALPH.md`](#ralphmd-format) file with YAML frontmatter. + **`ralph run -n 5`** runs the loop. **`ralph scaffold `** creates a ralph from a template. Pass user args as `--name value` flags. Everything is configured in a single [`RALPH.md`](#ralphmd-format) file with YAML frontmatter. ## `ralph` @@ -52,7 +52,7 @@ ralph run my-ralph --dir ./src # Pass user args to the ralph | Argument / Option | Short | Default | Description | |---|---|---|---| -| `PATH` | | (required) | Path to a ralph directory containing `RALPH.md`, a direct path to a `RALPH.md` file, or the name of an installed ralph (from `ralph add`) | +| `PATH` | | (required) | Path to a ralph directory containing `RALPH.md`, a direct path to a `RALPH.md` file, or the name of an installed ralph in `.agents/ralphs/` | | `-n` | | unlimited | Max number of iterations | | `--stop-on-error` | `-s` | off | Stop loop if agent exits non-zero or times out | | `--delay` | `-d` | `0` | Seconds to wait between iterations | @@ -116,13 +116,13 @@ The loop also stops automatically when: --- -## `ralph init` +## `ralph scaffold` -Scaffold a new ralph with a ready-to-customize template. No AI agent required. For a guided setup, see [`ralph new`](#ralph-new) instead. +Scaffold a new ralph with a ready-to-customize template. ```bash -ralph init my-task # Creates my-task/RALPH.md with a generic template -ralph init # Creates RALPH.md in the current directory +ralph scaffold my-task # Creates my-task/RALPH.md with a generic template +ralph scaffold # Creates RALPH.md in the current directory ``` | Argument | Default | Description | @@ -135,52 +135,6 @@ Errors if `RALPH.md` already exists at the target location. --- -## `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. - -```bash -ralph new # Agent helps you choose a name and build everything -ralph new my-task # Start with a name already chosen -``` - -| Argument | Default | Description | -|---|---|---| -| `[NAME]` | none | Name for the new ralph. If omitted, the agent will help you choose | - -The command detects your [agent](agents.md) and installs a skill to guide the creation process. - ---- - -## `ralph add` - -Add a ralph from a GitHub repository. Installs it to `.ralphify/ralphs//` so you can run it by name. - -```bash -ralph add owner/repo # Install all ralphs in the repo -ralph add owner/repo/ralph-name # Specific ralph by name -ralph add https://github.com/owner/repo # Full GitHub URL -ralph add https://github.com/owner/repo/tree/main/my-ralph # URL copied from GitHub browser -``` - -| Argument | Default | Description | -|---|---|---| -| `SOURCE` | required | GitHub source — shorthand (`owner/repo`), subpath (`owner/repo/path`), or full GitHub URL | - -**How it resolves:** - -- `owner/repo` — if the repo root contains `RALPH.md`, installs it as a single ralph named after the repo. Otherwise, finds and installs all ralphs in the repo. -- `owner/repo/ralph-name` — searches the repo for a directory named `ralph-name` containing `RALPH.md`. If multiple matches are found, prints the paths and asks you to use the full subpath to disambiguate. -- `https://github.com/owner/repo/tree/branch/path` — extracts the path from the URL. This is the format you get when you copy a URL from the GitHub web UI while browsing a directory. The branch name is used only to locate the path — `ralph add` always clones the default branch. - -After adding, run the ralph by name: - -```bash -ralph run ralph-name -``` - ---- - ## RALPH.md format The `RALPH.md` file is the single configuration and prompt file for a ralph. It uses YAML frontmatter for settings and the body for the prompt text. See [Writing Prompts](writing-prompts.md) for detailed guidance on crafting effective prompts. diff --git a/docs/contributing/codebase-map.md b/docs/contributing/codebase-map.md index d18ae3a..1019aa0 100644 --- a/docs/contributing/codebase-map.md +++ b/docs/contributing/codebase-map.md @@ -19,7 +19,7 @@ The core loop is simple. The complexity lives in **prompt assembly** — running ``` src/ralphify/ # All source code ├── __init__.py # Version detection + app entry point -├── cli.py # CLI commands (run, new, init) — delegates to engine for the loop +├── cli.py # CLI commands (run, scaffold) — delegates to engine for the loop ├── engine.py # Core run loop orchestration with structured event emission ├── manager.py # Multi-run orchestration (concurrent runs via threads) ├── _resolver.py # Template placeholder resolution ({{ commands.* }}, {{ args.* }}, {{ ralph.* }}) @@ -27,14 +27,10 @@ src/ralphify/ # All source code ├── _run_types.py # RunConfig, RunState, RunStatus, Command — shared data types ├── _runner.py # Execute shell commands with timeout and capture output ├── _frontmatter.py # Parse YAML frontmatter from RALPH.md, marker constants -├── _source.py # GitHub source parsing and git-based ralph fetching for `ralph add` -├── _skills.py # Skill installation and agent detection for `ralph new` ├── _console_emitter.py # Rich console renderer for run-loop events (ConsoleEmitter) ├── _events.py # Event types, emitter protocol, and BoundEmitter convenience wrapper ├── _output.py # ProcessResult base class, combine stdout+stderr, format durations -├── _brand.py # Brand color constants shared across CLI and console rendering -└── skills/ # Bundled skill definitions (installed into agent skill dirs) - └── new-ralph/ # AI-guided ralph creation skill for `ralph new` +└── _brand.py # Brand color constants shared across CLI and console rendering tests/ # Pytest tests — one test file per module docs/ # MkDocs site (Material theme) — user-facing documentation @@ -118,8 +114,6 @@ The CLI uses a `ConsoleEmitter` (defined in `_console_emitter.py`) that renders 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`. 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. - ## Traps and gotchas ### If you change frontmatter fields... diff --git a/docs/getting-started.md b/docs/getting-started.md index 1553a03..0a3973b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -7,7 +7,7 @@ keywords: set up autonomous AI coding agent, install ralphify, AI coding loop tu # Getting Started !!! tldr "TL;DR" - `uv tool install ralphify` → `ralph init my-ralph` → edit the RALPH.md → `ralph run my-ralph -n 1 --log-dir ralph_logs` to test → add a `commands` entry for your test suite → `ralph run my-ralph` to loop. The agent sees fresh test output each iteration and fixes what it breaks. + `uv tool install ralphify` → `ralph scaffold my-ralph` → edit the RALPH.md → `ralph run my-ralph -n 1 --log-dir ralph_logs` to test → add a `commands` entry for your test suite → `ralph run my-ralph` to loop. The agent sees fresh test output each iteration and fixes what it breaks. This tutorial walks through setting up ralphify, creating a ralph with commands, and running a productive autonomous loop. By the end, you'll have a self-healing coding loop that validates its own work. @@ -41,10 +41,10 @@ ralphify 0.3.0 ## Step 2: Create a ralph -The fastest way to scaffold a ralph is `ralph init`: +The fastest way to scaffold a ralph is `ralph scaffold`: ```bash -ralph init my-ralph +ralph scaffold my-ralph ``` ```text @@ -54,16 +54,16 @@ Edit the file, then run: ralph run my-ralph This creates `my-ralph/RALPH.md` with a ready-to-customize template including an example command, arg, and prompt. Edit the task section, [test it](#step-3-do-a-test-run), then follow [Step 4](#step-4-add-a-test-command) to add a test command — test feedback is what makes the loop self-healing. -Alternatively, use `ralph new` for AI-guided setup, or create the file manually as shown below. +Or create the file manually as shown below. !!! tip "Installing an existing ralph?" - If someone has shared a ralph on GitHub, skip the manual setup and install it directly: + Use [agr](https://github.com/computerlovetech/agr) to install shared ralphs from GitHub: ```bash - ralph add owner/repo + agr add owner/repo ``` - This installs to `.ralphify/ralphs/` so you can run it by name with `ralph run `. See the [CLI reference](cli.md#ralph-add) for all source formats. + This installs to `.agents/ralphs/` so you can run it by name with `ralph run `. ### Manual setup diff --git a/docs/index.md b/docs/index.md index 7a8a47c..b522929 100644 --- a/docs/index.md +++ b/docs/index.md @@ -127,16 +127,16 @@ Done: 3 iterations — 2 succeeded, 1 failed Edit `RALPH.md` while the loop is running — changes take effect on the next iteration. -## Or grab one from GitHub +## Or install one with agr -Install a pre-built ralph from any GitHub repo and run it immediately: +Install a pre-built ralph from any GitHub repo using [agr](https://github.com/computerlovetech/agr) and run it immediately: ```bash -ralph add owner/repo/my-ralph # Install a ralph from GitHub +agr add owner/repo/my-ralph # Install a ralph from GitHub ralph run my-ralph # Run it ``` -`ralph add` fetches the ralph and installs it locally. You can install a single ralph by name, or all ralphs in a repo at once with `ralph add owner/repo`. See the [CLI reference](cli.md#ralph-add) for details. +agr installs ralphs to `.agents/ralphs/` so you can run them by name. --- diff --git a/docs/llms.txt b/docs/llms.txt index 89360c6..b1345f7 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -45,7 +45,7 @@ ralph run my-ralph -n 5 # Run 5 iterations - **Fresh context every cycle**: No conversation bloat or hallucinated memories. The agent reads current codebase state each time. - **Progress in git**: Every iteration commits to git. Roll back with `git reset` if needed. - **Any agent**: Works with Claude Code, Aider, Codex CLI, or any CLI that reads stdin. -- **Shareable ralphs**: Install pre-built ralphs from GitHub with `ralph add owner/repo`. +- **Shareable ralphs**: Install pre-built ralphs from GitHub with [agr](https://github.com/computerlovetech/agr). ## Placeholder types @@ -58,9 +58,7 @@ ralph run my-ralph -n 5 # Run 5 iterations ## CLI commands - `ralph run [-n N] [--log-dir DIR] [--stop-on-error] [--timeout SEC] [--delay SEC]` — run the loop -- `ralph init ` — scaffold a ralph from template -- `ralph new [name]` — AI-guided ralph creation -- `ralph add ` — install ralph(s) from GitHub +- `ralph scaffold ` — scaffold a ralph from template ## Python API diff --git a/docs/quick-reference.md b/docs/quick-reference.md index 916631e..6a18934 100644 --- a/docs/quick-reference.md +++ b/docs/quick-reference.md @@ -1,7 +1,7 @@ --- title: "RALPH.md Syntax and CLI Cheat Sheet — Ralphify Quick Reference" -description: "Cheat sheet for ralphify — RALPH.md frontmatter format, CLI flags for ralph run/init/add/new, placeholder syntax for commands and args, and common loop patterns you can copy-paste." -keywords: RALPH.md format, RALPH.md frontmatter syntax, ralph run CLI flags, ralphify cheat sheet, AI coding agent loop syntax, ralph commands placeholder, ralph args placeholder, ralph add GitHub, ralph init scaffold, autonomous agent loop reference, ralphify quick reference +description: "Cheat sheet for ralphify — RALPH.md frontmatter format, CLI flags for ralph run/scaffold, placeholder syntax for commands and args, and common loop patterns you can copy-paste." +keywords: RALPH.md format, RALPH.md frontmatter syntax, ralph run CLI flags, ralphify cheat sheet, AI coding agent loop syntax, ralph commands placeholder, ralph args placeholder, ralph scaffold, autonomous agent loop reference, ralphify quick reference --- # Quick Reference @@ -20,15 +20,8 @@ ralph run my-ralph --delay 10 # Wait 10s between iterations ralph run my-ralph --timeout 300 # Kill agent after 5 min per iteration ralph run my-ralph --dir ./src # Pass user args to the ralph -ralph init my-task # Scaffold a ralph from template (no AI) -ralph init # Scaffold in current directory - -ralph new # AI-guided ralph creation -ralph new docs # AI-guided creation with name pre-filled - -ralph add owner/repo # Install ralph(s) from a GitHub repo -ralph add owner/repo/my-ralph # Install a specific ralph by name -ralph add https://github.com/owner/repo/tree/main/my-ralph # URL from GitHub +ralph scaffold my-task # Scaffold a ralph from template +ralph scaffold # Scaffold in current directory ralph --version # Show version ``` @@ -173,17 +166,6 @@ Each iteration: Tips on structuring prompts for different tasks → [Writing Prompts](writing-prompts.md) -## `ralph add` source formats - -| Format | Example | What it does | -|---|---|---| -| Shorthand | `owner/repo` | Installs all ralphs in the repo (or the repo itself if root has `RALPH.md`) | -| Subpath | `owner/repo/my-ralph` | Installs the ralph named `my-ralph` from the repo | -| Full URL | `https://github.com/owner/repo` | Same as shorthand | -| GitHub tree URL | `https://github.com/owner/repo/tree/main/my-ralph` | Extracts the path from the URL — works when you copy a URL from the GitHub browser UI | - -Installed ralphs go to `.ralphify/ralphs//`. Re-running `ralph add` overwrites without warning (that's how you update). See the [CLI reference](cli.md) for full `ralph add` options. - ## Common patterns ### Minimal ralph diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index d6f87ad..a510798 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,7 +1,7 @@ --- title: Troubleshooting Ralph Loops -description: Fix common ralphify issues — setup errors, agent hangs, command failures, ralph add problems, and permission issues. -keywords: ralphify troubleshooting, agent hangs, command failures, setup errors, debug ralph loop, ralph add issues +description: Fix common ralphify issues — setup errors, agent hangs, command failures, and permission issues. +keywords: ralphify troubleshooting, agent hangs, command failures, setup errors, debug ralph loop --- # Troubleshooting @@ -18,19 +18,19 @@ Common issues and how to fix them. If your problem isn't listed here, run [`ralp ### "is not a directory, RALPH.md file, or installed ralph" -The path you passed to [`ralph run`](cli.md#ralph-run) doesn't resolve to a valid ralph. The command accepts a **directory** containing `RALPH.md`, a **direct path** to a `RALPH.md` file, or the **name of an installed ralph** (from [`ralph add`](cli.md#ralph-add)): +The path you passed to [`ralph run`](cli.md#ralph-run) doesn't resolve to a valid ralph. The command accepts a **directory** containing `RALPH.md`, a **direct path** to a `RALPH.md` file, or the **name of an installed ralph** in `.agents/ralphs/`: ```bash ralph run my-ralph # directory containing RALPH.md ralph run my-ralph/RALPH.md # direct path to the file -ralph run my-ralph # installed ralph in .ralphify/ralphs/my-ralph/ +ralph run my-ralph # installed ralph in .agents/ralphs/my-ralph/ ``` If you're getting this error, check that the path exists and points to the right place: ```bash ls my-ralph/RALPH.md # local ralph -ls .ralphify/ralphs/my-ralph/RALPH.md # installed ralph +ls .agents/ralphs/my-ralph/RALPH.md # installed ralph ``` ### "Missing or empty 'agent' field in RALPH.md frontmatter" @@ -47,98 +47,6 @@ agent: claude -p --dangerously-skip-permissions The agent CLI isn't installed or isn't in your shell's PATH. Verify by running `claude --version` directly. If it's installed but not found, check your PATH. See [supported agents](agents.md) for setup instructions. -### "Ralph name '...' contains invalid characters" - -The ralph directory name you passed to [`ralph new`](cli.md#ralph-new) contains characters that aren't allowed. Names may only contain letters, digits, hyphens, and underscores: - -```bash -# ✗ Wrong — dots and spaces aren't allowed -ralph new my.ralph -ralph new "my ralph" - -# ✓ Correct — use hyphens or underscores -ralph new my-ralph -ralph new my_ralph -``` - -### "No agent found. Install Claude Code or Codex" - -[`ralph new`](cli.md#ralph-new) auto-detects which agent to use by checking your PATH for `claude` and `codex` (in that order). If neither is found, you get this error. Install one of the [supported agents](agents.md) and make sure it's on your PATH: - -```bash -# Check if an agent is available -claude --version # Claude Code -codex --version # Codex -``` - -### "Unknown agent: '...'. Supported: claude, codex" - -`ralph new` detected an agent on your PATH but couldn't match it to a supported agent name. The supported agents are `claude` and `codex`. If you want to use a different agent CLI, skip `ralph new` and create the `RALPH.md` manually — see [getting started](getting-started.md). - -### "RALPH.md already exists" - -You ran [`ralph new`](cli.md#ralph-new) in a directory that already contains a `RALPH.md`. Either use a different directory name or edit the existing file: - -```bash -# ✗ Fails — RALPH.md already exists in my-ralph/ -ralph new my-ralph - -# ✓ Option A — use a different name -ralph new my-other-ralph - -# ✓ Option B — edit the existing file directly -``` - -## `ralph add` issues - -### "Cannot parse source" - -The source format wasn't recognized. [`ralph add`](cli.md#ralph-add) accepts these formats: - -```bash -ralph add owner/repo # shorthand -ralph add owner/repo/ralph-name # specific ralph -ralph add https://github.com/owner/repo # full URL -ralph add https://github.com/owner/repo/tree/main/my-ralph # URL copied from GitHub -``` - -The easiest way to add a ralph from GitHub is to navigate to the directory in your browser and copy the URL — it works directly with `ralph add`. - -### "git is required for 'ralph add'" - -`ralph add` uses `git clone` under the hood. Install git from [git-scm.com](https://git-scm.com/) and make sure it's on your PATH: - -```bash -git --version -``` - -### "git clone failed" - -The repository couldn't be cloned. Common causes: - -- The repository doesn't exist or has a typo in the owner/repo name -- The repository is **private** — `ralph add` uses `git clone` under the hood, so your local git credentials must have access. If you can `git clone https://github.com/owner/repo.git` manually, `ralph add` will work too. - -### "No RALPH.md found" / "No ralph named '...' found" - -The repository was cloned successfully but no ralphs were found. Either the repo doesn't contain any `RALPH.md` files, or the ralph name you specified doesn't match any directory in the repo. - -### "Found multiple ralphs named '...'" - -The repository contains more than one ralph directory with the same name (in different subdirectories). Use the full path to tell `ralph add` which one you want: - -```bash -# ✗ Fails — ambiguous name -ralph add owner/repo/my-ralph - -# ✓ Correct — use the full path shown in the error message -ralph add owner/repo/examples/my-ralph -``` - -### Re-running `ralph add` overwrites without warning - -If you `ralph add` a ralph that's already installed in `.ralphify/ralphs/`, the existing copy is replaced silently. This is by design — it's how you update an installed ralph to the latest version. If you've made local edits to an installed ralph, copy them elsewhere before re-adding. - ## Loop issues ### Agent produces no output or seems to hang diff --git a/src/ralphify/_skills.py b/src/ralphify/_skills.py deleted file mode 100644 index 714143a..0000000 --- a/src/ralphify/_skills.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Skill installation and agent detection for ``ralph new``.""" - -from __future__ import annotations - -import importlib.resources -import shutil -from dataclasses import dataclass -from pathlib import Path - - -SKILL_MARKER = "SKILL.md" -"""Filename used for skill definition files inside agent skill directories.""" - - -@dataclass(frozen=True) -class DetectedAgent: - """Result of agent auto-detection.""" - - name: str - path: str - - -@dataclass(frozen=True) -class _AgentConfig: - """Skill integration settings for a supported agent.""" - - skill_dir: str - skill_prefix: str - extra_flags: tuple[str, ...] = () - - -_AGENTS: dict[str, _AgentConfig] = { - "claude": _AgentConfig( - skill_dir=".claude/skills", - skill_prefix="/", - extra_flags=("--dangerously-skip-permissions",), - ), - "codex": _AgentConfig(skill_dir=".agents/skills", skill_prefix="$"), -} - - -def _get_agent_config(agent_name: str) -> _AgentConfig: - """Look up agent-specific skill settings by name.""" - config = _AGENTS.get(agent_name) - if config is None: - raise RuntimeError( - f"Unknown agent: {agent_name!r}. Supported: {', '.join(_AGENTS)}" - ) - return config - - -def read_bundled_skill(skill_name: str) -> str: - """Read a bundled SKILL.md from the ``ralphify.skills`` package.""" - pkg = importlib.resources.files("ralphify.skills").joinpath( - skill_name, SKILL_MARKER - ) - return pkg.read_text(encoding="utf-8") - - -def detect_agent() -> DetectedAgent: - """Detect the agent binary to use. - - Auto-detects on PATH: ``claude``, then ``codex``. - - Returns a :class:`DetectedAgent` with the binary name and resolved path. - Raises ``RuntimeError`` when no agent can be found. - """ - for name in _AGENTS: - resolved = shutil.which(name) - if resolved: - return DetectedAgent(name=name, path=resolved) - - raise RuntimeError("No agent found. Install Claude Code or Codex.") - - -def install_skill(skill_name: str, agent_name: str) -> Path: - """Install a bundled skill into the agent's skill directory.""" - agent_config = _get_agent_config(agent_name) - content = read_bundled_skill(skill_name) - dest = Path(agent_config.skill_dir) / skill_name / SKILL_MARKER - dest.parent.mkdir(parents=True, exist_ok=True) - dest.write_text(content, encoding="utf-8") - return dest - - -def build_agent_command( - agent_name: str, skill_name: str, ralph_name: str | None -) -> list[str]: - """Build the command to launch the agent with the skill invoked.""" - agent_config = _get_agent_config(agent_name) - invocation = f"{agent_config.skill_prefix}{skill_name}" - if ralph_name: - invocation = f"{invocation} {ralph_name}" - return [agent_name, *agent_config.extra_flags, invocation] diff --git a/src/ralphify/_source.py b/src/ralphify/_source.py deleted file mode 100644 index fffed4d..0000000 --- a/src/ralphify/_source.py +++ /dev/null @@ -1,284 +0,0 @@ -"""GitHub source parsing and git-based ralph fetching. - -Parses ``owner/repo``, ``owner/repo/ralph-name``, and full GitHub URLs -into a normalised form, then clones the repo (shallow) and extracts the -requested ralph directory. -""" - -from __future__ import annotations - -import re -import shutil -import subprocess -import tempfile -from dataclasses import dataclass -from pathlib import Path - -from ralphify._frontmatter import RALPH_MARKER -from ralphify._output import SUBPROCESS_TEXT_KWARGS - -# The ".git" suffix used in clone URLs and as the directory name to exclude -# when copying. Repeated across URL construction, repo-name stripping, and -# copytree ignore patterns — a single constant keeps them in sync. -_GIT_SUFFIX = ".git" - - -@dataclass(frozen=True) -class ParsedSource: - """Normalised representation of a GitHub ralph source.""" - - owner_repo: str - """Owner and repo slug, e.g. ``owner/repo``.""" - - repo_url: str - """Clone URL, e.g. ``https://github.com/owner/repo.git``.""" - - subpath: str | None - """Path segment(s) after ``owner/repo``, or *None* for repo-root.""" - - handle: str - """Canonical short-form, e.g. ``owner/repo/ralph-name``.""" - - name: str - """Derived ralph name (leaf directory or repo name).""" - - -# --------------------------------------------------------------------------- -# GitHub URL helpers -# --------------------------------------------------------------------------- - -_GITHUB_URL_RE = re.compile( - r""" - ^https?://github\.com/ # scheme + host - (?P[^/]+)/ # owner segment - (?P[^/]+?) # repo segment (non-greedy) - (?:\.git)? # optional .git suffix - (?:/tree/[^/]+ # optional /tree/ - (?:/(?P.+))? # optional sub-path after branch - )? - /?$ # optional trailing slash - """, - re.VERBOSE, -) - -_SHORTHAND_RE = re.compile( - r""" - ^(?P[^/]+)/ # owner segment - (?P[^/]+) # repo segment - (?:/(?P.+))? # optional sub-path - /?$ # optional trailing slash - """, - re.VERBOSE, -) - - -def parse_github_source(source: str) -> ParsedSource: - """Parse a GitHub source string into a :class:`ParsedSource`. - - Accepted formats:: - - owner/repo - owner/repo/ralph-name - owner/repo/some/path/to/ralph - https://github.com/owner/repo - https://github.com/owner/repo/tree/main/path - - Query strings (``?tab=code``) and fragments (``#readme``) are - stripped so that URLs copied from a browser work transparently. - - Raises ``ValueError`` for unrecognised formats. - """ - # Strip query string and fragment — users often copy URLs from - # their browser that include these (e.g. ?tab=code, #readme). - source = source.split("?", 1)[0].split("#", 1)[0] - - owner: str | None = None - repo: str | None = None - raw_subpath: str | None = None - - # Try full URL first, then shorthand. Both regexes use the same named - # groups (owner, repo, subpath) so extraction is uniform. - m = _GITHUB_URL_RE.match(source) or _SHORTHAND_RE.match(source) - if m: - owner, repo, raw_subpath = m.group("owner"), m.group("repo"), m.group("subpath") - - # Strip .git suffix — the URL regex handles this via (?:\.git)? in the - # pattern, but the shorthand regex captures the full repo segment. - # Must happen before the empty check so that a bare ".git" repo name - # (e.g. "owner/.git") is correctly rejected as empty. - if repo: - repo = repo.removesuffix(_GIT_SUFFIX) - - if not owner or not repo: - raise ValueError( - f"Cannot parse source '{source}'. " - "Expected owner/repo, owner/repo/ralph-name, or a GitHub URL." - ) - - owner_repo = f"{owner}/{repo}" - repo_url = f"https://github.com/{owner}/{repo}{_GIT_SUFFIX}" - subpath = (raw_subpath.strip("/") or None) if raw_subpath else None - name = Path(subpath).name if subpath else repo - handle = f"{owner_repo}/{subpath}" if subpath else owner_repo - - return ParsedSource( - owner_repo=owner_repo, - repo_url=repo_url, - subpath=subpath, - handle=handle, - name=name, - ) - - -# --------------------------------------------------------------------------- -# Git clone + ralph extraction -# --------------------------------------------------------------------------- - - -def _find_ralphs_in(root: Path) -> list[Path]: - """Return all directories under *root* that contain a RALPH.md.""" - return sorted(p.parent for p in root.rglob(RALPH_MARKER) if p.is_file()) - - -def _shallow_clone(repo_url: str, dest: Path) -> None: - """Run ``git clone --depth 1`` into *dest*. - - Raises ``RuntimeError`` on failure. - """ - try: - subprocess.run( - ["git", "clone", "--depth", "1", repo_url, str(dest)], - capture_output=True, - **SUBPROCESS_TEXT_KWARGS, - check=True, - ) - except FileNotFoundError: - raise RuntimeError( - "git is required for 'ralph add'. Install it from https://git-scm.com/" - ) from None - except subprocess.CalledProcessError as exc: - stderr = (exc.stderr.strip() if exc.stderr else "") or "unknown error" - raise RuntimeError(f"git clone failed: {stderr}") from None - - -@dataclass(frozen=True) -class FetchResult: - """Result of fetching ralph(s) from a source.""" - - installed: list[tuple[str, Path]] - """List of ``(name, dest_path)`` for each installed ralph.""" - - -def _install_single_ralph(src: Path, name: str, ralphs_dir: Path) -> FetchResult: - """Copy a single ralph from *src* to *ralphs_dir*/*name* and return a FetchResult.""" - dest = ralphs_dir / name - _copy_ralph(src, dest) - return FetchResult(installed=[(name, dest)]) - - -def fetch_ralphs(parsed: ParsedSource, ralphs_dir: Path) -> FetchResult: - """Clone the repo and extract ralph(s) to *ralphs_dir*. - - *ralphs_dir* is the ``.ralphify/ralphs/`` directory. Each ralph is - placed in ``ralphs_dir//``. - - Returns a :class:`FetchResult` describing what was installed. - Raises ``RuntimeError`` on any failure. - """ - with tempfile.TemporaryDirectory() as tmp: - clone_dir = Path(tmp) / "repo" - _shallow_clone(parsed.repo_url, clone_dir) - - if parsed.subpath is None: - # owner/repo — check if root is a ralph, else install all. - return _fetch_repo_ralphs(clone_dir, parsed, ralphs_dir) - else: - # owner/repo/ralph-name — search for the ralph. - return _fetch_named_ralph(clone_dir, parsed, ralphs_dir) - - -def _fetch_repo_ralphs( - clone_dir: Path, - parsed: ParsedSource, - ralphs_dir: Path, -) -> FetchResult: - """Handle ``owner/repo`` — repo root is a ralph, or install all.""" - root_ralph = clone_dir / RALPH_MARKER - if root_ralph.is_file(): - return _install_single_ralph(clone_dir, parsed.name, ralphs_dir) - - # Scan for all ralphs in the repo. - ralph_dirs = _find_ralphs_in(clone_dir) - if not ralph_dirs: - raise RuntimeError(f"No {RALPH_MARKER} found in {parsed.handle}.") - - # Detect duplicate leaf names — installing two ralphs with the same - # directory name would silently overwrite the first. - seen: dict[str, list[Path]] = {} - for rd in ralph_dirs: - seen.setdefault(rd.name, []).append(rd) - for name, paths in seen.items(): - if len(paths) > 1: - raise _duplicate_ralph_error(name, paths, clone_dir, parsed.owner_repo) - - installed: list[tuple[str, Path]] = [] - for rd in ralph_dirs: - result = _install_single_ralph(rd, rd.name, ralphs_dir) - installed.extend(result.installed) - return FetchResult(installed=installed) - - -def _fetch_named_ralph( - clone_dir: Path, - parsed: ParsedSource, - ralphs_dir: Path, -) -> FetchResult: - """Handle ``owner/repo/ralph-name`` — search or exact subpath.""" - assert parsed.subpath is not None - ralph_name = parsed.name - - # First try exact subpath. - exact = clone_dir / parsed.subpath - if exact.is_dir() and (exact / RALPH_MARKER).is_file(): - return _install_single_ralph(exact, ralph_name, ralphs_dir) - - # Search by name (leaf segment). - all_ralphs = _find_ralphs_in(clone_dir) - matches = [rd for rd in all_ralphs if rd.name == ralph_name] - - if len(matches) == 1: - return _install_single_ralph(matches[0], ralph_name, ralphs_dir) - - elif len(matches) > 1: - raise _duplicate_ralph_error( - ralph_name, - matches, - clone_dir, - parsed.owner_repo, - ) - - raise RuntimeError(f"No ralph named '{ralph_name}' found in {parsed.handle}.") - - -def _duplicate_ralph_error( - name: str, - paths: list[Path], - clone_dir: Path, - owner_repo: str, -) -> RuntimeError: - """Build a ``RuntimeError`` for duplicate ralph names in a repo.""" - listing = "\n".join(f" - {p.relative_to(clone_dir)}/{RALPH_MARKER}" for p in paths) - example = f"{owner_repo}/{paths[0].relative_to(clone_dir)}" - return RuntimeError( - f"Found multiple ralphs named '{name}' in {owner_repo}:\n" - f"{listing}\n\n" - f"Use the full path to disambiguate, e.g.:\n" - f" ralph add {example}" - ) - - -def _copy_ralph(src: Path, dest: Path) -> None: - """Copy a ralph directory to *dest*, overwriting if it exists.""" - if dest.exists(): - shutil.rmtree(dest) - shutil.copytree(src, dest, ignore=shutil.ignore_patterns(_GIT_SUFFIX)) diff --git a/src/ralphify/cli.py b/src/ralphify/cli.py index 6d60b38..818c372 100644 --- a/src/ralphify/cli.py +++ b/src/ralphify/cli.py @@ -8,7 +8,6 @@ from __future__ import annotations import math -import os import shlex import shutil import signal @@ -101,9 +100,12 @@ def _validate_name(name: str, context: str) -> None: TAGLINE = "Stop stressing over not having an agent running. Ralph is always running" -_RALPHIFY_RALPHS_DIR = Path(".ralphify") / "ralphs" +_PROJECT_RALPHS_DIR = Path(".agents") / "ralphs" """Project-local directory for installed ralphs.""" +_USER_RALPHS_DIR = Path.home() / ".agents" / "ralphs" +"""User-level directory for installed ralphs.""" + _INIT_TEMPLATE = """\ --- agent: claude -p --dangerously-skip-permissions @@ -182,33 +184,7 @@ def main_callback( @app.command() -def new( - name: str | None = typer.Argument( - None, help="Name for the new ralph. If omitted, the agent will help you choose." - ), -) -> None: - """Create a new ralph with AI-guided setup.""" - from ralphify._skills import build_agent_command, detect_agent, install_skill - - try: - agent = detect_agent() - except RuntimeError as exc: - _exit_error(str(exc)) - - try: - install_skill("new-ralph", agent.name) - except RuntimeError as exc: - _exit_error(str(exc)) - - cmd = build_agent_command(agent.name, "new-ralph", name) - try: - os.execvp(cmd[0], cmd) - except FileNotFoundError: - _exit_error(f"Agent command '{cmd[0]}' not found on PATH.") - - -@app.command() -def init( +def scaffold( name: str | None = typer.Argument( None, help="Directory name. If omitted, creates RALPH.md in the current directory.", @@ -231,41 +207,6 @@ def init( _console.print(f"[dim]Edit the file, then run:[/dim] ralph run {name or '.'}") -@app.command() -def add( - source: str = typer.Argument( - ..., help="GitHub source: owner/repo or owner/repo/ralph-name" - ), -) -> None: - """Add a ralph from a GitHub repository.""" - from ralphify._source import fetch_ralphs, parse_github_source - - try: - parsed = parse_github_source(source) - except ValueError as exc: - _exit_error(str(exc)) - - ralphs_dir = Path.cwd() / _RALPHIFY_RALPHS_DIR - ralphs_dir.mkdir(parents=True, exist_ok=True) - - try: - result = fetch_ralphs(parsed, ralphs_dir) - except RuntimeError as exc: - _exit_error(str(exc)) - - if len(result.installed) == 1: - name, _ = result.installed[0] - _console.print(f"[green]Added[/green] {name}") - _console.print(f"[dim]Run it with:[/dim] ralph run {name}") - else: - _console.print( - f"[green]Added {len(result.installed)} ralphs from {parsed.handle}:[/green]" - ) - for name, _ in result.installed: - _console.print(f" {name}") - _console.print("\n[dim]Run any with:[/dim] ralph run ") - - def _parse_user_args( raw_args: list[str], declared_names: list[str] | None, @@ -398,10 +339,15 @@ def _validate_commands(raw_commands: Any) -> list[Command]: def _installed_ralph_path(name: str) -> Path | None: - """Return the installed ralph directory if it exists, else *None*.""" - path = Path.cwd() / _RALPHIFY_RALPHS_DIR / name - if (path / RALPH_MARKER).is_file(): - return path + """Return the installed ralph directory if it exists, else *None*. + + Checks project-level ``.agents/ralphs//`` first, then + user-level ``~/.agents/ralphs//``. + """ + for base in (Path.cwd() / _PROJECT_RALPHS_DIR, _USER_RALPHS_DIR): + path = base / name + if (path / RALPH_MARKER).is_file(): + return path return None @@ -409,6 +355,7 @@ def _resolve_ralph_paths(ralph_path: str) -> tuple[Path, Path]: """Resolve the ralph directory and RALPH.md file from a user-provided path. Accepts a directory containing RALPH.md or a direct path to RALPH.md. + Falls back to name-based lookup in ``.agents/ralphs/`` (project then user). Returns ``(ralph_dir, ralph_file)``. Exits with an error message when the path is invalid or RALPH.md is not found. """ @@ -420,11 +367,12 @@ def _resolve_ralph_paths(ralph_path: str) -> tuple[Path, Path]: ralph_dir = path.parent ralph_file = path else: - # Fallback: check installed ralphs in .ralphify/ralphs// + # Fallback: check installed ralphs in .agents/ralphs// installed = _installed_ralph_path(ralph_path) if installed is not None: ralph_dir = installed ralph_file = installed / RALPH_MARKER + _console.print(f"[dim]Resolved:[/dim] {ralph_file}") else: _exit_error( f"'{ralph_path}' is not a directory, {RALPH_MARKER} file, or installed ralph." @@ -539,7 +487,7 @@ def _build_run_config( ) def run( ctx: typer.Context, - path: str = typer.Argument(..., help="Path to a ralph directory or RALPH.md file."), + path: str = typer.Argument(..., help="Path to a ralph directory, RALPH.md file, or installed ralph name."), n: int | None = typer.Option( None, "-n", help="Max number of iterations. Infinite if not set." ), diff --git a/src/ralphify/skills/__init__.py b/src/ralphify/skills/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/ralphify/skills/new-ralph/SKILL.md b/src/ralphify/skills/new-ralph/SKILL.md deleted file mode 100644 index 5f8c73a..0000000 --- a/src/ralphify/skills/new-ralph/SKILL.md +++ /dev/null @@ -1,140 +0,0 @@ ---- -name: new-ralph -description: Create a new ralph from a plain-English description of what you want to automate -argument-hint: "[name]" -disable-model-invocation: true ---- - -You are helping the user create a new **ralph** — a reusable automation for autonomous AI coding loops powered by ralphify. The user does NOT need to know how ralphify works internally. Your job is to translate their plain description into a working ralph setup. - -## What you need from the user - -Ask the user to **describe what they want to automate** in plain language. For example: -- "I want to write tests for my Python project until I hit 90% coverage" -- "I want to refactor all my JavaScript files to TypeScript" -- "I want to fix linting errors across the codebase" - -If `$ARGUMENTS` was provided, use it as the ralph name. Otherwise, derive a short kebab-case name from their description. - -Ask **only what you need** to build a good setup: -- What does "done" look like for one cycle of work? -- What language/tools/framework is the project using? -- Any conventions or constraints to follow? - -Do NOT ask the user about commands, frontmatter, or other ralphify internals. Figure those out yourself based on their description. - -## How ralphs work (internal reference — do not expose to user) - -A ralph is a directory containing a `RALPH.md` file. The directory is a self-contained unit — everything the ralph needs lives there. - -``` -my-ralph/ -├── RALPH.md # the prompt (required) -├── check-coverage.sh # script (optional, used by commands) -├── style-guide.md # reference doc (optional) -└── test-data.json # any supporting file (optional) -``` - -### RALPH.md format - -```yaml ---- -agent: claude -p --dangerously-skip-permissions -commands: - - name: tests - run: uv run pytest - - name: git-log - run: git log --oneline -10 - - name: coverage - run: ./check-coverage.sh -args: - - module ---- - -You are a senior engineer working on this project. - -## Recent changes - -{{ commands.git-log }} - -## Test results - -{{ commands.tests }} - -If any tests are failing above, fix them before continuing. - -## Coverage - -{{ commands.coverage }} - -## Task - -... -``` - -#### Frontmatter fields - -| Field | Required | Description | -|-------|----------|-------------| -| `agent` | Yes | The agent command to run (full command string) | -| `commands` | No | List of commands to run each iteration | -| `commands[].name` | Yes | Identifier, used in `{{ commands. }}` placeholders. Letters, digits, hyphens, and underscores only. Must be unique. | -| `commands[].run` | Yes | Command to execute. Paths starting with `./` are relative to the ralph directory. | -| `commands[].timeout` | No | Max seconds before the command is killed (default: 60) | -| `args` | No | Declared argument names for positional CLI args. Letters, digits, hyphens, and underscores only. Must be unique. | -| `credit` | No | Append co-author trailer instruction (default: `true`). Set to `false` to disable. | - -#### Body - -The body is the prompt. It supports three placeholder types: -- `{{ commands. }}` — replaced with command output each iteration -- `{{ args. }}` — replaced with CLI arguments -- `{{ ralph. }}` — replaced with runtime metadata (`name`, `iteration`, `max_iterations`) - -HTML comments (``) are automatically stripped before the prompt is assembled. They never reach the agent. Use them for notes about why rules exist or TODOs for prompt maintenance. - -### Commands - -A command is a name and something to run. The framework executes it, captures stdout/stderr, and makes the output available via `{{ commands. }}`. - -- **Paths starting with `./` run relative to the ralph directory.** `run: ./check-coverage.sh` runs `my-ralph/check-coverage.sh`. -- **Other commands run from the project root.** `run: uv run pytest` runs in the working directory where `ralph run` was invoked. -- **Output is always captured** regardless of exit code. -- **No shell features by default.** Commands are parsed with `shlex.split()`. For pipes, redirects, `&&` — use a script. -- **`{{ args. }}` placeholders work in `run` strings.** Example: `run: gh issue view {{ args.issue }}` resolves before execution. - -### User arguments - -Ralphs can accept CLI arguments, making them reusable: - -- **Named flags**: `ralph run my-ralph --dir ./src --focus "perf"` or `--dir=./src` → `{{ args.dir }}`, `{{ args.focus }}` -- **Positional args**: `ralph run my-ralph ./src "perf"` — requires `args: [dir, focus]` in frontmatter -- Missing args resolve to empty string - -## Your workflow - -1. **Understand the task.** Get a plain-English description. Ask short clarifying questions if needed — no more than 2-3. - -2. **Design the ralph.** Based on the description, decide: - - What prompt to write - - What commands the agent needs (tests, lint, git log, coverage, etc.) - - Whether user arguments would make the ralph more reusable - - What supporting scripts or files are needed - -3. **Create everything:** - - A directory for the ralph - - `RALPH.md` with frontmatter (agent, commands) and a clear, specific prompt. Follow these patterns: - - Start with role and loop awareness: "You are an autonomous X agent running in a loop." - - Include: "Each iteration starts with a fresh context. Your progress lives in the code and git." - - Use `{{ commands. }}` placeholders to show command output in context - - Be specific about what one iteration of work looks like - - Include rules as a bulleted list - - End with commit conventions - - Any supporting scripts (remember `chmod +x`) - -4. **Present a summary** to the user: - - Show the file tree of what you created - - Briefly explain what the ralph will do in each iteration - - Mention what commands will run and what they validate - - Suggest a test run with log capture: `ralph run -n 1 --log-dir ralph_logs` - - Mention `--stop-on-error` for unattended runs: `ralph run --stop-on-error` diff --git a/src/ralphify/skills/new-ralph/__init__.py b/src/ralphify/skills/new-ralph/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/helpers.py b/tests/helpers.py index 08ed691..896bbda 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -35,9 +35,6 @@ MOCK_WAIT_FOR_STOP = "ralphify._run_types.RunState.wait_for_stop" """Patch target for RunState.wait_for_stop used by the engine delay logic.""" -MOCK_SKILLS_WHICH = "ralphify._skills.shutil.which" -"""Patch target for shutil.which inside the skills module.""" - # ── Factory helpers ─────────────────────────────────────────────────── diff --git a/tests/test_cli.py b/tests/test_cli.py index 68c9c59..697290c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -10,7 +10,6 @@ from helpers import ( MOCK_WAIT_FOR_STOP, - MOCK_SKILLS_WHICH, MOCK_SUBPROCESS, MOCK_WHICH, ok_proc, @@ -507,66 +506,10 @@ def test_timeout_shows_in_header(self, mock_run, mock_which, tmp_path, monkeypat assert "timeout 5m 0s" in result.output -class TestNew: - @patch("ralphify.cli.os.execvp") - @patch(MOCK_SKILLS_WHICH, return_value="/usr/bin/claude") - def test_installs_skill_and_launches_agent( - self, mock_which, mock_execvp, tmp_path, monkeypatch - ): - monkeypatch.chdir(tmp_path) - result = runner.invoke(app, ["new", "my-task"]) - assert result.exit_code == 0 - skill_file = tmp_path / ".claude" / "skills" / "new-ralph" / "SKILL.md" - assert skill_file.exists() - assert "new-ralph" in skill_file.read_text() - mock_execvp.assert_called_once_with( - "claude", ["claude", "--dangerously-skip-permissions", "/new-ralph my-task"] - ) - - @patch("ralphify.cli.os.execvp") - @patch(MOCK_SKILLS_WHICH, return_value="/usr/bin/claude") - def test_name_is_optional(self, mock_which, mock_execvp, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - result = runner.invoke(app, ["new"]) - assert result.exit_code == 0 - mock_execvp.assert_called_once_with( - "claude", ["claude", "--dangerously-skip-permissions", "/new-ralph"] - ) - - @patch(MOCK_SKILLS_WHICH, return_value=None) - def test_errors_when_no_agent_found(self, mock_which, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - result = runner.invoke(app, ["new"]) - assert result.exit_code == 1 - assert "No agent found" in result.output - - @patch( - "ralphify._skills.install_skill", side_effect=RuntimeError("permission denied") - ) - @patch(MOCK_SKILLS_WHICH, return_value="/usr/bin/claude") - def test_errors_when_install_skill_fails( - self, mock_which, mock_install, tmp_path, monkeypatch - ): - monkeypatch.chdir(tmp_path) - result = runner.invoke(app, ["new"]) - assert result.exit_code == 1 - assert "permission denied" in result.output - - @patch("ralphify.cli.os.execvp", side_effect=FileNotFoundError("not found")) - @patch(MOCK_SKILLS_WHICH, return_value="/usr/bin/claude") - def test_errors_when_agent_binary_not_found( - self, mock_which, mock_execvp, tmp_path, monkeypatch - ): - monkeypatch.chdir(tmp_path) - result = runner.invoke(app, ["new", "my-task"]) - assert result.exit_code == 1 - assert "not found on PATH" in result.output - - -class TestInit: +class TestScaffold: def test_creates_ralph_with_name(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) - result = runner.invoke(app, ["init", "my-task"]) + result = runner.invoke(app, ["scaffold", "my-task"]) assert result.exit_code == 0 ralph_file = tmp_path / "my-task" / RALPH_MARKER assert ralph_file.exists() @@ -574,27 +517,27 @@ def test_creates_ralph_with_name(self, tmp_path, monkeypatch): def test_creates_ralph_in_cwd(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) - result = runner.invoke(app, ["init"]) + result = runner.invoke(app, ["scaffold"]) assert result.exit_code == 0 assert (tmp_path / RALPH_MARKER).exists() def test_errors_if_exists(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) (tmp_path / RALPH_MARKER).write_text("existing") - result = runner.invoke(app, ["init"]) + result = runner.invoke(app, ["scaffold"]) assert result.exit_code == 1 assert "already exists" in result.output def test_creates_directory_if_missing(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) - result = runner.invoke(app, ["init", "new-dir"]) + result = runner.invoke(app, ["scaffold", "new-dir"]) assert result.exit_code == 0 assert (tmp_path / "new-dir" / RALPH_MARKER).exists() def test_uses_existing_directory(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) (tmp_path / "existing-dir").mkdir() - result = runner.invoke(app, ["init", "existing-dir"]) + result = runner.invoke(app, ["scaffold", "existing-dir"]) assert result.exit_code == 0 assert (tmp_path / "existing-dir" / RALPH_MARKER).exists() @@ -602,7 +545,7 @@ def test_template_has_valid_frontmatter(self, tmp_path, monkeypatch): from ralphify._frontmatter import parse_frontmatter monkeypatch.chdir(tmp_path) - runner.invoke(app, ["init", "my-task"]) + runner.invoke(app, ["scaffold", "my-task"]) content = (tmp_path / "my-task" / RALPH_MARKER).read_text() fm, body = parse_frontmatter(content) assert "agent" in fm @@ -952,110 +895,6 @@ def test_no_subcommand_shows_tagline(self): assert "Ralph is always running" in result.output -class TestAdd: - @patch("ralphify._source.fetch_ralphs") - @patch("ralphify._source.parse_github_source") - def test_add_single_ralph(self, mock_parse, mock_fetch, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - from ralphify._source import ParsedSource, FetchResult - - parsed = ParsedSource( - owner_repo="owner/repo", - repo_url="https://github.com/owner/repo.git", - subpath="my-ralph", - handle="owner/repo/my-ralph", - name="my-ralph", - ) - mock_parse.return_value = parsed - dest = tmp_path / ".ralphify" / "ralphs" / "my-ralph" - mock_fetch.return_value = FetchResult(installed=[("my-ralph", dest)]) - - result = runner.invoke(app, ["add", "owner/repo/my-ralph"]) - assert result.exit_code == 0 - assert "Added" in result.output - assert "my-ralph" in result.output - assert "ralph run my-ralph" in result.output - - @patch("ralphify._source.fetch_ralphs") - @patch("ralphify._source.parse_github_source") - def test_add_multiple_ralphs(self, mock_parse, mock_fetch, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - from ralphify._source import ParsedSource, FetchResult - - parsed = ParsedSource( - owner_repo="owner/repo", - repo_url="https://github.com/owner/repo.git", - subpath=None, - handle="owner/repo", - name="repo", - ) - mock_parse.return_value = parsed - mock_fetch.return_value = FetchResult( - installed=[ - ("ralph-a", tmp_path / "a"), - ("ralph-b", tmp_path / "b"), - ] - ) - - result = runner.invoke(app, ["add", "owner/repo"]) - assert result.exit_code == 0 - assert "Added 2 ralphs" in result.output - assert "ralph-a" in result.output - assert "ralph-b" in result.output - assert "ralph run " in result.output - - @patch( - "ralphify._source.parse_github_source", - side_effect=ValueError("Cannot parse source 'bad'"), - ) - def test_add_invalid_source_errors(self, mock_parse, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - result = runner.invoke(app, ["add", "bad"]) - assert result.exit_code == 1 - assert "Cannot parse source" in result.output - - @patch( - "ralphify._source.fetch_ralphs", side_effect=RuntimeError("git clone failed") - ) - @patch("ralphify._source.parse_github_source") - def test_add_fetch_failure_errors( - self, mock_parse, mock_fetch, tmp_path, monkeypatch - ): - monkeypatch.chdir(tmp_path) - from ralphify._source import ParsedSource - - mock_parse.return_value = ParsedSource( - owner_repo="owner/repo", - repo_url="https://github.com/owner/repo.git", - subpath=None, - handle="owner/repo", - name="repo", - ) - result = runner.invoke(app, ["add", "owner/repo"]) - assert result.exit_code == 1 - assert "git clone failed" in result.output - - @patch("ralphify._source.fetch_ralphs") - @patch("ralphify._source.parse_github_source") - def test_add_creates_ralphs_dir( - self, mock_parse, mock_fetch, tmp_path, monkeypatch - ): - monkeypatch.chdir(tmp_path) - from ralphify._source import ParsedSource, FetchResult - - mock_parse.return_value = ParsedSource( - owner_repo="owner/repo", - repo_url="https://github.com/owner/repo.git", - subpath="x", - handle="owner/repo/x", - name="x", - ) - mock_fetch.return_value = FetchResult(installed=[("x", tmp_path / "x")]) - result = runner.invoke(app, ["add", "owner/repo/x"]) - assert result.exit_code == 0 - assert (tmp_path / ".ralphify" / "ralphs").is_dir() - - class TestTwoStageCtrlC: """Test the two-stage Ctrl+C signal handler installed by the run command. @@ -1128,7 +967,7 @@ def test_run_resolves_installed_ralph_by_name( self, mock_which, tmp_path, monkeypatch ): monkeypatch.chdir(tmp_path) - installed = tmp_path / ".ralphify" / "ralphs" / "my-tool" + installed = tmp_path / ".agents" / "ralphs" / "my-tool" installed.mkdir(parents=True) (installed / RALPH_MARKER).write_text("---\nagent: claude -p\n---\ngo") result = runner.invoke(app, ["run", "my-tool", "-n", "1"]) @@ -1143,7 +982,7 @@ def test_local_path_takes_precedence(self, mock_which, tmp_path, monkeypatch): local.mkdir() (local / RALPH_MARKER).write_text("---\nagent: claude -p\n---\nlocal prompt") - installed = tmp_path / ".ralphify" / "ralphs" / "my-tool" + installed = tmp_path / ".agents" / "ralphs" / "my-tool" installed.mkdir(parents=True) (installed / RALPH_MARKER).write_text( "---\nagent: claude -p\n---\ninstalled prompt" @@ -1162,6 +1001,38 @@ def test_error_mentions_installed_ralph(self, mock_which, tmp_path, monkeypatch) assert result.exit_code == 1 assert "installed ralph" in result.output.lower() + def test_user_level_ralphs(self, mock_which, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + user_home = tmp_path / "fakehome" + user_ralphs = user_home / ".agents" / "ralphs" / "global-tool" + user_ralphs.mkdir(parents=True) + (user_ralphs / RALPH_MARKER).write_text("---\nagent: claude -p\n---\nglobal") + + with patch("ralphify.cli._USER_RALPHS_DIR", user_home / ".agents" / "ralphs"): + from ralphify.cli import _resolve_ralph_paths + + ralph_dir, ralph_file = _resolve_ralph_paths("global-tool") + assert "global" in ralph_file.read_text() + + def test_project_level_beats_user_level(self, mock_which, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + # Project-level + project = tmp_path / ".agents" / "ralphs" / "my-tool" + project.mkdir(parents=True) + (project / RALPH_MARKER).write_text("---\nagent: claude -p\n---\nproject") + + # User-level + user_home = tmp_path / "fakehome" + user = user_home / ".agents" / "ralphs" / "my-tool" + user.mkdir(parents=True) + (user / RALPH_MARKER).write_text("---\nagent: claude -p\n---\nuser") + + with patch("ralphify.cli._USER_RALPHS_DIR", user_home / ".agents" / "ralphs"): + from ralphify.cli import _resolve_ralph_paths + + ralph_dir, ralph_file = _resolve_ralph_paths("my-tool") + assert "project" in ralph_file.read_text() + class TestWin32Reconfigure: def test_reconfigures_stdout_and_stderr_on_win32(self): diff --git a/tests/test_skills.py b/tests/test_skills.py deleted file mode 100644 index 99f9a50..0000000 --- a/tests/test_skills.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Tests for skill installation and agent detection.""" - -from pathlib import Path -from unittest.mock import patch - -import pytest -from helpers import MOCK_SKILLS_WHICH - -from ralphify._skills import ( - build_agent_command, - detect_agent, - install_skill, - read_bundled_skill, -) - - -class TestReadBundledSkill: - def test_reads_new_ralph_skill(self): - content = read_bundled_skill("new-ralph") - assert "name: new-ralph" in content - assert "RALPH.md" in content - - def test_raises_for_nonexistent_skill(self): - with pytest.raises(FileNotFoundError): - read_bundled_skill("does-not-exist") - - -class TestDetectAgent: - def test_from_path_claude(self, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - - def fake_which(cmd): - if cmd == "claude": - return "/usr/bin/claude" - return None - - with patch(MOCK_SKILLS_WHICH, side_effect=fake_which): - agent = detect_agent() - assert agent.name == "claude" - - def test_prefers_codex_on_path_when_claude_missing(self, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - - def fake_which(cmd): - if cmd == "codex": - return "/usr/bin/codex" - return None - - with patch(MOCK_SKILLS_WHICH, side_effect=fake_which): - agent = detect_agent() - assert agent.name == "codex" - - def test_prefers_claude_over_codex_when_both_available(self, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - - def fake_which(cmd): - return f"/usr/bin/{cmd}" if cmd in ("claude", "codex") else None - - with patch(MOCK_SKILLS_WHICH, side_effect=fake_which): - agent = detect_agent() - assert agent.name == "claude" - - def test_raises_when_nothing_found(self, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - with patch(MOCK_SKILLS_WHICH, return_value=None): - with pytest.raises(RuntimeError, match="No agent found"): - detect_agent() - - -class TestInstallSkill: - def test_claude_skill_path(self, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - dest = install_skill("new-ralph", "claude") - assert dest == Path(".claude/skills/new-ralph/SKILL.md") - assert (tmp_path / dest).exists() - assert "new-ralph" in (tmp_path / dest).read_text() - - def test_codex_skill_path(self, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - dest = install_skill("new-ralph", "codex") - assert dest == Path(".agents/skills/new-ralph/SKILL.md") - assert (tmp_path / dest).exists() - - def test_raises_for_unknown_agent(self, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - with pytest.raises(RuntimeError, match="Unknown agent"): - install_skill("new-ralph", "unknown-agent") - - def test_overwrites_existing(self, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - skill_dir = tmp_path / ".claude" / "skills" / "new-ralph" - skill_dir.mkdir(parents=True) - (skill_dir / "SKILL.md").write_text("old version") - - install_skill("new-ralph", "claude") - assert (skill_dir / "SKILL.md").read_text() != "old version" - - -class TestBuildAgentCommand: - def test_claude_with_name(self): - cmd = build_agent_command("claude", "new-ralph", "my-task") - assert cmd == ["claude", "--dangerously-skip-permissions", "/new-ralph my-task"] - - def test_claude_without_name(self): - cmd = build_agent_command("claude", "new-ralph", None) - assert cmd == ["claude", "--dangerously-skip-permissions", "/new-ralph"] - - def test_codex_with_name(self): - cmd = build_agent_command("codex", "new-ralph", "my-task") - assert cmd == ["codex", "$new-ralph my-task"] - - def test_codex_without_name(self): - cmd = build_agent_command("codex", "new-ralph", None) - assert cmd == ["codex", "$new-ralph"] - - def test_raises_for_unknown_agent(self): - with pytest.raises(RuntimeError, match="Unknown agent"): - build_agent_command("unknown-agent", "new-ralph", None) diff --git a/tests/test_source.py b/tests/test_source.py deleted file mode 100644 index 39508c6..0000000 --- a/tests/test_source.py +++ /dev/null @@ -1,477 +0,0 @@ -"""Tests for GitHub source parsing and ralph fetching.""" - -from __future__ import annotations - -import subprocess -from unittest.mock import patch - -import pytest - -from ralphify._frontmatter import RALPH_MARKER -from ralphify._source import ( - ParsedSource, - _find_ralphs_in, - _fetch_named_ralph, - _fetch_repo_ralphs, - _shallow_clone, - fetch_ralphs, - parse_github_source, -) - - -# ── parse_github_source ───────────────────────────────────────────── - - -class TestParseGithubSource: - def test_owner_repo(self): - p = parse_github_source("acme/tools") - assert p.owner_repo == "acme/tools" - assert p.repo_url == "https://github.com/acme/tools.git" - assert p.subpath is None - assert p.handle == "acme/tools" - assert p.name == "tools" - - def test_owner_repo_with_ralph_name(self): - p = parse_github_source("acme/tools/linter") - assert p.owner_repo == "acme/tools" - assert p.repo_url == "https://github.com/acme/tools.git" - assert p.subpath == "linter" - assert p.handle == "acme/tools/linter" - assert p.name == "linter" - - def test_owner_repo_with_deep_path(self): - p = parse_github_source("acme/tools/some/nested/ralph") - assert p.repo_url == "https://github.com/acme/tools.git" - assert p.subpath == "some/nested/ralph" - assert p.handle == "acme/tools/some/nested/ralph" - assert p.name == "ralph" - - def test_full_github_url(self): - p = parse_github_source("https://github.com/acme/tools") - assert p.repo_url == "https://github.com/acme/tools.git" - assert p.subpath is None - assert p.name == "tools" - - def test_full_github_url_with_git_suffix(self): - p = parse_github_source("https://github.com/acme/tools.git") - assert p.repo_url == "https://github.com/acme/tools.git" - assert p.subpath is None - - def test_full_github_url_with_tree_path(self): - p = parse_github_source("https://github.com/acme/tools/tree/main/my-ralph") - assert p.repo_url == "https://github.com/acme/tools.git" - assert p.subpath == "my-ralph" - assert p.name == "my-ralph" - - def test_full_github_url_trailing_slash(self): - p = parse_github_source("https://github.com/acme/tools/") - assert p.repo_url == "https://github.com/acme/tools.git" - assert p.subpath is None - - def test_shorthand_trailing_slash(self): - """Trailing slash on shorthand format should be ignored, just like full URLs.""" - p = parse_github_source("acme/tools/") - assert p.repo_url == "https://github.com/acme/tools.git" - assert p.subpath is None - assert p.handle == "acme/tools" - assert p.name == "tools" - - def test_shorthand_double_trailing_slash(self): - """Double trailing slash 'owner/repo//' should be treated same as 'owner/repo'.""" - p = parse_github_source("acme/tools//") - assert p.repo_url == "https://github.com/acme/tools.git" - assert p.subpath is None - assert p.handle == "acme/tools" - assert p.name == "tools" - - def test_shorthand_with_git_suffix(self): - """Shorthand 'owner/repo.git' should strip .git, matching full URL behavior.""" - p = parse_github_source("acme/tools.git") - assert p.repo_url == "https://github.com/acme/tools.git" - assert p.subpath is None - assert p.handle == "acme/tools" - assert p.name == "tools" - - def test_shorthand_with_git_suffix_and_subpath(self): - """Shorthand 'owner/repo.git/ralph' should strip .git from the repo name.""" - p = parse_github_source("acme/tools.git/linter") - assert p.repo_url == "https://github.com/acme/tools.git" - assert p.subpath == "linter" - assert p.handle == "acme/tools/linter" - assert p.name == "linter" - - def test_invalid_source_raises(self): - with pytest.raises(ValueError, match="Cannot parse"): - parse_github_source("just-one-segment") - - def test_empty_string_raises(self): - with pytest.raises(ValueError, match="Cannot parse"): - parse_github_source("") - - def test_repo_that_is_only_git_suffix_raises(self): - """'owner/.git' should be rejected — after stripping .git the repo name is empty.""" - with pytest.raises(ValueError, match="Cannot parse"): - parse_github_source("owner/.git") - - def test_url_repo_that_is_only_git_suffix_raises(self): - """Full URL where repo is just '.git' should be rejected.""" - with pytest.raises(ValueError, match="Cannot parse"): - parse_github_source("https://github.com/owner/.git") - - def test_url_with_query_string_stripped(self): - """URLs copied from a browser may include ?tab=code — the query - string must be stripped so it doesn't pollute the repo name.""" - p = parse_github_source("https://github.com/acme/tools?tab=code") - assert p.owner_repo == "acme/tools" - assert p.repo_url == "https://github.com/acme/tools.git" - assert p.subpath is None - - def test_url_with_fragment_stripped(self): - """URLs may include #readme — the fragment must be stripped.""" - p = parse_github_source("https://github.com/acme/tools#readme") - assert p.owner_repo == "acme/tools" - assert p.repo_url == "https://github.com/acme/tools.git" - assert p.subpath is None - - def test_url_with_tree_path_and_query_string(self): - """Query string after a tree path should be stripped.""" - p = parse_github_source( - "https://github.com/acme/tools/tree/main/my-ralph?tab=code" - ) - assert p.owner_repo == "acme/tools" - assert p.subpath == "my-ralph" - assert p.name == "my-ralph" - - -# ── _find_ralphs_in ───────────────────────────────────────────────── - - -class TestFindRalphsIn: - def test_finds_nested_ralphs(self, tmp_path): - (tmp_path / "a" / "b").mkdir(parents=True) - (tmp_path / "a" / "b" / RALPH_MARKER).write_text("prompt") - (tmp_path / "c").mkdir() - (tmp_path / "c" / RALPH_MARKER).write_text("prompt") - result = _find_ralphs_in(tmp_path) - names = [p.name for p in result] - assert sorted(names) == ["b", "c"] - - def test_returns_empty_when_none(self, tmp_path): - (tmp_path / "a").mkdir() - assert _find_ralphs_in(tmp_path) == [] - - def test_includes_root_if_ralph(self, tmp_path): - (tmp_path / RALPH_MARKER).write_text("prompt") - result = _find_ralphs_in(tmp_path) - assert tmp_path in result - - -# ── _fetch_repo_ralphs (no git, uses pre-built directories) ───────── - - -class TestFetchRepoRalphs: - def test_root_is_ralph(self, tmp_path): - clone_dir = tmp_path / "repo" - clone_dir.mkdir() - (clone_dir / RALPH_MARKER).write_text("prompt") - - dest_dir = tmp_path / "installed" - dest_dir.mkdir() - - parsed = ParsedSource( - owner_repo="a/b", - repo_url="https://github.com/a/b.git", - subpath=None, - handle="a/b", - name="b", - ) - result = _fetch_repo_ralphs(clone_dir, parsed, dest_dir) - assert len(result.installed) == 1 - assert result.installed[0][0] == "b" - assert (dest_dir / "b" / RALPH_MARKER).is_file() - - def test_repo_with_multiple_ralphs(self, tmp_path): - clone_dir = tmp_path / "repo" - for name in ("alpha", "beta"): - d = clone_dir / "ralphs" / name - d.mkdir(parents=True) - (d / RALPH_MARKER).write_text("prompt") - - dest_dir = tmp_path / "installed" - dest_dir.mkdir() - - parsed = ParsedSource( - owner_repo="a/b", - repo_url="https://github.com/a/b.git", - subpath=None, - handle="a/b", - name="b", - ) - result = _fetch_repo_ralphs(clone_dir, parsed, dest_dir) - assert len(result.installed) == 2 - names = sorted(n for n, _ in result.installed) - assert names == ["alpha", "beta"] - - def test_duplicate_names_raises(self, tmp_path): - """When a repo contains multiple ralphs with the same leaf directory - name, _fetch_repo_ralphs should raise instead of silently overwriting.""" - clone_dir = tmp_path / "repo" - for path in ("tasks/lint", "tools/lint"): - d = clone_dir / path - d.mkdir(parents=True) - (d / RALPH_MARKER).write_text(f"prompt from {path}") - - dest_dir = tmp_path / "installed" - dest_dir.mkdir() - - parsed = ParsedSource( - owner_repo="a/b", - repo_url="https://github.com/a/b.git", - subpath=None, - handle="a/b", - name="b", - ) - with pytest.raises(RuntimeError, match="Found multiple ralphs named 'lint'"): - _fetch_repo_ralphs(clone_dir, parsed, dest_dir) - - def test_no_ralphs_raises(self, tmp_path): - clone_dir = tmp_path / "repo" - clone_dir.mkdir() - (clone_dir / "README.md").write_text("hello") - - dest_dir = tmp_path / "installed" - dest_dir.mkdir() - - parsed = ParsedSource( - owner_repo="a/b", - repo_url="https://github.com/a/b.git", - subpath=None, - handle="a/b", - name="b", - ) - with pytest.raises(RuntimeError, match="No RALPH.md found"): - _fetch_repo_ralphs(clone_dir, parsed, dest_dir) - - -# ── _fetch_named_ralph ────────────────────────────────────────────── - - -class TestFetchNamedRalph: - def test_exact_subpath_match(self, tmp_path): - clone_dir = tmp_path / "repo" - d = clone_dir / "cookbooks" / "lint" - d.mkdir(parents=True) - (d / RALPH_MARKER).write_text("prompt") - - dest_dir = tmp_path / "installed" - dest_dir.mkdir() - - parsed = ParsedSource( - owner_repo="a/b", - repo_url="https://github.com/a/b.git", - subpath="cookbooks/lint", - handle="a/b/cookbooks/lint", - name="lint", - ) - result = _fetch_named_ralph(clone_dir, parsed, dest_dir) - assert result.installed[0][0] == "lint" - assert (dest_dir / "lint" / RALPH_MARKER).is_file() - - def test_search_by_name(self, tmp_path): - clone_dir = tmp_path / "repo" - d = clone_dir / "deep" / "nested" / "lint" - d.mkdir(parents=True) - (d / RALPH_MARKER).write_text("prompt") - - dest_dir = tmp_path / "installed" - dest_dir.mkdir() - - # subpath is just "lint" — not the full path - parsed = ParsedSource( - owner_repo="a/b", - repo_url="https://github.com/a/b.git", - subpath="lint", - handle="a/b/lint", - name="lint", - ) - result = _fetch_named_ralph(clone_dir, parsed, dest_dir) - assert result.installed[0][0] == "lint" - - def test_ambiguous_raises_with_paths(self, tmp_path): - clone_dir = tmp_path / "repo" - for path in ("a/lint", "b/lint"): - d = clone_dir / path - d.mkdir(parents=True) - (d / RALPH_MARKER).write_text("prompt") - - dest_dir = tmp_path / "installed" - dest_dir.mkdir() - - parsed = ParsedSource( - owner_repo="x/y", - repo_url="https://github.com/x/y.git", - subpath="lint", - handle="x/y/lint", - name="lint", - ) - with pytest.raises(RuntimeError, match="Found multiple ralphs named 'lint'"): - _fetch_named_ralph(clone_dir, parsed, dest_dir) - - def test_not_found_raises(self, tmp_path): - clone_dir = tmp_path / "repo" - clone_dir.mkdir() - - dest_dir = tmp_path / "installed" - dest_dir.mkdir() - - parsed = ParsedSource( - owner_repo="a/b", - repo_url="https://github.com/a/b.git", - subpath="nope", - handle="a/b/nope", - name="nope", - ) - with pytest.raises(RuntimeError, match="No ralph named 'nope'"): - _fetch_named_ralph(clone_dir, parsed, dest_dir) - - def test_overwrites_existing(self, tmp_path): - clone_dir = tmp_path / "repo" - d = clone_dir / "lint" - d.mkdir(parents=True) - (d / RALPH_MARKER).write_text("new prompt") - - dest_dir = tmp_path / "installed" - old = dest_dir / "lint" - old.mkdir(parents=True) - (old / RALPH_MARKER).write_text("old prompt") - - parsed = ParsedSource( - owner_repo="a/b", - repo_url="https://github.com/a/b.git", - subpath="lint", - handle="a/b/lint", - name="lint", - ) - _fetch_named_ralph(clone_dir, parsed, dest_dir) - assert (dest_dir / "lint" / RALPH_MARKER).read_text() == "new prompt" - - -# ── _shallow_clone ───────────────────────────────────────────────── - - -class TestShallowClone: - def test_git_not_installed_raises(self, tmp_path): - with patch("ralphify._source.subprocess.run", side_effect=FileNotFoundError): - with pytest.raises(RuntimeError, match="git is required"): - _shallow_clone("https://github.com/a/b.git", tmp_path / "dest") - - def test_clone_failure_raises_with_stderr(self, tmp_path): - exc = subprocess.CalledProcessError(128, "git", stderr="repo not found") - with patch("ralphify._source.subprocess.run", side_effect=exc): - with pytest.raises(RuntimeError, match="git clone failed: repo not found"): - _shallow_clone("https://github.com/a/b.git", tmp_path / "dest") - - def test_clone_failure_empty_stderr(self, tmp_path): - exc = subprocess.CalledProcessError(128, "git", stderr="") - with patch("ralphify._source.subprocess.run", side_effect=exc): - with pytest.raises(RuntimeError, match="git clone failed: unknown error"): - _shallow_clone("https://github.com/a/b.git", tmp_path / "dest") - - def test_clone_failure_none_stderr(self, tmp_path): - exc = subprocess.CalledProcessError(128, "git", stderr=None) - with patch("ralphify._source.subprocess.run", side_effect=exc): - with pytest.raises(RuntimeError, match="git clone failed: unknown error"): - _shallow_clone("https://github.com/a/b.git", tmp_path / "dest") - - def test_clone_failure_whitespace_only_stderr(self, tmp_path): - """Whitespace-only stderr should fall back to 'unknown error', not an empty message.""" - exc = subprocess.CalledProcessError(128, "git", stderr=" \n") - with patch("ralphify._source.subprocess.run", side_effect=exc): - with pytest.raises(RuntimeError, match="git clone failed: unknown error"): - _shallow_clone("https://github.com/a/b.git", tmp_path / "dest") - - -# ── fetch_ralphs ─────────────────────────────────────────────────── - - -class TestFetchRalphs: - def _make_clone_dir(self, tmp_path, ralphs: dict[str, str] | None = None): - """Helper: build a fake clone directory with ralph files.""" - clone_dir = tmp_path / "repo" - clone_dir.mkdir(parents=True, exist_ok=True) - if ralphs: - for path, content in ralphs.items(): - d = clone_dir / path - d.mkdir(parents=True, exist_ok=True) - (d / RALPH_MARKER).write_text(content) - return clone_dir - - def test_fetch_without_subpath_delegates_to_repo_ralphs(self, tmp_path): - """fetch_ralphs with subpath=None installs repo-root ralph.""" - ralphs_dir = tmp_path / "installed" - ralphs_dir.mkdir() - - parsed = ParsedSource( - owner_repo="a/b", - repo_url="https://github.com/a/b.git", - subpath=None, - handle="a/b", - name="b", - ) - - def fake_clone(repo_url, dest): - dest.mkdir(parents=True, exist_ok=True) - (dest / RALPH_MARKER).write_text("root prompt") - - with patch("ralphify._source._shallow_clone", side_effect=fake_clone): - result = fetch_ralphs(parsed, ralphs_dir) - - assert len(result.installed) == 1 - assert result.installed[0][0] == "b" - assert (ralphs_dir / "b" / RALPH_MARKER).read_text() == "root prompt" - - def test_fetch_with_subpath_delegates_to_named_ralph(self, tmp_path): - """fetch_ralphs with subpath set installs the named ralph.""" - ralphs_dir = tmp_path / "installed" - ralphs_dir.mkdir() - - parsed = ParsedSource( - owner_repo="a/b", - repo_url="https://github.com/a/b.git", - subpath="my-ralph", - handle="a/b/my-ralph", - name="my-ralph", - ) - - def fake_clone(repo_url, dest): - dest.mkdir(parents=True, exist_ok=True) - rd = dest / "my-ralph" - rd.mkdir() - (rd / RALPH_MARKER).write_text("named prompt") - - with patch("ralphify._source._shallow_clone", side_effect=fake_clone): - result = fetch_ralphs(parsed, ralphs_dir) - - assert len(result.installed) == 1 - assert result.installed[0][0] == "my-ralph" - assert (ralphs_dir / "my-ralph" / RALPH_MARKER).read_text() == "named prompt" - - def test_fetch_clone_failure_propagates(self, tmp_path): - """fetch_ralphs propagates RuntimeError from _shallow_clone.""" - ralphs_dir = tmp_path / "installed" - ralphs_dir.mkdir() - - parsed = ParsedSource( - owner_repo="a/b", - repo_url="https://github.com/a/b.git", - subpath=None, - handle="a/b", - name="b", - ) - - with patch( - "ralphify._source._shallow_clone", - side_effect=RuntimeError("git clone failed: not found"), - ): - with pytest.raises(RuntimeError, match="git clone failed"): - fetch_ralphs(parsed, ralphs_dir)