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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.0.4] - 2026-05-07

### Changed

- GitHub API authentication now reads `GH_TOKEN` in addition to `GITHUB_TOKEN`,
matching the `gh` CLI convention. `GH_TOKEN` takes precedence when both are
set. (#67)

## [1.0.0] - 2026-03-24

### Added
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "skillshub"
version = "1.0.3"
version = "1.0.4"
edition = "2021"
rust-version = "1.74.0"
description = "A package manager for AI coding agent skills - like homebrew for skills"
Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,9 @@ skillshub star-list https://github.com/stars/username/lists/skills
skillshub star-list https://github.com/stars/username/lists/skills --install
```

This requires a `GITHUB_TOKEN` (the GraphQL API requires authentication).
This requires `GH_TOKEN` or `GITHUB_TOKEN` (the GraphQL API requires
authentication). `GH_TOKEN` takes precedence when both are set, matching the
`gh` CLI convention.

### Agent Linking

Expand Down Expand Up @@ -222,9 +224,12 @@ The GitHub API is only used for:
- **Gist skills** (`skillshub add https://gist.github.com/...`)
- **Star list imports** (`skillshub star-list ...`)

For these operations, set a `GITHUB_TOKEN` to avoid rate limiting:
For these operations, set `GH_TOKEN` (preferred) or `GITHUB_TOKEN` to avoid
rate limiting. When both are set, `GH_TOKEN` wins, matching the `gh` CLI:

```bash
export GH_TOKEN=your_token_here
# or
export GITHUB_TOKEN=your_token_here
```

Expand Down
3 changes: 2 additions & 1 deletion docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ skillshub star-list <url> # Add all repos from a star list as
skillshub star-list <url> --install # Also install all skills from each tap
```

Requires `GITHUB_TOKEN` (GraphQL API requires authentication).
Requires `GH_TOKEN` or `GITHUB_TOKEN` (GraphQL API requires authentication).
`GH_TOKEN` is checked first, matching the `gh` CLI.

## Tap Management

Expand Down
98 changes: 84 additions & 14 deletions src/registry/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,8 @@ fn print_rate_limit_wait(reason: &str, wait_secs: u64, attempt: u32) {
" {} Waiting {}s before retrying (attempt {}/{})...",
reason, wait_secs, attempt, MAX_RETRIES
);
if std::env::var("GITHUB_TOKEN").is_err() {
eprint!("\n Tip: Set GITHUB_TOKEN for higher rate limits (5000/hour vs 60/hour).");
if github_token().is_none() {
eprint!("\n Tip: Set GH_TOKEN or GITHUB_TOKEN for higher rate limits (5000/hour vs 60/hour).");
}
eprintln!();
}
Expand Down Expand Up @@ -164,7 +164,7 @@ where
if let Some(wait) = rate_info.wait_duration() {
if wait.as_secs() > MAX_RATE_LIMIT_WAIT_SECS {
anyhow::bail!(
"Rate limit reset is {}s away (>{} max). Set GITHUB_TOKEN for higher limits.",
"Rate limit reset is {}s away (>{} max). Set GH_TOKEN or GITHUB_TOKEN for higher limits.",
wait.as_secs(),
MAX_RATE_LIMIT_WAIT_SECS
);
Expand Down Expand Up @@ -267,9 +267,24 @@ fn build_client() -> Result<Client> {
})
}

/// Add GitHub token authentication to a request if GITHUB_TOKEN is set
/// Read the GitHub auth token from the environment.
///
/// Checks `GH_TOKEN` first (matching the `gh` CLI convention), then falls
/// back to `GITHUB_TOKEN`. Empty values are treated as unset.
fn github_token() -> Option<String> {
for var in ["GH_TOKEN", "GITHUB_TOKEN"] {
if let Ok(token) = std::env::var(var) {
if !token.is_empty() {
return Some(token);
}
}
}
None
}

/// Add GitHub token authentication to a request if a token env var is set.
fn with_auth(request: RequestBuilder) -> RequestBuilder {
if let Ok(token) = std::env::var("GITHUB_TOKEN") {
if let Some(token) = github_token() {
request.bearer_auth(token)
} else {
request
Expand Down Expand Up @@ -337,7 +352,7 @@ pub fn get_default_branch(owner: &str, repo: &str) -> Result<String> {
"Repository not found on GitHub: {}/{}\n\
Please check that:\n\
- The repository exists and is spelled correctly\n\
- The repository is public (or GITHUB_TOKEN is set for private repos)",
- The repository is public (or GH_TOKEN/GITHUB_TOKEN is set for private repos)",
owner,
repo
);
Expand Down Expand Up @@ -457,7 +472,7 @@ fn is_valid_repo_id(s: &str) -> bool {
///
/// Uses the GitHub Tree API to recursively find all SKILL.md files in the repo,
/// then fetches each one to extract metadata.
/// Set GITHUB_TOKEN environment variable to avoid rate limiting.
/// Set `GH_TOKEN` or `GITHUB_TOKEN` environment variable to avoid rate limiting.
pub fn discover_skills_from_repo(github_url: &GitHubUrl, tap_name: &str) -> Result<TapRegistry> {
let client = build_client()?;

Expand Down Expand Up @@ -632,7 +647,7 @@ pub fn fetch_gist(gist_id: &str) -> Result<GistResponse> {
anyhow::bail!(
"Gist not found: {}\n\
Please check that the gist ID is correct and the gist is public \
(or GITHUB_TOKEN is set for secret gists)",
(or GH_TOKEN/GITHUB_TOKEN is set for secret gists)",
gist_id
);
}
Expand Down Expand Up @@ -733,11 +748,11 @@ pub fn parse_star_list_url(url: &str) -> Result<(String, String)> {
///
/// Returns a list of "owner/repo" identifiers.
pub fn fetch_star_list_repos(username: &str, list_name: &str) -> Result<Vec<String>> {
let token = std::env::var("GITHUB_TOKEN").with_context(|| {
"GITHUB_TOKEN is required for star list operations.\n\
let token = github_token().context(
"GH_TOKEN or GITHUB_TOKEN is required for star list operations.\n\
The GraphQL API does not support unauthenticated requests.\n\
Set GITHUB_TOKEN with a personal access token."
})?;
Set GH_TOKEN (preferred) or GITHUB_TOKEN with a personal access token.",
)?;
let client = build_client()?;
let gql_url = graphql_url();

Expand Down Expand Up @@ -768,7 +783,7 @@ pub fn fetch_star_list_repos(username: &str, list_name: &str) -> Result<Vec<Stri
if !resp.status().is_success() {
anyhow::bail!(
"Failed to query GitHub GraphQL API: HTTP {}\n\
Make sure GITHUB_TOKEN is set and valid.",
Make sure GH_TOKEN or GITHUB_TOKEN is set and valid.",
resp.status()
);
}
Expand Down Expand Up @@ -887,6 +902,56 @@ mod tests {
assert!(result.is_ok(), "build_client should succeed in normal conditions");
}

#[test]
#[serial]
fn test_github_token_prefers_gh_token() {
std::env::set_var("GH_TOKEN", "gh-value");
std::env::set_var("GITHUB_TOKEN", "github-value");
let token = github_token();
std::env::remove_var("GH_TOKEN");
std::env::remove_var("GITHUB_TOKEN");
assert_eq!(token.as_deref(), Some("gh-value"));
}

#[test]
#[serial]
fn test_github_token_falls_back_to_github_token() {
std::env::remove_var("GH_TOKEN");
std::env::set_var("GITHUB_TOKEN", "github-value");
let token = github_token();
std::env::remove_var("GITHUB_TOKEN");
assert_eq!(token.as_deref(), Some("github-value"));
}

#[test]
#[serial]
fn test_github_token_none_when_unset() {
std::env::remove_var("GH_TOKEN");
std::env::remove_var("GITHUB_TOKEN");
assert!(github_token().is_none());
}

#[test]
#[serial]
fn test_github_token_treats_empty_as_unset() {
std::env::set_var("GH_TOKEN", "");
std::env::set_var("GITHUB_TOKEN", "github-value");
let token = github_token();
std::env::remove_var("GH_TOKEN");
std::env::remove_var("GITHUB_TOKEN");
assert_eq!(token.as_deref(), Some("github-value"));
}
Comment thread
fenfenai marked this conversation as resolved.

#[test]
#[serial]
fn test_github_token_treats_empty_github_token_as_unset() {
std::env::remove_var("GH_TOKEN");
std::env::set_var("GITHUB_TOKEN", "");
let token = github_token();
std::env::remove_var("GITHUB_TOKEN");
assert!(token.is_none());
}

#[test]
fn test_parse_skill_md_content() {
let content = r#"---
Expand Down Expand Up @@ -1844,13 +1909,16 @@ name: minimal-skill
// Point GraphQL URL to mock server
let gql_url = format!("{}/graphql", server.uri());
std::env::set_var("SKILLSHUB_GITHUB_GRAPHQL_URL", &gql_url);
// Set a dummy token so GITHUB_TOKEN check succeeds
// Clear GH_TOKEN so a dev's real token doesn't get sent to the mock,
// then set a dummy GITHUB_TOKEN so the auth check succeeds.
std::env::remove_var("GH_TOKEN");
std::env::set_var("GITHUB_TOKEN", "test-token");

let result = fetch_star_list_repos("testuser", "skills");

// Clean up env vars
std::env::remove_var("SKILLSHUB_GITHUB_GRAPHQL_URL");
std::env::remove_var("GH_TOKEN");
std::env::remove_var("GITHUB_TOKEN");

assert!(result.is_ok(), "fetch should succeed: {:?}", result.err());
Expand Down Expand Up @@ -1891,11 +1959,13 @@ name: minimal-skill

let gql_url = format!("{}/graphql", server.uri());
std::env::set_var("SKILLSHUB_GITHUB_GRAPHQL_URL", &gql_url);
std::env::remove_var("GH_TOKEN");
std::env::set_var("GITHUB_TOKEN", "test-token");

let result = fetch_star_list_repos("testuser", "nonexistent");

std::env::remove_var("SKILLSHUB_GITHUB_GRAPHQL_URL");
std::env::remove_var("GH_TOKEN");
std::env::remove_var("GITHUB_TOKEN");

assert!(result.is_err());
Expand Down
Loading