diff --git a/docs/api-spec.md b/docs/api-spec.md index 8c439eb..a4fafb1 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -44,7 +44,7 @@ GET /api/v1/skills | `tag` | string | 否 | 按标签筛选 | | `owner` | string | 否 | 按仓库所有者筛选 | | `repo` | string | 否 | 按仓库名筛选 | -| `sort` | string | 否 | 排序: `stars`, `updated`, `name` | +| `sort` | string | 否 | 排序: `llm_score`, `popular_score`, `stars`, `updated`, `name` | | `order` | string | 否 | 排序方向: `asc`, `desc` | **Response:** diff --git a/docs/cli-reference.md b/docs/cli-reference.md index f81e488..5058f3c 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -191,6 +191,7 @@ Installed skills: ## askill find Search for skills on askill.sh. +Default ranking follows Registry homepage AI Picks order (`llm_score` descending). ### Usage @@ -204,7 +205,7 @@ askill find [query] [options] |--------|-------------| | `--tag ` | Filter by tag | | `--page ` | Results page (default: 1) | -| `--limit ` | Number of results per page (default: 20) | +| `--limit ` | Number of results per page (default: 20, max: 100) | | `--json` | Output machine-readable JSON | ### Examples @@ -219,6 +220,9 @@ askill find memory # Search by multiple keywords askill find code review +# Pagination +askill find memory --page 2 --limit 20 + # Machine-readable results for web integrations askill find memory --json diff --git a/docs/json-contracts/askill-cli-json.schema.json b/docs/json-contracts/askill-cli-json.schema.json index 600b7d9..3919324 100644 --- a/docs/json-contracts/askill-cli-json.schema.json +++ b/docs/json-contracts/askill-cli-json.schema.json @@ -165,7 +165,7 @@ }, "FindResponse": { "type": "object", - "required": ["ok", "query", "filters", "pagination", "count", "skills"], + "required": ["ok", "query", "filters", "sort", "pagination", "count", "skills"], "properties": { "ok": { "const": true }, "query": { "type": "string" }, @@ -175,7 +175,18 @@ "properties": { "tag": { "type": ["string", "null"] } }, "additionalProperties": true }, - "sort": { "type": "object" }, + "sort": { + "type": "object", + "required": ["field", "order"], + "properties": { + "field": { + "type": "string", + "enum": ["llm_score", "popular_score", "stars", "updated", "name"] + }, + "order": { "type": "string", "enum": ["asc", "desc"] } + }, + "additionalProperties": true + }, "pagination": { "$ref": "#/$defs/Pagination" }, "count": { "type": "integer", "minimum": 0 }, "skills": { "type": "array", "items": { "$ref": "#/$defs/FindSkillItem" } } diff --git a/src/api.ts b/src/api.ts index 212e7df..8ee33d5 100644 --- a/src/api.ts +++ b/src/api.ts @@ -51,6 +51,8 @@ export interface SkillListResponse { }; } +export type SkillSort = 'llm_score' | 'popular_score' | 'stars' | 'updated' | 'name'; + export interface SearchResult { id: number; name: string | null; @@ -171,7 +173,7 @@ class APIClient { tag?: string; owner?: string; repo?: string; - sort?: 'stars' | 'updated' | 'name'; + sort?: SkillSort; order?: 'asc' | 'desc'; } = {}): Promise { const params = new URLSearchParams(); @@ -239,8 +241,19 @@ class APIClient { /** * Search for skills (uses listSkills with q parameter) */ - async search(q: string, limit: number = 10): Promise { - return this.listSkills({ q, limit }); + async search(q: string, options: { + page?: number; + limit?: number; + sort?: SkillSort; + order?: 'asc' | 'desc'; + } = {}): Promise { + return this.listSkills({ + q, + page: options.page, + limit: options.limit, + sort: options.sort, + order: options.order, + }); } /** diff --git a/src/cli.ts b/src/cli.ts index ba26c11..2b94b06 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,7 +4,7 @@ // Install AI agent skills from askill.sh import { VERSION, REGISTRY_URL, RESET, BOLD, DIM, CYAN, GREEN, YELLOW, RED, GRAY, agents, AGENTS_DIR, SKILLS_SUBDIR, POPULAR_AGENTS, type AgentType } from './constants.ts'; -import { api, APIError, type Skill, type RepoSkill } from './api.ts'; +import { api, APIError, type Skill, type RepoSkill, type SkillSort } from './api.ts'; import { installSkill, installSkillFromDir, detectInstalledAgents, listInstalledSkills, removeSkill, removeCanonicalSkill, sanitizeName, getCanonicalSkillsDir, type InstallMode, type InstalledSkill } from './installer.ts'; import { getAvailableUpdate, selfUpdate } from './updater.ts'; import { getPreferredAgents, savePreferredAgents } from './config.ts'; @@ -113,6 +113,9 @@ ${BOLD}Run Options:${RESET} ${BOLD}Search Options:${RESET} --full-desc Show full skill descriptions in find/search + --tag Filter by tag + --page Results page (default: 1) + --limit Results per page (default: 20, max: 100) --json Output machine-readable JSON ${BOLD}Options:${RESET} @@ -260,7 +263,7 @@ function showCommandHelp(commandInput: string): boolean { list: `${BOLD}askill list${RESET}\n\nUsage:\n askill list [options]\n\nDescription:\n List installed skills and where they are available.\n\nOptions:\n -g, --global Show global skills only\n -p, --project Show project skills only\n -a, --agent Filter by agent(s)\n --json Output machine-readable JSON\n\nExamples:\n askill list\n askill list -g\n askill list -p -a opencode --json`, - find: `${BOLD}askill find${RESET}\n\nUsage:\n askill find [query] [options]\n\nDescription:\n Search indexed and published skills on askill.sh.\n\nOptions:\n --full-desc Show full descriptions\n --tag Filter by tag\n --page Results page (default: 1)\n --limit Results per page (default: 20)\n --json Output machine-readable JSON\n\nExamples:\n askill find memory\n askill find code review --full-desc\n askill find memory --tag productivity --limit 10 --json`, + find: `${BOLD}askill find${RESET}\n\nUsage:\n askill find [query] [options]\n\nDescription:\n Search indexed and published skills on askill.sh.\n Default order matches Registry homepage AI Picks ranking (llm_score desc).\n\nOptions:\n --full-desc Show full descriptions\n --tag Filter by tag\n --page Results page (default: 1)\n --limit Results per page (default: 20, max: 100)\n --json Output machine-readable JSON\n\nExamples:\n askill find memory\n askill find code review --full-desc\n askill find memory --page 2 --limit 20\n askill find memory --tag productivity --limit 10 --json`, info: `${BOLD}askill info${RESET}\n\nUsage:\n askill info [options]\n\nDescription:\n Show detailed metadata and installation info for one skill.\n\nOptions:\n --json Output machine-readable JSON\n\nExamples:\n askill info @johndoe/awesome-tool\n askill info gh:facebook/react@extract-errors --json`, @@ -1549,6 +1552,10 @@ async function runInstall(args: string[]): Promise { // ============================================ const SEARCH_DESCRIPTION_MAX_LENGTH = 500; +const SEARCH_DEFAULT_LIMIT = 20; +const SEARCH_MAX_LIMIT = 100; +const SEARCH_DEFAULT_SORT: SkillSort = 'llm_score'; +const SEARCH_DEFAULT_ORDER: 'desc' = 'desc'; interface SearchOptions { fullDesc: boolean; @@ -1559,11 +1566,14 @@ interface SearchOptions { limit: number; } -function parsePositiveIntegerOption(value: string | undefined, optionName: string): number { +function parsePositiveIntegerOption(value: string | undefined, optionName: string, max?: number): number { const parsed = Number.parseInt(value || '', 10); if (!Number.isFinite(parsed) || parsed < 1) { throw new Error(`${optionName} must be a positive integer`); } + if (max !== undefined && parsed > max) { + throw new Error(`${optionName} must be <= ${max}`); + } return parsed; } @@ -1731,7 +1741,7 @@ function parseSearchOptions(args: string[]): SearchOptions { let json = false; let tag: string | undefined; let page = 1; - let limit = 20; + let limit = SEARCH_DEFAULT_LIMIT; const queryParts: string[] = []; for (let index = 0; index < args.length; index += 1) { @@ -1747,6 +1757,14 @@ function parseSearchOptions(args: string[]): SearchOptions { continue; } + if (arg.startsWith('--tag=')) { + tag = arg.slice('--tag='.length); + if (!tag) { + throw new Error('--tag requires a value'); + } + continue; + } + if (arg === '--tag') { tag = args[index + 1]; if (!tag || tag.startsWith('-')) { @@ -1756,14 +1774,24 @@ function parseSearchOptions(args: string[]): SearchOptions { continue; } + if (arg.startsWith('--page=')) { + page = parsePositiveIntegerOption(arg.slice('--page='.length), '--page'); + continue; + } + if (arg === '--page') { page = parsePositiveIntegerOption(args[index + 1], '--page'); index += 1; continue; } + if (arg.startsWith('--limit=')) { + limit = parsePositiveIntegerOption(arg.slice('--limit='.length), '--limit', SEARCH_MAX_LIMIT); + continue; + } + if (arg === '--limit') { - limit = parsePositiveIntegerOption(args[index + 1], '--limit'); + limit = parsePositiveIntegerOption(args[index + 1], '--limit', SEARCH_MAX_LIMIT); index += 1; continue; } @@ -1783,6 +1811,31 @@ function parseSearchOptions(args: string[]): SearchOptions { }; } +function shellEscapeArg(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +function buildFindCommand(query: string, tag: string | undefined, page: number, limit: number, fullDesc: boolean): string { + const parts = ['askill find']; + if (query) { + parts.push(shellEscapeArg(query)); + } + if (tag) { + parts.push(`--tag ${shellEscapeArg(tag)}`); + } + if (fullDesc) { + parts.push('--full-desc'); + } + if (limit !== SEARCH_DEFAULT_LIMIT) { + parts.push(`--limit ${limit}`); + } + if (page > 1) { + parts.push(`--page ${page}`); + } + + return parts.join(' '); +} + async function runSearch(args: string[]): Promise { let parsedOptions: SearchOptions; try { @@ -1803,7 +1856,11 @@ async function runSearch(args: string[]): Promise { } const spinner = createSpinner(json); - const searchLabel = query ? `Searching for "${query}"...` : tag ? `Loading skills tagged "${tag}"...` : 'Loading skills...'; + const searchLabel = query + ? `Searching for "${query}" (page ${page})...` + : tag + ? `Loading skills tagged "${tag}" (page ${page})...` + : `Loading skills (page ${page})...`; spinner.start(searchLabel); try { @@ -1812,10 +1869,18 @@ async function runSearch(args: string[]): Promise { tag, page, limit, + sort: SEARCH_DEFAULT_SORT, + order: SEARCH_DEFAULT_ORDER, }); const skills = response.data || []; - spinner.stop(`Found ${skills.length} result(s)`); + const pagination = response.pagination || { + page, + limit, + total: skills.length, + totalPages: skills.length === 0 ? 0 : 1, + }; + spinner.stop(`Found ${pagination.total} result(s), page ${pagination.page}/${pagination.totalPages}`); const normalized = skills.map((skill) => { const displayName = skill.name || 'unknown'; @@ -1852,15 +1917,10 @@ async function runSearch(args: string[]): Promise { tag: tag || null, }, sort: { - field: null, - order: null, - }, - pagination: response.pagination || { - page, - limit, - total: normalized.length, - totalPages: normalized.length === 0 ? 0 : 1, + field: SEARCH_DEFAULT_SORT, + order: SEARCH_DEFAULT_ORDER, }, + pagination, count: normalized.length, skills: normalized, }); @@ -1868,7 +1928,11 @@ async function runSearch(args: string[]): Promise { } if (skills.length === 0) { - p.log.info('No skills found'); + if (pagination.page > 1) { + p.log.info(`No skills found on page ${pagination.page}`); + } else { + p.log.info('No skills found'); + } p.outro(`Browse all skills at ${pc.cyan('https://askill.sh')}`); return; } @@ -1901,6 +1965,17 @@ async function runSearch(args: string[]): Promise { console.log(); } + if (pagination.totalPages > 1) { + console.log(` ${pc.dim(`Page ${pagination.page}/${pagination.totalPages} · ${pagination.total} total`)}`); + if (pagination.page > 1) { + console.log(` ${pc.dim('Prev:')} ${buildFindCommand(query, tag, pagination.page - 1, pagination.limit, fullDesc)}`); + } + if (pagination.page < pagination.totalPages) { + console.log(` ${pc.dim('Next:')} ${buildFindCommand(query, tag, pagination.page + 1, pagination.limit, fullDesc)}`); + } + console.log(); + } + p.outro(`Browse more at ${pc.cyan('https://askill.sh')}`); } catch (error) { spinner.stop('Search failed'); diff --git a/test/e2e/mock-registry.mjs b/test/e2e/mock-registry.mjs index a75e87a..a25bbbb 100644 --- a/test/e2e/mock-registry.mjs +++ b/test/e2e/mock-registry.mjs @@ -9,8 +9,10 @@ const skills = { id: 101, name: 'alpha-collection-skill', description: 'Alpha skill from shared collection', - tags: ['collection', 'alpha'], + tags: ['collection', 'alpha', 'shell $(unsafe)'], stars: 3, + llmScore: 40, + popularScore: 30, owner: 'mock', repo: 'skills', path: 'alpha', @@ -31,8 +33,10 @@ version: 1.0.0 id: 102, name: 'beta-collection-skill', description: 'Beta skill from shared collection', - tags: ['collection', 'beta'], + tags: ['collection', 'beta', 'shell $(unsafe)'], stars: 5, + llmScore: 90, + popularScore: 50, owner: 'mock', repo: 'skills', path: 'beta', @@ -55,6 +59,8 @@ version: 1.0.0 description: 'Alpha skill next major release', tags: ['collection', 'alpha'], stars: 7, + llmScore: 60, + popularScore: 70, owner: 'mock', repo: 'skills', path: 'alpha', @@ -77,6 +83,8 @@ version: 2.0.0 description: 'Alpha skill latest 1.x release', tags: ['collection', 'alpha'], stars: 8, + llmScore: 70, + popularScore: 80, owner: 'mock', repo: 'skills', path: 'alpha', @@ -99,6 +107,8 @@ version: 1.1.0 description: 'Alpha skill with renamed remote frontmatter', tags: ['collection', 'alpha'], stars: 9, + llmScore: 50, + popularScore: 90, owner: 'mock', repo: 'skills', path: 'alpha', @@ -155,6 +165,37 @@ function rawForSkill(slug, raw) { return raw; } +function sortValue(skill, sort) { + switch (sort) { + case 'llm_score': + return skill.llmScore ?? skill.aiScore ?? 0; + case 'popular_score': + return skill.popularScore ?? skill.stars ?? 0; + case 'stars': + return skill.stars ?? 0; + case 'updated': + return Date.parse(skill.updatedAt || '') || 0; + case 'name': + return skill.name || ''; + default: + return null; + } +} + +function sortSkills(items, sort, order) { + if (!sort) return items; + const direction = order === 'desc' ? -1 : 1; + return [...items].sort((left, right) => { + const leftValue = sortValue(left, sort); + const rightValue = sortValue(right, sort); + if (leftValue === null || rightValue === null) return 0; + if (typeof leftValue === 'string' || typeof rightValue === 'string') { + return String(leftValue).localeCompare(String(rightValue)) * direction; + } + return (Number(leftValue) - Number(rightValue)) * direction; + }); +} + const server = createServer((req, res) => { const url = new URL(req.url || '/', `http://127.0.0.1:${port}`); const path = decodeURIComponent(url.pathname); @@ -186,28 +227,33 @@ const server = createServer((req, res) => { if (path === '/api/v1/skills') { const query = (url.searchParams.get('q') || '').trim().toLowerCase(); const tag = (url.searchParams.get('tag') || '').trim().toLowerCase(); + const sort = (url.searchParams.get('sort') || '').trim(); + const order = (url.searchParams.get('order') || 'asc').trim().toLowerCase(); const page = parsePositiveInt(url.searchParams.get('page'), 1); const limit = parsePositiveInt(url.searchParams.get('limit'), 20); - const filtered = skillCatalog - .filter((skill) => { + const filtered = sortSkills( + skillCatalog.filter((skill) => { if (!query) return true; - const haystack = [ - skill.name, - skill.description, - skill.owner, - skill.repo, - ...(Array.isArray(skill.tags) ? skill.tags : []), - ] - .filter(Boolean) - .join(' ') - .toLowerCase(); - return haystack.includes(query); + const haystack = [ + skill.name, + skill.description, + skill.owner, + skill.repo, + ...(Array.isArray(skill.tags) ? skill.tags : []), + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); + return haystack.includes(query); }) - .filter((skill) => { - if (!tag) return true; - return Array.isArray(skill.tags) && skill.tags.some((value) => String(value).toLowerCase() === tag); - }); + .filter((skill) => { + if (!tag) return true; + return Array.isArray(skill.tags) && skill.tags.some((value) => String(value).toLowerCase() === tag); + }), + sort, + order + ); const total = filtered.length; const totalPages = total === 0 ? 0 : Math.ceil(total / limit); diff --git a/test/e2e/run.sh b/test/e2e/run.sh index b2e4bea..8b01e05 100644 --- a/test/e2e/run.sh +++ b/test/e2e/run.sh @@ -48,7 +48,7 @@ strip_ansi() { # Check if cleaned output matches pattern (for inline use) output_matches() { local output="$1" pattern="$2" - echo "$output" | strip_ansi | grep -qi "$pattern" + echo "$output" | strip_ansi | grep -qi -- "$pattern" } # Assert output contains a string (ANSI-stripped) @@ -3058,6 +3058,8 @@ test_find_json_output() { if (!Array.isArray(data.skills)) process.exit(1); if (typeof data.count !== "number") process.exit(1); if (data.count !== data.skills.length) process.exit(1); + if (!data.sort || data.sort.field !== "llm_score" || data.sort.order !== "desc") process.exit(1); + if (!data.pagination || typeof data.pagination.page !== "number") process.exit(1); if (data.skills.length > 0) { const first = data.skills[0]; if (typeof first.name !== "string") process.exit(1); @@ -3088,17 +3090,20 @@ test_dashboard_json_contracts() { local api_url="${registry_url}/api/v1" local find_output - find_output=$(cd "$WORKSPACE" && ASKILL_REGISTRY_URL="$registry_url" ASKILL_API_BASE_URL="$api_url" $CLI find --tag collection --limit 1 --page 1 --json 2>&1) || true + find_output=$(cd "$WORKSPACE" && ASKILL_REGISTRY_URL="$registry_url" ASKILL_API_BASE_URL="$api_url" $CLI find --tag collection --limit=1 --page=1 --json 2>&1) || true if echo "$find_output" | node -e ' const fs = require("fs"); const data = JSON.parse(fs.readFileSync(0, "utf8")); if (data.ok !== true) process.exit(1); if (!data.filters || data.filters.tag !== "collection") process.exit(1); + if (!data.sort || data.sort.field !== "llm_score" || data.sort.order !== "desc") process.exit(1); if (!data.pagination || data.pagination.page !== 1 || data.pagination.limit !== 1) process.exit(1); if (data.count !== 1 || !Array.isArray(data.skills) || data.skills.length !== 1) process.exit(1); + if (data.skills[0].name !== "beta-collection-skill") process.exit(1); + if (data.skills[0].aiScore !== 90) process.exit(1); if (!data.skills[0].tags.includes("collection")) process.exit(1); '; then - pass "find --json supports tag and pagination filters" + pass "find --json supports AI Picks sorting plus tag and pagination filters" else fail "find --json tag/pagination payload mismatch" echo "$find_output" | strip_ansi | head -10 | sed 's/^/ /' @@ -3106,6 +3111,17 @@ test_dashboard_json_contracts() { return fi + local find_nav_output + find_nav_output=$(cd "$WORKSPACE" && ASKILL_REGISTRY_URL="$registry_url" ASKILL_API_BASE_URL="$api_url" $CLI find --tag collection --limit 1 2>&1) || true + assert_contains "$find_nav_output" "Next:" "find output shows next-page label" + assert_contains "$find_nav_output" "--tag 'collection'" "find output shell-escapes tag argument" + assert_contains "$find_nav_output" "--page 2" "find output shows next-page number" + + local find_escape_output + find_escape_output=$(cd "$WORKSPACE" && ASKILL_REGISTRY_URL="$registry_url" ASKILL_API_BASE_URL="$api_url" $CLI find 'shell $(unsafe)' --limit 1 2>&1) || true + assert_contains "$find_escape_output" "Next:" "find output shows next-page label for query" + assert_contains "$find_escape_output" "askill find 'shell \$(unsafe)' --limit 1 --page 2" "find output shell-escapes query argument" + local info_output info_output=$(cd "$WORKSPACE" && ASKILL_REGISTRY_URL="$registry_url" ASKILL_API_BASE_URL="$api_url" $CLI info @mock/alpha --json 2>&1) || true if echo "$info_output" | node -e '