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
2 changes: 1 addition & 1 deletion docs/api-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down
6 changes: 5 additions & 1 deletion docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -204,7 +205,7 @@ askill find [query] [options]
|--------|-------------|
| `--tag <tag>` | Filter by tag |
| `--page <n>` | Results page (default: 1) |
| `--limit <n>` | Number of results per page (default: 20) |
| `--limit <n>` | Number of results per page (default: 20, max: 100) |
| `--json` | Output machine-readable JSON |

### Examples
Expand All @@ -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

Expand Down
15 changes: 13 additions & 2 deletions docs/json-contracts/askill-cli-json.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand All @@ -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" } }
Expand Down
19 changes: 16 additions & 3 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -171,7 +173,7 @@ class APIClient {
tag?: string;
owner?: string;
repo?: string;
sort?: 'stars' | 'updated' | 'name';
sort?: SkillSort;
order?: 'asc' | 'desc';
} = {}): Promise<SkillListResponse> {
const params = new URLSearchParams();
Expand Down Expand Up @@ -239,8 +241,19 @@ class APIClient {
/**
* Search for skills (uses listSkills with q parameter)
*/
async search(q: string, limit: number = 10): Promise<SkillListResponse> {
return this.listSkills({ q, limit });
async search(q: string, options: {
page?: number;
limit?: number;
sort?: SkillSort;
order?: 'asc' | 'desc';
} = {}): Promise<SkillListResponse> {
return this.listSkills({
q,
page: options.page,
limit: options.limit,
sort: options.sort,
order: options.order,
});
}

/**
Expand Down
107 changes: 91 additions & 16 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -113,6 +113,9 @@ ${BOLD}Run Options:${RESET}

${BOLD}Search Options:${RESET}
--full-desc Show full skill descriptions in find/search
--tag <tag> Filter by tag
--page <n> Results page (default: 1)
--limit <n> Results per page (default: 20, max: 100)
--json Output machine-readable JSON

${BOLD}Options:${RESET}
Expand Down Expand Up @@ -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 <agents...> 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 <tag> Filter by tag\n --page <n> Results page (default: 1)\n --limit <n> 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 <tag> Filter by tag\n --page <n> Results page (default: 1)\n --limit <n> 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 <slug> [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`,

Expand Down Expand Up @@ -1549,6 +1552,10 @@ async function runInstall(args: string[]): Promise<void> {
// ============================================

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;
Expand All @@ -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;
}

Expand Down Expand Up @@ -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) {
Expand All @@ -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('-')) {
Expand All @@ -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;
}
Expand All @@ -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<void> {
let parsedOptions: SearchOptions;
try {
Expand All @@ -1803,7 +1856,11 @@ async function runSearch(args: string[]): Promise<void> {
}

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 {
Expand All @@ -1812,10 +1869,18 @@ async function runSearch(args: string[]): Promise<void> {
tag,
page,
limit,
sort: SEARCH_DEFAULT_SORT,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don't request an unsupported sort key

For registries that implement the documented /api/v1/skills contract, this makes every askill find request use an unsupported sort value: the checked API spec still lists sort as only stars, updated, or name (docs/api-spec.md:47). Unless the server contract is updated everywhere before this CLI ships, these searches can be rejected or silently fall back to a different order despite the JSON claiming llm_score desc, so either update the API contract/server support or avoid sending this key.

Useful? React with 👍 / 👎.

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';
Expand Down Expand Up @@ -1852,23 +1917,22 @@ async function runSearch(args: string[]): Promise<void> {
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,
});
return;
}

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;
}
Expand Down Expand Up @@ -1901,6 +1965,17 @@ async function runSearch(args: string[]): Promise<void> {
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');
Expand Down
Loading
Loading