diff --git a/src/cli/agent-help.ts b/src/cli/agent-help.ts index fccd8ee..3b66dc5 100644 --- a/src/cli/agent-help.ts +++ b/src/cli/agent-help.ts @@ -17,6 +17,7 @@ import { skillsListMeta, skillsAddMeta, skillsRemoveMeta, + skillsSearchMeta, } from './metadata/plugin-skills.js'; const allCommands: AgentCommandMeta[] = [ @@ -35,6 +36,7 @@ const allCommands: AgentCommandMeta[] = [ skillsListMeta, skillsAddMeta, skillsRemoveMeta, + skillsSearchMeta, updateMeta, ]; diff --git a/src/cli/commands/plugin-skills.ts b/src/cli/commands/plugin-skills.ts index 51fcaa8..4134f6f 100644 --- a/src/cli/commands/plugin-skills.ts +++ b/src/cli/commands/plugin-skills.ts @@ -28,7 +28,13 @@ import { skillsListMeta, skillsRemoveMeta, skillsAddMeta, + skillsSearchMeta, } from '../metadata/plugin-skills.js'; +import { + searchSkills, + SkillSearchError, + type SkillSearchOptions, +} from '../../core/skill-search.js'; import { getHomeDir, CONFIG_DIR, WORKSPACE_CONFIG_FILE } from '../../constants.js'; import { isGitHubUrl, parseGitHubUrl } from '../../utils/plugin-path.js'; import { fetchPlugin, getPluginName, seedFetchCache } from '../../core/plugin.js'; @@ -1245,6 +1251,108 @@ const addCmd = command({ }, }); +// ============================================================================= +// skill search (GitHub Code Search) +// ============================================================================= + +const searchCmd = command({ + name: 'search', + description: buildDescription(skillsSearchMeta), + args: { + query: positional({ type: string, displayName: 'query' }), + owner: option({ + type: optional(string), + long: 'owner', + description: 'Scope to a single GitHub owner (org or user).', + }), + page: option({ + type: optional(string), + long: 'page', + description: 'Result page (1-indexed, default 1).', + }), + limit: option({ + type: optional(string), + long: 'limit', + description: 'Results per page (1–100, default 30).', + }), + }, + handler: async ({ query, owner, page, limit }) => { + try { + const opts: SkillSearchOptions = {}; + if (owner) opts.owner = owner; + if (page !== undefined) { + const n = Number.parseInt(page, 10); + if (Number.isNaN(n)) { + const err = '--page must be an integer.'; + if (isJsonMode()) { + jsonOutput({ success: false, command: 'skill search', error: err }); + process.exit(2); + } + console.error(`Error: ${err}`); + process.exit(2); + } + opts.page = n; + } + if (limit !== undefined) { + const n = Number.parseInt(limit, 10); + if (Number.isNaN(n)) { + const err = '--limit must be an integer.'; + if (isJsonMode()) { + jsonOutput({ success: false, command: 'skill search', error: err }); + process.exit(2); + } + console.error(`Error: ${err}`); + process.exit(2); + } + opts.limit = n; + } + + const result = await searchSkills(query, opts); + + if (isJsonMode()) { + jsonOutput({ + success: true, + command: 'skill search', + data: result, + }); + return; + } + + if (result.items.length === 0) { + console.log(`No skills found for "${query}".`); + return; + } + + console.log(`Found ${result.total} skill(s)${result.truncated ? ' (results truncated)' : ''}:`); + for (const item of result.items) { + const repoCol = item.repo.padEnd(28); + const nameCol = item.name.padEnd(28); + const desc = item.description ? ` ${item.description}` : ''; + console.log(` ${repoCol} ${nameCol}${desc}`); + } + } catch (error) { + if (error instanceof SkillSearchError) { + const exitCode = error.kind === 'validation' ? 2 : 1; + if (isJsonMode()) { + jsonOutput({ success: false, command: 'skill search', error: error.message }); + process.exit(exitCode); + } + console.error(`Error: ${error.message}`); + process.exit(exitCode); + } + if (error instanceof Error) { + if (isJsonMode()) { + jsonOutput({ success: false, command: 'skill search', error: error.message }); + process.exit(1); + } + console.error(`Error: ${error.message}`); + process.exit(1); + } + throw error; + } + }, +}); + // ============================================================================= // skill subcommands group (canonical singular; `skills` is a CLI alias) // ============================================================================= @@ -1256,5 +1364,6 @@ export const skillsCmd = conciseSubcommands({ list: listCmd, remove: removeCmd, add: addCmd, + search: searchCmd, }, }); diff --git a/src/cli/metadata/plugin-skills.ts b/src/cli/metadata/plugin-skills.ts index ccbfb69..604da0a 100644 --- a/src/cli/metadata/plugin-skills.ts +++ b/src/cli/metadata/plugin-skills.ts @@ -41,6 +41,34 @@ export const skillsRemoveMeta: AgentCommandMeta = { }, }; +export const skillsSearchMeta: AgentCommandMeta = { + command: 'skill search', + description: 'Search GitHub for skills by querying SKILL.md files via the Code Search API', + whenToUse: + 'To discover available skills from public GitHub repositories without leaving the CLI. Bridges "I want a skill that does X" → install.', + examples: [ + 'allagents skill search terraform', + 'allagents skill search terraform --owner hashicorp', + 'allagents skill search docs --page 2 --limit 10', + 'allagents --json skill search docs --limit 5', + ], + expectedOutput: 'Ranked list of matching skills with repo, path, and description', + positionals: [ + { name: 'query', type: 'string', required: true, description: 'Search query (≥2 characters).' }, + ], + options: [ + { flag: '--owner', type: 'string', description: 'Scope to a single GitHub owner (org or user).' }, + { flag: '--page', type: 'string', description: 'Result page (1-indexed, default 1).' }, + { flag: '--limit', type: 'string', description: 'Results per page (1–100, default 30).' }, + ], + outputSchema: { + query: 'string', + items: [{ name: 'string', repo: 'string', path: 'string', description: 'string', sha: 'string' }], + total: 'number', + truncated: 'boolean', + }, +}; + export const skillsAddMeta: AgentCommandMeta = { command: 'skill add', description: 'Add a skill from a plugin, or re-enable a previously disabled skill', diff --git a/src/core/skill-search.ts b/src/core/skill-search.ts new file mode 100644 index 0000000..e52d5e6 --- /dev/null +++ b/src/core/skill-search.ts @@ -0,0 +1,204 @@ +/** + * GitHub Code Search wrapper for `allagents skill search`. + * + * Hits `GET /search/code` with a `path:SKILL.md filename:SKILL.md ` + * pattern, ranks results by relevance, and maps rate-limit errors to a + * single actionable message so callers never see a raw 403 / HTML dump. + * + * 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. + */ + +const OWNER_REGEX = /^[A-Za-z0-9-]{1,39}$/; + +export interface SkillSearchItem { + /** Skill folder name (parent directory of SKILL.md). */ + name: string; + /** `owner/repo` */ + repo: string; + /** Path to SKILL.md inside the repo. */ + path: string; + /** Repo description (often empty for Code Search results). */ + description: string; + /** File blob SHA. */ + sha: string; +} + +export interface SkillSearchResult { + query: string; + items: SkillSearchItem[]; + total: number; + truncated: boolean; +} + +export interface SkillSearchOptions { + owner?: string; + page?: number; + limit?: number; +} + +export class SkillSearchError extends Error { + constructor(message: string, public readonly kind: 'validation' | 'rate-limit' | 'api') { + super(message); + this.name = 'SkillSearchError'; + } +} + +/** + * Validate caller-supplied arguments. Throws SkillSearchError on bad input + * so the CLI handler can format the message consistently. + */ +export function validateSkillSearchArgs( + query: string, + options: SkillSearchOptions, +): void { + if (query.trim().length < 2) { + throw new SkillSearchError('Search query must be at least 2 characters.', 'validation'); + } + if (options.page !== undefined && options.page < 1) { + throw new SkillSearchError('--page must be >= 1.', 'validation'); + } + if (options.limit !== undefined && (options.limit < 1 || options.limit > 100)) { + throw new SkillSearchError('--limit must be between 1 and 100.', 'validation'); + } + if (options.owner !== undefined && !OWNER_REGEX.test(options.owner)) { + throw new SkillSearchError( + `Invalid --owner "${options.owner}": GitHub owners are alphanumeric + dashes, ≤ 39 chars.`, + 'validation', + ); + } +} + +/** + * Map a GitHub API response to a SkillSearchError. The 403 / rate-limit body + * has a distinctive `documentation_url` and `message` shape; everything else + * falls back to a generic API error. + */ +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 === 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.', + 'rate-limit', + ); + } + if (status === 422) { + return new SkillSearchError( + `GitHub rejected the search query: ${msg || 'unprocessable entity'}.`, + 'api', + ); + } + return new SkillSearchError( + `GitHub Code Search returned ${status}${msg ? `: ${msg}` : ''}.`, + 'api', + ); +} + +/** + * Build the `q=` querystring value: ` filename:SKILL.md path:SKILL.md [user:]`. + */ +function buildQueryString(query: string, owner: string | undefined): string { + const parts = [query.trim(), 'filename:SKILL.md', 'path:SKILL.md']; + if (owner) parts.push(`user:${owner}`); + return parts.join(' '); +} + +/** + * Run the GitHub Code Search request. Network/auth comes from the environment + * via fetch + GITHUB_TOKEN; no extra deps. + * + * Items are returned in upstream relevance order. The handler may apply + * name-match-first re-ranking on top of this — kept separate so the wire + * format stays close to the gh-skill reference impl. + */ +export async function searchSkills( + query: string, + options: SkillSearchOptions = {}, + deps: { fetch?: typeof fetch } = {}, +): Promise { + validateSkillSearchArgs(query, options); + const fetchFn = deps.fetch ?? fetch; + + const page = options.page ?? 1; + const limit = options.limit ?? 30; + const q = buildQueryString(query, options.owner); + const url = new URL('https://api.github.com/search/code'); + url.searchParams.set('q', q); + url.searchParams.set('per_page', String(limit)); + url.searchParams.set('page', String(page)); + + const headers: Record = { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'allagents-cli', + }; + const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; + if (token) headers.Authorization = `token ${token}`; + + const response = await fetchFn(url.toString(), { headers }); + let body: unknown = null; + try { + body = await response.json(); + } catch { + // ignore — classifyApiError handles missing body gracefully + } + if (!response.ok) { + throw classifyApiError(response.status, body); + } + + // GitHub Code Search response shape: { total_count, incomplete_results, items: [...] } + const parsed = body as { + total_count?: number; + incomplete_results?: boolean; + items?: Array<{ + path?: string; + sha?: string; + repository?: { full_name?: string; description?: string }; + }>; + }; + const items: SkillSearchItem[] = (parsed.items ?? []).map((item) => { + const path = item.path ?? ''; + // Skill name = parent dir of SKILL.md. For `skills//SKILL.md` this is + // ; for repo-root `SKILL.md` the parent is the repo name. + const parts = path.split('/'); + const name = parts.length >= 2 ? parts[parts.length - 2] ?? '' : item.repository?.full_name?.split('/').pop() ?? ''; + return { + name, + repo: item.repository?.full_name ?? '', + path, + description: item.repository?.description ?? '', + sha: item.sha ?? '', + }; + }); + + return { + query, + items: rankItems(items, query), + total: parsed.total_count ?? items.length, + truncated: Boolean(parsed.incomplete_results), + }; +} + +/** + * Rank items: exact name match first, then prefix-match, then upstream order. + * Mirrors gh-skill's relevance heuristic so users see the obvious hits up top. + */ +function rankItems(items: SkillSearchItem[], query: string): SkillSearchItem[] { + const q = query.toLowerCase(); + return [...items].sort((a, b) => { + const aScore = score(a, q); + const bScore = score(b, q); + return bScore - aScore; + }); +} + +function score(item: SkillSearchItem, q: string): number { + const name = item.name.toLowerCase(); + if (name === q) return 3; + if (name.startsWith(q)) return 2; + if (name.includes(q)) return 1; + return 0; +} diff --git a/tests/unit/cli/agent-help.test.ts b/tests/unit/cli/agent-help.test.ts index c1b593d..a2b6c22 100644 --- a/tests/unit/cli/agent-help.test.ts +++ b/tests/unit/cli/agent-help.test.ts @@ -17,6 +17,7 @@ import { skillsListMeta, skillsAddMeta, skillsRemoveMeta, + skillsSearchMeta, } from '../../../src/cli/metadata/plugin-skills.js'; import type { AgentCommandMeta } from '../../../src/cli/help.js'; @@ -36,6 +37,7 @@ const allCommands: AgentCommandMeta[] = [ skillsListMeta, skillsAddMeta, skillsRemoveMeta, + skillsSearchMeta, updateMeta, ]; @@ -66,8 +68,8 @@ describe('extractAgentHelpFlag', () => { }); describe('agent command metadata', () => { - test('contains exactly 16 commands', () => { - expect(allCommands.length).toBe(16); + test('contains exactly 17 commands', () => { + expect(allCommands.length).toBe(17); }); test('all expected commands are present', () => { @@ -86,6 +88,7 @@ describe('agent command metadata', () => { 'skill add', 'skill list', 'skill remove', + 'skill search', 'update', 'workspace init', 'workspace status', diff --git a/tests/unit/core/skill-search.test.ts b/tests/unit/core/skill-search.test.ts new file mode 100644 index 0000000..1fe0def --- /dev/null +++ b/tests/unit/core/skill-search.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'bun:test'; +import { + SkillSearchError, + searchSkills, + validateSkillSearchArgs, +} from '../../../src/core/skill-search.js'; + +describe('validateSkillSearchArgs', () => { + it('rejects queries under 2 chars', () => { + expect(() => validateSkillSearchArgs('a', {})).toThrow(SkillSearchError); + }); + + it('rejects --page < 1', () => { + expect(() => validateSkillSearchArgs('docs', { page: 0 })).toThrow(SkillSearchError); + }); + + it('rejects --limit < 1 or > 100', () => { + expect(() => validateSkillSearchArgs('docs', { limit: 0 })).toThrow(SkillSearchError); + expect(() => validateSkillSearchArgs('docs', { limit: 101 })).toThrow(SkillSearchError); + }); + + it('rejects malformed --owner', () => { + expect(() => validateSkillSearchArgs('docs', { owner: 'with spaces' })).toThrow(SkillSearchError); + expect(() => validateSkillSearchArgs('docs', { owner: 'owner/repo' })).toThrow(SkillSearchError); + }); + + it('accepts valid arguments', () => { + expect(() => validateSkillSearchArgs('docs', { page: 2, limit: 50, owner: 'github' })).not.toThrow(); + }); +}); + +describe('searchSkills error mapping', () => { + it('maps 403 rate-limit to a SkillSearchError with kind "rate-limit"', async () => { + const fakeFetch = (async () => ({ + ok: false, + status: 403, + json: async () => ({ message: 'API rate limit exceeded for ...' }), + })) as unknown as typeof fetch; + + try { + await searchSkills('docs', {}, { fetch: fakeFetch }); + throw new Error('should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(SkillSearchError); + expect((error as SkillSearchError).kind).toBe('rate-limit'); + expect((error as SkillSearchError).message).toContain('rate limit'); + expect((error as SkillSearchError).message).toContain('GITHUB_TOKEN'); + } + }); + + it('maps 422 to a SkillSearchError with kind "api"', async () => { + const fakeFetch = (async () => ({ + ok: false, + status: 422, + json: async () => ({ message: 'Validation Failed' }), + })) as unknown as typeof fetch; + + try { + await searchSkills('docs', {}, { fetch: fakeFetch }); + throw new Error('should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(SkillSearchError); + expect((error as SkillSearchError).kind).toBe('api'); + } + }); + + it('returns normalized items on a successful response', async () => { + const fakeFetch = (async () => ({ + ok: true, + status: 200, + json: async () => ({ + total_count: 2, + incomplete_results: false, + items: [ + { + path: 'skills/docs-writer/SKILL.md', + sha: 'abc', + repository: { full_name: 'org/repo', description: 'Some skills' }, + }, + { + path: 'skills/api-docs/SKILL.md', + sha: 'def', + repository: { full_name: 'org2/repo2', description: '' }, + }, + ], + }), + })) as unknown as typeof fetch; + + const result = await searchSkills('docs', { limit: 5 }, { fetch: fakeFetch }); + expect(result.total).toBe(2); + expect(result.items.length).toBe(2); + expect(result.items.map((i) => i.name)).toContain('docs-writer'); + expect(result.items.map((i) => i.name)).toContain('api-docs'); + }); +});