feat(explore): add --sort parameter for hot/newest/downloads ordering

- Use /api/v1/skills (compat API) when query is empty to get full stats
- Map sort options: hot=rating, newest=newest, downloads=downloads
- Remove vim-style k/j navigation to fix 'k' key input issue
This commit is contained in:
chenbaowang 2026-04-14 14:12:37 +08:00
parent ffdf8ae30e
commit c86d99f227
4 changed files with 98 additions and 22 deletions

View file

@ -1,5 +1,5 @@
{
"name": "@motovis/skillhub",
"name": "@iflytek/skillhub",
"version": "1.0.0",
"type": "module",
"description": "SkillHub CLI - 企业级 Agent Skill 管理工具,支持命名空间",

View file

@ -47,7 +47,8 @@ async function fetchSkillDetail(client: ApiClient, namespace: string, name: stri
async function runInteractiveSearch(
client: ApiClient,
initialQuery: string = ""
initialQuery: string = "",
sort: string = "newest"
): Promise<string | null> {
const MAX_VISIBLE = 8;
let query = initialQuery;
@ -136,7 +137,7 @@ async function runInteractiveSearch(
debounceTimer = setTimeout(async () => {
try {
results = await searchSkills(client, q);
results = await searchSkills(client, q, 10, sort);
selectedIndex = 0;
} catch {
results = [];
@ -179,13 +180,13 @@ async function runInteractiveSearch(
return;
}
if (key.name === "up" || key.name === "k") {
if (key.name === "up") {
selectedIndex = Math.max(0, selectedIndex - 1);
render();
return;
}
if (key.name === "down" || key.name === "j") {
if (key.name === "down") {
selectedIndex = Math.min(Math.max(0, results.length - 1), selectedIndex + 1);
render();
return;
@ -219,14 +220,17 @@ export function registerExplore(program: Command) {
.description("Browse or search skills from the registry")
.argument("[query]", "Search query for finding skills")
.option("-n, --limit <n>", "Max results", "20")
.action(async (query: string | undefined, opts: { limit: string }) => {
.option("-s, --sort <sort>", "Sort by: hot, newest, downloads (default: interactive mode)")
.action(async (query: string | undefined, opts: { limit: string; sort?: string }) => {
const config = loadConfig();
const token = await readToken();
const client = new ApiClient({ baseUrl: config.registry, token: token || undefined });
const sortMap: Record<string, string> = { hot: "rating", newest: "newest", downloads: "downloads" };
const apiSort = sortMap[opts.sort || "newest"] || "newest";
try {
if (!query) {
const selected = await runInteractiveSearch(client, "");
if (!query && !opts.sort) {
const selected = await runInteractiveSearch(client, "", apiSort);
if (!selected) {
console.log("\nCancelled.");
return;
@ -236,14 +240,14 @@ export function registerExplore(program: Command) {
return;
}
const results = await searchSkills(client, query, parseInt(opts.limit, 10));
const results = await searchSkills(client, query || "", parseInt(opts.limit, 10), apiSort);
if (results.length === 0) {
console.log(`${DIM}No skills found for "${query}"${RESET}`);
console.log(`${DIM}No skills found${RESET}`);
return;
}
const maxResults = Math.min(results.length, 6);
const maxResults = Math.min(results.length, parseInt(opts.limit, 10));
const detailPromises = results.slice(0, maxResults).map((s) =>
fetchSkillDetail(client, s.namespace, s.name)

View file

@ -1,5 +1,5 @@
import { ApiClient } from "./api-client.js";
import { ApiRoutes, SearchResponse } from "../schema/routes.js";
import { ApiRoutes, SearchResponse, SkillsListResponse } from "../schema/routes.js";
import * as readline from "readline";
import { dim, info } from "../utils/logger.js";
@ -59,17 +59,56 @@ async function fetchSkillDetail(client: ApiClient, namespace: string, name: stri
export async function searchSkills(
client: ApiClient,
query: string,
limit: number = 10
limit: number = 10,
sort?: string
): Promise<SearchSkill[]> {
if (!query) {
const params = new URLSearchParams({ limit: limit.toString() });
if (sort && sort !== "newest") {
params.set("sort", sort);
}
const result = await client.get<SkillsListResponse>(
`${ApiRoutes.skills}?${params.toString()}`
);
if (!result.items || result.items.length === 0) {
return [];
}
const skills = result.items.map((s) => {
const { namespace, name } = parseNamespace(s.slug);
return {
name,
slug: s.slug,
namespace,
version: s.latestVersion?.version || "",
summary: s.summary,
installs: s.stats?.downloads || 0,
stars: s.stats?.stars || 0,
rating: 0,
updatedAt: s.updatedAt || 0,
};
});
if (sort === "downloads") {
return skills.sort((a, b) => b.installs - a.installs);
} else if (sort === "rating") {
return skills.sort((a, b) => b.rating - a.rating || b.stars - a.stars);
} else {
return skills.sort((a, b) => b.updatedAt - a.updatedAt);
}
}
const params = new URLSearchParams({ q: query, limit: limit.toString() });
if (sort) {
params.set("sort", sort);
}
const result = await client.get<SearchResponse>(
`${ApiRoutes.search}?q=${encodeURIComponent(query)}&limit=${limit}`
`${ApiRoutes.search}?${params.toString()}`
);
if (!result.results || result.results.length === 0) {
return [];
}
return result.results.map((s) => {
const skills = result.results.map((s) => {
const { namespace, name } = parseNamespace(s.slug);
return {
name,
@ -77,14 +116,26 @@ export async function searchSkills(
namespace,
version: s.version,
summary: s.summary,
installs: (s as any).installCount || 0,
installs: s.downloadCount || 0,
stars: s.starCount || 0,
rating: s.ratingAvg || 0,
updatedAt: s.updatedAt ? new Date(s.updatedAt).getTime() : 0,
};
}).sort((a, b) => (b.installs || 0) - (a.installs || 0));
});
if (sort === "downloads") {
return skills.sort((a, b) => b.installs - a.installs);
} else if (sort === "rating") {
return skills.sort((a, b) => b.rating - a.rating || b.stars - a.stars);
} else {
return skills.sort((a, b) => b.updatedAt - a.updatedAt);
}
}
export async function runInteractiveSearch(
client: ApiClient,
initialQuery: string = ""
initialQuery: string = "",
sort: string = "newest"
): Promise<string | null> {
const MAX_VISIBLE = 8;
let query = initialQuery;
@ -172,7 +223,7 @@ export async function runInteractiveSearch(
debounceTimer = setTimeout(async () => {
try {
results = await searchSkills(client, q);
results = await searchSkills(client, q, 10, sort);
selectedIndex = 0;
} catch {
results = [];
@ -215,13 +266,13 @@ export async function runInteractiveSearch(
return;
}
if (key.name === "up" || key.name === "k") {
if (key.name === "up") {
selectedIndex = Math.max(0, selectedIndex - 1);
render();
return;
}
if (key.name === "down" || key.name === "j") {
if (key.name === "down") {
selectedIndex = Math.min(Math.max(0, results.length - 1), selectedIndex + 1);
render();
return;

View file

@ -41,6 +41,27 @@ export interface SearchResponse {
displayName: string;
summary: string;
version: string;
namespace?: string; // Namespace where the skill is published
namespace?: string;
downloadCount?: number;
starCount?: number;
ratingAvg?: number;
updatedAt?: string;
}>;
}
export interface SkillsListResponse {
items: Array<{
slug: string;
displayName: string;
summary: string;
updatedAt: number;
stats: {
downloads?: number;
stars?: number;
};
latestVersion?: {
version: string;
};
}>;
nextCursor: string | null;
}