diff --git a/README.md b/README.md index 056d251..03521ed 100644 --- a/README.md +++ b/README.md @@ -265,6 +265,10 @@ skillport add https://github.com/anthropics/skills/tree/main/skills # Specific path in a repo 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 ``` **Discover more:** diff --git a/guide/cli.md b/guide/cli.md index 4b3e0c3..ce361a5 100644 --- a/guide/cli.md +++ b/guide/cli.md @@ -157,7 +157,7 @@ skillport add [options] > **GitHub URL support**: > - Works with or without trailing slash > - Auto-detects default branch when not specified -> - Private repositories require `GITHUB_TOKEN` environment variable +> - Private repositories: use `gh auth login` (recommended) or set `GITHUB_TOKEN` #### Options @@ -899,7 +899,34 @@ CLI commands resolve `skills_dir` / `db_path` in this order: |----------|-------------| | `SKILLPORT_SKILLS_DIR` | Skills directory | | `SKILLPORT_AUTO_REINDEX` | Enable/disable automatic reindexing | -| `GITHUB_TOKEN` | GitHub authentication for private repos | + +### GitHub Authentication + +SkillPort automatically detects GitHub authentication using the following fallback chain: + +1. **`GH_TOKEN`** — Environment variable (fine-grained PAT recommended) +2. **`GITHUB_TOKEN`** — Environment variable (classic PAT) +3. **`gh auth token`** — GitHub CLI authentication + +**Recommended**: If you have [GitHub CLI](https://cli.github.com/) installed and authenticated (`gh auth login`), SkillPort will automatically use your credentials. No additional configuration needed. + +```bash +# One-time setup (if not already done) +gh auth login + +# Now private repos just work +skillport add https://github.com/your-org/private-skills +``` + +**Alternative**: Set an environment variable with a [Personal Access Token](https://github.com/settings/tokens): + +```bash +export GITHUB_TOKEN=ghp_xxxxxxxxxxxx +``` + +Required token scopes: +- **Classic PAT**: `repo` scope +- **Fine-grained PAT**: `Contents: Read` permission ## See Also diff --git a/guide/configuration.md b/guide/configuration.md index ec400f5..9f4f364 100644 --- a/guide/configuration.md +++ b/guide/configuration.md @@ -234,14 +234,28 @@ Give each AI agent a different view of the same skill repository: ### Authentication -Set `GITHUB_TOKEN` for: -- Private repository access -- Higher rate limits (5,000 req/hour vs 60 req/hour) +SkillPort automatically detects GitHub credentials using this fallback chain: + +1. **`GH_TOKEN`** environment variable +2. **`GITHUB_TOKEN`** environment variable +3. **`gh auth token`** (GitHub CLI) + +**Recommended:** Use [GitHub CLI](https://cli.github.com/) — no manual token management: + +```bash +gh auth login # one-time setup, then private repos just work +``` + +**Alternative:** Set an environment variable: ```bash export GITHUB_TOKEN=ghp_xxxxx ``` +Authentication provides: +- Private repository access +- Higher rate limits (5,000 req/hour vs 60 req/hour) + ### Supported URL Formats ```bash diff --git a/src/skillport/interfaces/cli/commands/validate.py b/src/skillport/interfaces/cli/commands/validate.py index 6c204ea..f07db69 100644 --- a/src/skillport/interfaces/cli/commands/validate.py +++ b/src/skillport/interfaces/cli/commands/validate.py @@ -109,9 +109,7 @@ def validate( skills = _scan_skills_from_path(target_path) except typer.BadParameter as e: if json_output: - console.print_json( - data={"valid": False, "message": str(e), "skills": []} - ) + console.print_json(data={"valid": False, "message": str(e), "skills": []}) else: print_error(str(e)) raise typer.Exit(code=1) @@ -141,8 +139,7 @@ def validate( "id": skill.get("id", skill.get("name")), "valid": all(issue.severity != "fatal" for issue in issues), "issues": [ - {"severity": i.severity, "field": i.field, "message": i.message} - for i in issues + {"severity": i.severity, "field": i.field, "message": i.message} for i in issues ], } all_results.append(skill_result) diff --git a/src/skillport/modules/skills/internal/github.py b/src/skillport/modules/skills/internal/github.py index f6af948..e79bc92 100644 --- a/src/skillport/modules/skills/internal/github.py +++ b/src/skillport/modules/skills/internal/github.py @@ -1,4 +1,5 @@ -import os +from __future__ import annotations + import re import shutil import tarfile @@ -9,6 +10,8 @@ import requests +from skillport.shared.auth import TokenResult, is_gh_cli_available, resolve_github_token + GITHUB_URL_RE = re.compile( r"^https://github\.com/(?P[^/]+)/(?P[^/]+)(?:/tree/(?P[^/]+)(?P/.*)?)?/?$" ) @@ -29,6 +32,53 @@ } +# --- Error Message Builders --- + + +def _build_404_error_message(auth: TokenResult) -> str: + """Build context-aware error message for 404 responses.""" + if auth.has_token: + # Token exists but still got 404 + source_hint = f" (from {auth.source})" if auth.source else "" + return ( + f"Repository not found or token lacks access{source_hint}.\n" + "Check:\n" + " - Is the repository URL correct?\n" + " - Does the token have 'repo' scope (classic) or 'Contents: Read' (fine-grained)?\n" + " - Are you a collaborator on this private repository?" + ) + else: + # No token available + if is_gh_cli_available(): + return ( + "Repository not found or private.\n" + "For private repos, authenticate with: gh auth login" + ) + else: + return ( + "Repository not found or private.\n" + "For private repos:\n" + " - Install GitHub CLI and run: gh auth login\n" + " - Or set: export GITHUB_TOKEN=ghp_..." + ) + + +def _build_403_error_message(auth: TokenResult) -> str: + """Build context-aware error message for 403 responses.""" + if auth.has_token: + return ( + "GitHub API access denied. Your token may lack required permissions.\n" + "Ensure the token has 'repo' scope for private repositories." + ) + else: + return ( + "GitHub API rate limit exceeded.\n" + "Authenticate to increase your rate limit:\n" + " - gh auth login (recommended)\n" + " - Or set: export GITHUB_TOKEN=ghp_..." + ) + + @dataclass class ParsedGitHubURL: owner: str @@ -53,11 +103,11 @@ class GitHubFetchResult: commit_sha: str # Short SHA (first 7 chars typically) -def _get_default_branch(owner: str, repo: str, token: str | None) -> str: +def _get_default_branch(owner: str, repo: str, auth: TokenResult) -> str: """Fetch default branch from GitHub API.""" headers = {"Accept": "application/vnd.github+json"} - if token: - headers["Authorization"] = f"Bearer {token}" + if auth.has_token: + headers["Authorization"] = f"Bearer {auth.token}" url = f"https://api.github.com/repos/{owner}/{repo}" try: @@ -69,7 +119,12 @@ def _get_default_branch(owner: str, repo: str, token: str | None) -> str: return "main" -def parse_github_url(url: str, *, resolve_default_branch: bool = False) -> ParsedGitHubURL: +def parse_github_url( + url: str, + *, + resolve_default_branch: bool = False, + auth: TokenResult | None = None, +) -> ParsedGitHubURL: match = GITHUB_URL_RE.match(url.strip()) if not match: raise ValueError( @@ -87,8 +142,8 @@ def parse_github_url(url: str, *, resolve_default_branch: bool = False) -> Parse # If no ref specified, resolve default branch from API if not ref: if resolve_default_branch: - token = os.getenv("GITHUB_TOKEN") - ref = _get_default_branch(owner, repo, token) + resolved_auth = auth if auth is not None else resolve_github_token() + ref = _get_default_branch(owner, repo, resolved_auth) else: ref = "main" @@ -103,16 +158,16 @@ def _iter_members_for_prefix(tar: tarfile.TarFile, prefix: str) -> Iterable[tarf yield member -def download_tarball(parsed: ParsedGitHubURL, token: str | None) -> Path: +def download_tarball(parsed: ParsedGitHubURL, auth: TokenResult) -> Path: headers = {"Accept": "application/vnd.github+json"} - if token: - headers["Authorization"] = f"Bearer {token}" + if auth.has_token: + headers["Authorization"] = f"Bearer {auth.token}" resp = requests.get(parsed.tarball_url, headers=headers, stream=True, timeout=60) if resp.status_code == 404: - raise ValueError("Repository not found or private. Set GITHUB_TOKEN for private repos.") + raise ValueError(_build_404_error_message(auth)) if resp.status_code == 403: - raise ValueError("GitHub API rate limit. Set GITHUB_TOKEN.") + raise ValueError(_build_403_error_message(auth)) if not resp.ok: raise ValueError(f"Failed to fetch tarball: HTTP {resp.status_code}") @@ -211,9 +266,9 @@ def fetch_github_source(url: str) -> Path: def fetch_github_source_with_info(url: str) -> GitHubFetchResult: """Fetch GitHub source and return extracted path with commit info.""" - parsed = parse_github_url(url, resolve_default_branch=True) - token = os.getenv("GITHUB_TOKEN") - tar_path = download_tarball(parsed, token) + auth = resolve_github_token() + parsed = parse_github_url(url, resolve_default_branch=True, auth=auth) + tar_path = download_tarball(parsed, auth) try: extracted_path, commit_sha = extract_tarball(tar_path, parsed) return GitHubFetchResult( diff --git a/src/skillport/modules/skills/internal/validation.py b/src/skillport/modules/skills/internal/validation.py index 1df5f3b..c453c8a 100644 --- a/src/skillport/modules/skills/internal/validation.py +++ b/src/skillport/modules/skills/internal/validation.py @@ -28,6 +28,7 @@ def _validate_name_chars(name: str) -> bool: normalized = unicodedata.normalize("NFKC", name) return all(_is_valid_name_char(c) for c in normalized) + # Allowed top-level frontmatter properties ALLOWED_FRONTMATTER_KEYS: set[str] = { "name", diff --git a/src/skillport/modules/skills/public/update.py b/src/skillport/modules/skills/public/update.py index 35dd306..c05afd5 100644 --- a/src/skillport/modules/skills/public/update.py +++ b/src/skillport/modules/skills/public/update.py @@ -6,7 +6,6 @@ from __future__ import annotations -import os import shutil from collections.abc import Callable from dataclasses import dataclass @@ -27,6 +26,7 @@ rename_single_skill_dir, update_origin, ) +from skillport.shared.auth import resolve_github_token from skillport.shared.config import Config from .types import Origin, UpdateResult, UpdateResultItem @@ -657,18 +657,18 @@ def _github_source_hash(origin: Origin, skill_id: str, *, config: Config) -> tup if not source_url: return "", "Missing source URL" - parsed = parse_github_url(source_url, resolve_default_branch=True) + auth = resolve_github_token() + parsed = parse_github_url(source_url, resolve_default_branch=True, auth=auth) path = origin.get("path") or parsed.normalized_path or skill_id.split("/")[-1] - token = os.getenv("GITHUB_TOKEN") - remote_hash = get_remote_tree_hash(parsed, token, path) + remote_hash = get_remote_tree_hash(parsed, auth.token, path) # Try narrowing path if initial attempt failed if not remote_hash or path == parsed.normalized_path: skill_tail = skill_id.split("/")[-1] candidate = "/".join(p for p in [parsed.normalized_path, skill_tail] if p) if candidate != path: - alt_hash = get_remote_tree_hash(parsed, token, candidate) + alt_hash = get_remote_tree_hash(parsed, auth.token, candidate) if alt_hash: remote_hash = alt_hash try: diff --git a/src/skillport/shared/__init__.py b/src/skillport/shared/__init__.py index ace92ff..c82853f 100644 --- a/src/skillport/shared/__init__.py +++ b/src/skillport/shared/__init__.py @@ -1,5 +1,6 @@ """Shared infrastructure for SkillPort.""" +from .auth import TokenResult, is_gh_cli_available, resolve_github_token from .config import SKILLPORT_HOME, Config from .exceptions import ( AmbiguousSkillError, @@ -22,14 +23,21 @@ from .utils import parse_frontmatter, resolve_inside __all__ = [ + # Auth + "TokenResult", + "is_gh_cli_available", + "resolve_github_token", + # Config "Config", "SKILLPORT_HOME", + # Exceptions "SkillPortError", "SkillNotFoundError", "AmbiguousSkillError", "ValidationError", "IndexingError", "SourceError", + # Types "FrozenModel", "Severity", "SourceType", @@ -37,6 +45,7 @@ "SkillId", "SkillName", "Namespace", + # Utils "normalize_token", "parse_frontmatter", "is_skill_enabled", diff --git a/src/skillport/shared/auth.py b/src/skillport/shared/auth.py new file mode 100644 index 0000000..3a6c62d --- /dev/null +++ b/src/skillport/shared/auth.py @@ -0,0 +1,122 @@ +"""GitHub authentication token resolution with fallback chain. + +Design: Function-based with pluggable resolvers for easy extension. + +Fallback chain (in order): +1. GH_TOKEN environment variable (fine-grained PAT recommended by GitHub) +2. GITHUB_TOKEN environment variable (classic, widely used) +3. gh CLI auth token (for local development) + +Usage: + from skillport.shared.auth import resolve_github_token + + result = resolve_github_token() + if result.token: + headers["Authorization"] = f"Bearer {result.token}" +""" + +from __future__ import annotations + +import os +import subprocess +from collections.abc import Callable +from dataclasses import dataclass + +# Type alias for resolver functions +TokenResolver = Callable[[], "TokenResult | None"] + + +@dataclass(frozen=True) +class TokenResult: + """Result of token resolution with source information.""" + + token: str | None + source: str | None # e.g., "GH_TOKEN", "GITHUB_TOKEN", "gh_cli" + + @property + def has_token(self) -> bool: + return bool(self.token) + + def __bool__(self) -> bool: + return self.has_token + + +# --- Token Resolvers (each returns TokenResult or None) --- + + +def _resolve_from_gh_token_env() -> TokenResult | None: + """Resolve from GH_TOKEN (preferred for fine-grained PAT).""" + if token := os.getenv("GH_TOKEN"): + return TokenResult(token=token, source="GH_TOKEN") + return None + + +def _resolve_from_github_token_env() -> TokenResult | None: + """Resolve from GITHUB_TOKEN (classic, widely used).""" + if token := os.getenv("GITHUB_TOKEN"): + return TokenResult(token=token, source="GITHUB_TOKEN") + return None + + +def _resolve_from_gh_cli() -> TokenResult | None: + """Resolve from gh CLI auth token.""" + try: + result = subprocess.run( + ["gh", "auth", "token"], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0 and (token := result.stdout.strip()): + return TokenResult(token=token, source="gh_cli") + except FileNotFoundError: + # gh CLI not installed + pass + except subprocess.TimeoutExpired: + # gh CLI timed out + pass + except Exception: + # Any other error (permissions, etc.) + pass + return None + + +# --- Fallback Chain Configuration --- + +# Order matters: first match wins +# To customize, modify this list or use resolve_github_token(resolvers=[...]) +DEFAULT_RESOLVERS: list[TokenResolver] = [ + _resolve_from_gh_token_env, + _resolve_from_github_token_env, + _resolve_from_gh_cli, +] + + +def resolve_github_token( + resolvers: list[TokenResolver] | None = None, +) -> TokenResult: + """Resolve GitHub token using fallback chain. + + Args: + resolvers: Custom list of resolver functions. Defaults to DEFAULT_RESOLVERS. + + Returns: + TokenResult with token and source, or empty TokenResult if none found. + """ + for resolver in resolvers or DEFAULT_RESOLVERS: + if result := resolver(): + return result + return TokenResult(token=None, source=None) + + +def is_gh_cli_available() -> bool: + """Check if gh CLI is installed and available.""" + try: + result = subprocess.run( + ["gh", "--version"], + capture_output=True, + timeout=5, + ) + return result.returncode == 0 + except (FileNotFoundError, subprocess.TimeoutExpired, Exception): + return False diff --git a/uv.lock b/uv.lock index b5973ab..8bd7f8f 100644 --- a/uv.lock +++ b/uv.lock @@ -1832,7 +1832,7 @@ wheels = [ [[package]] name = "skillport" -version = "0.5.0" +version = "0.5.2" source = { editable = "." } dependencies = [ { name = "fastmcp" },