diff --git a/agr/commands/add.py b/agr/commands/add.py index a77394c..53d5093 100644 --- a/agr/commands/add.py +++ b/agr/commands/add.py @@ -1,18 +1,22 @@ """agr add command implementation.""" +from pathlib import Path + from agr.commands import CommandResult from agr.commands._tool_helpers import load_existing_config, save_and_summarize_results from agr.commands.migrations import run_tool_migrations -from agr.config import DEPENDENCY_TYPE_SKILL, Dependency +from agr.config import DEPENDENCY_TYPE_RALPH, DEPENDENCY_TYPE_SKILL, Dependency from agr.console import get_console from agr.exceptions import ( INSTALL_ERROR_TYPES, AgrError, + RalphNotFoundError, SkillNotFoundError, format_install_error, ) from agr.fetcher import ( InstallResult, + fetch_and_install_ralph, fetch_and_install_to_tools, list_remote_repo_skills, ) @@ -25,9 +29,30 @@ save_lockfile, update_lockfile_entry, ) +from agr.ralph import is_valid_ralph_dir +from agr.skill import is_valid_skill_dir from agr.source import SourceResolver +def _detect_local_type(source_path: Path) -> str: + """Detect whether a local path is a skill or ralph. + + Checks for RALPH.md and SKILL.md markers. If both exist, + raises an error. If neither exists, defaults to skill (existing behaviour). + """ + has_ralph = is_valid_ralph_dir(source_path) + has_skill = is_valid_skill_dir(source_path) + + if has_ralph and has_skill: + raise AgrError( + f"'{source_path}' contains both SKILL.md and RALPH.md. " + "A directory can only be one type. Remove one marker file." + ) + if has_ralph: + return DEPENDENCY_TYPE_RALPH + return DEPENDENCY_TYPE_SKILL + + def run_add( refs: list[str], overwrite: bool = False, @@ -50,8 +75,8 @@ def run_add( # Track results for summary results: list[CommandResult] = [] - # Track install results for lockfile - lockfile_updates: list[tuple[ParsedHandle, str, InstallResult]] = [] + # Track install results for lockfile: (handle, ref, install_result, dep_type) + lockfile_updates: list[tuple[ParsedHandle, str, InstallResult, str]] = [] for ref in refs: try: @@ -59,26 +84,90 @@ def run_add( handle = parse_handle(ref, default_owner=config.default_owner) if source and handle.is_local: - raise AgrError("Local skills cannot specify a source") + raise AgrError("Local dependencies cannot specify a source") # Validate explicit source if provided if source: resolver.get(source) - # Install the skill to all configured tools (downloads once) - installed_paths_dict, install_result = fetch_and_install_to_tools( - handle, - repo_root, - tools, - overwrite, - resolver=resolver, - source=source, - skills_dirs=skills_dirs, - default_repo=config.default_repo, - ) - installed_paths = [ - f"{name}: {path}" for name, path in installed_paths_dict.items() - ] + # Auto-detect type and install + if handle.is_local: + source_path = handle.resolve_local_path(repo_root) + dep_type = _detect_local_type(source_path) + else: + dep_type = DEPENDENCY_TYPE_SKILL # default, may change below + + if dep_type == DEPENDENCY_TYPE_RALPH or ( + not handle.is_local and dep_type == DEPENDENCY_TYPE_SKILL + ): + # For remote handles, try skill first; if not found, try ralph + if handle.is_local: + # Local ralph + installed_path, install_result = fetch_and_install_ralph( + handle, + repo_root, + overwrite, + resolver=resolver, + source=source, + default_repo=config.default_repo, + ) + installed_paths = [str(installed_path)] + dep_type = DEPENDENCY_TYPE_RALPH + else: + # Remote: try as skill first + try: + installed_paths_dict, install_result = ( + fetch_and_install_to_tools( + handle, + repo_root, + tools, + overwrite, + resolver=resolver, + source=source, + skills_dirs=skills_dirs, + default_repo=config.default_repo, + ) + ) + installed_paths = [ + f"{name}: {path}" + for name, path in installed_paths_dict.items() + ] + dep_type = DEPENDENCY_TYPE_SKILL + except SkillNotFoundError: + # Skill not found — try as ralph + try: + installed_path, install_result = fetch_and_install_ralph( + handle, + repo_root, + overwrite, + resolver=resolver, + source=source, + default_repo=config.default_repo, + ) + installed_paths = [str(installed_path)] + dep_type = DEPENDENCY_TYPE_RALPH + except RalphNotFoundError: + # Neither skill nor ralph found — re-raise as skill error + # for backwards-compatible error messages + raise SkillNotFoundError( + f"'{handle.name}' not found as a skill or ralph " + f"in any configured source." + ) from None + else: + # Local skill (already detected) + installed_paths_dict, install_result = fetch_and_install_to_tools( + handle, + repo_root, + tools, + overwrite, + resolver=resolver, + source=source, + skills_dirs=skills_dirs, + default_repo=config.default_repo, + ) + installed_paths = [ + f"{name}: {path}" for name, path in installed_paths_dict.items() + ] # Add to config if handle.is_local: @@ -87,21 +176,21 @@ def run_add( path_value = str(handle.resolve_local_path()) config.add_dependency( Dependency( - type=DEPENDENCY_TYPE_SKILL, + type=dep_type, path=path_value, ) ) else: config.add_dependency( Dependency( - type=DEPENDENCY_TYPE_SKILL, + type=dep_type, handle=handle.to_toml_handle(), source=source, ), also_matches=[ref], ) - lockfile_updates.append((handle, ref, install_result)) + lockfile_updates.append((handle, ref, install_result, dep_type)) results.append(CommandResult(ref, True, ", ".join(installed_paths))) except SkillNotFoundError as e: @@ -131,11 +220,13 @@ def _print_add_result(result: CommandResult) -> None: if lockfile_updates: lockfile_path = build_lockfile_path(config_path) lockfile = load_lockfile(lockfile_path) or Lockfile() - for handle, ref, install_result in lockfile_updates: + for handle, ref, install_result, dep_type in lockfile_updates: + is_ralph = dep_type == DEPENDENCY_TYPE_RALPH if handle.is_local: update_lockfile_entry( lockfile, LockedSkill(path=ref, installed_name=handle.name), + ralph=is_ralph, ) else: update_lockfile_entry( @@ -147,6 +238,7 @@ def _print_add_result(result: CommandResult) -> None: content_hash=install_result.content_hash, installed_name=handle.name, ), + ralph=is_ralph, ) save_lockfile(lockfile, lockfile_path) diff --git a/agr/commands/list.py b/agr/commands/list.py index cd10997..534a03e 100644 --- a/agr/commands/list.py +++ b/agr/commands/list.py @@ -5,10 +5,11 @@ from rich.table import Table from agr.commands._tool_helpers import load_existing_config, print_missing_config_hint +from agr.config import DEPENDENCY_TYPE_RALPH from agr.console import get_console from agr.exceptions import AgrError, InvalidHandleError from agr.metadata import METADATA_TYPE_LOCAL, METADATA_TYPE_REMOTE -from agr.fetcher import filter_tools_needing_install +from agr.fetcher import filter_tools_needing_install, is_ralph_installed from agr.handle import ParsedHandle from agr.tool import ToolConfig @@ -45,6 +46,17 @@ def _get_installation_status( return "[yellow]not synced[/yellow]" +def _get_ralph_installation_status( + handle: ParsedHandle, + repo_root: Path | None, + source: str | None = None, +) -> str: + """Get installation status for a ralph dependency.""" + if is_ralph_installed(handle, repo_root, source): + return "[green]installed[/green]" + return "[yellow]not synced[/yellow]" + + def run_list(global_install: bool = False) -> None: """Run the list command. @@ -60,12 +72,12 @@ def run_list(global_install: bool = False) -> None: if not config.dependencies: console.print("[yellow]No dependencies in agr.toml.[/yellow]") - console.print("[dim]Run 'agr add ' to add skills.[/dim]") + console.print("[dim]Run 'agr add ' to add skills or ralphs.[/dim]") return # Build table table = Table(show_header=True, header_style="bold") - table.add_column("Skill", style="cyan") + table.add_column("Name", style="cyan") table.add_column("Type") table.add_column("Status") @@ -78,18 +90,25 @@ def run_list(global_install: bool = False) -> None: display_name = dep.handle or "" kind = METADATA_TYPE_REMOTE + # Show dep type alongside local/remote + dep_type_label = dep.type + kind_display = f"{kind} ({dep_type_label})" + # Check installation status try: handle, source_name = dep.resolve( config.default_source, config.default_owner ) - status = _get_installation_status( - handle, repo_root, tools, source_name, skills_dirs - ) + if dep.type == DEPENDENCY_TYPE_RALPH: + status = _get_ralph_installation_status(handle, repo_root, source_name) + else: + status = _get_installation_status( + handle, repo_root, tools, source_name, skills_dirs + ) except (InvalidHandleError, AgrError): status = "[red]invalid[/red]" - table.add_row(display_name, kind, status) + table.add_row(display_name, kind_display, status) console.print(table) diff --git a/agr/commands/remove.py b/agr/commands/remove.py index bcdefb4..9768c18 100644 --- a/agr/commands/remove.py +++ b/agr/commands/remove.py @@ -3,9 +3,10 @@ from agr.commands import CommandResult from agr.commands._tool_helpers import load_existing_config, save_and_summarize_results from agr.commands.migrations import run_tool_migrations +from agr.config import DEPENDENCY_TYPE_RALPH from agr.console import get_console, print_error from agr.exceptions import INSTALL_ERROR_TYPES, format_install_error -from agr.fetcher import uninstall_skill +from agr.fetcher import uninstall_ralph, uninstall_skill from agr.handle import ParsedHandle, parse_handle from agr.lockfile import ( build_lockfile_path, @@ -54,6 +55,7 @@ def run_remove(refs: list[str], global_install: bool = False) -> None: # removal so we can update the lockfile without re-parsing handles. results: list[CommandResult] = [] removed_candidates: list[list[str]] = [] + removed_ralph_flags: list[bool] = [] for ref in refs: try: @@ -77,17 +79,23 @@ def run_remove(refs: list[str], global_install: bool = False) -> None: if dep and dep.is_remote: source_name = dep.source or config.default_source - # Remove from filesystem for all configured tools + is_ralph = dep is not None and dep.type == DEPENDENCY_TYPE_RALPH + + # Remove from filesystem removed_fs = False - for tool in tools: - if uninstall_skill( - handle, - repo_root, - tool, - source_name, - skills_dir=lookup_skills_dir(skills_dirs, tool), - ): + if is_ralph: + if uninstall_ralph(handle, repo_root, source_name): removed_fs = True + else: + for tool in tools: + if uninstall_skill( + handle, + repo_root, + tool, + source_name, + skills_dir=lookup_skills_dir(skills_dirs, tool), + ): + removed_fs = True # Remove from config (try same candidate identifiers) removed_config = False @@ -99,6 +107,7 @@ def run_remove(refs: list[str], global_install: bool = False) -> None: if removed_fs or removed_config: results.append(CommandResult(ref, True, "Removed")) removed_candidates.append(candidates) + removed_ralph_flags.append(is_ralph) else: results.append(CommandResult(ref, False, "Not found")) @@ -124,12 +133,12 @@ def _print_remove_result(result: CommandResult) -> None: exit_on_failure=False, ) - # Update lockfile: remove entries for successfully removed skills + # Update lockfile: remove entries for successfully removed deps if removed_candidates: lockfile_path = build_lockfile_path(config_path) lockfile = load_lockfile(lockfile_path) if lockfile is not None: - for candidates in removed_candidates: + for candidates, is_ralph_lf in zip(removed_candidates, removed_ralph_flags): for identifier in candidates: - remove_lockfile_entry(lockfile, identifier) + remove_lockfile_entry(lockfile, identifier, ralph=is_ralph_lf) save_lockfile(lockfile, lockfile_path) diff --git a/agr/commands/sync.py b/agr/commands/sync.py index 3c73263..1898340 100644 --- a/agr/commands/sync.py +++ b/agr/commands/sync.py @@ -12,15 +12,24 @@ migrate_legacy_directories, run_tool_migrations, ) -from agr.config import AgrConfig, find_config, require_repo_root +from agr.config import ( + DEPENDENCY_TYPE_RALPH, + AgrConfig, + find_config, + require_repo_root, +) from agr.console import error_exit, get_console, print_error from agr.exceptions import INSTALL_ERROR_TYPES, AgrError, format_install_error from agr.fetcher import ( InstallResult, + fetch_and_install_ralph, fetch_and_install_to_tools, filter_tools_needing_install, + install_ralph_from_repo, install_skill_from_repo_to_tools, + is_ralph_installed, prepare_repo_for_skills, + get_ralphs_dir, skill_not_found_message, ) from agr.git import downloaded_repo, fetch_and_checkout_commit, get_head_commit_full @@ -372,6 +381,29 @@ def _sync_one_dependency( return SyncResult.from_install_result(install_result) +def _sync_ralph_entries( + entries: list[SyncEntry], + results: list[SyncResult], + repo_root: Path | None, + resolver: SourceResolver, + default_repo: str | None = None, +) -> None: + """Sync ralph dependencies to the project-level ralphs directory.""" + for entry in entries: + try: + path, install_result = fetch_and_install_ralph( + entry.handle, + repo_root, + overwrite=False, + resolver=resolver, + source=entry.source_name, + default_repo=default_repo, + ) + results[entry.index] = SyncResult.from_install_result(install_result) + except INSTALL_ERROR_TYPES as e: + results[entry.index] = SyncResult.from_error(e) + + def _run_global_sync() -> None: """Sync global dependencies from ~/.agr/agr.toml.""" console = get_console() @@ -508,6 +540,7 @@ def run_sync( results: list[SyncResult] = [SyncResult.pending() for _ in config.dependencies] pending_local: list[SyncEntry] = [] pending_remote: list[SyncEntry] = [] + pending_ralph: list[SyncEntry] = [] for index, dep in enumerate(config.dependencies): try: @@ -515,30 +548,39 @@ def run_sync( config.default_source, config.default_owner ) - # Skip dependencies already installed on every configured tool. - tools_needing_install = filter_tools_needing_install( - handle, repo_root, tools, source_name - ) + if dep.type == DEPENDENCY_TYPE_RALPH: + # Ralphs are tool-agnostic: check project-level ralphs dir. + if is_ralph_installed(handle, repo_root, source_name): + results[index] = SyncResult.up_to_date() + continue + pending_ralph.append( + SyncEntry(index=index, handle=handle, source_name=source_name) + ) + else: + # Skills: check per-tool install status. + tools_needing_install = filter_tools_needing_install( + handle, repo_root, tools, source_name + ) - if not tools_needing_install: - results[index] = SyncResult.up_to_date() - continue + if not tools_needing_install: + results[index] = SyncResult.up_to_date() + continue - entry = SyncEntry( - index=index, - handle=handle, - source_name=source_name, - tools_needing_install=tools_needing_install, - ) - if dep.is_local: - pending_local.append(entry) - else: - pending_remote.append(entry) + entry = SyncEntry( + index=index, + handle=handle, + source_name=source_name, + tools_needing_install=tools_needing_install, + ) + if dep.is_local: + pending_local.append(entry) + else: + pending_remote.append(entry) except INSTALL_ERROR_TYPES as e: results[index] = SyncResult.from_error(e) # --- Phase 2: Install pending dependencies --- - # Three categories are processed separately for efficiency: + # Skills: three categories processed separately for efficiency. # # 1. Local skills — no git download, just copy from the local path. _sync_individual_entries( @@ -578,6 +620,15 @@ def run_sync( default_repo=config.default_repo, ) + # 4. Ralphs — installed to project-level .agents/ralphs/ directory. + _sync_ralph_entries( + pending_ralph, + results, + repo_root, + resolver, + config.default_repo, + ) + # --- Phase 3: Update lockfile --- new_lockfile = _build_lockfile_from_results(config, results, existing_lockfile) save_lockfile(new_lockfile, lockfile_path) @@ -599,8 +650,8 @@ def _sync_from_lockfile( ) -> None: """Install dependencies from lockfile pins (--frozen/--locked mode). - For remote skills with a pinned commit, clones the repo and checks - out the exact commit. For local skills, installs from disk as usual. + For remote skills/ralphs with a pinned commit, clones the repo and checks + out the exact commit. For local deps, installs from disk as usual. """ results: list[tuple[str, SyncResult]] = [] @@ -610,26 +661,43 @@ def _sync_from_lockfile( config.default_source, config.default_owner ) - tools_needing_install = filter_tools_needing_install( - handle, repo_root, tools, source_name - ) - if not tools_needing_install: - results.append((dep.identifier, SyncResult.up_to_date())) - continue + is_ralph_dep = dep.type == DEPENDENCY_TYPE_RALPH + + if is_ralph_dep: + # Ralph: check project-level install status + if is_ralph_installed(handle, repo_root, source_name): + results.append((dep.identifier, SyncResult.up_to_date())) + continue + else: + tools_needing_install = filter_tools_needing_install( + handle, repo_root, tools, source_name + ) + if not tools_needing_install: + results.append((dep.identifier, SyncResult.up_to_date())) + continue locked_skill = find_locked_skill(lockfile, dep) if dep.is_local: - # Local skills: install from disk, no lockfile pin - _paths, _result = fetch_and_install_to_tools( - handle, - repo_root, - tools_needing_install, - overwrite=False, - resolver=resolver, - source=source_name, - default_repo=config.default_repo, - ) + if is_ralph_dep: + _path, _result = fetch_and_install_ralph( + handle, + repo_root, + overwrite=False, + resolver=resolver, + source=source_name, + default_repo=config.default_repo, + ) + else: + _paths, _result = fetch_and_install_to_tools( + handle, + repo_root, + tools_needing_install, + overwrite=False, + resolver=resolver, + source=source_name, + default_repo=config.default_repo, + ) results.append((dep.identifier, SyncResult.installed())) continue @@ -644,15 +712,27 @@ def _sync_from_lockfile( owner, repo_name = handle.get_github_repo(default_repo=config.default_repo) with downloaded_repo(source_config, owner, repo_name) as repo_dir: fetch_and_checkout_commit(repo_dir, locked_skill.commit) - install_skill_from_repo_to_tools( - repo_dir, - handle.name, - handle, - tools_needing_install, - repo_root, - overwrite=False, - install_source=source_name, - ) + if is_ralph_dep: + ralphs_dir = get_ralphs_dir(repo_root) + install_ralph_from_repo( + repo_dir, + handle.name, + handle, + ralphs_dir, + repo_root, + overwrite=False, + install_source=source_name, + ) + else: + install_skill_from_repo_to_tools( + repo_dir, + handle.name, + handle, + tools_needing_install, + repo_root, + overwrite=False, + install_source=source_name, + ) results.append((dep.identifier, SyncResult.installed())) except INSTALL_ERROR_TYPES as e: @@ -668,16 +748,16 @@ def _build_lockfile_from_results( ) -> Lockfile: """Build a new lockfile from sync results. - For freshly installed skills, uses commit/hash from SyncResult. - For up-to-date skills, carries forward existing lockfile entries. + For freshly installed skills/ralphs, uses commit/hash from SyncResult. + For up-to-date entries, carries forward existing lockfile entries. """ lockfile = Lockfile() for index, dep in enumerate(config.dependencies): result = results[index] + is_ralph = dep.type == DEPENDENCY_TYPE_RALPH if dep.is_local: - # Local skills: record path and name, no commit or hash handle = dep.to_parsed_handle(config.default_owner) update_lockfile_entry( lockfile, @@ -685,11 +765,11 @@ def _build_lockfile_from_results( path=dep.path, installed_name=handle.name, ), + ralph=is_ralph, ) continue if result.status == SyncStatus.INSTALLED and result.commit: - # Freshly installed: use captured metadata handle = dep.to_parsed_handle(config.default_owner) update_lockfile_entry( lockfile, @@ -700,23 +780,19 @@ def _build_lockfile_from_results( content_hash=result.content_hash, installed_name=handle.name, ), + ralph=is_ralph, ) else: - # Up-to-date or error: carry forward existing entry if available existing = ( find_locked_skill(existing_lockfile, dep) if existing_lockfile is not None else None ) if existing is not None: - update_lockfile_entry(lockfile, existing) + update_lockfile_entry(lockfile, existing, ralph=is_ralph) elif result.status == SyncStatus.ERROR: - # Failed installs: skip — don't write partial entries - # that would break --frozen sync. pass else: - # No existing entry — create a partial one (no commit). - # --frozen sync will reject this and require a full sync. handle = dep.to_parsed_handle(config.default_owner) update_lockfile_entry( lockfile, @@ -725,6 +801,7 @@ def _build_lockfile_from_results( source=dep.resolve_source_name(config.default_source), installed_name=handle.name, ), + ralph=is_ralph, ) return lockfile diff --git a/agr/config.py b/agr/config.py index 7518f65..60f7094 100644 --- a/agr/config.py +++ b/agr/config.py @@ -36,6 +36,8 @@ CONFIG_FILENAME = "agr.toml" DEPENDENCY_TYPE_SKILL = "skill" +DEPENDENCY_TYPE_RALPH = "ralph" +VALID_DEPENDENCY_TYPES = {DEPENDENCY_TYPE_SKILL, DEPENDENCY_TYPE_RALPH} def validate_canonical_instructions(value: str) -> None: @@ -66,8 +68,7 @@ def _validate_config_identifier(value: object, key: str, description: str) -> st ) if "/" in result: raise ConfigError( - f"{key} cannot contain '/': got '{result}'. " - f"Use a plain {description}." + f"{key} cannot contain '/': got '{result}'. Use a plain {description}." ) if INSTALLED_NAME_SEPARATOR in result: raise ConfigError( @@ -179,7 +180,7 @@ class Dependency: Local: { path = "./my-skill", type = "skill" } """ - type: str # Always "skill" for now + type: str # "skill" or "ralph" handle: str | None = None # Remote Git reference path: str | None = None # Local path source: str | None = None # Optional source name for remote handles @@ -260,6 +261,11 @@ def _parse_dependencies_from_doc( if not isinstance(item, dict): continue dep_type = item.get("type", DEPENDENCY_TYPE_SKILL) + if dep_type not in VALID_DEPENDENCY_TYPES: + raise ConfigError( + f"Unknown dependency type '{dep_type}'. " + f"Valid types: {', '.join(sorted(VALID_DEPENDENCY_TYPES))}" + ) handle = item.get("handle") path_val = item.get("path") source = item.get("source") diff --git a/agr/exceptions.py b/agr/exceptions.py index b47154e..797239f 100644 --- a/agr/exceptions.py +++ b/agr/exceptions.py @@ -17,6 +17,10 @@ class SkillNotFoundError(AgrError): """Raised when the skill doesn't exist in the repo.""" +class RalphNotFoundError(AgrError): + """Raised when the ralph doesn't exist in the repo.""" + + class ConfigError(AgrError): """Raised when agr.toml has issues (not found or invalid).""" diff --git a/agr/fetcher.py b/agr/fetcher.py index cd0ce68..ee9790b 100644 --- a/agr/fetcher.py +++ b/agr/fetcher.py @@ -13,6 +13,7 @@ from agr.exceptions import ( AgrError, + RalphNotFoundError, RepoNotFoundError, SkillNotFoundError, ) @@ -37,8 +38,15 @@ build_handle_ids, compute_content_hash, read_skill_metadata, + stamp_ralph_metadata, stamp_skill_metadata, ) +from agr.ralph import ( + RALPH_MARKER, + find_ralph_in_repo, + find_ralphs_in_repo_listing, + is_valid_ralph_dir, +) from agr.skill import ( SKILL_MARKER, discover_skills_in_repo_listing, @@ -53,6 +61,10 @@ ) from agr.tool import DEFAULT_TOOL, ToolConfig, lookup_skills_dir +# Ralph installation directory constants +RALPHS_CONFIG_DIR = ".agents" +RALPHS_SUBDIR = "ralphs" + logger = logging.getLogger(__name__) @@ -907,3 +919,317 @@ def filter_tools_needing_install( skills_dir=lookup_skills_dir(skills_dirs, tool), ) ] + + +# --------------------------------------------------------------------------- +# Ralph installation functions +# --------------------------------------------------------------------------- + + +def get_ralphs_dir(repo_root: Path) -> Path: + """Return the project-level ralphs directory.""" + return repo_root / RALPHS_CONFIG_DIR / RALPHS_SUBDIR + + +def _find_existing_ralph_dir( + handle: ParsedHandle, + ralphs_dir: Path, + repo_root: Path | None, + source: str | None = None, +) -> Path | None: + """Find an existing installed ralph directory for this handle.""" + handle_ids = build_handle_ids(handle, repo_root, source) + name_path = ralphs_dir / handle.name + full_path = ralphs_dir / handle.to_installed_name() + + if is_valid_ralph_dir(name_path) and _skill_dir_matches_handle( + name_path, handle_ids + ): + return name_path + + if is_valid_ralph_dir(full_path): + return full_path + + return None + + +def _resolve_ralph_destination( + handle: ParsedHandle, + ralphs_dir: Path, + repo_root: Path | None, + source: str | None = None, +) -> Path: + """Resolve the destination path for installing a ralph.""" + existing = _find_existing_ralph_dir(handle, ralphs_dir, repo_root, source) + if existing: + return existing + + name_path = ralphs_dir / handle.name + if is_valid_ralph_dir(name_path): + return ralphs_dir / handle.to_installed_name() + + return name_path + + +def _copy_ralph_to_destination( + source: Path, + dest: Path, + handle: ParsedHandle, + overwrite: bool, + repo_root: Path | None, + install_source: str | None = None, +) -> Path: + """Copy ralph source to destination with overwrite handling.""" + if dest.exists() and not overwrite: + raise FileExistsError( + f"Ralph already exists at {dest}. Use --overwrite to replace." + ) + + if dest.exists(): + shutil.rmtree(dest) + + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(source, dest) + + stamp_ralph_metadata(dest, handle, repo_root, dest.name, install_source) + + return dest + + +def ralph_not_found_message(name: str) -> str: + """Build a user-friendly message for a missing ralph in a repository.""" + return ( + f"Ralph '{name}' not found in repository.\n" + f"No directory named '{name}' containing RALPH.md was found.\n" + f"Hint: Create a ralph at 'ralphs/{name}/RALPH.md' or '{name}/RALPH.md'" + ) + + +def prepare_repo_for_ralph(repo_dir: Path, ralph_name: str) -> Path | None: + """Prepare a repo so that only the ralph path is checked out.""" + result = prepare_repo_for_ralphs(repo_dir, [ralph_name]) + return result.get(ralph_name) + + +def prepare_repo_for_ralphs(repo_dir: Path, ralph_names: list[str]) -> dict[str, Path]: + """Prepare a repo so multiple ralph paths are checked out.""" + unique_names = list(dict.fromkeys(ralph_names)) + if not unique_names: + return {} + + try: + paths = git_list_files(repo_dir) + rel_paths = { + name: Path(d) + for name, d in find_ralphs_in_repo_listing(paths, unique_names).items() + } + + if rel_paths: + checkout_sparse_paths(repo_dir, list(rel_paths.values())) + resolved = { + name: repo_dir / rel_path for name, rel_path in rel_paths.items() + } + for path in resolved.values(): + if not path.exists(): + raise AgrError("Failed to checkout ralph path.") + return resolved + + return {} + except AgrError: + checkout_full(repo_dir) + resolved_dict: dict[str, Path] = {} + for name in unique_names: + ralph_path = find_ralph_in_repo(repo_dir, name) + if ralph_path is not None: + resolved_dict[name] = ralph_path + return resolved_dict + + +def install_ralph_from_repo( + repo_dir: Path, + ralph_name: str, + handle: ParsedHandle, + ralphs_dir: Path, + repo_root: Path | None, + overwrite: bool = False, + install_source: str | None = None, + ralph_source: Path | None = None, +) -> Path: + """Install a ralph from a downloaded repository.""" + if ralph_source is None: + ralph_source = find_ralph_in_repo(repo_dir, ralph_name) + if ralph_source is None: + raise RalphNotFoundError(ralph_not_found_message(ralph_name)) + + ralph_dest = _resolve_ralph_destination( + handle, ralphs_dir, repo_root, install_source + ) + + return _copy_ralph_to_destination( + ralph_source, ralph_dest, handle, overwrite, repo_root, install_source + ) + + +def install_local_ralph( + source_path: Path, + ralphs_dir: Path, + overwrite: bool = False, + repo_root: Path | None = None, + handle: ParsedHandle | None = None, +) -> Path: + """Install a local ralph.""" + if not is_valid_ralph_dir(source_path): + raise RalphNotFoundError( + f"'{source_path}' is not a valid ralph (missing {RALPH_MARKER})" + ) + + if INSTALLED_NAME_SEPARATOR in source_path.name: + raise AgrError( + f"Ralph name '{source_path.name}' contains " + f"reserved sequence '{INSTALLED_NAME_SEPARATOR}'" + ) + + handle = handle or ParsedHandle( + is_local=True, name=source_path.name, local_path=source_path + ) + if repo_root is None: + repo_root = Path.cwd() + + # Self-install case + default_dest = ralphs_dir / handle.name + if source_path.resolve() == default_dest.resolve() and is_valid_ralph_dir( + default_dest + ): + if read_skill_metadata(default_dest) is None: + stamp_ralph_metadata(default_dest, handle, repo_root, default_dest.name) + return default_dest + + ralph_dest = _resolve_ralph_destination(handle, ralphs_dir, repo_root) + + return _copy_ralph_to_destination( + source_path, ralph_dest, handle, overwrite, repo_root + ) + + +@contextmanager +def _locate_remote_ralph( + handle: ParsedHandle, + resolver: SourceResolver | None = None, + source: str | None = None, + default_repo: str | None = None, +) -> Generator[_RemoteSkillLocation, None, None]: + """Search for a remote ralph across sources and repo candidates. + + Reuses _RemoteSkillLocation since the fields are identical. + """ + resolver = resolver or SourceResolver.default() + owner = handle.username or "" + + for repo_name, is_legacy in iter_repo_candidates(handle.repo, default_repo): + for source_config in resolver.ordered(source): + try: + with downloaded_repo(source_config, owner, repo_name) as repo_dir: + ralph_source = prepare_repo_for_ralph(repo_dir, handle.name) + if ralph_source is None: + continue + try: + commit = get_head_commit_full(repo_dir) + except AgrError: + commit = None + yield _RemoteSkillLocation( + repo_dir=repo_dir, + skill_source=ralph_source, + source_config=source_config, + is_legacy=is_legacy, + commit=commit, + ) + return + except RepoNotFoundError: + if source is not None: + raise + continue + + raise RalphNotFoundError( + f"Ralph '{handle.name}' not found in sources: " + f"{', '.join(s.name for s in resolver.ordered(source))}" + ) + + +def fetch_and_install_ralph( + handle: ParsedHandle, + repo_root: Path | None, + overwrite: bool = False, + resolver: SourceResolver | None = None, + source: str | None = None, + default_repo: str | None = None, +) -> tuple[Path, InstallResult]: + """Fetch and install a ralph to the project-level ralphs directory. + + Returns: + Tuple of (installed path, InstallResult with lockfile metadata). + """ + if repo_root is None: + raise ValueError("repo_root is required for ralph installation") + + ralphs_dir = get_ralphs_dir(repo_root) + + if handle.is_local: + if handle.local_path is None: + raise ValueError("Local handle missing path") + source_path = handle.resolve_local_path(repo_root) + resolved_handle = ParsedHandle( + is_local=True, name=handle.name, local_path=source_path + ) + path = install_local_ralph( + source_path, ralphs_dir, overwrite, repo_root, resolved_handle + ) + return path, InstallResult() + + with _locate_remote_ralph(handle, resolver, source, default_repo) as loc: + if loc.is_legacy: + warn_legacy_repo() + path = install_ralph_from_repo( + loc.repo_dir, + handle.name, + handle, + ralphs_dir, + repo_root, + overwrite, + install_source=loc.source_config.name, + ralph_source=loc.skill_source, + ) + content_hash = compute_content_hash(path) + install_result = InstallResult( + commit=loc.commit, + content_hash=content_hash, + source_name=loc.source_config.name, + ) + return path, install_result + + +def uninstall_ralph( + handle: ParsedHandle, + repo_root: Path | None, + source: str | None = None, +) -> bool: + """Uninstall a ralph from the project-level ralphs directory.""" + if repo_root is None: + return False + ralphs_dir = get_ralphs_dir(repo_root) + ralph_path = _find_existing_ralph_dir(handle, ralphs_dir, repo_root, source) + if not ralph_path: + return False + shutil.rmtree(ralph_path) + return True + + +def is_ralph_installed( + handle: ParsedHandle, + repo_root: Path | None, + source: str | None = None, +) -> bool: + """Check if a ralph is installed.""" + if repo_root is None: + return False + ralphs_dir = get_ralphs_dir(repo_root) + return _find_existing_ralph_dir(handle, ralphs_dir, repo_root, source) is not None diff --git a/agr/handle.py b/agr/handle.py index b1a71ea..82d5540 100644 --- a/agr/handle.py +++ b/agr/handle.py @@ -302,17 +302,11 @@ def parse_remote_handle( InvalidHandleError: If the handle resolves to a local path. """ if is_local_path_ref(handle): - raise InvalidHandleError( - f"'{handle}' is a local path, not a remote handle" - ) + raise InvalidHandleError(f"'{handle}' is a local path, not a remote handle") - parsed = parse_handle( - handle, prefer_local=False, default_owner=default_owner - ) + parsed = parse_handle(handle, prefer_local=False, default_owner=default_owner) if parsed.is_local: - raise InvalidHandleError( - f"'{handle}' is a local path, not a remote handle" - ) + raise InvalidHandleError(f"'{handle}' is a local path, not a remote handle") return parsed diff --git a/agr/lockfile.py b/agr/lockfile.py index a3c2b2c..72b2119 100644 --- a/agr/lockfile.py +++ b/agr/lockfile.py @@ -16,7 +16,7 @@ from tomlkit import TOMLDocument from tomlkit.exceptions import TOMLKitError -from agr.config import Dependency +from agr.config import DEPENDENCY_TYPE_RALPH, Dependency from agr.exceptions import ConfigError LOCKFILE_FILENAME = "agr.lock" @@ -63,6 +63,7 @@ def identifier(self) -> str: @classmethod def from_dict(cls, data: dict[str, object]) -> LockedSkill: """Deserialize a TOML table into a LockedSkill.""" + def _get_optional_str(key: str) -> str | None: value = data.get(key) return str(value) if value is not None else None @@ -93,6 +94,7 @@ class Lockfile: version: int = LOCKFILE_VERSION skills: list[LockedSkill] = field(default_factory=list) + ralphs: list[LockedSkill] = field(default_factory=list) def build_lockfile_path(config_path: Path) -> Path: @@ -100,6 +102,16 @@ def build_lockfile_path(config_path: Path) -> Path: return config_path.parent / LOCKFILE_FILENAME +def _parse_locked_entries(doc: TOMLDocument, key: str) -> list[LockedSkill]: + """Parse locked entries from a TOML section.""" + entries: list[LockedSkill] = [] + for item in doc.get(key, []): + if not isinstance(item, dict): + continue + entries.append(LockedSkill.from_dict(item)) + return entries + + def load_lockfile(path: Path) -> Lockfile | None: """Load a lockfile from disk. @@ -121,13 +133,10 @@ def load_lockfile(path: Path) -> Lockfile | None: f"Unsupported lockfile version {version} (expected {LOCKFILE_VERSION})" ) - skills: list[LockedSkill] = [] - for item in doc.get("skill", []): - if not isinstance(item, dict): - continue - skills.append(LockedSkill.from_dict(item)) + skills = _parse_locked_entries(doc, "skill") + ralphs = _parse_locked_entries(doc, "ralph") - return Lockfile(version=version, skills=skills) + return Lockfile(version=version, skills=skills, ralphs=ralphs) def save_lockfile(lockfile: Lockfile, path: Path) -> None: @@ -137,20 +146,31 @@ def save_lockfile(lockfile: Lockfile, path: Path) -> None: doc.add(tomlkit.nl()) doc["version"] = lockfile.version - skills_aot = tomlkit.aot() - for skill in lockfile.skills: - skills_aot.append(skill.to_toml_table()) + def _build_aot(entries: list[LockedSkill]) -> tomlkit.items.AoT: + aot = tomlkit.aot() + for entry in entries: + aot.append(entry.to_toml_table()) + return aot - doc["skill"] = skills_aot + doc["skill"] = _build_aot(lockfile.skills) + if lockfile.ralphs: + doc["ralph"] = _build_aot(lockfile.ralphs) path.write_text(tomlkit.dumps(doc)) +def _lockfile_list_for_dep(lockfile: Lockfile, dep: Dependency) -> list[LockedSkill]: + """Return the appropriate lockfile list for a dependency's type.""" + if dep.type == DEPENDENCY_TYPE_RALPH: + return lockfile.ralphs + return lockfile.skills + + def find_locked_skill(lockfile: Lockfile, dep: Dependency) -> LockedSkill | None: """Look up a dependency's entry in the lockfile.""" identifier = dep.identifier - for skill in lockfile.skills: - if skill.identifier == identifier: - return skill + for entry in _lockfile_list_for_dep(lockfile, dep): + if entry.identifier == identifier: + return entry return None @@ -160,17 +180,41 @@ def is_lockfile_current(lockfile: Lockfile, dependencies: list[Dependency]) -> b Returns True only if the lockfile has entries for all dependencies and no extra entries. Does not check whether SHAs are stale. """ - lockfile_ids = {s.identifier for s in lockfile.skills} - config_ids = {d.identifier for d in dependencies} - return lockfile_ids == config_ids + lockfile_skill_ids = {s.identifier for s in lockfile.skills} + lockfile_ralph_ids = {r.identifier for r in lockfile.ralphs} + config_skill_ids = { + d.identifier for d in dependencies if d.type != DEPENDENCY_TYPE_RALPH + } + config_ralph_ids = { + d.identifier for d in dependencies if d.type == DEPENDENCY_TYPE_RALPH + } + return ( + lockfile_skill_ids == config_skill_ids + and lockfile_ralph_ids == config_ralph_ids + ) -def update_lockfile_entry(lockfile: Lockfile, entry: LockedSkill) -> None: +def update_lockfile_entry( + lockfile: Lockfile, entry: LockedSkill, *, ralph: bool = False +) -> None: """Add or replace an entry in the lockfile by identifier.""" - lockfile.skills = [s for s in lockfile.skills if s.identifier != entry.identifier] - lockfile.skills.append(entry) - - -def remove_lockfile_entry(lockfile: Lockfile, identifier: str) -> None: + if ralph: + lockfile.ralphs = [ + r for r in lockfile.ralphs if r.identifier != entry.identifier + ] + lockfile.ralphs.append(entry) + else: + lockfile.skills = [ + s for s in lockfile.skills if s.identifier != entry.identifier + ] + lockfile.skills.append(entry) + + +def remove_lockfile_entry( + lockfile: Lockfile, identifier: str, *, ralph: bool = False +) -> None: """Remove an entry from the lockfile by identifier.""" - lockfile.skills = [s for s in lockfile.skills if s.identifier != identifier] + if ralph: + lockfile.ralphs = [r for r in lockfile.ralphs if r.identifier != identifier] + else: + lockfile.skills = [s for s in lockfile.skills if s.identifier != identifier] diff --git a/agr/metadata.py b/agr/metadata.py index b9c8152..951d6c3 100644 --- a/agr/metadata.py +++ b/agr/metadata.py @@ -136,6 +136,37 @@ def write_skill_metadata( metadata_path.write_text(json.dumps(data, indent=2, ensure_ascii=True) + "\n") +def write_ralph_metadata( + ralph_dir: Path, + handle: ParsedHandle, + repo_root: Path | None, + installed_name: str, + source: str | None = None, + content_hash: str | None = None, +) -> None: + """Write metadata for an installed ralph (tool-agnostic, no tool field).""" + resolved_local = ( + handle.resolve_local_path(repo_root) if handle.local_path is not None else None + ) + data: dict[str, Any] = { + METADATA_KEY_ID: build_handle_id(handle, repo_root, source), + METADATA_KEY_INSTALLED_NAME: installed_name, + } + if handle.is_local: + data[METADATA_KEY_TYPE] = METADATA_TYPE_LOCAL + data[METADATA_KEY_LOCAL_PATH] = str(resolved_local) if resolved_local else None + else: + data[METADATA_KEY_TYPE] = METADATA_TYPE_REMOTE + data[METADATA_KEY_HANDLE] = handle.to_toml_handle() + data[METADATA_KEY_SOURCE] = source or DEFAULT_SOURCE_NAME + + if content_hash is not None: + data[METADATA_KEY_CONTENT_HASH] = content_hash + + metadata_path = ralph_dir / METADATA_FILENAME + metadata_path.write_text(json.dumps(data, indent=2, ensure_ascii=True) + "\n") + + def stamp_skill_metadata( skill_dir: Path, handle: ParsedHandle, @@ -160,3 +191,22 @@ def stamp_skill_metadata( source, content_hash, ) + + +def stamp_ralph_metadata( + ralph_dir: Path, + handle: ParsedHandle, + repo_root: Path | None, + installed_name: str, + source: str | None = None, +) -> None: + """Compute content hash and write metadata for a ralph in one step.""" + content_hash = compute_content_hash(ralph_dir) + write_ralph_metadata( + ralph_dir, + handle, + repo_root, + installed_name, + source, + content_hash, + ) diff --git a/agr/ralph.py b/agr/ralph.py new file mode 100644 index 0000000..d83dd9f --- /dev/null +++ b/agr/ralph.py @@ -0,0 +1,99 @@ +"""Ralph validation and RALPH.md handling.""" + +from pathlib import Path, PurePosixPath + +from agr.skill import EXCLUDED_DIRS, _shallowest + + +# Marker file for ralphs +RALPH_MARKER = "RALPH.md" + + +def _is_excluded_ralph_path(parts: tuple[str, ...]) -> bool: + """Check if a relative RALPH.md path should be excluded from discovery. + + Same rules as skill discovery: + 1. Root-level RALPH.md is a repo marker, not a ralph directory. + 2. Any path component matching EXCLUDED_DIRS disqualifies the entry. + """ + if len(parts) == 1: + return True + return any(part in EXCLUDED_DIRS for part in parts) + + +def _is_excluded_path(path: Path, repo_dir: Path) -> bool: + """Check if a path should be excluded from ralph discovery.""" + rel = path.relative_to(repo_dir) + return _is_excluded_ralph_path(rel.parts) + + +def is_valid_ralph_dir(path: Path) -> bool: + """Check if a directory is a valid ralph (contains RALPH.md).""" + if not path.is_dir(): + return False + return (path / RALPH_MARKER).exists() + + +def _find_ralph_dirs(repo_dir: Path) -> list[Path]: + """Find all valid ralph directories in a repo.""" + dirs: list[Path] = [] + for ralph_md in repo_dir.rglob(RALPH_MARKER): + if _is_excluded_path(ralph_md, repo_dir): + continue + dirs.append(ralph_md.parent) + return dirs + + +def find_ralph_in_repo(repo_dir: Path, ralph_name: str) -> Path | None: + """Find a ralph directory in a downloaded repo. + + Searches recursively for any directory containing RALPH.md where the + directory name matches the ralph name. + """ + matches = [d for d in _find_ralph_dirs(repo_dir) if d.name == ralph_name] + if not matches: + return None + return _shallowest(matches) + + +def _find_ralph_dirs_in_listing(paths: list[str]) -> list[PurePosixPath]: + """Return valid ralph directories from a git file listing.""" + results: list[PurePosixPath] = [] + for rel in paths: + rel_path = PurePosixPath(rel) + if rel_path.name != RALPH_MARKER: + continue + if _is_excluded_ralph_path(rel_path.parts): + continue + results.append(rel_path.parent) + return results + + +def find_ralph_in_repo_listing( + paths: list[str], ralph_name: str +) -> PurePosixPath | None: + """Find a ralph directory from a git file listing.""" + matches = [d for d in _find_ralph_dirs_in_listing(paths) if d.name == ralph_name] + if not matches: + return None + return _shallowest(matches) + + +def find_ralphs_in_repo_listing( + paths: list[str], ralph_names: list[str] +) -> dict[str, PurePosixPath]: + """Find multiple ralph directories from a git file listing in a single pass.""" + name_set = set(ralph_names) + matches: dict[str, list[PurePosixPath]] = {} + for d in _find_ralph_dirs_in_listing(paths): + if d.name in name_set: + matches.setdefault(d.name, []).append(d) + return {name: _shallowest(dirs) for name, dirs in matches.items()} + + +def discover_ralphs_in_repo_listing(paths: list[str]) -> list[str]: + """Discover all ralph names from a git file listing. + + Returns all unique ralph names found, sorted alphabetically. + """ + return sorted({d.name for d in _find_ralph_dirs_in_listing(paths)}) diff --git a/agr/skill.py b/agr/skill.py index d167a72..03d81e5 100644 --- a/agr/skill.py +++ b/agr/skill.py @@ -223,7 +223,6 @@ def discover_skills_in_repo_listing(paths: list[str]) -> list[str]: return sorted({d.name for d in _find_skill_dirs_in_listing(paths)}) - def parse_frontmatter(content: str) -> tuple[str, str] | None: """Parse YAML frontmatter from SKILL.md content. @@ -242,7 +241,6 @@ def parse_frontmatter(content: str) -> tuple[str, str] | None: return parts[1], parts[2] - def update_skill_md_name(skill_dir: Path, new_name: str) -> None: """Update the name field in SKILL.md. diff --git a/tests/cli/agr/test_add.py b/tests/cli/agr/test_add.py index 21ca5cd..be9f7a2 100644 --- a/tests/cli/agr/test_add.py +++ b/tests/cli/agr/test_add.py @@ -1,6 +1,6 @@ """CLI tests for agr add command.""" -from agr.config import AgrConfig +from agr.config import DEPENDENCY_TYPE_RALPH, AgrConfig from tests.cli.assertions import assert_cli @@ -88,3 +88,35 @@ def test_add_existing_config_keeps_tools( assert_cli(result).succeeded() config = AgrConfig.load(cli_project / "agr.toml") assert config.tools == ["claude"] + + +class TestAgrAddRalph: + """Tests for agr add with ralph type.""" + + def test_add_local_ralph_succeeds(self, agr, cli_ralph): + """agr add ./path adds local ralph.""" + result = agr("add", "./ralphs/test-ralph") + assert_cli(result).succeeded().stdout_contains("Added:") + + def test_add_local_ralph_creates_installed_dir(self, agr, cli_project, cli_ralph): + """agr add creates ralph in .agents/ralphs.""" + agr("add", "./ralphs/test-ralph") + installed = cli_project / ".agents" / "ralphs" / "test-ralph" + assert installed.exists() + assert (installed / "RALPH.md").exists() + + def test_add_local_ralph_updates_config_with_ralph_type( + self, agr, cli_project, cli_ralph + ): + """agr add updates agr.toml with type = ralph.""" + agr("add", "./ralphs/test-ralph") + config = AgrConfig.load(cli_project / "agr.toml") + ralph_deps = [d for d in config.dependencies if d.type == DEPENDENCY_TYPE_RALPH] + assert len(ralph_deps) == 1 + assert ralph_deps[0].path == "./ralphs/test-ralph" + + def test_add_local_ralph_not_installed_to_tools(self, agr, cli_project, cli_ralph): + """Ralphs should NOT be installed to .claude/skills/.""" + agr("add", "./ralphs/test-ralph") + tool_dir = cli_project / ".claude" / "skills" / "test-ralph" + assert not tool_dir.exists() diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py index 49f7c6f..bd55467 100644 --- a/tests/cli/conftest.py +++ b/tests/cli/conftest.py @@ -57,6 +57,25 @@ def cli_skill(cli_project: Path) -> Path: return skill_dir +@pytest.fixture +def cli_ralph(cli_project: Path) -> Path: + """Create a test ralph in the project.""" + ralph_dir = cli_project / "ralphs" / "test-ralph" + ralph_dir.mkdir(parents=True) + (ralph_dir / "RALPH.md").write_text("""--- +agent: claude -p +commands: + - name: tests + run: uv run pytest +--- + +# Test Ralph + +A test ralph for CLI testing. +""") + return ralph_dir + + @pytest.fixture def cli_config(cli_project: Path): """Factory fixture to create agr.toml with custom content.""" diff --git a/tests/conftest.py b/tests/conftest.py index 3c1b8cf..a57518f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -61,3 +61,22 @@ def skill_fixture(tmp_path: Path) -> Path: A test skill for unit tests. """) return skill_dir + + +@pytest.fixture +def ralph_fixture(tmp_path: Path) -> Path: + """Create a valid ralph directory.""" + ralph_dir = tmp_path / "test-ralph" + ralph_dir.mkdir() + (ralph_dir / "RALPH.md").write_text("""--- +agent: claude -p +commands: + - name: tests + run: uv run pytest +--- + +# Test Ralph + +A test ralph for unit tests. +""") + return ralph_dir diff --git a/tests/test_config.py b/tests/test_config.py index 59e1beb..86735c7 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -118,6 +118,57 @@ def test_resolve_local(self): assert source_name is None +class TestRalphDependency: + """Tests for ralph dependency type.""" + + def test_ralph_remote_dependency(self): + """Create a remote ralph dependency.""" + dep = Dependency(type="ralph", handle="user/repo/my-ralph") + assert dep.is_remote + assert dep.type == "ralph" + assert dep.identifier == "user/repo/my-ralph" + + def test_ralph_local_dependency(self): + """Create a local ralph dependency.""" + dep = Dependency(type="ralph", path="./my-ralph") + assert dep.is_local + assert dep.type == "ralph" + + def test_unknown_type_in_toml(self, tmp_path): + """Unknown dependency type raises ConfigError.""" + config_path = tmp_path / "agr.toml" + config_path.write_text( + 'dependencies = [{handle = "user/repo/foo", type = "unknown"}]\n' + ) + with pytest.raises(ConfigError, match="Unknown dependency type"): + AgrConfig.load(config_path) + + def test_ralph_type_in_toml(self, tmp_path): + """Ralph type parses successfully.""" + config_path = tmp_path / "agr.toml" + config_path.write_text( + 'dependencies = [{handle = "user/repo/my-ralph", type = "ralph"}]\n' + ) + config = AgrConfig.load(config_path) + assert len(config.dependencies) == 1 + assert config.dependencies[0].type == "ralph" + assert config.dependencies[0].handle == "user/repo/my-ralph" + + def test_mixed_skill_and_ralph(self, tmp_path): + """Config with both skill and ralph deps.""" + config_path = tmp_path / "agr.toml" + config_path.write_text( + "dependencies = [\n" + ' {handle = "user/repo/my-skill", type = "skill"},\n' + ' {handle = "user/repo/my-ralph", type = "ralph"},\n' + "]\n" + ) + config = AgrConfig.load(config_path) + assert len(config.dependencies) == 2 + assert config.dependencies[0].type == "skill" + assert config.dependencies[1].type == "ralph" + + class TestAgrConfig: """Tests for AgrConfig class.""" diff --git a/tests/test_fetcher.py b/tests/test_fetcher.py index 247f330..d59411b 100644 --- a/tests/test_fetcher.py +++ b/tests/test_fetcher.py @@ -761,3 +761,122 @@ def test_empty_tools_list_raises(self, tmp_path, skill_fixture): with pytest.raises(ValueError, match="No tools provided"): fetch_and_install_to_tools(handle, repo_root, [], overwrite=False) + + +class TestInstallLocalRalph: + """Tests for installing a local ralph.""" + + def test_installs_local_ralph(self, tmp_path, ralph_fixture): + """Installs a local ralph to ralphs dir.""" + from agr.fetcher import install_local_ralph, get_ralphs_dir + + repo_root = tmp_path / "repo" + repo_root.mkdir() + (repo_root / ".git").mkdir() + + ralphs_dir = get_ralphs_dir(repo_root) + path = install_local_ralph(ralph_fixture, ralphs_dir, repo_root=repo_root) + + assert path.exists() + assert (path / "RALPH.md").exists() + assert path.parent == ralphs_dir + + def test_rejects_non_ralph_dir(self, tmp_path): + """Raises when source dir is not a valid ralph.""" + from agr.exceptions import RalphNotFoundError + from agr.fetcher import install_local_ralph, get_ralphs_dir + + repo_root = tmp_path / "repo" + repo_root.mkdir() + (repo_root / ".git").mkdir() + + not_a_ralph = tmp_path / "empty-dir" + not_a_ralph.mkdir() + + ralphs_dir = get_ralphs_dir(repo_root) + with pytest.raises(RalphNotFoundError, match="not a valid ralph"): + install_local_ralph(not_a_ralph, ralphs_dir, repo_root=repo_root) + + def test_stamps_metadata(self, tmp_path, ralph_fixture): + """Installed ralph has .agr.json metadata.""" + from agr.fetcher import install_local_ralph, get_ralphs_dir + + repo_root = tmp_path / "repo" + repo_root.mkdir() + (repo_root / ".git").mkdir() + + ralphs_dir = get_ralphs_dir(repo_root) + path = install_local_ralph(ralph_fixture, ralphs_dir, repo_root=repo_root) + + meta = read_skill_metadata(path) + assert meta is not None + assert "tool" not in meta # Ralphs are tool-agnostic + + +class TestUninstallRalph: + """Tests for uninstalling a ralph.""" + + def test_uninstall_ralph(self, tmp_path, ralph_fixture): + """Uninstalls an installed ralph.""" + from agr.fetcher import install_local_ralph, uninstall_ralph, get_ralphs_dir + + repo_root = tmp_path / "repo" + repo_root.mkdir() + (repo_root / ".git").mkdir() + + ralphs_dir = get_ralphs_dir(repo_root) + path = install_local_ralph(ralph_fixture, ralphs_dir, repo_root=repo_root) + assert path.exists() + + handle = ParsedHandle( + is_local=True, name=ralph_fixture.name, local_path=ralph_fixture + ) + result = uninstall_ralph(handle, repo_root) + assert result is True + assert not path.exists() + + def test_uninstall_nonexistent_ralph(self, tmp_path): + """Returns False when ralph is not installed.""" + from agr.fetcher import uninstall_ralph + + repo_root = tmp_path / "repo" + repo_root.mkdir() + (repo_root / ".git").mkdir() + + handle = ParsedHandle( + is_local=True, name="nonexistent", local_path=tmp_path / "nonexistent" + ) + assert uninstall_ralph(handle, repo_root) is False + + +class TestIsRalphInstalled: + """Tests for checking ralph installation status.""" + + def test_is_installed(self, tmp_path, ralph_fixture): + """Returns True when ralph is installed.""" + from agr.fetcher import install_local_ralph, is_ralph_installed, get_ralphs_dir + + repo_root = tmp_path / "repo" + repo_root.mkdir() + (repo_root / ".git").mkdir() + + ralphs_dir = get_ralphs_dir(repo_root) + install_local_ralph(ralph_fixture, ralphs_dir, repo_root=repo_root) + + handle = ParsedHandle( + is_local=True, name=ralph_fixture.name, local_path=ralph_fixture + ) + assert is_ralph_installed(handle, repo_root) is True + + def test_not_installed(self, tmp_path): + """Returns False when ralph is not installed.""" + from agr.fetcher import is_ralph_installed + + repo_root = tmp_path / "repo" + repo_root.mkdir() + (repo_root / ".git").mkdir() + + handle = ParsedHandle( + is_local=True, name="nonexistent", local_path=tmp_path / "nonexistent" + ) + assert is_ralph_installed(handle, repo_root) is False diff --git a/tests/test_ralph.py b/tests/test_ralph.py new file mode 100644 index 0000000..74595a6 --- /dev/null +++ b/tests/test_ralph.py @@ -0,0 +1,134 @@ +"""Tests for ralph discovery and validation.""" + +from pathlib import PurePosixPath + +from agr.ralph import ( + RALPH_MARKER, + discover_ralphs_in_repo_listing, + find_ralph_in_repo, + find_ralph_in_repo_listing, + find_ralphs_in_repo_listing, + is_valid_ralph_dir, +) + + +class TestIsValidRalphDir: + def test_valid_ralph_dir(self, ralph_fixture): + """Directory containing RALPH.md is valid.""" + assert is_valid_ralph_dir(ralph_fixture) is True + + def test_dir_without_ralph_md(self, tmp_path): + """Directory without RALPH.md is invalid.""" + empty_dir = tmp_path / "empty" + empty_dir.mkdir() + assert is_valid_ralph_dir(empty_dir) is False + + def test_nonexistent_dir(self, tmp_path): + """Non-existent path is invalid.""" + assert is_valid_ralph_dir(tmp_path / "nope") is False + + def test_file_not_dir(self, tmp_path): + """File path is invalid.""" + f = tmp_path / "file.txt" + f.write_text("hi") + assert is_valid_ralph_dir(f) is False + + +class TestFindRalphInRepo: + def test_finds_ralph(self, tmp_path): + """Finds a ralph by directory name.""" + ralph_dir = tmp_path / "ralphs" / "my-ralph" + ralph_dir.mkdir(parents=True) + (ralph_dir / RALPH_MARKER).write_text("---\nagent: claude\n---\n") + result = find_ralph_in_repo(tmp_path, "my-ralph") + assert result == ralph_dir + + def test_returns_none_when_missing(self, tmp_path): + """Returns None when ralph not found.""" + result = find_ralph_in_repo(tmp_path, "nope") + assert result is None + + def test_excludes_root_level(self, tmp_path): + """Root-level RALPH.md is not a ralph directory.""" + (tmp_path / RALPH_MARKER).write_text("---\nagent: claude\n---\n") + # Need a subdirectory named same as root to check exclusion + result = find_ralph_in_repo(tmp_path, tmp_path.name) + assert result is None + + def test_excludes_git_dirs(self, tmp_path): + """RALPH.md inside .git is excluded.""" + git_ralph = tmp_path / ".git" / "my-ralph" + git_ralph.mkdir(parents=True) + (git_ralph / RALPH_MARKER).write_text("---\nagent: claude\n---\n") + result = find_ralph_in_repo(tmp_path, "my-ralph") + assert result is None + + def test_prefers_shallowest(self, tmp_path): + """When multiple matches, returns the shallowest.""" + shallow = tmp_path / "my-ralph" + shallow.mkdir() + (shallow / RALPH_MARKER).write_text("---\nagent: claude\n---\n") + + deep = tmp_path / "nested" / "deep" / "my-ralph" + deep.mkdir(parents=True) + (deep / RALPH_MARKER).write_text("---\nagent: claude\n---\n") + + result = find_ralph_in_repo(tmp_path, "my-ralph") + assert result == shallow + + +class TestFindRalphInRepoListing: + def test_finds_ralph(self): + """Finds ralph in git listing.""" + paths = ["ralphs/my-ralph/RALPH.md", "README.md"] + result = find_ralph_in_repo_listing(paths, "my-ralph") + assert result == PurePosixPath("ralphs/my-ralph") + + def test_returns_none_when_missing(self): + """Returns None when ralph not in listing.""" + paths = ["ralphs/other/RALPH.md"] + result = find_ralph_in_repo_listing(paths, "my-ralph") + assert result is None + + def test_excludes_root_level(self): + """Root-level RALPH.md excluded.""" + paths = ["RALPH.md"] + result = find_ralph_in_repo_listing(paths, "") + assert result is None + + +class TestFindRalphsInRepoListing: + def test_finds_multiple(self): + """Finds multiple ralphs in one pass.""" + paths = [ + "ralphs/alpha/RALPH.md", + "ralphs/beta/RALPH.md", + "ralphs/gamma/RALPH.md", + ] + result = find_ralphs_in_repo_listing(paths, ["alpha", "gamma"]) + assert set(result.keys()) == {"alpha", "gamma"} + assert result["alpha"] == PurePosixPath("ralphs/alpha") + assert result["gamma"] == PurePosixPath("ralphs/gamma") + + def test_missing_ralphs_omitted(self): + """Missing ralphs are not in result.""" + paths = ["ralphs/alpha/RALPH.md"] + result = find_ralphs_in_repo_listing(paths, ["alpha", "missing"]) + assert set(result.keys()) == {"alpha"} + + +class TestDiscoverRalphsInRepoListing: + def test_discovers_all(self): + """Lists all ralph names sorted.""" + paths = [ + "ralphs/beta/RALPH.md", + "ralphs/alpha/RALPH.md", + "README.md", + "RALPH.md", # root-level — excluded + ] + result = discover_ralphs_in_repo_listing(paths) + assert result == ["alpha", "beta"] + + def test_empty_listing(self): + """Empty listing yields empty result.""" + assert discover_ralphs_in_repo_listing([]) == [] diff --git a/tests/unit/test_lockfile.py b/tests/unit/test_lockfile.py index 5fa3b34..5a7c63e 100644 --- a/tests/unit/test_lockfile.py +++ b/tests/unit/test_lockfile.py @@ -273,3 +273,124 @@ def test_noop_for_unknown_identifier(self): ) remove_lockfile_entry(lockfile, "user/repo/unknown") assert len(lockfile.skills) == 1 + + +class TestRalphLockfileSupport: + """Tests for ralph entries in the lockfile.""" + + def test_round_trip_ralph(self, tmp_path): + lockfile = Lockfile( + skills=[], + ralphs=[ + LockedSkill( + handle="user/repo/my-ralph", + source="github", + commit="c" * 40, + content_hash="sha256:" + "d" * 64, + installed_name="my-ralph", + ), + ], + ) + path = tmp_path / "agr.lock" + save_lockfile(lockfile, path) + loaded = load_lockfile(path) + + assert loaded is not None + assert len(loaded.ralphs) == 1 + assert len(loaded.skills) == 0 + r = loaded.ralphs[0] + assert r.handle == "user/repo/my-ralph" + assert r.source == "github" + assert r.commit == "c" * 40 + assert r.installed_name == "my-ralph" + + def test_round_trip_mixed_skills_and_ralphs(self, tmp_path): + lockfile = Lockfile( + skills=[ + LockedSkill(handle="user/repo/skill", installed_name="skill"), + ], + ralphs=[ + LockedSkill(handle="user/repo/ralph", installed_name="ralph"), + ], + ) + path = tmp_path / "agr.lock" + save_lockfile(lockfile, path) + loaded = load_lockfile(path) + + assert loaded is not None + assert len(loaded.skills) == 1 + assert len(loaded.ralphs) == 1 + assert loaded.skills[0].handle == "user/repo/skill" + assert loaded.ralphs[0].handle == "user/repo/ralph" + + def test_update_lockfile_entry_ralph(self): + lockfile = Lockfile(skills=[], ralphs=[]) + entry = LockedSkill(handle="user/repo/ralph", installed_name="ralph") + update_lockfile_entry(lockfile, entry, ralph=True) + assert len(lockfile.ralphs) == 1 + assert len(lockfile.skills) == 0 + assert lockfile.ralphs[0].handle == "user/repo/ralph" + + def test_remove_lockfile_entry_ralph(self): + lockfile = Lockfile( + skills=[], + ralphs=[ + LockedSkill(handle="user/repo/a", installed_name="a"), + LockedSkill(handle="user/repo/b", installed_name="b"), + ], + ) + remove_lockfile_entry(lockfile, "user/repo/a", ralph=True) + assert len(lockfile.ralphs) == 1 + assert lockfile.ralphs[0].handle == "user/repo/b" + + def test_find_locked_ralph(self): + lockfile = Lockfile( + skills=[], + ralphs=[ + LockedSkill(handle="user/repo/ralph", installed_name="ralph"), + ], + ) + dep = Dependency(type="ralph", handle="user/repo/ralph") + result = find_locked_skill(lockfile, dep) + assert result is not None + assert result.installed_name == "ralph" + + def test_find_locked_ralph_not_in_skills(self): + """Ralph dep should not match entries in the skills list.""" + lockfile = Lockfile( + skills=[ + LockedSkill(handle="user/repo/ralph", installed_name="ralph"), + ], + ralphs=[], + ) + dep = Dependency(type="ralph", handle="user/repo/ralph") + result = find_locked_skill(lockfile, dep) + assert result is None + + def test_is_lockfile_current_with_ralphs(self): + lockfile = Lockfile( + skills=[ + LockedSkill(handle="user/repo/skill", installed_name="skill"), + ], + ralphs=[ + LockedSkill(handle="user/repo/ralph", installed_name="ralph"), + ], + ) + deps = [ + Dependency(type="skill", handle="user/repo/skill"), + Dependency(type="ralph", handle="user/repo/ralph"), + ] + assert is_lockfile_current(lockfile, deps) is True + + def test_is_lockfile_current_missing_ralph(self): + lockfile = Lockfile( + skills=[ + LockedSkill(handle="user/repo/skill", installed_name="skill"), + ], + ralphs=[], + ) + deps = [ + Dependency(type="skill", handle="user/repo/skill"), + Dependency(type="ralph", handle="user/repo/ralph"), + ] + assert is_lockfile_current(lockfile, deps) is False