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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 114 additions & 22 deletions agr/commands/add.py
Original file line number Diff line number Diff line change
@@ -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,
)
Expand All @@ -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,
Expand All @@ -50,35 +75,99 @@ 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:
# Parse handle
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:
Expand All @@ -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:
Expand Down Expand Up @@ -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(
Expand All @@ -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)

Expand Down
33 changes: 26 additions & 7 deletions agr/commands/list.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand All @@ -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 <handle>' to add skills.[/dim]")
console.print("[dim]Run 'agr add <handle>' 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")

Expand All @@ -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)

Expand Down
35 changes: 22 additions & 13 deletions agr/commands/remove.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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"))

Expand All @@ -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)
Loading
Loading