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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down
31 changes: 29 additions & 2 deletions guide/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ skillport add <source> [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

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

Expand Down
20 changes: 17 additions & 3 deletions guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 2 additions & 5 deletions src/skillport/interfaces/cli/commands/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
85 changes: 70 additions & 15 deletions src/skillport/modules/skills/internal/github.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
from __future__ import annotations

import re
import shutil
import tarfile
Expand All @@ -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<owner>[^/]+)/(?P<repo>[^/]+)(?:/tree/(?P<ref>[^/]+)(?P<path>/.*)?)?/?$"
)
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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(
Expand All @@ -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"

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

Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions src/skillport/modules/skills/internal/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 5 additions & 5 deletions src/skillport/modules/skills/public/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

from __future__ import annotations

import os
import shutil
from collections.abc import Callable
from dataclasses import dataclass
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
9 changes: 9 additions & 0 deletions src/skillport/shared/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -22,21 +23,29 @@
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",
"ValidationIssue",
"SkillId",
"SkillName",
"Namespace",
# Utils
"normalize_token",
"parse_frontmatter",
"is_skill_enabled",
Expand Down
Loading