Skip to content

Commit c54dc7a

Browse files
committed
feat: add ralph as a dependency type so users can manage ralphs via agr
Ralphs are autonomous agent loop definitions (RALPH.md marker files) from the ralphify project. This adds full lifecycle support: add, remove, sync, and list. Ralphs auto-detect from marker files and install to .agents/ralphs/ (tool-agnostic, unlike skills which are per-tool). Key changes: - New agr/ralph.py discovery module (mirrors skill.py) - Ralph install/uninstall functions in fetcher.py - Lockfile [[ralph]] section support - Auto-detection in add command (RALPH.md vs SKILL.md) - Type validation in config parsing - Tool-agnostic metadata stamping https://claude.ai/code/session_01J5T3Nahs5A8RvizTq4jeQL
1 parent 77550c1 commit c54dc7a

File tree

19 files changed

+1350
-137
lines changed

19 files changed

+1350
-137
lines changed

agr/commands/add.py

Lines changed: 114 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
11
"""agr add command implementation."""
22

3+
from pathlib import Path
4+
35
from agr.commands import CommandResult
46
from agr.commands._tool_helpers import load_existing_config, save_and_summarize_results
57
from agr.commands.migrations import run_tool_migrations
6-
from agr.config import DEPENDENCY_TYPE_SKILL, Dependency
8+
from agr.config import DEPENDENCY_TYPE_RALPH, DEPENDENCY_TYPE_SKILL, Dependency
79
from agr.console import get_console
810
from agr.exceptions import (
911
INSTALL_ERROR_TYPES,
1012
AgrError,
13+
RalphNotFoundError,
1114
SkillNotFoundError,
1215
format_install_error,
1316
)
1417
from agr.fetcher import (
1518
InstallResult,
19+
fetch_and_install_ralph,
1620
fetch_and_install_to_tools,
1721
list_remote_repo_skills,
1822
)
@@ -25,9 +29,30 @@
2529
save_lockfile,
2630
update_lockfile_entry,
2731
)
32+
from agr.ralph import is_valid_ralph_dir
33+
from agr.skill import is_valid_skill_dir
2834
from agr.source import SourceResolver
2935

3036

37+
def _detect_local_type(source_path: Path) -> str:
38+
"""Detect whether a local path is a skill or ralph.
39+
40+
Checks for RALPH.md and SKILL.md markers. If both exist,
41+
raises an error. If neither exists, defaults to skill (existing behaviour).
42+
"""
43+
has_ralph = is_valid_ralph_dir(source_path)
44+
has_skill = is_valid_skill_dir(source_path)
45+
46+
if has_ralph and has_skill:
47+
raise AgrError(
48+
f"'{source_path}' contains both SKILL.md and RALPH.md. "
49+
"A directory can only be one type. Remove one marker file."
50+
)
51+
if has_ralph:
52+
return DEPENDENCY_TYPE_RALPH
53+
return DEPENDENCY_TYPE_SKILL
54+
55+
3156
def run_add(
3257
refs: list[str],
3358
overwrite: bool = False,
@@ -50,35 +75,99 @@ def run_add(
5075

5176
# Track results for summary
5277
results: list[CommandResult] = []
53-
# Track install results for lockfile
54-
lockfile_updates: list[tuple[ParsedHandle, str, InstallResult]] = []
78+
# Track install results for lockfile: (handle, ref, install_result, dep_type)
79+
lockfile_updates: list[tuple[ParsedHandle, str, InstallResult, str]] = []
5580

5681
for ref in refs:
5782
try:
5883
# Parse handle
5984
handle = parse_handle(ref, default_owner=config.default_owner)
6085

6186
if source and handle.is_local:
62-
raise AgrError("Local skills cannot specify a source")
87+
raise AgrError("Local dependencies cannot specify a source")
6388

6489
# Validate explicit source if provided
6590
if source:
6691
resolver.get(source)
6792

68-
# Install the skill to all configured tools (downloads once)
69-
installed_paths_dict, install_result = fetch_and_install_to_tools(
70-
handle,
71-
repo_root,
72-
tools,
73-
overwrite,
74-
resolver=resolver,
75-
source=source,
76-
skills_dirs=skills_dirs,
77-
default_repo=config.default_repo,
78-
)
79-
installed_paths = [
80-
f"{name}: {path}" for name, path in installed_paths_dict.items()
81-
]
93+
# Auto-detect type and install
94+
if handle.is_local:
95+
source_path = handle.resolve_local_path(repo_root)
96+
dep_type = _detect_local_type(source_path)
97+
else:
98+
dep_type = DEPENDENCY_TYPE_SKILL # default, may change below
99+
100+
if dep_type == DEPENDENCY_TYPE_RALPH or (
101+
not handle.is_local and dep_type == DEPENDENCY_TYPE_SKILL
102+
):
103+
# For remote handles, try skill first; if not found, try ralph
104+
if handle.is_local:
105+
# Local ralph
106+
installed_path, install_result = fetch_and_install_ralph(
107+
handle,
108+
repo_root,
109+
overwrite,
110+
resolver=resolver,
111+
source=source,
112+
default_repo=config.default_repo,
113+
)
114+
installed_paths = [str(installed_path)]
115+
dep_type = DEPENDENCY_TYPE_RALPH
116+
else:
117+
# Remote: try as skill first
118+
try:
119+
installed_paths_dict, install_result = (
120+
fetch_and_install_to_tools(
121+
handle,
122+
repo_root,
123+
tools,
124+
overwrite,
125+
resolver=resolver,
126+
source=source,
127+
skills_dirs=skills_dirs,
128+
default_repo=config.default_repo,
129+
)
130+
)
131+
installed_paths = [
132+
f"{name}: {path}"
133+
for name, path in installed_paths_dict.items()
134+
]
135+
dep_type = DEPENDENCY_TYPE_SKILL
136+
except SkillNotFoundError:
137+
# Skill not found — try as ralph
138+
try:
139+
installed_path, install_result = fetch_and_install_ralph(
140+
handle,
141+
repo_root,
142+
overwrite,
143+
resolver=resolver,
144+
source=source,
145+
default_repo=config.default_repo,
146+
)
147+
installed_paths = [str(installed_path)]
148+
dep_type = DEPENDENCY_TYPE_RALPH
149+
except RalphNotFoundError:
150+
# Neither skill nor ralph found — re-raise as skill error
151+
# for backwards-compatible error messages
152+
raise SkillNotFoundError(
153+
f"'{handle.name}' not found as a skill or ralph "
154+
f"in any configured source."
155+
) from None
156+
else:
157+
# Local skill (already detected)
158+
installed_paths_dict, install_result = fetch_and_install_to_tools(
159+
handle,
160+
repo_root,
161+
tools,
162+
overwrite,
163+
resolver=resolver,
164+
source=source,
165+
skills_dirs=skills_dirs,
166+
default_repo=config.default_repo,
167+
)
168+
installed_paths = [
169+
f"{name}: {path}" for name, path in installed_paths_dict.items()
170+
]
82171

83172
# Add to config
84173
if handle.is_local:
@@ -87,21 +176,21 @@ def run_add(
87176
path_value = str(handle.resolve_local_path())
88177
config.add_dependency(
89178
Dependency(
90-
type=DEPENDENCY_TYPE_SKILL,
179+
type=dep_type,
91180
path=path_value,
92181
)
93182
)
94183
else:
95184
config.add_dependency(
96185
Dependency(
97-
type=DEPENDENCY_TYPE_SKILL,
186+
type=dep_type,
98187
handle=handle.to_toml_handle(),
99188
source=source,
100189
),
101190
also_matches=[ref],
102191
)
103192

104-
lockfile_updates.append((handle, ref, install_result))
193+
lockfile_updates.append((handle, ref, install_result, dep_type))
105194
results.append(CommandResult(ref, True, ", ".join(installed_paths)))
106195

107196
except SkillNotFoundError as e:
@@ -131,11 +220,13 @@ def _print_add_result(result: CommandResult) -> None:
131220
if lockfile_updates:
132221
lockfile_path = build_lockfile_path(config_path)
133222
lockfile = load_lockfile(lockfile_path) or Lockfile()
134-
for handle, ref, install_result in lockfile_updates:
223+
for handle, ref, install_result, dep_type in lockfile_updates:
224+
is_ralph = dep_type == DEPENDENCY_TYPE_RALPH
135225
if handle.is_local:
136226
update_lockfile_entry(
137227
lockfile,
138228
LockedSkill(path=ref, installed_name=handle.name),
229+
ralph=is_ralph,
139230
)
140231
else:
141232
update_lockfile_entry(
@@ -147,6 +238,7 @@ def _print_add_result(result: CommandResult) -> None:
147238
content_hash=install_result.content_hash,
148239
installed_name=handle.name,
149240
),
241+
ralph=is_ralph,
150242
)
151243
save_lockfile(lockfile, lockfile_path)
152244

agr/commands/list.py

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@
55
from rich.table import Table
66

77
from agr.commands._tool_helpers import load_existing_config, print_missing_config_hint
8+
from agr.config import DEPENDENCY_TYPE_RALPH
89
from agr.console import get_console
910
from agr.exceptions import AgrError, InvalidHandleError
1011
from agr.metadata import METADATA_TYPE_LOCAL, METADATA_TYPE_REMOTE
11-
from agr.fetcher import filter_tools_needing_install
12+
from agr.fetcher import filter_tools_needing_install, is_ralph_installed
1213
from agr.handle import ParsedHandle
1314
from agr.tool import ToolConfig
1415

@@ -45,6 +46,17 @@ def _get_installation_status(
4546
return "[yellow]not synced[/yellow]"
4647

4748

49+
def _get_ralph_installation_status(
50+
handle: ParsedHandle,
51+
repo_root: Path | None,
52+
source: str | None = None,
53+
) -> str:
54+
"""Get installation status for a ralph dependency."""
55+
if is_ralph_installed(handle, repo_root, source):
56+
return "[green]installed[/green]"
57+
return "[yellow]not synced[/yellow]"
58+
59+
4860
def run_list(global_install: bool = False) -> None:
4961
"""Run the list command.
5062
@@ -60,12 +72,12 @@ def run_list(global_install: bool = False) -> None:
6072

6173
if not config.dependencies:
6274
console.print("[yellow]No dependencies in agr.toml.[/yellow]")
63-
console.print("[dim]Run 'agr add <handle>' to add skills.[/dim]")
75+
console.print("[dim]Run 'agr add <handle>' to add skills or ralphs.[/dim]")
6476
return
6577

6678
# Build table
6779
table = Table(show_header=True, header_style="bold")
68-
table.add_column("Skill", style="cyan")
80+
table.add_column("Name", style="cyan")
6981
table.add_column("Type")
7082
table.add_column("Status")
7183

@@ -78,18 +90,25 @@ def run_list(global_install: bool = False) -> None:
7890
display_name = dep.handle or ""
7991
kind = METADATA_TYPE_REMOTE
8092

93+
# Show dep type alongside local/remote
94+
dep_type_label = dep.type
95+
kind_display = f"{kind} ({dep_type_label})"
96+
8197
# Check installation status
8298
try:
8399
handle, source_name = dep.resolve(
84100
config.default_source, config.default_owner
85101
)
86-
status = _get_installation_status(
87-
handle, repo_root, tools, source_name, skills_dirs
88-
)
102+
if dep.type == DEPENDENCY_TYPE_RALPH:
103+
status = _get_ralph_installation_status(handle, repo_root, source_name)
104+
else:
105+
status = _get_installation_status(
106+
handle, repo_root, tools, source_name, skills_dirs
107+
)
89108
except (InvalidHandleError, AgrError):
90109
status = "[red]invalid[/red]"
91110

92-
table.add_row(display_name, kind, status)
111+
table.add_row(display_name, kind_display, status)
93112

94113
console.print(table)
95114

agr/commands/remove.py

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
from agr.commands import CommandResult
44
from agr.commands._tool_helpers import load_existing_config, save_and_summarize_results
55
from agr.commands.migrations import run_tool_migrations
6+
from agr.config import DEPENDENCY_TYPE_RALPH
67
from agr.console import get_console, print_error
78
from agr.exceptions import INSTALL_ERROR_TYPES, format_install_error
8-
from agr.fetcher import uninstall_skill
9+
from agr.fetcher import uninstall_ralph, uninstall_skill
910
from agr.handle import ParsedHandle, parse_handle
1011
from agr.lockfile import (
1112
build_lockfile_path,
@@ -54,6 +55,7 @@ def run_remove(refs: list[str], global_install: bool = False) -> None:
5455
# removal so we can update the lockfile without re-parsing handles.
5556
results: list[CommandResult] = []
5657
removed_candidates: list[list[str]] = []
58+
removed_ralph_flags: list[bool] = []
5759

5860
for ref in refs:
5961
try:
@@ -77,17 +79,23 @@ def run_remove(refs: list[str], global_install: bool = False) -> None:
7779
if dep and dep.is_remote:
7880
source_name = dep.source or config.default_source
7981

80-
# Remove from filesystem for all configured tools
82+
is_ralph = dep is not None and dep.type == DEPENDENCY_TYPE_RALPH
83+
84+
# Remove from filesystem
8185
removed_fs = False
82-
for tool in tools:
83-
if uninstall_skill(
84-
handle,
85-
repo_root,
86-
tool,
87-
source_name,
88-
skills_dir=lookup_skills_dir(skills_dirs, tool),
89-
):
86+
if is_ralph:
87+
if uninstall_ralph(handle, repo_root, source_name):
9088
removed_fs = True
89+
else:
90+
for tool in tools:
91+
if uninstall_skill(
92+
handle,
93+
repo_root,
94+
tool,
95+
source_name,
96+
skills_dir=lookup_skills_dir(skills_dirs, tool),
97+
):
98+
removed_fs = True
9199

92100
# Remove from config (try same candidate identifiers)
93101
removed_config = False
@@ -99,6 +107,7 @@ def run_remove(refs: list[str], global_install: bool = False) -> None:
99107
if removed_fs or removed_config:
100108
results.append(CommandResult(ref, True, "Removed"))
101109
removed_candidates.append(candidates)
110+
removed_ralph_flags.append(is_ralph)
102111
else:
103112
results.append(CommandResult(ref, False, "Not found"))
104113

@@ -124,12 +133,12 @@ def _print_remove_result(result: CommandResult) -> None:
124133
exit_on_failure=False,
125134
)
126135

127-
# Update lockfile: remove entries for successfully removed skills
136+
# Update lockfile: remove entries for successfully removed deps
128137
if removed_candidates:
129138
lockfile_path = build_lockfile_path(config_path)
130139
lockfile = load_lockfile(lockfile_path)
131140
if lockfile is not None:
132-
for candidates in removed_candidates:
141+
for candidates, is_ralph_lf in zip(removed_candidates, removed_ralph_flags):
133142
for identifier in candidates:
134-
remove_lockfile_entry(lockfile, identifier)
143+
remove_lockfile_entry(lockfile, identifier, ralph=is_ralph_lf)
135144
save_lockfile(lockfile, lockfile_path)

0 commit comments

Comments
 (0)