diff --git a/src/core/skill-search.ts b/src/core/skill-search.ts index 06a59e7..03a3af8 100644 --- a/src/core/skill-search.ts +++ b/src/core/skill-search.ts @@ -8,9 +8,9 @@ * mentions `` — the path-targeted query finds them when the bare * content query can't. * - * Auth comes from `GITHUB_TOKEN` when present; unauthenticated requests are - * subject to the public Code Search rate limit (10 req/min). See cli/cli - * issue #13293 for upstream tracking. + * Auth is resolved by `resolveGhToken`: env vars first, then `gh auth token` + * so that credentials from `gh auth login` are picked up automatically. See + * cli/cli issue #13293 for upstream tracking. */ const OWNER_REGEX = /^[A-Za-z0-9-]{1,39}$/; @@ -166,6 +166,12 @@ function classifyApiError(status: number, body: unknown): SkillSearchError { const msg = typeof body === 'object' && body !== null && 'message' in body ? String((body as { message: unknown }).message ?? '') : ''; + if (status === 401) { + return new SkillSearchError( + 'GitHub Code Search requires authentication. Run `gh auth login` or set GITHUB_TOKEN.', + 'api', + ); + } if (status === 403 && /rate limit/i.test(msg)) { return new SkillSearchError( 'GitHub Code Search rate limit exceeded. Authenticate with `gh auth login` or set GITHUB_TOKEN to raise the quota.', @@ -184,6 +190,30 @@ function classifyApiError(status: number, body: unknown): SkillSearchError { ); } +/** + * Resolve a GitHub API token for Code Search, mirroring the lookup order used + * by the `gh` CLI: + * 1. `GITHUB_TOKEN` env var + * 2. `GH_TOKEN` env var + * 3. `gh auth token` — reads the active credential from `gh`'s config/keyring + * + * Returns `undefined` when no credential is available (unauthenticated). + */ +export async function resolveGhToken(): Promise { + const env = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; + if (env) return env; + try { + const { execFile } = await import('node:child_process'); + return await new Promise((resolve) => { + execFile('gh', ['auth', 'token'], { timeout: 3000 }, (err, stdout) => { + resolve(err ? undefined : stdout.trim() || undefined); + }); + }); + } catch { + return undefined; + } +} + /** * Render the namespace-qualified skill name (`/`) when a * namespace is set, or just `` otherwise. Used as the dedup key @@ -328,13 +358,17 @@ async function runOneQuery( * `repo + qualifiedName` keeping the first occurrence. That makes the * path bucket win over the content bucket when both match the same skill. * - * Network/auth come from `process.env.GITHUB_TOKEN` / `GH_TOKEN` when set; - * unauthenticated requests share the public 10 req/min Code Search rate limit. + * Auth is resolved by `resolveGhToken` (env vars → `gh auth token`) so + * credentials from `gh auth login` are used automatically. */ export async function searchSkills( query: string, options: SkillSearchOptions = {}, - deps: { fetch?: typeof fetch; logger?: (msg: string) => void } = {}, + deps: { + fetch?: typeof fetch; + logger?: (msg: string) => void; + tokenResolver?: () => Promise; + } = {}, ): Promise { validateSkillSearchArgs(query, options); const fetchFn = deps.fetch ?? fetch; @@ -342,7 +376,7 @@ export async function searchSkills( const page = options.page ?? 1; const limit = options.limit ?? 30; - const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; + const token = await (deps.tokenResolver ?? resolveGhToken)(); const queries = buildSearchQueries(query, options.owner); const settled = await Promise.allSettled( diff --git a/tests/unit/core/skill-search.test.ts b/tests/unit/core/skill-search.test.ts index 144875f..f7e5dee 100644 --- a/tests/unit/core/skill-search.test.ts +++ b/tests/unit/core/skill-search.test.ts @@ -4,6 +4,7 @@ import { buildSearchQueries, couldBeOwner, qualifiedName, + resolveGhToken, searchSkills, validateSkillSearchArgs, } from '../../../src/core/skill-search.js'; @@ -527,3 +528,88 @@ describe('multi-query merge + dedup', () => { expect(result.items.map((i) => i.sha)).toEqual(['p1', 'p2', 'p4']); }); }); + +describe('token resolution', () => { + it('sends Authorization header from injected tokenResolver', async () => { + let capturedAuth: string | undefined; + const capturingFetch = (async (_url: string, init?: RequestInit) => { + capturedAuth = (init?.headers as Record)?.Authorization; + return new Response( + JSON.stringify({ total_count: 0, incomplete_results: false, items: [] }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }) as typeof fetch; + + await searchSkills('docs', {}, { + fetch: capturingFetch, + logger: silentLogger, + tokenResolver: async () => 'test-token-xyz', + }); + + expect(capturedAuth).toBe('token test-token-xyz'); + }); + + it('omits Authorization header when tokenResolver returns undefined', async () => { + let capturedAuth: string | undefined; + const capturingFetch = (async (_url: string, init?: RequestInit) => { + capturedAuth = (init?.headers as Record)?.Authorization; + return new Response( + JSON.stringify({ total_count: 0, incomplete_results: false, items: [] }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }) as typeof fetch; + + await searchSkills('docs', {}, { + fetch: capturingFetch, + logger: silentLogger, + tokenResolver: async () => undefined, + }); + + expect(capturedAuth).toBeUndefined(); + }); +}); + +describe('resolveGhToken', () => { + it('returns GITHUB_TOKEN env var when set', async () => { + const orig = process.env.GITHUB_TOKEN; + process.env.GITHUB_TOKEN = 'ghp_fromenv'; + try { + expect(await resolveGhToken()).toBe('ghp_fromenv'); + } finally { + if (orig === undefined) delete process.env.GITHUB_TOKEN; + else process.env.GITHUB_TOKEN = orig; + } + }); + + it('returns GH_TOKEN env var when GITHUB_TOKEN is absent', async () => { + const origG = process.env.GITHUB_TOKEN; + const origGH = process.env.GH_TOKEN; + delete process.env.GITHUB_TOKEN; + process.env.GH_TOKEN = 'ghp_fromgh'; + try { + expect(await resolveGhToken()).toBe('ghp_fromgh'); + } finally { + if (origG === undefined) delete process.env.GITHUB_TOKEN; + else process.env.GITHUB_TOKEN = origG; + if (origGH === undefined) delete process.env.GH_TOKEN; + else process.env.GH_TOKEN = origGH; + } + }); +}); + +describe('searchSkills 401 error', () => { + it('maps 401 on the primary query to a SkillSearchError with kind "api"', async () => { + const fakeFetch = makeFakeFetch([ + { match: () => true, items: [], status: 401, message: 'Requires authentication' }, + ]); + + try { + await searchSkills('docs', {}, { fetch: fakeFetch, logger: silentLogger, tokenResolver: async () => undefined }); + throw new Error('should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(SkillSearchError); + expect((error as SkillSearchError).kind).toBe('api'); + expect((error as SkillSearchError).message).toContain('gh auth login'); + } + }); +});