diff --git a/.gitignore b/.gitignore index 646e0b4..16beca9 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,9 @@ uv.lock htmlcov/ .notes/ -.claude/ \ No newline at end of file + +# Agent resources environment directories +.claude/ +.opencode/ +.codex/ +.config/opencode/ \ No newline at end of file diff --git a/README.md b/README.md index 818579f..aa62be2 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,28 @@ uvx add-command / # Slash commands uvx add-agent / # Sub-agents ``` +### Options for codex, opencode or different repo names + +```bash +# Install from different repository structures +uvx add-skill username/skill-name --repo different-repo + +# Install to different environments +uvx add-skill username/skill-name --env opencode # OpenCode +uvx add-skill username/skill-name --env codex # Codex + +# Custom installation path +uvx add-skill username/skill-name --dest ./my-path/ + +# Global installation +uvx add-skill username/skill-name --global +``` + +**Supports multiple repository structures:** +- `.claude/skills/` (standard) +- `skills/` (Anthropics style) +- `skill/` (OpenCode style) + --- ## 🚀 Create Your Own diff --git a/packages/agent-resources/pyproject.toml b/packages/agent-resources/pyproject.toml index 1d5d392..54575f6 100644 --- a/packages/agent-resources/pyproject.toml +++ b/packages/agent-resources/pyproject.toml @@ -12,6 +12,15 @@ license = "MIT" dependencies = [ "httpx>=0.27", "typer>=0.12", + "pyyaml>=6.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", + "ruff>=0.1.0", + "mypy>=1.0", + "types-PyYAML>=6.0", ] [project.scripts] diff --git a/packages/agent-resources/src/agent_resources/cli/agent.py b/packages/agent-resources/src/agent_resources/cli/agent.py index 047334f..eddd72e 100644 --- a/packages/agent-resources/src/agent_resources/cli/agent.py +++ b/packages/agent-resources/src/agent_resources/cli/agent.py @@ -43,6 +43,27 @@ def add( help="Install to ~/.claude/ instead of ./.claude/", ), ] = False, + repo: Annotated[ + str, + typer.Option( + "--repo", + help="Repository name to fetch from (default: agent-resources)", + ), + ] = "agent-resources", + dest: Annotated[ + str, + typer.Option( + "--dest", + help="Custom destination path", + ), + ] = "", + environment: Annotated[ + str, + typer.Option( + "--env", + help="Target environment (claude, opencode, codex)", + ), + ] = "", ) -> None: """ Add a sub-agent from a GitHub user's agent-resources repository. @@ -60,14 +81,17 @@ def add( typer.echo(f"Error: {e}", err=True) raise typer.Exit(1) - dest = get_destination("agents", global_install) + # Simple destination handling + dest_path = get_destination( + "agents", global_install, dest if dest else None, environment if environment else None + ) scope = "user" if global_install else "project" - typer.echo(f"Fetching agent '{agent_name}' from {username}/agent-resources...") + typer.echo(f"Fetching agent '{agent_name}' from {username}/{repo}...") try: agent_path = fetch_resource( - username, agent_name, dest, ResourceType.AGENT, overwrite + username, agent_name, dest_path, ResourceType.AGENT, overwrite, repo ) typer.echo(f"Added agent '{agent_name}' to {agent_path} ({scope} scope)") except RepoNotFoundError as e: diff --git a/packages/agent-resources/src/agent_resources/cli/command.py b/packages/agent-resources/src/agent_resources/cli/command.py index 9de1dc2..1506a94 100644 --- a/packages/agent-resources/src/agent_resources/cli/command.py +++ b/packages/agent-resources/src/agent_resources/cli/command.py @@ -43,6 +43,27 @@ def add( help="Install to ~/.claude/ instead of ./.claude/", ), ] = False, + repo: Annotated[ + str, + typer.Option( + "--repo", + help="Repository name to fetch from (default: agent-resources)", + ), + ] = "agent-resources", + dest: Annotated[ + str, + typer.Option( + "--dest", + help="Custom destination path", + ), + ] = "", + environment: Annotated[ + str, + typer.Option( + "--env", + help="Target environment (claude, opencode, codex)", + ), + ] = "", ) -> None: """ Add a slash command from a GitHub user's agent-resources repository. @@ -60,14 +81,17 @@ def add( typer.echo(f"Error: {e}", err=True) raise typer.Exit(1) - dest = get_destination("commands", global_install) + # Simple destination handling + dest_path = get_destination( + "commands", global_install, dest if dest else None, environment if environment else None + ) scope = "user" if global_install else "project" - typer.echo(f"Fetching command '{command_name}' from {username}/agent-resources...") + typer.echo(f"Fetching command '{command_name}' from {username}/{repo}...") try: command_path = fetch_resource( - username, command_name, dest, ResourceType.COMMAND, overwrite + username, command_name, dest_path, ResourceType.COMMAND, overwrite, repo ) typer.echo(f"Added command '{command_name}' to {command_path} ({scope} scope)") except RepoNotFoundError as e: diff --git a/packages/agent-resources/src/agent_resources/cli/common.py b/packages/agent-resources/src/agent_resources/cli/common.py index f4bb212..c6cee83 100644 --- a/packages/agent-resources/src/agent_resources/cli/common.py +++ b/packages/agent-resources/src/agent_resources/cli/common.py @@ -3,6 +3,55 @@ from pathlib import Path import typer +import yaml + + +# Default environment configurations +DEFAULT_ENVIRONMENTS = { + "claude": { + "skill_dir": ".claude/skills", + "command_dir": ".claude/commands", + "agent_dir": ".claude/agents", + }, + "opencode": { + "skill_dir": ".opencode/skill", + "command_dir": ".opencode/command", + "agent_dir": ".opencode/agent", + "global_skill_dir": ".config/opencode/skill", + "global_command_dir": ".config/opencode/command", + "global_agent_dir": ".config/opencode/agent", + }, + "codex": { + "skill_dir": ".codex/skills", + "command_dir": ".codex/commands", + "agent_dir": ".codex/agents", + }, +} + + +def get_environment_config(environment: str | None = None) -> dict: + """Simple config loading - no caching, no complexity""" + config_path = Path.home() / ".agent-resources-config.yaml" + + # Load user config if exists + user_config: dict = {} + if config_path.exists(): + with config_path.open("r") as f: + user_config = yaml.safe_load(f) or {} + + # Merge with defaults - simple and straightforward + environments = {**DEFAULT_ENVIRONMENTS, **user_config.get("environments", {})} + + # Default to claude if no environment specified + env_name = environment or "claude" + + if env_name not in environments: + raise typer.BadParameter( + f"Unknown environment: '{env_name}'. " + f"Available: {', '.join(environments.keys())}" + ) + + return environments[env_name] def parse_resource_ref(ref: str) -> tuple[str, str]: @@ -31,20 +80,38 @@ def parse_resource_ref(ref: str) -> tuple[str, str]: return username, name -def get_destination(resource_subdir: str, global_install: bool) -> Path: +def get_destination( + resource_subdir: str, + global_install: bool, + custom_dest: str | None = None, + environment: str | None = None, +) -> Path: """ Get the destination directory for a resource. Args: resource_subdir: The subdirectory name (e.g., "skills", "commands", "agents") - global_install: If True, install to ~/.claude/, else to ./.claude/ + global_install: If True, install to home directory, else to current directory + custom_dest: Optional custom destination path + environment: Optional environment name (claude, opencode, codex) Returns: Path to the destination directory """ - if global_install: - base = Path.home() / ".claude" - else: - base = Path.cwd() / ".claude" - - return base / resource_subdir + if custom_dest: + return Path(custom_dest).expanduser() + + # Get environment configuration + env_config = get_environment_config(environment) + + # Build config key based on resource type and global flag + prefix = "global_" if global_install else "" + key = f"{prefix}{resource_subdir.rstrip('s')}_dir" # "skills" -> "skill_dir" + + # Get the directory, fallback to non-global if global key doesn't exist + env_dir = env_config.get(key, env_config[key.replace("global_", "")]) + + # Determine base path + base = Path.home() if global_install else Path.cwd() + + return base / env_dir diff --git a/packages/agent-resources/src/agent_resources/cli/skill.py b/packages/agent-resources/src/agent_resources/cli/skill.py index ce9c289..ab29f3c 100644 --- a/packages/agent-resources/src/agent_resources/cli/skill.py +++ b/packages/agent-resources/src/agent_resources/cli/skill.py @@ -43,6 +43,27 @@ def add( help="Install to ~/.claude/ instead of ./.claude/", ), ] = False, + repo: Annotated[ + str, + typer.Option( + "--repo", + help="Repository name to fetch from (default: agent-resources)", + ), + ] = "agent-resources", + dest: Annotated[ + str, + typer.Option( + "--dest", + help="Custom destination path", + ), + ] = "", + environment: Annotated[ + str, + typer.Option( + "--env", + help="Target environment (claude, opencode, codex)", + ), + ] = "", ) -> None: """ Add a skill from a GitHub user's agent-resources repository. @@ -60,14 +81,17 @@ def add( typer.echo(f"Error: {e}", err=True) raise typer.Exit(1) - dest = get_destination("skills", global_install) + # Simple destination handling + dest_path = get_destination( + "skills", global_install, dest if dest else None, environment if environment else None + ) scope = "user" if global_install else "project" - typer.echo(f"Fetching skill '{skill_name}' from {username}/agent-resources...") + typer.echo(f"Fetching skill '{skill_name}' from {username}/{repo}...") try: skill_path = fetch_resource( - username, skill_name, dest, ResourceType.SKILL, overwrite + username, skill_name, dest_path, ResourceType.SKILL, overwrite, repo ) typer.echo(f"Added skill '{skill_name}' to {skill_path} ({scope} scope)") except RepoNotFoundError as e: diff --git a/packages/agent-resources/src/agent_resources/fetcher.py b/packages/agent-resources/src/agent_resources/fetcher.py index 0e2b31d..d177652 100644 --- a/packages/agent-resources/src/agent_resources/fetcher.py +++ b/packages/agent-resources/src/agent_resources/fetcher.py @@ -60,16 +60,82 @@ class ResourceConfig: ), } +# Pattern-based search for different repository structures +RESOURCE_SEARCH_PATTERNS = { + ResourceType.SKILL: [ + ".claude/skills/{name}/", # Current (first for backward compat) + "skills/{name}/", # Anthropics pattern + "skill/{name}/", # opencode pattern + ], + ResourceType.COMMAND: [ + ".claude/commands/{name}.md", # Current + "commands/{name}.md", + "command/{name}.md", # opencode pattern + ], + ResourceType.AGENT: [ + ".claude/agents/{name}.md", # Current + "agents/{name}.md", + "agent/{name}.md", # opencode pattern + ], +} + # Name of the repository to fetch resources from REPO_NAME = "agent-resources" +def validate_repository_structure(repo_dir: Path) -> dict: + """Simple validation that provides useful feedback""" + + # Check for any recognizable patterns + patterns_found = [] + for pattern in [ + ".claude/skills", + "skills", + "skill", + ".claude/commands", + "commands", + "command", + ".claude/agents", + "agents", + "agent", + ]: + if (repo_dir / pattern).exists(): + patterns_found.append(pattern) + + # Simple suggestions + suggestions = [] + if not patterns_found: + suggestions.append("Repository doesn't match common agent-resources patterns") + suggestions.append("Expected: .claude/skills/, skills/, or skill/ directories") + + return {"patterns_found": patterns_found, "suggestions": suggestions} + + +def find_resource_in_repo( + repo_dir: Path, resource_type: ResourceType, name: str +) -> Path | None: + """Simple pattern-based search - no caching, no complexity""" + config = RESOURCE_CONFIGS[resource_type] + + for pattern in RESOURCE_SEARCH_PATTERNS[resource_type]: + search_path = pattern.format(name=name) + if config.file_extension and not search_path.endswith(config.file_extension): + search_path += config.file_extension + + resource_path = repo_dir / search_path + if resource_path.exists(): + return resource_path + + return None + + def fetch_resource( username: str, name: str, dest: Path, resource_type: ResourceType, overwrite: bool = False, + repo: str = "agent-resources", # Simple repo name override ) -> Path: """ Fetch a resource from a user's agent-resources repo and copy it to dest. @@ -105,9 +171,7 @@ def fetch_resource( ) # Download tarball - tarball_url = ( - f"https://github.com/{username}/{REPO_NAME}/archive/refs/heads/main.tar.gz" - ) + tarball_url = f"https://github.com/{username}/{repo}/archive/refs/heads/main.tar.gz" with tempfile.TemporaryDirectory() as tmp_dir: tmp_path = Path(tmp_dir) @@ -119,7 +183,7 @@ def fetch_resource( response = client.get(tarball_url) if response.status_code == 404: raise RepoNotFoundError( - f"Repository '{username}/{REPO_NAME}' not found on GitHub." + f"Repository '{username}/{repo}' not found on GitHub." ) response.raise_for_status() @@ -134,25 +198,47 @@ def fetch_resource( with tarfile.open(tarball_path, "r:gz") as tar: tar.extractall(extract_path) - # Find the resource in extracted content - # Tarball extracts to: agent-resources-main/.claude//[.md] - repo_dir = extract_path / f"{REPO_NAME}-main" + # Find the resource in extracted content using pattern-based search + # Tarball extracts to: -main/ + repo_dir = extract_path / f"{repo}-main" - if config.is_directory: - resource_source = repo_dir / config.source_subdir / name - else: - resource_source = repo_dir / config.source_subdir / f"{name}{config.file_extension}" + # Use pattern-based search to find the resource + resource_source = find_resource_in_repo(repo_dir, resource_type, name) - if not resource_source.exists(): - if config.is_directory: - expected_location = f"{config.source_subdir}/{name}/" - else: - expected_location = f"{config.source_subdir}/{name}{config.file_extension}" - raise ResourceNotFoundError( - f"{resource_type.value.capitalize()} '{name}' not found in {username}/{REPO_NAME}.\n" - f"Expected location: {expected_location}" + if resource_source is None or not resource_source.exists(): + patterns_tried = [ + p.format(name=name) for p in RESOURCE_SEARCH_PATTERNS[resource_type] + ] + patterns_list = "\n".join([f"- {p}" for p in patterns_tried]) + + # Validate repository structure for helpful suggestions + validation = validate_repository_structure(repo_dir) + + error_msg = ( + f"{resource_type.value.capitalize()} '{name}' not found in {username}/{repo}.\n" + f"Tried these locations:\n{patterns_list}\n" ) + # Add validation suggestions if available + if validation["suggestions"]: + error_msg += "\nRepository structure issues:\n" + error_msg += "\n".join([f"• {s}" for s in validation["suggestions"]]) + error_msg += "\n" + elif validation["patterns_found"]: + error_msg += ( + f"\nFound directories: {', '.join(validation['patterns_found'])}\n" + ) + + error_msg += ( + "\nQuick fixes:\n" + "• Double-check the resource name\n" + "• Try --repo REPO_NAME if using a different repository\n" + "• Try --dest PATH for custom installation location\n" + f"• Visit https://github.com/{username}/{repo} to verify the resource exists" + ) + + raise ResourceNotFoundError(error_msg) + # Remove existing if overwriting if resource_dest.exists(): if config.is_directory: @@ -165,8 +251,8 @@ def fetch_resource( # Copy resource to destination if config.is_directory: - shutil.copytree(resource_source, resource_dest) + shutil.copytree(str(resource_source), str(resource_dest)) else: - shutil.copy2(resource_source, resource_dest) + shutil.copy2(str(resource_source), str(resource_dest)) return resource_dest diff --git a/packages/agent-resources/tests/test_integration.py b/packages/agent-resources/tests/test_integration.py new file mode 100644 index 0000000..16f1329 --- /dev/null +++ b/packages/agent-resources/tests/test_integration.py @@ -0,0 +1,250 @@ +"""Integration tests that simulate real-world usage.""" + +import sys +import tempfile +import tarfile +from pathlib import Path +from unittest.mock import patch, MagicMock + +# Add src to path for non-installed testing +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from agent_resources.fetcher import fetch_resource, ResourceType +from agent_resources.exceptions import ResourceNotFoundError +import httpx + + +def create_mock_repo_tarball(tmp_path: Path, repo_name: str, structure: str) -> bytes: + """Create a mock GitHub tarball with specified structure. + + Args: + tmp_path: Temporary directory path + repo_name: Name of the repository + structure: Type of structure - 'claude', 'anthropics', or 'opencode' + + Returns: + Tarball bytes + """ + # Create repo directory structure + repo_dir = tmp_path / f"{repo_name}-main" + + if structure == "claude": + # .claude/skills/test-skill/ structure + skill_dir = repo_dir / ".claude" / "skills" / "test-skill" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text("# Test Skill (Claude structure)") + + cmd_dir = repo_dir / ".claude" / "commands" + cmd_dir.mkdir(parents=True) + (cmd_dir / "test-cmd.md").write_text("# Test Command (Claude structure)") + + elif structure == "anthropics": + # skills/test-skill/ structure + skill_dir = repo_dir / "skills" / "test-skill" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text("# Test Skill (Anthropics structure)") + + cmd_dir = repo_dir / "commands" + cmd_dir.mkdir(parents=True) + (cmd_dir / "test-cmd.md").write_text("# Test Command (Anthropics structure)") + + elif structure == "opencode": + # skill/test-skill/ structure + skill_dir = repo_dir / "skill" / "test-skill" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text("# Test Skill (OpenCode structure)") + + cmd_dir = repo_dir / "command" + cmd_dir.mkdir(parents=True) + (cmd_dir / "test-cmd.md").write_text("# Test Command (OpenCode structure)") + + # Create tarball + tarball_path = tmp_path / "repo.tar.gz" + with tarfile.open(tarball_path, "w:gz") as tar: + tar.add(repo_dir, arcname=f"{repo_name}-main") + + return tarball_path.read_bytes() + + +def test_backward_compatibility_claude_structure(): + """Test backward compatibility with .claude/skills structure""" + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_path = Path(tmp_dir) + dest_path = tmp_path / "destination" + + # Create mock tarball with Claude structure + tarball_bytes = create_mock_repo_tarball(tmp_path / "source", "agent-resources", "claude") + + # Mock httpx response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.content = tarball_bytes + + with patch('httpx.Client') as mock_client: + mock_client.return_value.__enter__.return_value.get.return_value = mock_response + + # Should work with default repo name + result = fetch_resource( + "testuser", + "test-skill", + dest_path, + ResourceType.SKILL, + overwrite=False, + repo="agent-resources" + ) + + assert result.exists() + assert result.name == "test-skill" + assert (result / "SKILL.md").exists() + content = (result / "SKILL.md").read_text() + assert "Claude structure" in content + + +def test_anthropics_pattern_detection(): + """Test pattern detection for Anthropics-style repos (skills/)""" + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_path = Path(tmp_dir) + dest_path = tmp_path / "destination" + + # Create mock tarball with Anthropics structure + tarball_bytes = create_mock_repo_tarball(tmp_path / "source", "skills", "anthropics") + + # Mock httpx response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.content = tarball_bytes + + with patch('httpx.Client') as mock_client: + mock_client.return_value.__enter__.return_value.get.return_value = mock_response + + # Should work with --repo skills + result = fetch_resource( + "anthropics", + "test-skill", + dest_path, + ResourceType.SKILL, + overwrite=False, + repo="skills" + ) + + assert result.exists() + assert result.name == "test-skill" + content = (result / "SKILL.md").read_text() + assert "Anthropics structure" in content + + +def test_opencode_pattern_detection(): + """Test pattern detection for opencode-style repos (skill/)""" + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_path = Path(tmp_dir) + dest_path = tmp_path / "destination" + + # Create mock tarball with OpenCode structure + tarball_bytes = create_mock_repo_tarball(tmp_path / "source", "codingagents", "opencode") + + # Mock httpx response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.content = tarball_bytes + + with patch('httpx.Client') as mock_client: + mock_client.return_value.__enter__.return_value.get.return_value = mock_response + + # Should work with --repo codingagents + result = fetch_resource( + "opencode", + "test-skill", + dest_path, + ResourceType.SKILL, + overwrite=False, + repo="codingagents" + ) + + assert result.exists() + assert result.name == "test-skill" + content = (result / "SKILL.md").read_text() + assert "OpenCode structure" in content + + +def test_custom_destination(): + """Test custom destination path""" + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_path = Path(tmp_dir) + custom_dest = tmp_path / "my-custom" / "location" + + # Create mock tarball + tarball_bytes = create_mock_repo_tarball(tmp_path / "source", "agent-resources", "claude") + + # Mock httpx response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.content = tarball_bytes + + with patch('httpx.Client') as mock_client: + mock_client.return_value.__enter__.return_value.get.return_value = mock_response + + # Should install to custom destination + result = fetch_resource( + "testuser", + "test-skill", + custom_dest, + ResourceType.SKILL, + overwrite=False, + repo="agent-resources" + ) + + assert result.exists() + assert str(custom_dest) in str(result) + assert result.name == "test-skill" + + +def test_enhanced_error_messages(): + """Test that error messages show all attempted patterns""" + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_path = Path(tmp_dir) + dest_path = tmp_path / "destination" + + # Create empty tarball (no resources) + repo_dir = tmp_path / "source" / "agent-resources-main" + repo_dir.mkdir(parents=True) + + tarball_path = tmp_path / "repo.tar.gz" + with tarfile.open(tarball_path, "w:gz") as tar: + tar.add(repo_dir, arcname="agent-resources-main") + + tarball_bytes = tarball_path.read_bytes() + + # Mock httpx response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.content = tarball_bytes + + with patch('httpx.Client') as mock_client: + mock_client.return_value.__enter__.return_value.get.return_value = mock_response + + try: + fetch_resource( + "testuser", + "nonexistent", + dest_path, + ResourceType.SKILL, + overwrite=False, + repo="agent-resources" + ) + assert False, "Should have raised ResourceNotFoundError" + except ResourceNotFoundError as e: + error_msg = str(e) + # Should show all attempted patterns + assert "Tried these locations:" in error_msg + assert ".claude/skills/nonexistent" in error_msg + assert "skills/nonexistent" in error_msg + assert "skill/nonexistent" in error_msg + # Should show helpful suggestions + assert "Quick fixes:" in error_msg + assert "--repo" in error_msg + assert "--dest" in error_msg + + +if __name__ == "__main__": + import pytest + pytest.main([__file__, "-v"])