diff --git a/src/cli/agent-help.ts b/src/cli/agent-help.ts index a818d25..59fb1a2 100644 --- a/src/cli/agent-help.ts +++ b/src/cli/agent-help.ts @@ -1,4 +1,5 @@ import type { AgentCommandMeta } from './help.js'; +import { normalizeSkillHelpArgs } from './skill-arg-normalizer.js'; import { initMeta, syncMeta, statusMeta } from './metadata/workspace.js'; import { @@ -83,13 +84,8 @@ export function findMetaByCommand(commandPath: string): AgentCommandMeta | undef export function printAgentHelp(args: string[], version: string): void { // Determine which command is being asked about by looking at remaining args. - // `skills` is a permanent alias for the canonical singular `skill`; normalize - // the lookup key so `--agent-help "skills list"` resolves to the `skill list` - // meta. const positional = args.filter(a => !a.startsWith('-')); - const normalized = positional[0] === 'skills' - ? ['skill', ...positional.slice(1)] - : positional; + const normalized = normalizeSkillHelpArgs(positional); const commandPath = normalized.join(' '); if (!commandPath) { diff --git a/src/cli/commands/plugin-skills.ts b/src/cli/commands/plugin-skills.ts index 6475b21..396fff7 100644 --- a/src/cli/commands/plugin-skills.ts +++ b/src/cli/commands/plugin-skills.ts @@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import chalk from 'chalk'; -import { command, positional, option, flag, string, optional } from 'cmd-ts'; +import { command, positional, option, flag, string, optional, restPositionals } from 'cmd-ts'; import { syncWorkspace, syncUserWorkspace } from '../../core/sync.js'; import { addDisabledSkill, @@ -1508,7 +1508,7 @@ const searchCmd = command({ name: 'search', description: buildDescription(skillsSearchMeta), args: { - query: positional({ type: string, displayName: 'query' }), + query: restPositionals({ type: string, displayName: 'query' }), owner: option({ type: optional(string), long: 'owner', @@ -1527,6 +1527,7 @@ const searchCmd = command({ }, handler: async ({ query, owner, page, limit }) => { try { + const searchQuery = query.join(' ').trim(); const opts: SkillSearchOptions = {}; if (owner) opts.owner = owner; if (page !== undefined) { @@ -1556,7 +1557,7 @@ const searchCmd = command({ opts.limit = n; } - const result = await searchSkills(query, opts); + const result = await searchSkills(searchQuery, opts); if (isJsonMode()) { jsonOutput({ @@ -1568,7 +1569,7 @@ const searchCmd = command({ } if (result.items.length === 0) { - console.log(`No skills found for "${query}".`); + console.log(`No skills found for "${searchQuery}".`); return; } @@ -1576,14 +1577,14 @@ const searchCmd = command({ if (!isTTY) { // Non-interactive: print table with stars and exit - printSearchResults(result.items, query, result.truncated); + printSearchResults(result.items, searchQuery, result.truncated); return; } // Interactive mode: filter-as-you-type multiselect with install support const { autocompleteMultiselect, isCancel, log } = await import('@clack/prompts'); - log.success(formatSkillSearchSummary(result.items.length, query, result.truncated)); + log.success(formatSkillSearchSummary(result.items.length, searchQuery, result.truncated)); const options = result.items.map((item) => ({ label: `${qualifiedName(item)} ${chalk.dim(item.repo)}`, diff --git a/src/cli/index.ts b/src/cli/index.ts index 105e3af..907b34f 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -19,6 +19,7 @@ import { printAgentHelp, } from './agent-help.js'; import { getUpdateNotice } from './update-check.js'; +import { normalizeSkillArgs, normalizeSkillHelpArgs } from './skill-arg-normalizer.js'; import packageJson from '../../package.json'; const app = conciseSubcommands({ @@ -41,13 +42,8 @@ const app = conciseSubcommands({ const rawArgs = process.argv.slice(2); const { args: argsNoJson, json, jsonFields } = extractJsonFlag(rawArgs); const { args: argsNoJq, jqExpr } = extractJqFlag(argsNoJson); -const { args: argsWithSkillAlias, agentHelp } = extractAgentHelpFlag(argsNoJq); -// `skills` is a permanent alias for the canonical singular `skill`. Normalize -// up-front so cmd-ts only ever dispatches the singular and both invocations -// produce byte-identical help/output. -const finalArgs = argsWithSkillAlias[0] === 'skills' - ? ['skill', ...argsWithSkillAlias.slice(1)] - : argsWithSkillAlias; +const { args: argsAfterAgentHelp, agentHelp } = extractAgentHelpFlag(argsNoJq); +const finalArgs = normalizeSkillArgs(argsAfterAgentHelp); // `--jq` requires `--json` so we have an envelope to pipe through. if (jqExpr && !json) { @@ -81,7 +77,7 @@ if (!agentHelp && !json && !isWizard) { } if (agentHelp) { - printAgentHelp(finalArgs, packageJson.version); + printAgentHelp(normalizeSkillHelpArgs(argsAfterAgentHelp), packageJson.version); } else if (isWizard) { // Interactive wizard when no args and running in a terminal const { runWizard } = await import('./tui/wizard.js'); diff --git a/src/cli/metadata/plugin-skills.ts b/src/cli/metadata/plugin-skills.ts index e7084c6..31d596b 100644 --- a/src/cli/metadata/plugin-skills.ts +++ b/src/cli/metadata/plugin-skills.ts @@ -45,16 +45,18 @@ 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. Results are sorted by star count. In TTY mode, shows a filter-as-you-type multi-select picker and offers to install the selected skills.', + description: 'Search GitHub for skills by querying SKILL.md files via the Code Search API. Results are ranked by relevance, with skill-name matches first. In TTY mode, shows a filter-as-you-type multi-select picker and offers to install the selected skills.', 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 pr-search', + 'allagents skill "pr search"', 'allagents skill search terraform --owner hashicorp', 'allagents skill search docs --page 2 --limit 10', 'allagents --json skill search docs --limit 5', ], - expectedOutput: 'Skills sorted by star count: repo, skill name, stars, description. In TTY mode, followed by a searchable multi-select install prompt.', + expectedOutput: 'Skills ranked by relevance: repo, skill name, stars, description. In TTY mode, followed by a searchable multi-select install prompt.', positionals: [ { name: 'query', type: 'string', required: true, description: 'Search query (≥2 characters).' }, ], diff --git a/src/cli/skill-arg-normalizer.ts b/src/cli/skill-arg-normalizer.ts new file mode 100644 index 0000000..37589b2 --- /dev/null +++ b/src/cli/skill-arg-normalizer.ts @@ -0,0 +1,60 @@ +const SKILL_SUBCOMMANDS = new Set(['list', 'remove', 'add', 'search']); + +export function normalizeSkillAlias(args: string[]): string[] { + if (args.length === 0) return args; + return args[0] === 'skills' + ? ['skill', ...args.slice(1)] + : args; +} + +function shouldUseSkillSearchShorthand(rest: string[]): boolean { + const first = rest[0] ?? ''; + if (rest.length === 0) return false; + if (first.startsWith('-') || SKILL_SUBCOMMANDS.has(first)) return false; + + // Keep the shorthand narrow: multi-token queries and hyphenated names are + // clearly search-shaped, while a lone bare word should still behave like a + // subcommand lookup so typos remain visible. + return rest.length > 1 || /[-\s]/.test(first); +} + +export function normalizeSkillHelpArgs(args: string[]): string[] { + const normalized = normalizeSkillAlias(args); + if (normalized.length === 0 || normalized[0] !== 'skill') { + return normalized; + } + + const [, ...rest] = normalized; + return shouldUseSkillSearchShorthand(rest) + ? ['skill', 'search'] + : normalized; +} + +/** + * Normalize the canonical `skill` command path and support a narrow search + * shorthand for obviously query-shaped invocations: + * - `allagents skills ...` -> `allagents skill ...` + * - `allagents skill pr-search` -> `allagents skill search pr-search` + * - `allagents skill pr search` -> `allagents skill search pr search` + * - `allagents skill "pr search"` -> `allagents skill search "pr search"` + * + * A plain single bare word like `allagents skill terraform` stays unchanged so + * typos of real subcommands still surface as parser errors instead of silently + * turning into a search. + */ +export function normalizeSkillArgs(args: string[]): string[] { + const normalized = normalizeSkillAlias(args); + if (normalized.length === 0 || normalized[0] !== 'skill') { + return args; + } + + const [, ...rest] = normalized; + if (rest.length === 0) { + return ['skill']; + } + + if (!shouldUseSkillSearchShorthand(rest)) { + return ['skill', ...rest]; + } + return ['skill', 'search', ...rest]; +} diff --git a/src/core/skill-search.ts b/src/core/skill-search.ts index 45314b7..9f013af 100644 --- a/src/core/skill-search.ts +++ b/src/core/skill-search.ts @@ -16,6 +16,8 @@ import { parseSkillMetadata } from '../validators/skill.js'; */ const OWNER_REGEX = /^[A-Za-z0-9-]{1,39}$/; +const SEARCH_PAGE_SIZE = 100; +const MAX_RESULTS = 1000; /** * GitHub username pattern: starts and ends with alphanumeric, may contain @@ -363,6 +365,37 @@ async function runOneQuery( }; } +async function fetchPrimaryPages( + q: string, + page: number, + limit: number, + token: string | undefined, + fetchFn: typeof fetch, +): Promise { + const needed = page * limit * 3; + const numPages = Math.min( + Math.max(1, Math.ceil(needed / SEARCH_PAGE_SIZE)), + MAX_RESULTS / SEARCH_PAGE_SIZE, + ); + + const items: SkillSearchItem[] = []; + let total = 0; + let truncated = false; + + for (let currentPage = 1; currentPage <= numPages; currentPage += 1) { + const result = await runOneQuery(q, currentPage, SEARCH_PAGE_SIZE, token, fetchFn); + items.push(...result.items); + total = result.total; + truncated = truncated || result.truncated; + + if (result.items.length < SEARCH_PAGE_SIZE) { + break; + } + } + + return { items, total, truncated }; +} + /** * Run the multi-query Code Search and merge results. * @@ -398,7 +431,11 @@ export async function searchSkills( const queries = buildSearchQueries(query, options.owner); const settled = await Promise.allSettled( - queries.map((entry) => runOneQuery(entry.q, page, limit, token, fetchFn)), + queries.map((entry) => + entry.priority === 4 + ? fetchPrimaryPages(entry.q, page, limit, token, fetchFn) + : runOneQuery(entry.q, 1, SEARCH_PAGE_SIZE, token, fetchFn) + ), ); // Locate the primary content (priority 4) result. If it failed, surface its error. @@ -438,20 +475,24 @@ export async function searchSkills( const firstSegment = item.path.split('/')[0] ?? ''; return !firstSegment.startsWith('.'); }); + + rankByRelevance(visible, query); + const workingSet = truncateForProcessing(visible, page, limit); await Promise.all([ - fetchStarsForItems(visible, token, fetchFn), - enrichDescriptionsForItems(visible, token, fetchFn), + fetchStarsForItems(workingSet, token, fetchFn), + enrichDescriptionsForItems(workingSet, token, fetchFn), ]); - visible.sort((a, b) => b.stars - a.stars); - // Apply the limit to the merged output so `--limit N` caps total results, - // not just per-query results (each query runs with the same limit). - const finalItems = visible.slice(0, limit); + + const filtered = filterByRelevance(workingSet, query); + rankByRelevance(filtered, query); + const dedupedByName = deduplicateByName(filtered); + const { items: finalItems, totalPages } = paginate(dedupedByName, page, limit); return { query, items: finalItems, - total: finalItems.length, - truncated: buckets.some((b) => b.result.truncated) || visible.length > limit, + total: dedupedByName.length, + truncated: buckets.some((b) => b.result.truncated) || totalPages > page, }; } @@ -473,6 +514,101 @@ function dedupeItems(items: SkillSearchItem[]): SkillSearchItem[] { return out; } +function splitRepo(item: Pick): { owner: string; repoName: string } { + const [owner = item.repo, repoName = ''] = item.repo.split('/', 2); + return { owner, repoName }; +} + +function relevanceScore(item: SkillSearchItem, query: string): number { + const term = query.trim().toLowerCase(); + const termHyphen = term.replace(/ /g, '-'); + const name = item.name.toLowerCase(); + const namespace = item.namespace.toLowerCase(); + const description = item.description.toLowerCase(); + + let score = 0; + if (name === term || name === termHyphen) { + score += 3000; + } else if (name.includes(term) || name.includes(termHyphen)) { + score += 1000; + } + + if (namespace?.includes(term)) { + score += 500; + } + + if (description.includes(term)) { + score += 100; + } + + if (item.stars > 0) { + score += Math.floor(Math.sqrt(item.stars) * 30); + } + + return score; +} + +function rankByRelevance(items: SkillSearchItem[], query: string): void { + items.sort((a, b) => relevanceScore(b, query) - relevanceScore(a, query)); +} + +function filterByRelevance(items: SkillSearchItem[], query: string): SkillSearchItem[] { + const term = query.trim().toLowerCase(); + const termHyphen = term.replace(/ /g, '-'); + + return items.filter((item) => { + const { owner, repoName } = splitRepo(item); + return ( + item.name.toLowerCase().includes(term) || + item.name.toLowerCase().includes(termHyphen) || + item.namespace.toLowerCase().includes(term) || + item.description.toLowerCase().includes(term) || + owner.toLowerCase().includes(term) || + repoName.toLowerCase().includes(term) + ); + }); +} + +function truncateForProcessing(items: SkillSearchItem[], page: number, limit: number): SkillSearchItem[] { + const maxToProcess = Math.max(page * limit * 3, limit * 3); + return items.length > maxToProcess + ? items.slice(0, maxToProcess) + : items; +} + +function deduplicateByName(items: SkillSearchItem[]): SkillSearchItem[] { + const maxPerName = 3; + const counts = new Map(); + const out: SkillSearchItem[] = []; + + for (const item of items) { + const key = qualifiedName(item).toLowerCase(); + const count = counts.get(key) ?? 0; + if (count >= maxPerName) continue; + counts.set(key, count + 1); + out.push(item); + } + + return out; +} + +function paginate( + items: SkillSearchItem[], + page: number, + limit: number, +): { items: SkillSearchItem[]; totalPages: number } { + if (items.length === 0) { + return { items: [], totalPages: 0 }; + } + + const totalPages = Math.ceil(items.length / limit); + const start = (page - 1) * limit; + return { + items: items.slice(start, start + limit), + totalPages, + }; +} + /** * Fetch star counts for unique repos in parallel and annotate items in-place. * The GitHub Code Search API does not include stargazers_count in repository diff --git a/tests/unit/cli/skill-arg-normalizer.test.ts b/tests/unit/cli/skill-arg-normalizer.test.ts new file mode 100644 index 0000000..d1114f8 --- /dev/null +++ b/tests/unit/cli/skill-arg-normalizer.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from 'bun:test'; +import { + normalizeSkillAlias, + normalizeSkillArgs, + normalizeSkillHelpArgs, +} from '../../../src/cli/skill-arg-normalizer.js'; + +describe('normalizeSkillAlias', () => { + test('normalizes the plural skills alias', () => { + expect(normalizeSkillAlias(['skills', 'list'])).toEqual(['skill', 'list']); + }); +}); + +describe('normalizeSkillHelpArgs', () => { + test('maps bare hyphenated skill queries to skill search help', () => { + expect(normalizeSkillHelpArgs(['skill', 'pr-search'])).toEqual(['skill', 'search']); + }); + + test('maps bare split skill queries to skill search help', () => { + expect(normalizeSkillHelpArgs(['skill', 'pr', 'search'])).toEqual(['skill', 'search']); + }); + + test('keeps single bare words unchanged so typos still fail help lookup', () => { + expect(normalizeSkillHelpArgs(['skill', 'searh'])).toEqual(['skill', 'searh']); + }); +}); + +describe('normalizeSkillArgs', () => { + test('keeps non-skill commands unchanged', () => { + expect(normalizeSkillArgs(['plugin', 'install', 'foo'])).toEqual(['plugin', 'install', 'foo']); + }); + + test('normalizes the plural skills alias before dispatch', () => { + expect(normalizeSkillArgs(['skills', 'list'])).toEqual(['skill', 'list']); + }); + + test('rewrites a hyphenated bare skill query to search', () => { + expect(normalizeSkillArgs(['skill', 'pr-search'])).toEqual(['skill', 'search', 'pr-search']); + }); + + test('rewrites a split multi-word bare skill query to search', () => { + expect(normalizeSkillArgs(['skill', 'pr', 'search'])).toEqual(['skill', 'search', 'pr', 'search']); + }); + + test('rewrites a quoted multi-word bare skill query to search', () => { + expect(normalizeSkillArgs(['skill', 'pr search'])).toEqual(['skill', 'search', 'pr search']); + }); + + test('preserves explicit skill subcommands', () => { + expect(normalizeSkillArgs(['skill', 'search', 'pr-search'])).toEqual(['skill', 'search', 'pr-search']); + }); + + test('keeps a single unknown bare word unchanged', () => { + expect(normalizeSkillArgs(['skill', 'searh'])).toEqual(['skill', 'searh']); + }); +}); diff --git a/tests/unit/core/skill-search.test.ts b/tests/unit/core/skill-search.test.ts index c7e2852..e48e6d4 100644 --- a/tests/unit/core/skill-search.test.ts +++ b/tests/unit/core/skill-search.test.ts @@ -618,13 +618,177 @@ describe('multi-query merge + dedup', () => { ]); const result = await searchSkills('build worker', {}, { fetch: fakeFetch, logger: silentLogger }); - // Both results appear. - expect(result.items.map((i) => i.sha)).toContain('primary-sha'); - expect(result.items.map((i) => i.sha)).toContain('path-sha'); - // Path (P1) sorts before primary (P4) when both have 0 stars — priority order preserved. - const pathIdx = result.items.findIndex((i) => i.sha === 'path-sha'); - const primaryIdx = result.items.findIndex((i) => i.sha === 'primary-sha'); - expect(pathIdx).toBeLessThan(primaryIdx); + // The path hit survives because it matches the query in the skill name. + // The broad primary hit is filtered out after relevance enrichment. + expect(result.items.map((i) => i.sha)).toEqual(['path-sha']); + }); +}); + +describe('search relevance', () => { + it('filters noisy broad matches that do not mention the query after enrichment', async () => { + const fakeFetch = (async (url: string) => { + const u = new URL(url); + + if (u.pathname === '/search/code') { + return { + ok: true, + status: 200, + json: async () => ({ + total_count: 3, + incomplete_results: false, + items: [ + { + path: 'skills/docs-writer/SKILL.md', + sha: 'docs-sha', + repository: { full_name: 'org/docs-skill', description: 'General repo' }, + }, + { + path: 'SKILL.md', + sha: 'noise-sha', + repository: { full_name: 'org/noise-repo', description: 'General repo' }, + }, + { + path: 'skills/api-docs/SKILL.md', + sha: 'api-sha', + repository: { full_name: 'org/api-repo', description: 'General repo' }, + }, + ], + }), + }; + } + + if (u.pathname === '/repos/org/docs-skill') { + return { ok: true, status: 200, json: async () => ({ stargazers_count: 1 }) }; + } + if (u.pathname === '/repos/org/noise-repo') { + return { ok: true, status: 200, json: async () => ({ stargazers_count: 5000 }) }; + } + if (u.pathname === '/repos/org/api-repo') { + return { ok: true, status: 200, json: async () => ({ stargazers_count: 2 }) }; + } + + if (u.pathname === '/repos/org/docs-skill/git/blobs/docs-sha') { + return { + ok: true, + status: 200, + json: async () => ({ + encoding: 'base64', + content: Buffer.from('---\nname: docs-writer\ndescription: Write docs for developer workflows.\n---\n').toString('base64'), + }), + }; + } + if (u.pathname === '/repos/org/noise-repo/git/blobs/noise-sha') { + return { + ok: true, + status: 200, + json: async () => ({ + encoding: 'base64', + content: Buffer.from('---\nname: paper\ndescription: Completely unrelated writing helper.\n---\n').toString('base64'), + }), + }; + } + if (u.pathname === '/repos/org/api-repo/git/blobs/api-sha') { + return { + ok: true, + status: 200, + json: async () => ({ + encoding: 'base64', + content: Buffer.from('---\nname: api-docs\ndescription: Generate API docs from code.\n---\n').toString('base64'), + }), + }; + } + + throw new Error(`Unexpected URL: ${url}`); + }) as unknown as typeof fetch; + + const result = await searchSkills('docs', {}, { fetch: fakeFetch, logger: silentLogger }); + + expect(result.items.map((item) => item.name)).toEqual(['api-docs', 'docs-writer']); + }); + + it('ranks exact and partial name matches ahead of description-only matches even with fewer stars', async () => { + const fakeFetch = (async (url: string) => { + const u = new URL(url); + + if (u.pathname === '/search/code') { + return { + ok: true, + status: 200, + json: async () => ({ + total_count: 3, + incomplete_results: false, + items: [ + { + path: 'skills/terraform/SKILL.md', + sha: 'exact-sha', + repository: { full_name: 'org/exact', description: '' }, + }, + { + path: 'skills/terraform-plan/SKILL.md', + sha: 'partial-sha', + repository: { full_name: 'org/partial', description: '' }, + }, + { + path: 'skills/iac-helper/SKILL.md', + sha: 'desc-sha', + repository: { full_name: 'org/desc', description: '' }, + }, + ], + }), + }; + } + + if (u.pathname === '/repos/org/exact') { + return { ok: true, status: 200, json: async () => ({ stargazers_count: 1 }) }; + } + if (u.pathname === '/repos/org/partial') { + return { ok: true, status: 200, json: async () => ({ stargazers_count: 10 }) }; + } + if (u.pathname === '/repos/org/desc') { + return { ok: true, status: 200, json: async () => ({ stargazers_count: 900 }) }; + } + + if (u.pathname === '/repos/org/exact/git/blobs/exact-sha') { + return { + ok: true, + status: 200, + json: async () => ({ + encoding: 'base64', + content: Buffer.from('---\nname: terraform\ndescription: Core Terraform skill.\n---\n').toString('base64'), + }), + }; + } + if (u.pathname === '/repos/org/partial/git/blobs/partial-sha') { + return { + ok: true, + status: 200, + json: async () => ({ + encoding: 'base64', + content: Buffer.from('---\nname: terraform-plan\ndescription: Plan Terraform changes safely.\n---\n').toString('base64'), + }), + }; + } + if (u.pathname === '/repos/org/desc/git/blobs/desc-sha') { + return { + ok: true, + status: 200, + json: async () => ({ + encoding: 'base64', + content: Buffer.from('---\nname: iac-helper\ndescription: Review terraform changes and summarize impact.\n---\n').toString('base64'), + }), + }; + } + + throw new Error(`Unexpected URL: ${url}`); + }) as unknown as typeof fetch; + + const result = await searchSkills('terraform', {}, { fetch: fakeFetch, logger: silentLogger }); + + expect(result.items.map((item) => item.name)).toEqual([ + 'terraform', + 'terraform-plan', + 'iac-helper', + ]); }); });