Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/surface-github-api-error.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions packages/cli/src/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -483,6 +486,8 @@ async function setupCli(options: SetupOptions): Promise<void> {
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;
}

Expand Down Expand Up @@ -516,6 +521,8 @@ async function setupCli(options: SetupOptions): Promise<void> {
` ${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}`);
Expand Down
45 changes: 40 additions & 5 deletions packages/cli/src/utils/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,15 +152,49 @@ function getGitHubHeaders(): Record<string, string> {
};
}

// 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<string, string>
): Promise<GitHubTreeResponse | null> {
): Promise<GitHubTreeResponse | { status: number }> {
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;
}

Expand Down Expand Up @@ -193,7 +227,8 @@ export async function listSkillsFromGitHub(project: string): Promise<GitHubSkill
if ("status" in branchResult) return { status: "repo_not_found" };

const treeData = await fetchRepoTree(owner, repo, branchResult.branch, headers);
if (!treeData) return { status: "error", error: "Could not fetch repository tree" };
if ("status" in treeData)
return { status: "error", error: describeGitHubError(treeData.status) };

const skillMdFiles = treeData.tree.filter(
(item) => item.type === "blob" && item.path.toLowerCase().endsWith("skill.md")
Expand Down Expand Up @@ -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(
Expand Down
Loading