diff --git a/.changeset/slow-adults-bet.md b/.changeset/slow-adults-bet.md new file mode 100644 index 000000000..4b1089e9a --- /dev/null +++ b/.changeset/slow-adults-bet.md @@ -0,0 +1,13 @@ +--- +"ctx7": patch +--- + +Surface GitHub API error details when skill download fails (#2363) + +Previously, any GitHub API failure during `ctx7 setup` or `ctx7 setup --cli` produced the opaque message "GitHub API error", making it impossible to distinguish a 403 rate-limit from a 401 bad token or a 404 wrong branch. + +Changes: +- `fetchRepoTree` and `fetchDefaultBranch` now extract the HTTP status and GitHub error body, returning descriptive strings like `"HTTP 403: API rate limit exceeded"` +- `listSkillsFromGitHub` distinguishes a true 404 (repo not found) from other errors (rate-limit, bad credentials) that previously all collapsed into the same silent result +- When a request fails unauthenticated with a 403/429, a hint is shown: `run \`gh auth login\` or set the GITHUB_TOKEN env var to increase rate limits` +- Failed skill entries in the setup results table now show a red `✖` with the error detail on its own line instead of embedding it in the status string diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index 4039a4600..c5b8a3f75 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -384,6 +384,22 @@ async function setupAgent( }; } +function logSkillStatus(skillStatus: string, skillPath: string): void { + const skillFailed = skillStatus.startsWith("failed:"); + const skillIcon = + skillStatus === "installed" ? pc.green("+") : skillFailed ? pc.red("✖") : pc.dim("~"); + log.plain(` ${skillIcon} Skill ${skillFailed ? "failed" : skillStatus}`); + log.plain(` ${pc.dim(skillPath)}`); + if (skillFailed) { + log.plain(` ${pc.red(skillStatus.slice("failed: ".length))}`); + if (skillStatus.includes("EACCES")) { + log.plain( + ` ${pc.yellow("tip:")} fix permissions with: ${pc.cyan(`sudo chown -R $(whoami) ${dirname(dirname(skillPath))}`)}` + ); + } + } +} + async function setupMcp(agents: SetupAgent[], options: SetupOptions, scope: Scope): Promise { const transport = resolveTransport(options); if (transport === "stdio" && options.oauth) { @@ -420,14 +436,7 @@ async function setupMcp(agents: SetupAgent[], options: SetupOptions, scope: Scop const ruleIcon = r.ruleStatus === "installed" ? pc.green("+") : pc.dim("~"); log.plain(` ${ruleIcon} Rule ${r.ruleStatus}`); log.plain(` ${pc.dim(r.rulePath)}`); - const skillIcon = r.skillStatus === "installed" ? pc.green("+") : pc.dim("~"); - log.plain(` ${skillIcon} Skill ${r.skillStatus}`); - log.plain(` ${pc.dim(r.skillPath)}`); - if (r.skillStatus.includes("EACCES")) { - log.plain( - ` ${pc.yellow("tip:")} fix permissions with: ${pc.cyan(`sudo chown -R $(whoami) ${dirname(dirname(r.skillPath))}`)}` - ); - } + logSkillStatus(r.skillStatus, r.skillPath); } log.blank(); @@ -498,9 +507,10 @@ async function setupCli(options: SetupOptions): Promise { }> = []; for (const agentName of agents) { - installSpinner.text = `Setting up ${getAgent(agentName).displayName}...`; + const agentDef = getAgent(agentName); + installSpinner.text = `Setting up ${agentDef.displayName}...`; const r = await setupCliAgent(agentName, scope, downloadData); - results.push({ agent: getAgent(agentName).displayName, ...r }); + results.push({ agent: agentDef.displayName, ...r }); } installSpinner.succeed("Context7 CLI setup complete"); @@ -508,14 +518,7 @@ async function setupCli(options: SetupOptions): Promise { log.blank(); for (const r of results) { log.plain(` ${pc.bold(r.agent)}`); - const skillIcon = r.skillStatus === "installed" ? pc.green("+") : pc.dim("~"); - log.plain(` ${skillIcon} Skill ${r.skillStatus}`); - log.plain(` ${pc.dim(r.skillPath)}`); - if (r.skillStatus.includes("EACCES")) { - log.plain( - ` ${pc.yellow("tip:")} fix permissions with: ${pc.cyan(`sudo chown -R $(whoami) ${dirname(dirname(r.skillPath))}`)}` - ); - } + logSkillStatus(r.skillStatus, r.skillPath); 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..f5518e95a 100644 --- a/packages/cli/src/utils/github.ts +++ b/packages/cli/src/utils/github.ts @@ -152,15 +152,24 @@ function getGitHubHeaders(): Record { }; } +async function extractGitHubError(response: Response): Promise { + let detail = `HTTP ${response.status}`; + try { + const body = (await response.json()) as { message?: string }; + if (body.message) detail += `: ${body.message}`; + } catch {} + return detail; +} + 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 { error: await extractGitHubError(response) }; return (await response.json()) as GitHubTreeResponse; } @@ -168,9 +177,9 @@ async function fetchDefaultBranch( owner: string, repo: string, headers: Record -): Promise<{ branch: string } | { status: number }> { +): Promise<{ branch: string } | { error: string; status: number }> { const response = await fetch(`${GITHUB_API}/repos/${owner}/${repo}`, { headers }); - if (!response.ok) return { status: response.status }; + if (!response.ok) return { error: await extractGitHubError(response), status: response.status }; const data = (await response.json()) as { default_branch: string }; return { branch: data.default_branch }; } @@ -190,10 +199,13 @@ export async function listSkillsFromGitHub(project: string): Promise item.type === "blob" && item.path.toLowerCase().endsWith("skill.md") @@ -250,8 +262,12 @@ export async function downloadSkillFromGitHub( const ghHeaders = getGitHubHeaders(); const treeData = await fetchRepoTree(owner, repo, branch, ghHeaders); - if (!treeData) { - return { files: [], error: `GitHub API error` }; + if ("error" in treeData) { + const hint = + !ghHeaders["Authorization"] && /403|429|rate/.test(treeData.error) + ? " — run `gh auth login` or set the GITHUB_TOKEN env var to increase rate limits" + : ""; + return { files: [], error: `GitHub API error: ${treeData.error}${hint}` }; } const skillFiles = treeData.tree.filter(