Skip to content
Merged
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
8 changes: 2 additions & 6 deletions src/cli/agent-help.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down
13 changes: 7 additions & 6 deletions src/cli/commands/plugin-skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand All @@ -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) {
Expand Down Expand Up @@ -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({
Expand All @@ -1568,22 +1569,22 @@ const searchCmd = command({
}

if (result.items.length === 0) {
console.log(`No skills found for "${query}".`);
console.log(`No skills found for "${searchQuery}".`);
return;
}

const isTTY = process.stdout.isTTY && process.stdin.isTTY;

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)}`,
Expand Down
12 changes: 4 additions & 8 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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) {
Expand Down Expand Up @@ -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');
Expand Down
6 changes: 4 additions & 2 deletions src/cli/metadata/plugin-skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).' },
],
Expand Down
60 changes: 60 additions & 0 deletions src/cli/skill-arg-normalizer.ts
Original file line number Diff line number Diff line change
@@ -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];
}
154 changes: 145 additions & 9 deletions src/core/skill-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -363,6 +365,37 @@ async function runOneQuery(
};
}

async function fetchPrimaryPages(
q: string,
page: number,
limit: number,
token: string | undefined,
fetchFn: typeof fetch,
): Promise<QueryRunResult> {
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.
*
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
};
}

Expand All @@ -473,6 +514,101 @@ function dedupeItems(items: SkillSearchItem[]): SkillSearchItem[] {
return out;
}

function splitRepo(item: Pick<SkillSearchItem, 'repo'>): { 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<string, number>();
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
Expand Down
Loading