diff --git a/README.md b/README.md index 03521ed..96287c4 100644 --- a/README.md +++ b/README.md @@ -90,12 +90,16 @@ Enables `add`, `update`, `remove`, `validate`, `search`, `show`, and `doc` (gene # Add a sample skill skillport add hello-world -# Or add from GitHub +# Or add from GitHub (shorthand format) +skillport add anthropics/skills skills # specific path +skillport add anthropics/skills skills examples # multiple paths (1 download) + +# Or add from GitHub (full URL) skillport add https://github.com/anthropics/skills/tree/main/skills -# Or add from GitHub with custom skills directory (Claude Code, Codex) -skillport --skills-dir .claude/skills add https://github.com/anthropics/skills/tree/main/skills -skillport --skills-dir ~/.codex/skills add https://github.com/anthropics/skills/tree/main/skills/frontend-design +# With custom skills directory (Claude Code, Codex) +skillport --skills-dir .claude/skills add anthropics/skills skills +skillport --skills-dir ~/.codex/skills add anthropics/skills skills/frontend-design ``` ### 3. Add to Your MCP Client @@ -199,7 +203,8 @@ skillport init # 3. Add skills (uses skills_dir from .skillportrc) skillport add hello-world -skillport add https://github.com/anthropics/skills/tree/main/skills +skillport add anthropics/skills skills # shorthand format +skillport add anthropics/skills skills examples # multiple paths skillport add https://github.com/anthropics/skills/tree/main/skills/frontend-design ``` @@ -257,18 +262,21 @@ skillport show # View skill details and instructions **Install from GitHub:** -One command to install skills from any GitHub URL-no cloning required. Supports branches and subdirectories: +One command to install skills from any GitHub URL-no cloning required. Supports shorthand format, branches, and subdirectories: ```bash -# Anthropic official skills -skillport add https://github.com/anthropics/skills/tree/main/skills +# Shorthand format (owner/repo [paths...]) +skillport add anthropics/skills skills # specific path +skillport add anthropics/skills skills examples # multiple paths (1 download) +skillport add owner/repo # repo root -# Specific path in a repo +# Full URL format +skillport add https://github.com/anthropics/skills/tree/main/skills skillport add https://github.com/wshobson/agents/tree/main/plugins/developer-essentials/skills # Private repos work automatically if you use GitHub CLI gh auth login # one-time setup -skillport add https://github.com/your-org/private-skills +skillport add your-org/private-skills skills ``` **Discover more:** diff --git a/guide/cli.md b/guide/cli.md index ce361a5..79f4339 100644 --- a/guide/cli.md +++ b/guide/cli.md @@ -146,14 +146,21 @@ skillport add [options] | Local | `./my-collection/` | Directory containing multiple skills | | Local | `./mixed/` | Directory containing both skill directories and zip files | | Zip | `./my-skill.zip` | Single skill in zip format (1 zip = 1 skill) | -| GitHub | `https://github.com/user/repo` | Repository root (auto-detects default branch) | -| GitHub | `https://github.com/user/repo/tree/main/skills` | Specific directory | +| GitHub | `user/repo` | Shorthand format (auto-detects default branch) | +| GitHub | `user/repo skills` | Shorthand with path(s) - single download, multiple paths | +| GitHub | `https://github.com/user/repo` | Full URL (auto-detects default branch) | +| GitHub | `https://github.com/user/repo/tree/main/skills` | Full URL with specific directory | > **Zip file support**: > - Each zip file must contain exactly one skill > - Zip files in a directory are automatically detected and extracted > - Useful for skills exported from Claude.ai +> **GitHub shorthand** (`owner/repo`): +> - Simpler syntax: `skillport add anthropics/skills` +> - Supports multiple paths: `skillport add owner/repo skills examples` (single download) +> - Local paths take priority (if `./owner/repo` exists, it's treated as local) + > **GitHub URL support**: > - Works with or without trailing slash > - Auto-detects default branch when not specified @@ -243,7 +250,20 @@ skillport add ./my-skill.zip --namespace my-ns # → skills/my-ns/my-skill/ ``` -**GitHub:** +**GitHub (shorthand):** +```bash +# All skills from repository root +skillport add anthropics/skills + +# Specific path(s) within repository - single download, efficient +skillport add anthropics/skills skills +skillport add owner/repo skills examples # multiple paths + +# Force overwrite existing +skillport add anthropics/skills --force +``` + +**GitHub (full URL):** ```bash # Specific skill from repository skillport add https://github.com/user/repo/tree/main/skills/code-review diff --git a/src/skillport/interfaces/cli/commands/add.py b/src/skillport/interfaces/cli/commands/add.py index 108376c..738161d 100644 --- a/src/skillport/interfaces/cli/commands/add.py +++ b/src/skillport/interfaces/cli/commands/add.py @@ -13,6 +13,9 @@ detect_skills, extract_zip, fetch_github_source_with_info, + get_default_branch, + is_github_shorthand, + parse_github_shorthand, parse_github_url, ) @@ -27,13 +30,25 @@ ) +def _is_github_shorthand_source(source: str) -> bool: + """Check if source is GitHub shorthand (owner/repo) and not a local path.""" + if not is_github_shorthand(source): + return False + # Local path takes priority over GitHub shorthand + candidate = Path(source).expanduser().resolve() + return not candidate.exists() + + def _is_external_source(source: str) -> bool: - """Check if source is a path or URL (not builtin).""" + """Check if source is a path, URL, or GitHub shorthand (not builtin).""" if source.startswith((".", "/", "~", "https://")): return True # Also consider .zip files as external sources if source.lower().endswith(".zip"): return True + # GitHub shorthand (owner/repo) is external + if _is_github_shorthand_source(source): + return True return False @@ -42,6 +57,10 @@ def _get_source_name(source: str) -> str: if source.startswith("https://"): parsed = parse_github_url(source) return Path(parsed.normalized_path or parsed.repo).name + # GitHub shorthand: owner/repo + shorthand = parse_github_shorthand(source) + if shorthand: + return shorthand[1] # repo name return Path(source.rstrip("/")).name @@ -50,9 +69,254 @@ def _get_default_namespace(source: str) -> str: if source.startswith("https://"): parsed = parse_github_url(source) return parsed.repo + # GitHub shorthand: owner/repo + shorthand = parse_github_shorthand(source) + if shorthand: + return shorthand[1] # repo name return Path(source.rstrip("/")).name +class UserSkipped(Exception): + """Raised when user chooses to skip.""" + + pass + + +def _prompt_namespace_selection( + skill_names: list[str], + source: str, + *, + yes: bool, + keep_structure: bool | None, + namespace: str | None, +) -> tuple[bool | None, str | None]: + """Prompt user for namespace selection if needed. + + Args: + skill_names: List of detected skill names + source: Original source string + yes: Skip interactive prompts + keep_structure: Current keep_structure setting + namespace: Current namespace setting + + Returns: + (keep_structure, namespace) + + Raises: + UserSkipped: If user chooses to skip + """ + # Already configured - no prompt needed + if keep_structure is not None or namespace is not None: + return keep_structure, namespace + + is_single = len(skill_names) == 1 + + # Non-interactive mode: use sensible defaults + if yes or not is_interactive(): + if is_single: + return False, namespace + else: + return True, namespace or _get_default_namespace(source) + + # Interactive mode + skill_display = ( + skill_names[0] + if is_single + else ", ".join(skill_names[:3]) + ("..." if len(skill_names) > 3 else "") + ) + + console.print(f"\n[bold]Found {len(skill_names)} skill(s):[/bold] {skill_display}") + console.print("[bold]Where to add?[/bold]") + if is_single: + console.print(f" [info][1][/info] Flat → skills/{skill_names[0]}/") + console.print( + f" [info][2][/info] Namespace → skills/[dim][/dim]/{skill_names[0]}/ " + "[warning](Claude Code incompatible)[/warning]" + ) + else: + console.print( + f" [info][1][/info] Flat → skills/{skill_names[0]}/, skills/{skill_names[1]}/, ..." + ) + console.print( + f" [info][2][/info] Namespace → skills/[dim][/dim]/{skill_names[0]}/, ... " + "[warning](Claude Code incompatible)[/warning]" + ) + console.print(" [info][3][/info] Skip") + choice = Prompt.ask("Choice", choices=["1", "2", "3"], default="1") + + if choice == "3": + raise UserSkipped() + if choice == "1": + return False, namespace + if choice == "2": + ns = Prompt.ask("Namespace", default=_get_default_namespace(source)) + return True, ns + + return keep_structure, namespace + + +def _display_add_result(result: "AddResult", json_output: bool) -> int: # noqa: F821 + """Display add result and return exit code.""" + # JSON output for programmatic use + if json_output: + console.print_json( + data={ + "added": result.added, + "skipped": result.skipped, + "message": result.message, + "details": [d.model_dump() for d in getattr(result, "details", [])], + } + ) + return 1 if (not result.added and result.skipped) else 0 + + # Human-readable output + if result.added: + for skill_id in result.added: + console.print(f"[success] ✓ Added '{skill_id}'[/success]") + if result.skipped: + for skill_id in result.skipped: + detail_reason = next( + ( + d.message + for d in getattr(result, "details", []) + if d.skill_id == skill_id and d.message + ), + None, + ) + skip_reason = detail_reason or result.message or "skipped" + console.print(f"[warning] ⊘ Skipped '{skill_id}' ({skip_reason})[/warning]") + + # Summary + if result.added and not result.skipped: + print_success(f"Added {len(result.added)} skill(s)") + return 0 + elif result.added and result.skipped: + print_warning( + f"Added {len(result.added)}, skipped {len(result.skipped)} ({result.message})" + ) + return 0 + elif result.skipped: + print_error(result.message or f"All {len(result.skipped)} skill(s) skipped") + return 1 + else: + print_error(result.message) + return 1 + + +def _add_from_github_paths( + source: str, + paths: list[str], + *, + config: "Config", # noqa: F821 + force: bool, + yes: bool, + keep_structure: bool | None, + namespace: str | None, +) -> "AddResult": # noqa: F821 + """Add skills from GitHub shorthand with multiple paths. + + Downloads the repository once and adds skills from each specified path. + + Raises: + UserSkipped: If user chooses to skip + """ + from skillport.modules.skills.public.types import AddResult, AddResultItem + + parsed = parse_github_shorthand(source) + if not parsed: + return AddResult( + success=False, + skill_id="", + message=f"Invalid GitHub shorthand: {source}", + ) + + owner, repo = parsed + base_url = f"https://github.com/{owner}/{repo}" + + temp_dir: Path | None = None + try: + # Phase 1: Fetch tarball + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=stderr_console, + transient=True, + ) as progress: + progress.add_task(f"Fetching {base_url}...", total=None) + fetch_result = fetch_github_source_with_info(base_url) + temp_dir = fetch_result.extracted_path + commit_sha = fetch_result.commit_sha + + default_branch = get_default_branch(owner, repo) + + # Phase 2: Collect paths and detect skills + path_infos: list[tuple[str, str, Path]] = [] + all_skill_names: list[str] = [] + invalid_paths: list[tuple[str, str]] = [] + + for path in paths: + path = path.strip("/") + path_url = f"https://github.com/{owner}/{repo}/tree/{default_branch}/{path}" + path_dir = temp_dir / path + + if not path_dir.exists(): + invalid_paths.append((path, f"Path not found in repository: {path}")) + continue + + path_infos.append((path, path_url, path_dir)) + skills = detect_skills(path_dir) + all_skill_names.extend([s.name for s in skills]) + + # Phase 3: Interactive prompt (raises UserSkipped if user skips) + if path_infos and all_skill_names: + keep_structure, namespace = _prompt_namespace_selection( + all_skill_names, + source, + yes=yes, + keep_structure=keep_structure, + namespace=namespace, + ) + + # Phase 4: Add skills + all_added: list[str] = [] + all_skipped: list[str] = [] + all_details: list[AddResultItem] = [] + messages: list[str] = [] + + for path, msg in invalid_paths: + all_skipped.append(path) + all_details.append(AddResultItem(skill_id=path, success=False, message=msg)) + + for _path, path_url, path_dir in path_infos: + result = add_skill( + path_url, + config=config, + force=force, + keep_structure=keep_structure, + namespace=namespace, + pre_fetched_dir=path_dir, + pre_fetched_commit_sha=commit_sha, + ) + all_added.extend(result.added) + all_skipped.extend(result.skipped) + all_details.extend(result.details) + if result.message and result.message not in messages: + messages.append(result.message) + + return AddResult( + success=len(all_added) > 0, + skill_id=all_added[0] if all_added else "", + message="; ".join(messages) if messages else "", + added=all_added, + skipped=all_skipped, + details=all_details, + ) + + finally: + if temp_dir and temp_dir.exists(): + shutil.rmtree(temp_dir, ignore_errors=True) + + def _detect_skills_from_source(source: str) -> tuple[list[str], str, Path | None, str]: """Detect skills from source. Returns (skill_names, source_name, temp_dir, commit_sha).""" source_name = _get_source_name(source) @@ -111,9 +375,13 @@ def add( ctx: typer.Context, source: str = typer.Argument( ..., - help="Built-in name, local path, or GitHub URL", + help="Built-in name, local path, GitHub URL, or owner/repo", show_default=False, ), + paths: list[str] = typer.Argument( + None, + help="Paths within the repository (GitHub shorthand only)", + ), force: bool = typer.Option( False, "--force", @@ -148,72 +416,55 @@ def add( help="Output as JSON (for scripting/AI agents)", ), ): - """Add skills from various sources.""" + """Add skills from various sources. + + Examples: + skillport add owner/repo # Add from repo root + skillport add owner/repo skills # Add from skills/ directory + skillport add owner/repo skills examples # Add from multiple paths + skillport add https://github.com/o/r # Full URL (existing) + """ temp_dir: Path | None = None commit_sha: str = "" + paths = paths or [] + config = get_config(ctx) try: - # Interactive namespace selection for external sources - if _is_external_source(source) and keep_structure is None and namespace is None: - skill_names, source_name, temp_dir, commit_sha = _detect_skills_from_source(source) - is_single = len(skill_names) == 1 - - # Non-interactive mode: use sensible defaults - if yes or not is_interactive(): - if is_single: - keep_structure = False - else: - keep_structure = True - namespace = namespace or _get_default_namespace(source) - else: - # Interactive mode - skill_display = ( - skill_names[0] - if is_single - else ", ".join(skill_names[:3]) + ("..." if len(skill_names) > 3 else "") + # Route 1: GitHub shorthand with paths (multi-path, single download) + if _is_github_shorthand_source(source) and paths: + result = _add_from_github_paths( + source, + paths, + config=config, + force=force, + yes=yes, + keep_structure=keep_structure, + namespace=namespace, + ) + else: + # Route 2: Standard flow (URL, local, builtin, shorthand without paths) + if _is_external_source(source) and keep_structure is None and namespace is None: + skill_names, _source_name, temp_dir, commit_sha = _detect_skills_from_source(source) + keep_structure, namespace = _prompt_namespace_selection( + skill_names, + source, + yes=yes, + keep_structure=keep_structure, + namespace=namespace, ) - console.print(f"\n[bold]Found {len(skill_names)} skill(s):[/bold] {skill_display}") - console.print("[bold]Where to add?[/bold]") - if is_single: - console.print(f" [info][1][/info] Flat → skills/{skill_names[0]}/") - console.print( - f" [info][2][/info] Namespace → skills/[dim][/dim]/{skill_names[0]}/ " - "[warning](Claude Code incompatible)[/warning]" - ) - else: - console.print( - f" [info][1][/info] Flat → skills/{skill_names[0]}/, skills/{skill_names[1]}/, ..." - ) - console.print( - f" [info][2][/info] Namespace → skills/[dim][/dim]/{skill_names[0]}/, ... " - "[warning](Claude Code incompatible)[/warning]" - ) - console.print(" [info][3][/info] Skip") - choice = Prompt.ask("Choice", choices=["1", "2", "3"], default="1") - - if choice == "3": - print_warning("Skipped") - raise typer.Exit(code=0) - if choice == "1": - keep_structure = False - if choice == "2": - keep_structure = True - namespace = Prompt.ask("Namespace", default=_get_default_namespace(source)) - - config = get_config(ctx) - result = add_skill( - source, - config=config, - force=force, - keep_structure=keep_structure, - namespace=namespace, - name=name, - pre_fetched_dir=temp_dir, - pre_fetched_commit_sha=commit_sha, - ) + result = add_skill( + source, + config=config, + force=force, + keep_structure=keep_structure, + namespace=namespace, + name=name, + pre_fetched_dir=temp_dir, + pre_fetched_commit_sha=commit_sha, + ) - # Auto-reindex if skills were added + # Shared: Auto-reindex if skills were added if result.added: with Progress( SpinnerColumn(), @@ -224,53 +475,15 @@ def add( progress.add_task("Updating index...", total=None) build_index(config=config, force=False) - # JSON output for programmatic use - if json_output: - console.print_json( - data={ - "added": result.added, - "skipped": result.skipped, - "message": result.message, - "details": [d.model_dump() for d in getattr(result, "details", [])], - } - ) - if not result.added and result.skipped: - raise typer.Exit(code=1) - return + # Shared: Display result + exit_code = _display_add_result(result, json_output) + if exit_code != 0: + raise typer.Exit(code=exit_code) + + except UserSkipped: + print_warning("Skipped") + raise typer.Exit(code=0) - # Human-readable output - if result.added: - for skill_id in result.added: - console.print(f"[success] ✓ Added '{skill_id}'[/success]") - if result.skipped: - for skill_id in result.skipped: - detail_reason = next( - ( - d.message - for d in getattr(result, "details", []) - if d.skill_id == skill_id and d.message - ), - None, - ) - skip_reason = detail_reason or result.message or "skipped" - console.print(f"[warning] ⊘ Skipped '{skill_id}' ({skip_reason})[/warning]") - - # Summary - if result.added and not result.skipped: - print_success(f"Added {len(result.added)} skill(s)") - elif result.added and result.skipped: - print_warning( - f"Added {len(result.added)}, skipped {len(result.skipped)} ({result.message})" - ) - elif result.skipped: - print_error( - result.message or f"All {len(result.skipped)} skill(s) skipped", - ) - raise typer.Exit(code=1) - else: - print_error(result.message) - raise typer.Exit(code=1) finally: - # Cleanup temp dir from pre-scan if temp_dir and Path(temp_dir).exists(): shutil.rmtree(temp_dir, ignore_errors=True) diff --git a/src/skillport/modules/skills/internal/__init__.py b/src/skillport/modules/skills/internal/__init__.py index 0308549..9861e62 100644 --- a/src/skillport/modules/skills/internal/__init__.py +++ b/src/skillport/modules/skills/internal/__init__.py @@ -5,16 +5,20 @@ ParsedGitHubURL, fetch_github_source, fetch_github_source_with_info, + get_default_branch, get_latest_commit_sha, get_remote_tree_hash, parse_github_url, rename_single_skill_dir, ) from .manager import ( + GITHUB_SHORTHAND_RE, SkillInfo, add_builtin, add_local, detect_skills, + is_github_shorthand, + parse_github_shorthand, remove_skill, resolve_source, ) @@ -54,11 +58,15 @@ "parse_github_url", "fetch_github_source", "fetch_github_source_with_info", + "get_default_branch", "get_latest_commit_sha", "get_remote_tree_hash", "rename_single_skill_dir", "ParsedGitHubURL", "GitHubFetchResult", + "GITHUB_SHORTHAND_RE", + "is_github_shorthand", + "parse_github_shorthand", "record_origin", "remove_origin_record", "get_origin", diff --git a/src/skillport/modules/skills/internal/github.py b/src/skillport/modules/skills/internal/github.py index e79bc92..53d5dd5 100644 --- a/src/skillport/modules/skills/internal/github.py +++ b/src/skillport/modules/skills/internal/github.py @@ -103,8 +103,20 @@ class GitHubFetchResult: commit_sha: str # Short SHA (first 7 chars typically) -def _get_default_branch(owner: str, repo: str, auth: TokenResult) -> str: - """Fetch default branch from GitHub API.""" +def get_default_branch(owner: str, repo: str, auth: TokenResult | None = None) -> str: + """Fetch default branch from GitHub API. + + Args: + owner: Repository owner + repo: Repository name + auth: Optional TokenResult. If None, resolves token automatically. + + Returns: + Default branch name (falls back to "main" on error) + """ + if auth is None: + auth = resolve_github_token() + headers = {"Accept": "application/vnd.github+json"} if auth.has_token: headers["Authorization"] = f"Bearer {auth.token}" @@ -143,7 +155,7 @@ def parse_github_url( if not ref: if resolve_default_branch: resolved_auth = auth if auth is not None else resolve_github_token() - ref = _get_default_branch(owner, repo, resolved_auth) + ref = get_default_branch(owner, repo, resolved_auth) else: ref = "main" diff --git a/src/skillport/modules/skills/internal/manager.py b/src/skillport/modules/skills/internal/manager.py index d2792b6..860d849 100644 --- a/src/skillport/modules/skills/internal/manager.py +++ b/src/skillport/modules/skills/internal/manager.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import re import shutil import sys from dataclasses import dataclass @@ -15,6 +16,9 @@ from .validation import validate_skill_record +# GitHub shorthand pattern: owner/repo (no slashes in owner or repo) +GITHUB_SHORTHAND_RE = re.compile(r"^(?P[a-zA-Z0-9_-]+)/(?P[a-zA-Z0-9_.-]+)$") + # Built-in skills BUILTIN_SKILLS = { "hello-world": """\ @@ -77,6 +81,19 @@ class SkillInfo: source_path: Path +def is_github_shorthand(source: str) -> bool: + """Check if source matches GitHub shorthand format (owner/repo).""" + return bool(GITHUB_SHORTHAND_RE.match(source)) + + +def parse_github_shorthand(source: str) -> tuple[str, str] | None: + """Parse GitHub shorthand format. Returns (owner, repo) or None.""" + match = GITHUB_SHORTHAND_RE.match(source) + if match: + return match.group("owner"), match.group("repo") + return None + + def resolve_source(source: str) -> tuple[SourceType, str]: """Determine source type and resolved value.""" if not source: @@ -85,6 +102,8 @@ def resolve_source(source: str) -> tuple[SourceType, str]: return SourceType.BUILTIN, source if source.startswith("https://github.com/"): return SourceType.GITHUB, source + + # Check local path first (priority over GitHub shorthand) candidate = Path(source).expanduser().resolve() if candidate.exists(): if candidate.is_dir(): @@ -92,6 +111,13 @@ def resolve_source(source: str) -> tuple[SourceType, str]: if candidate.is_file() and candidate.suffix.lower() == ".zip": return SourceType.ZIP, str(candidate) raise ValueError(f"Source is not a directory or zip file: {candidate}") + + # GitHub shorthand: owner/repo (only if not a local path) + parsed = parse_github_shorthand(source) + if parsed: + owner, repo = parsed + return SourceType.GITHUB, f"https://github.com/{owner}/{repo}" + raise ValueError(f"Source not found: {source}")