diff --git a/.changeset/surface-github-api-error.md b/.changeset/surface-github-api-error.md new file mode 100644 index 000000000..1db4181d5 --- /dev/null +++ b/.changeset/surface-github-api-error.md @@ -0,0 +1,5 @@ +--- +"ctx7": patch +--- + +Surface the real GitHub status when skill downloads fail. A bare "GitHub API error" now becomes an actionable message distinguishing 401 (invalid/expired token), 403 (rate limit), and 404 (repo/branch not found), and setup prints a `tip:` line telling users to refresh GITHUB_TOKEN/GH_TOKEN or run `gh auth login`, so an expired token is no longer mistaken for a rate limit. diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index 4039a4600..66edad2eb 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -10,6 +10,7 @@ import { log } from "../utils/logger.js"; import { checkboxWithHover } from "../utils/prompts.js"; import { trackEvent } from "../utils/tracking.js"; import { getBaseUrl, downloadSkill } from "../utils/api.js"; +import { githubErrorTip } from "../utils/github.js"; import { installSkillFiles } from "../utils/installer.js"; import { performLogin } from "./auth.js"; import { saveTokens, getValidAccessToken } from "../utils/auth.js"; @@ -428,6 +429,8 @@ async function setupMcp(agents: SetupAgent[], options: SetupOptions, scope: Scop ` ${pc.yellow("tip:")} fix permissions with: ${pc.cyan(`sudo chown -R $(whoami) ${dirname(dirname(r.skillPath))}`)}` ); } + const skillTip = githubErrorTip(r.skillStatus); + if (skillTip) log.plain(` ${pc.yellow("tip:")} ${skillTip}`); } log.blank(); @@ -483,6 +486,8 @@ async function setupCli(options: SetupOptions): Promise { const downloadData = await downloadSkill("/upstash/context7", "find-docs"); if (downloadData.error || downloadData.files.length === 0) { spinner.fail(`Failed to download find-docs skill: ${downloadData.error || "no files"}`); + const tip = githubErrorTip(downloadData.error); + if (tip) log.plain(` ${pc.yellow("tip:")} ${tip}`); return; } @@ -516,6 +521,8 @@ async function setupCli(options: SetupOptions): Promise { ` ${pc.yellow("tip:")} fix permissions with: ${pc.cyan(`sudo chown -R $(whoami) ${dirname(dirname(r.skillPath))}`)}` ); } + const skillTip = githubErrorTip(r.skillStatus); + if (skillTip) log.plain(` ${pc.yellow("tip:")} ${skillTip}`); const ruleIcon = r.ruleStatus === "installed" || r.ruleStatus === "updated" ? pc.green("+") : pc.dim("~"); log.plain(` ${ruleIcon} Rule ${r.ruleStatus}`); diff --git a/packages/cli/src/utils/github.ts b/packages/cli/src/utils/github.ts index 5ef14d7f5..a717f80f1 100644 --- a/packages/cli/src/utils/github.ts +++ b/packages/cli/src/utils/github.ts @@ -152,15 +152,49 @@ function getGitHubHeaders(): Record { }; } +// Turns a GitHub HTTP status into a short, recognizable message. The common +// failures are an expired/invalid token (401) shadowing a working `gh` login, +// or the anonymous rate limit (403); both previously surfaced as a bare +// "GitHub API error" with no way to tell them apart. The actionable fix is +// returned separately by `githubErrorTip` so callers can render it as a hint. +function describeGitHubError(status: number): string { + switch (status) { + case 401: + return "GitHub API error: 401 (invalid or expired token)"; + case 403: + return "GitHub API error: 403 (rate limited)"; + case 404: + return "GitHub API error: 404 (repository or branch not found)"; + default: + return `GitHub API error: ${status}`; + } +} + +/** + * Returns an actionable hint for a GitHub error message produced by + * `describeGitHubError`, or `undefined` when there's nothing useful to add. + * Meant to be shown as a `tip:` line below the failure. + */ +export function githubErrorTip(error: string | undefined): string | undefined { + if (!error) return undefined; + if (error.includes("401")) { + return "your GitHub token is invalid or expired; refresh GITHUB_TOKEN/GH_TOKEN or run `gh auth login`"; + } + if (error.includes("403")) { + return "GitHub rate limit reached; set GITHUB_TOKEN/GH_TOKEN or run `gh auth login` for higher limits"; + } + return undefined; +} + async function fetchRepoTree( owner: string, repo: string, branch: string, headers: Record -): Promise { +): Promise { const treeUrl = `${GITHUB_API}/repos/${owner}/${repo}/git/trees/${branch}?recursive=1`; const response = await fetch(treeUrl, { headers }); - if (!response.ok) return null; + if (!response.ok) return { status: response.status }; return (await response.json()) as GitHubTreeResponse; } @@ -193,7 +227,8 @@ export async function listSkillsFromGitHub(project: string): Promise item.type === "blob" && item.path.toLowerCase().endsWith("skill.md") @@ -250,8 +285,8 @@ export async function downloadSkillFromGitHub( const ghHeaders = getGitHubHeaders(); const treeData = await fetchRepoTree(owner, repo, branch, ghHeaders); - if (!treeData) { - return { files: [], error: `GitHub API error` }; + if ("status" in treeData) { + return { files: [], error: describeGitHubError(treeData.status) }; } const skillFiles = treeData.tree.filter(