Skip to content

Commit 34f31ad

Browse files
Kasper Jungeclaude
authored andcommitted
feat: add ralph add to install ralphs from GitHub repos
Users can now install shared ralphs from GitHub with `ralph add owner/repo` or `ralph add owner/repo/ralph-name`. Installed ralphs go to `.ralphify/ralphs/<name>/` and can be run by name with `ralph run <name>`. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 34592f3 commit 34f31ad

8 files changed

Lines changed: 579 additions & 1 deletion

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ ralph_logs/
1111
node_modules/
1212
.playwright-cli/
1313
mess/
14+
.ralphify/
1415
.DS_Store

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ Key modules:
2929
- `_events.py` — Event types, emitter protocol (NullEmitter, QueueEmitter, FanoutEmitter), and BoundEmitter convenience wrapper
3030
- `_console_emitter.py` — Rich terminal rendering of events
3131
- `_output.py``ProcessResult` base class, combine stdout+stderr, format durations
32+
- `_source.py` — GitHub source parsing and git-based ralph fetching for `ralph add`
3233
- `_skills.py` — Skill installation, agent detection, and command building for `ralph new`
3334
- `skills/new-ralph/SKILL.md` — AI-guided ralph creation skill (bundled, installed into agent skill dir)
3435

docs/cli.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,33 @@ The command detects your agent and installs a skill to guide the creation proces
133133

134134
---
135135

136+
## `ralph add`
137+
138+
Add a ralph from a GitHub repository. Installs it to `.ralphify/ralphs/<name>/` so you can run it by name.
139+
140+
```bash
141+
ralph add owner/repo # Install repo as a ralph (or all ralphs in the repo)
142+
ralph add owner/repo/ralph-name # Install a specific ralph by name
143+
ralph add https://github.com/owner/repo # Full GitHub URL also works
144+
```
145+
146+
| Argument | Default | Description |
147+
|---|---|---|
148+
| `SOURCE` | required | GitHub source: `owner/repo` or `owner/repo/ralph-name` |
149+
150+
**How it resolves:**
151+
152+
- `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.
153+
- `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.
154+
155+
After adding, run the ralph by name:
156+
157+
```bash
158+
ralph run ralph-name
159+
```
160+
161+
---
162+
136163
## RALPH.md format
137164

138165
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.

docs/contributing/codebase-map.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ src/ralphify/ # All source code
2727
├── _run_types.py # RunConfig, RunState, RunStatus, Command — shared data types
2828
├── _runner.py # Execute shell commands with timeout and capture output
2929
├── _frontmatter.py # Parse YAML frontmatter from RALPH.md, marker constants
30+
├── _source.py # GitHub source parsing and git-based ralph fetching for `ralph add`
3031
├── _skills.py # Skill installation and agent detection for `ralph new`
3132
├── _console_emitter.py # Rich console renderer for run-loop events (ConsoleEmitter)
3233
├── _events.py # Event types, emitter protocol, and BoundEmitter convenience wrapper

src/ralphify/_source.py

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
"""GitHub source parsing and git-based ralph fetching.
2+
3+
Parses ``owner/repo``, ``owner/repo/ralph-name``, and full GitHub URLs
4+
into a normalised form, then clones the repo (shallow) and extracts the
5+
requested ralph directory.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import re
11+
import shutil
12+
import subprocess
13+
import tempfile
14+
from dataclasses import dataclass
15+
from pathlib import Path
16+
17+
from ralphify._frontmatter import RALPH_MARKER
18+
19+
20+
@dataclass(frozen=True)
21+
class ParsedSource:
22+
"""Normalised representation of a GitHub ralph source."""
23+
24+
repo_url: str
25+
"""Clone URL, e.g. ``https://github.com/owner/repo.git``."""
26+
27+
subpath: str | None
28+
"""Path segment(s) after ``owner/repo``, or *None* for repo-root."""
29+
30+
handle: str
31+
"""Canonical short-form, e.g. ``owner/repo/ralph-name``."""
32+
33+
name: str
34+
"""Derived ralph name (leaf directory or repo name)."""
35+
36+
37+
# ---------------------------------------------------------------------------
38+
# GitHub URL helpers
39+
# ---------------------------------------------------------------------------
40+
41+
_GITHUB_URL_RE = re.compile(
42+
r"^https?://github\.com/(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?(?:/tree/[^/]+(?:/(?P<path>.+))?)?/?$"
43+
)
44+
45+
_SHORTHAND_RE = re.compile(r"^(?P<owner>[^/]+)/(?P<repo>[^/]+)(?:/(?P<rest>.+))?$")
46+
47+
48+
def parse_github_source(source: str) -> ParsedSource:
49+
"""Parse a GitHub source string into a :class:`ParsedSource`.
50+
51+
Accepted formats::
52+
53+
owner/repo
54+
owner/repo/ralph-name
55+
owner/repo/some/path/to/ralph
56+
https://github.com/owner/repo
57+
https://github.com/owner/repo/tree/main/path
58+
59+
Raises ``ValueError`` for unrecognised formats.
60+
"""
61+
owner: str | None = None
62+
repo: str | None = None
63+
rest: str | None = None
64+
65+
# Try full URL first.
66+
m = _GITHUB_URL_RE.match(source)
67+
if m:
68+
owner, repo, rest = m.group("owner"), m.group("repo"), m.group("path")
69+
else:
70+
m = _SHORTHAND_RE.match(source)
71+
if m:
72+
owner, repo, rest = m.group("owner"), m.group("repo"), m.group("rest")
73+
74+
if not owner or not repo:
75+
raise ValueError(
76+
f"Cannot parse source '{source}'. "
77+
"Expected owner/repo, owner/repo/ralph-name, or a GitHub URL."
78+
)
79+
80+
repo_url = f"https://github.com/{owner}/{repo}.git"
81+
subpath = rest.strip("/") if rest else None
82+
name = subpath.rstrip("/").rsplit("/", 1)[-1] if subpath else repo
83+
handle = f"{owner}/{repo}/{subpath}" if subpath else f"{owner}/{repo}"
84+
85+
return ParsedSource(repo_url=repo_url, subpath=subpath, handle=handle, name=name)
86+
87+
88+
# ---------------------------------------------------------------------------
89+
# Git clone + ralph extraction
90+
# ---------------------------------------------------------------------------
91+
92+
93+
def _find_ralphs_in(root: Path) -> list[Path]:
94+
"""Return all directories under *root* that contain a RALPH.md."""
95+
return sorted(
96+
p.parent for p in root.rglob(RALPH_MARKER) if p.is_file()
97+
)
98+
99+
100+
def _shallow_clone(repo_url: str, dest: Path) -> None:
101+
"""Run ``git clone --depth 1`` into *dest*.
102+
103+
Raises ``RuntimeError`` on failure.
104+
"""
105+
try:
106+
subprocess.run(
107+
["git", "clone", "--depth", "1", repo_url, str(dest)],
108+
capture_output=True,
109+
text=True,
110+
check=True,
111+
)
112+
except FileNotFoundError:
113+
raise RuntimeError(
114+
"git is required for 'ralph add'. Install it from https://git-scm.com/"
115+
) from None
116+
except subprocess.CalledProcessError as exc:
117+
stderr = exc.stderr.strip() if exc.stderr else "unknown error"
118+
raise RuntimeError(f"git clone failed: {stderr}") from None
119+
120+
121+
@dataclass(frozen=True)
122+
class FetchResult:
123+
"""Result of fetching ralph(s) from a source."""
124+
125+
installed: list[tuple[str, Path]]
126+
"""List of ``(name, dest_path)`` for each installed ralph."""
127+
128+
129+
def fetch_ralphs(parsed: ParsedSource, ralphs_dir: Path) -> FetchResult:
130+
"""Clone the repo and extract ralph(s) to *ralphs_dir*.
131+
132+
*ralphs_dir* is the ``.ralphify/ralphs/`` directory. Each ralph is
133+
placed in ``ralphs_dir/<name>/``.
134+
135+
Returns a :class:`FetchResult` describing what was installed.
136+
Raises ``RuntimeError`` on any failure.
137+
"""
138+
with tempfile.TemporaryDirectory() as tmp:
139+
clone_dir = Path(tmp) / "repo"
140+
_shallow_clone(parsed.repo_url, clone_dir)
141+
142+
if parsed.subpath is None:
143+
# owner/repo — check if root is a ralph, else install all.
144+
return _fetch_repo_ralphs(clone_dir, parsed, ralphs_dir)
145+
else:
146+
# owner/repo/ralph-name — search for the ralph.
147+
return _fetch_named_ralph(clone_dir, parsed, ralphs_dir)
148+
149+
150+
def _fetch_repo_ralphs(
151+
clone_dir: Path, parsed: ParsedSource, ralphs_dir: Path,
152+
) -> FetchResult:
153+
"""Handle ``owner/repo`` — repo root is a ralph, or install all."""
154+
root_ralph = clone_dir / RALPH_MARKER
155+
if root_ralph.is_file():
156+
dest = ralphs_dir / parsed.name
157+
_copy_ralph(clone_dir, dest)
158+
return FetchResult(installed=[(parsed.name, dest)])
159+
160+
# Scan for all ralphs in the repo.
161+
ralph_dirs = _find_ralphs_in(clone_dir)
162+
if not ralph_dirs:
163+
raise RuntimeError(
164+
f"No {RALPH_MARKER} found in {parsed.handle}."
165+
)
166+
167+
installed: list[tuple[str, Path]] = []
168+
for rd in ralph_dirs:
169+
name = rd.name
170+
dest = ralphs_dir / name
171+
_copy_ralph(rd, dest)
172+
installed.append((name, dest))
173+
return FetchResult(installed=installed)
174+
175+
176+
def _fetch_named_ralph(
177+
clone_dir: Path, parsed: ParsedSource, ralphs_dir: Path,
178+
) -> FetchResult:
179+
"""Handle ``owner/repo/ralph-name`` — search or exact subpath."""
180+
assert parsed.subpath is not None
181+
182+
# First try exact subpath.
183+
exact = clone_dir / parsed.subpath
184+
if exact.is_dir() and (exact / RALPH_MARKER).is_file():
185+
dest = ralphs_dir / parsed.name
186+
_copy_ralph(exact, dest)
187+
return FetchResult(installed=[(parsed.name, dest)])
188+
189+
# Search by name (leaf segment).
190+
ralph_name = parsed.name
191+
all_ralphs = _find_ralphs_in(clone_dir)
192+
matches = [rd for rd in all_ralphs if rd.name == ralph_name]
193+
194+
if len(matches) == 1:
195+
dest = ralphs_dir / ralph_name
196+
_copy_ralph(matches[0], dest)
197+
return FetchResult(installed=[(ralph_name, dest)])
198+
199+
if len(matches) > 1:
200+
paths = "\n".join(
201+
f" - {m.relative_to(clone_dir)}/{RALPH_MARKER}" for m in matches
202+
)
203+
owner_repo = "/".join(parsed.handle.split("/")[:2])
204+
raise RuntimeError(
205+
f"Found multiple ralphs named '{ralph_name}' in {owner_repo}:\n"
206+
f"{paths}\n\n"
207+
f"Use the full path to disambiguate, e.g.:\n"
208+
f" ralph add {owner_repo}/{matches[0].relative_to(clone_dir)}"
209+
)
210+
211+
raise RuntimeError(
212+
f"No ralph named '{ralph_name}' found in {parsed.handle}."
213+
)
214+
215+
216+
def _copy_ralph(src: Path, dest: Path) -> None:
217+
"""Copy a ralph directory to *dest*, overwriting if it exists."""
218+
if dest.exists():
219+
shutil.rmtree(dest)
220+
shutil.copytree(src, dest, ignore=shutil.ignore_patterns(".git"))

src/ralphify/cli.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,37 @@ def init(
203203
_console.print(f"[dim]Edit the file, then run:[/dim] ralph run {name or '.'}")
204204

205205

206+
@app.command()
207+
def add(
208+
source: str = typer.Argument(..., help="GitHub source: owner/repo or owner/repo/ralph-name"),
209+
) -> None:
210+
"""Add a ralph from a GitHub repository."""
211+
from ralphify._source import fetch_ralphs, parse_github_source
212+
213+
try:
214+
parsed = parse_github_source(source)
215+
except ValueError as exc:
216+
_exit_error(str(exc))
217+
218+
ralphs_dir = Path.cwd() / _RALPHIFY_RALPHS_DIR
219+
ralphs_dir.mkdir(parents=True, exist_ok=True)
220+
221+
try:
222+
result = fetch_ralphs(parsed, ralphs_dir)
223+
except RuntimeError as exc:
224+
_exit_error(str(exc))
225+
226+
if len(result.installed) == 1:
227+
name, _ = result.installed[0]
228+
_console.print(f"[green]Added[/green] {name}")
229+
_console.print(f"[dim]Run it with:[/dim] ralph run {name}")
230+
else:
231+
_console.print(f"[green]Added {len(result.installed)} ralphs from {parsed.handle}:[/green]")
232+
for name, _ in result.installed:
233+
_console.print(f" {name}")
234+
_console.print(f"\n[dim]Run any with:[/dim] ralph run <name>")
235+
236+
206237
def _parse_user_args(
207238
raw_args: list[str],
208239
declared_names: list[str] | None,
@@ -321,6 +352,18 @@ def _validate_commands(raw_commands: Any) -> list[Command]:
321352
return _parse_command_items(raw_commands)
322353

323354

355+
_RALPHIFY_RALPHS_DIR = Path(".ralphify") / "ralphs"
356+
"""Project-local directory for installed ralphs."""
357+
358+
359+
def _installed_ralph_path(name: str) -> Path | None:
360+
"""Return the installed ralph directory if it exists, else *None*."""
361+
path = Path.cwd() / _RALPHIFY_RALPHS_DIR / name
362+
if (path / RALPH_MARKER).is_file():
363+
return path
364+
return None
365+
366+
324367
def _resolve_ralph_paths(ralph_path: str) -> tuple[Path, Path]:
325368
"""Resolve the ralph directory and RALPH.md file from a user-provided path.
326369
@@ -336,7 +379,15 @@ def _resolve_ralph_paths(ralph_path: str) -> tuple[Path, Path]:
336379
ralph_dir = path.parent
337380
ralph_file = path
338381
else:
339-
_exit_error(f"'{ralph_path}' is not a directory or {RALPH_MARKER} file.")
382+
# Fallback: check installed ralphs in .ralphify/ralphs/<name>/
383+
installed = _installed_ralph_path(ralph_path)
384+
if installed is not None:
385+
ralph_dir = installed
386+
ralph_file = installed / RALPH_MARKER
387+
else:
388+
_exit_error(
389+
f"'{ralph_path}' is not a directory, {RALPH_MARKER} file, or installed ralph."
390+
)
340391

341392
if not ralph_file.exists():
342393
_exit_error(f"{RALPH_MARKER} not found at '{ralph_file}'.")

tests/test_cli.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -875,3 +875,42 @@ def test_run_command_restores_original_handler(self, mock_which, mock_run, tmp_p
875875
runner.invoke(app, ["run", str(ralph_dir), "-n", "1"])
876876
restored = signal.getsignal(signal.SIGINT)
877877
assert restored == original
878+
879+
880+
# ── Name-based resolution (installed ralphs) ────────────────────────
881+
882+
883+
@patch(MOCK_WHICH, return_value="/usr/bin/claude")
884+
class TestInstalledRalphResolution:
885+
def test_run_resolves_installed_ralph_by_name(self, mock_which, tmp_path, monkeypatch):
886+
monkeypatch.chdir(tmp_path)
887+
installed = tmp_path / ".ralphify" / "ralphs" / "my-tool"
888+
installed.mkdir(parents=True)
889+
(installed / RALPH_MARKER).write_text("---\nagent: claude -p\n---\ngo")
890+
result = runner.invoke(app, ["run", "my-tool", "-n", "1"])
891+
# Should attempt to run (may fail at agent exec, but should NOT error on path resolution)
892+
assert "not a directory" not in result.output.lower()
893+
assert "installed ralph" not in result.output.lower()
894+
895+
def test_local_path_takes_precedence(self, mock_which, tmp_path, monkeypatch):
896+
monkeypatch.chdir(tmp_path)
897+
# Create both a local dir and an installed ralph with the same name
898+
local = tmp_path / "my-tool"
899+
local.mkdir()
900+
(local / RALPH_MARKER).write_text("---\nagent: claude -p\n---\nlocal prompt")
901+
902+
installed = tmp_path / ".ralphify" / "ralphs" / "my-tool"
903+
installed.mkdir(parents=True)
904+
(installed / RALPH_MARKER).write_text("---\nagent: claude -p\n---\ninstalled prompt")
905+
906+
# Run should use the local path, not the installed one
907+
# We verify by checking the config reads the local prompt
908+
from ralphify.cli import _resolve_ralph_paths
909+
ralph_dir, ralph_file = _resolve_ralph_paths("my-tool")
910+
assert "local prompt" in ralph_file.read_text()
911+
912+
def test_error_mentions_installed_ralph(self, mock_which, tmp_path, monkeypatch):
913+
monkeypatch.chdir(tmp_path)
914+
result = runner.invoke(app, ["run", "nonexistent"])
915+
assert result.exit_code == 1
916+
assert "installed ralph" in result.output.lower()

0 commit comments

Comments
 (0)