[Feat] Add ClawHub skill search and detail in Control UI (#60134)

* feat(gateway): add skills.search and skills.detail RPC methods

Expose ClawHub search and detail capabilities through the Gateway protocol,
enabling desktop/web clients to browse and inspect skills from the registry.

New RPCs:
- skills.search: search ClawHub skills by query with optional limit
- skills.detail: fetch full detail for a single skill by slug

Both methods delegate to existing agent-layer functions
(searchSkillsFromClawHub, fetchSkillDetailFromClawHub) which wrap
the ClawHub HTTP client. No new external dependencies.

Signed-off-by: samzong <samzong.lu@gmail.com>

* feat(skills): add ClawHub skill search and detail in Control UI

Add skills.search and skills.detail Gateway RPC methods with typed
protocol schemas, AJV validators, and handler implementations. Wire
the new RPCs into the Control UI Skills panel with a debounced search
input, results list, detail dialog, and one-click install from ClawHub.

Gateway:
- SkillsSearchParams/ResultSchema and SkillsDetailParams/ResultSchema
- Handler calls searchClawHubSkills and fetchClawHubSkillDetail directly
- Remove zero-logic fetchSkillDetailFromClawHub wrapper
- 9 handler tests including boundary validation

Control UI:
- searchClawHub, loadClawHubDetail, installFromClawHub controllers
- 300ms debounced search input to avoid 429 rate limits
- Dedicated install busy state (clawhubInstallSlug) with success/error feedback
- Install buttons disabled during install with progress text
- Detail dialog with owner, version, changelog, platform metadata

Part of #43301

Signed-off-by: samzong <samzong.lu@gmail.com>

* fix(skills): guard search and detail responses against stale writes

Signed-off-by: samzong <samzong.lu@gmail.com>

* fix(skills): reset loading flags on query clear and detail close

Signed-off-by: samzong <samzong.lu@gmail.com>

* fix(gateway): register skills.search/detail in read scope and method list

Add skills.search and skills.detail to the operator READ scope group
and the server methods list. Without this, unclassified methods default
to operator.admin, blocking read-only operator sessions.

Also guard the detail loading reset in the finally block by the active
slug to prevent a transient flash when rapidly switching skills.

Signed-off-by: samzong <samzong.lu@gmail.com>

* fix(skills): guard search loading reset by active query

Signed-off-by: samzong <samzong.lu@gmail.com>

* test: cover ClawHub skills UI flow

* fix: clear stale ClawHub search results

---------

Signed-off-by: samzong <samzong.lu@gmail.com>
Co-authored-by: Frank Yang <frank.ekn@gmail.com>
This commit is contained in:
samzong
2026-04-03 19:30:44 +08:00
committed by GitHub
parent d39e4dff6a
commit 37ab4b7fdc
17 changed files with 983 additions and 13 deletions

View File

@@ -69,6 +69,8 @@ const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
"agents.list",
"agent.identity.get",
"skills.status",
"skills.search",
"skills.detail",
"voicewake.get",
"sessions.list",
"sessions.get",

View File

@@ -225,8 +225,16 @@ import {
type SkillsBinsParams,
SkillsBinsParamsSchema,
type SkillsBinsResult,
type SkillsDetailParams,
SkillsDetailParamsSchema,
type SkillsDetailResult,
SkillsDetailResultSchema,
type SkillsInstallParams,
SkillsInstallParamsSchema,
type SkillsSearchParams,
SkillsSearchParamsSchema,
type SkillsSearchResult,
SkillsSearchResultSchema,
type SkillsStatusParams,
SkillsStatusParamsSchema,
type SkillsUpdateParams,
@@ -404,6 +412,8 @@ export const validateSkillsBinsParams = ajv.compile<SkillsBinsParams>(SkillsBins
export const validateSkillsInstallParams =
ajv.compile<SkillsInstallParams>(SkillsInstallParamsSchema);
export const validateSkillsUpdateParams = ajv.compile<SkillsUpdateParams>(SkillsUpdateParamsSchema);
export const validateSkillsSearchParams = ajv.compile<SkillsSearchParams>(SkillsSearchParamsSchema);
export const validateSkillsDetailParams = ajv.compile<SkillsDetailParams>(SkillsDetailParamsSchema);
export const validateCronListParams = ajv.compile<CronListParams>(CronListParamsSchema);
export const validateCronStatusParams = ajv.compile<CronStatusParams>(CronStatusParamsSchema);
export const validateCronAddParams = ajv.compile<CronAddParams>(CronAddParamsSchema);
@@ -590,6 +600,10 @@ export {
ToolsCatalogParamsSchema,
ToolsEffectiveParamsSchema,
SkillsInstallParamsSchema,
SkillsSearchParamsSchema,
SkillsSearchResultSchema,
SkillsDetailParamsSchema,
SkillsDetailResultSchema,
SkillsUpdateParamsSchema,
CronJobSchema,
CronListParamsSchema,
@@ -685,6 +699,10 @@ export type {
ToolsEffectiveResult,
SkillsBinsParams,
SkillsBinsResult,
SkillsSearchParams,
SkillsSearchResult,
SkillsDetailParams,
SkillsDetailResult,
SkillsInstallParams,
SkillsUpdateParams,
NodePairRejectParams,

View File

@@ -241,6 +241,98 @@ export const SkillsUpdateParamsSchema = Type.Union([
),
]);
export const SkillsSearchParamsSchema = Type.Object(
{
query: Type.Optional(NonEmptyString),
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
},
{ additionalProperties: false },
);
export const SkillsSearchResultSchema = Type.Object(
{
results: Type.Array(
Type.Object(
{
score: Type.Number(),
slug: NonEmptyString,
displayName: NonEmptyString,
summary: Type.Optional(Type.String()),
version: Type.Optional(NonEmptyString),
updatedAt: Type.Optional(Type.Integer()),
},
{ additionalProperties: false },
),
),
},
{ additionalProperties: false },
);
export const SkillsDetailParamsSchema = Type.Object(
{
slug: NonEmptyString,
},
{ additionalProperties: false },
);
export const SkillsDetailResultSchema = Type.Object(
{
skill: Type.Union([
Type.Object(
{
slug: NonEmptyString,
displayName: NonEmptyString,
summary: Type.Optional(Type.String()),
tags: Type.Optional(Type.Record(NonEmptyString, Type.String())),
createdAt: Type.Integer(),
updatedAt: Type.Integer(),
},
{ additionalProperties: false },
),
Type.Null(),
]),
latestVersion: Type.Optional(
Type.Union([
Type.Object(
{
version: NonEmptyString,
createdAt: Type.Integer(),
changelog: Type.Optional(Type.String()),
},
{ additionalProperties: false },
),
Type.Null(),
]),
),
metadata: Type.Optional(
Type.Union([
Type.Object(
{
os: Type.Optional(Type.Union([Type.Array(Type.String()), Type.Null()])),
systems: Type.Optional(Type.Union([Type.Array(Type.String()), Type.Null()])),
},
{ additionalProperties: false },
),
Type.Null(),
]),
),
owner: Type.Optional(
Type.Union([
Type.Object(
{
handle: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
displayName: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
image: Type.Optional(Type.Union([Type.String(), Type.Null()])),
},
{ additionalProperties: false },
),
Type.Null(),
]),
),
},
{ additionalProperties: false },
);
export const ToolsCatalogParamsSchema = Type.Object(
{
agentId: Type.Optional(NonEmptyString),

View File

@@ -31,7 +31,11 @@ import {
ModelsListResultSchema,
SkillsBinsParamsSchema,
SkillsBinsResultSchema,
SkillsDetailParamsSchema,
SkillsDetailResultSchema,
SkillsInstallParamsSchema,
SkillsSearchParamsSchema,
SkillsSearchResultSchema,
SkillsStatusParamsSchema,
SkillsUpdateParamsSchema,
ToolCatalogEntrySchema,
@@ -286,6 +290,10 @@ export const ProtocolSchemas = {
ToolsEffectiveResult: ToolsEffectiveResultSchema,
SkillsBinsParams: SkillsBinsParamsSchema,
SkillsBinsResult: SkillsBinsResultSchema,
SkillsSearchParams: SkillsSearchParamsSchema,
SkillsSearchResult: SkillsSearchResultSchema,
SkillsDetailParams: SkillsDetailParamsSchema,
SkillsDetailResult: SkillsDetailResultSchema,
SkillsInstallParams: SkillsInstallParamsSchema,
SkillsUpdateParams: SkillsUpdateParamsSchema,
CronJob: CronJobSchema,

View File

@@ -108,6 +108,10 @@ export type ToolsEffectiveGroup = SchemaType<"ToolsEffectiveGroup">;
export type ToolsEffectiveResult = SchemaType<"ToolsEffectiveResult">;
export type SkillsBinsParams = SchemaType<"SkillsBinsParams">;
export type SkillsBinsResult = SchemaType<"SkillsBinsResult">;
export type SkillsSearchParams = SchemaType<"SkillsSearchParams">;
export type SkillsSearchResult = SchemaType<"SkillsSearchResult">;
export type SkillsDetailParams = SchemaType<"SkillsDetailParams">;
export type SkillsDetailResult = SchemaType<"SkillsDetailResult">;
export type SkillsInstallParams = SchemaType<"SkillsInstallParams">;
export type SkillsUpdateParams = SchemaType<"SkillsUpdateParams">;
export type CronJob = SchemaType<"CronJob">;

View File

@@ -50,6 +50,8 @@ const BASE_METHODS = [
"agents.files.get",
"agents.files.set",
"skills.status",
"skills.search",
"skills.detail",
"skills.bins",
"skills.install",
"skills.update",

View File

@@ -0,0 +1,203 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const searchSkillsFromClawHubMock = vi.fn();
const fetchClawHubSkillDetailMock = vi.fn();
vi.mock("../../config/config.js", () => ({
loadConfig: vi.fn(() => ({})),
writeConfigFile: vi.fn(),
}));
vi.mock("../../agents/agent-scope.js", () => ({
listAgentIds: vi.fn(() => ["main"]),
resolveDefaultAgentId: vi.fn(() => "main"),
resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"),
}));
vi.mock("../../agents/skills-clawhub.js", () => ({
installSkillFromClawHub: vi.fn(),
updateSkillsFromClawHub: vi.fn(),
searchSkillsFromClawHub: (...args: unknown[]) => searchSkillsFromClawHubMock(...args),
}));
vi.mock("../../infra/clawhub.js", () => ({
fetchClawHubSkillDetail: (...args: unknown[]) => fetchClawHubSkillDetailMock(...args),
resolveClawHubBaseUrl: vi.fn(() => "https://clawhub.ai"),
searchClawHubSkills: vi.fn(),
downloadClawHubSkillArchive: vi.fn(),
}));
vi.mock("../../agents/skills-install.js", () => ({
installSkill: vi.fn(),
}));
const { skillsHandlers } = await import("./skills.js");
function callHandler(method: string, params: Record<string, unknown>) {
let ok: boolean | null = null;
let response: unknown;
let error: unknown;
const result = skillsHandlers[method]({
params,
req: {} as never,
client: null as never,
isWebchatConnect: () => false,
context: {} as never,
respond: (success: boolean, res: unknown, err: unknown) => {
ok = success;
response = res;
error = err;
},
});
return Promise.resolve(result).then(() => ({ ok, response, error }));
}
describe("skills.search handler", () => {
beforeEach(() => {
searchSkillsFromClawHubMock.mockReset();
fetchClawHubSkillDetailMock.mockReset();
});
it("searches ClawHub with query and limit", async () => {
searchSkillsFromClawHubMock.mockResolvedValue([
{
score: 0.95,
slug: "github",
displayName: "GitHub",
summary: "GitHub integration",
version: "1.0.0",
updatedAt: 1700000000,
},
]);
const { ok, response, error } = await callHandler("skills.search", {
query: "github",
limit: 10,
});
expect(searchSkillsFromClawHubMock).toHaveBeenCalledWith({
query: "github",
limit: 10,
});
expect(ok).toBe(true);
expect(error).toBeUndefined();
expect(response).toEqual({
results: [
{
score: 0.95,
slug: "github",
displayName: "GitHub",
summary: "GitHub integration",
version: "1.0.0",
updatedAt: 1700000000,
},
],
});
});
it("searches without query (browse all)", async () => {
searchSkillsFromClawHubMock.mockResolvedValue([]);
const { ok, response } = await callHandler("skills.search", {});
expect(searchSkillsFromClawHubMock).toHaveBeenCalledWith({
query: undefined,
limit: undefined,
});
expect(ok).toBe(true);
expect(response).toEqual({ results: [] });
});
it("returns error when ClawHub is unreachable", async () => {
searchSkillsFromClawHubMock.mockRejectedValue(new Error("connection refused"));
const { ok, error } = await callHandler("skills.search", { query: "test" });
expect(ok).toBe(false);
expect(error).toMatchObject({ message: "connection refused" });
});
it("rejects limit below minimum", async () => {
const { ok, error } = await callHandler("skills.search", {
query: "test",
limit: 0,
});
expect(ok).toBe(false);
expect(error).toMatchObject({ code: "INVALID_REQUEST" });
expect(searchSkillsFromClawHubMock).not.toHaveBeenCalled();
});
it("rejects limit above maximum", async () => {
const { ok, error } = await callHandler("skills.search", {
query: "test",
limit: 101,
});
expect(ok).toBe(false);
expect(error).toMatchObject({ code: "INVALID_REQUEST" });
expect(searchSkillsFromClawHubMock).not.toHaveBeenCalled();
});
});
describe("skills.detail handler", () => {
beforeEach(() => {
searchSkillsFromClawHubMock.mockReset();
fetchClawHubSkillDetailMock.mockReset();
});
it("fetches detail for a valid slug", async () => {
const detail = {
skill: {
slug: "github",
displayName: "GitHub",
summary: "GitHub integration",
createdAt: 1700000000,
updatedAt: 1700000000,
},
latestVersion: {
version: "1.0.0",
createdAt: 1700000000,
},
owner: {
handle: "openclaw",
displayName: "OpenClaw",
},
};
fetchClawHubSkillDetailMock.mockResolvedValue(detail);
const { ok, response, error } = await callHandler("skills.detail", {
slug: "github",
});
expect(fetchClawHubSkillDetailMock).toHaveBeenCalledWith({ slug: "github" });
expect(ok).toBe(true);
expect(error).toBeUndefined();
expect(response).toEqual(detail);
});
it("returns error when slug is not found", async () => {
fetchClawHubSkillDetailMock.mockRejectedValue(new Error("not found"));
const { ok, error } = await callHandler("skills.detail", { slug: "nonexistent" });
expect(ok).toBe(false);
expect(error).toMatchObject({ message: "not found" });
});
it("rejects missing slug", async () => {
const { ok, error } = await callHandler("skills.detail", {});
expect(ok).toBe(false);
expect(error).toMatchObject({ code: "INVALID_REQUEST" });
expect(fetchClawHubSkillDetailMock).not.toHaveBeenCalled();
});
it("rejects empty slug", async () => {
const { ok, error } = await callHandler("skills.detail", { slug: "" });
expect(ok).toBe(false);
expect(error).toMatchObject({ code: "INVALID_REQUEST" });
expect(fetchClawHubSkillDetailMock).not.toHaveBeenCalled();
});
});

View File

@@ -3,13 +3,18 @@ import {
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../../agents/agent-scope.js";
import { installSkillFromClawHub, updateSkillsFromClawHub } from "../../agents/skills-clawhub.js";
import {
installSkillFromClawHub,
searchSkillsFromClawHub,
updateSkillsFromClawHub,
} from "../../agents/skills-clawhub.js";
import { installSkill } from "../../agents/skills-install.js";
import { buildWorkspaceSkillStatus } from "../../agents/skills-status.js";
import { loadWorkspaceSkillEntries, type SkillEntry } from "../../agents/skills.js";
import { listAgentWorkspaceDirs } from "../../agents/workspace-dirs.js";
import type { OpenClawConfig } from "../../config/config.js";
import { loadConfig, writeConfigFile } from "../../config/config.js";
import { fetchClawHubSkillDetail } from "../../infra/clawhub.js";
import { getRemoteSkillEligibility } from "../../infra/skills-remote.js";
import { normalizeAgentId } from "../../routing/session-key.js";
import { normalizeSecretInput } from "../../utils/normalize-secret-input.js";
@@ -18,7 +23,9 @@ import {
errorShape,
formatValidationErrors,
validateSkillsBinsParams,
validateSkillsDetailParams,
validateSkillsInstallParams,
validateSkillsSearchParams,
validateSkillsStatusParams,
validateSkillsUpdateParams,
} from "../protocol/index.js";
@@ -112,6 +119,57 @@ export const skillsHandlers: GatewayRequestHandlers = {
}
respond(true, { bins: [...bins].toSorted() }, undefined);
},
"skills.search": async ({ params, respond }) => {
if (!validateSkillsSearchParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid skills.search params: ${formatValidationErrors(validateSkillsSearchParams.errors)}`,
),
);
return;
}
try {
const results = await searchSkillsFromClawHub({
query: (params as { query?: string }).query,
limit: (params as { limit?: number }).limit,
});
respond(true, { results }, undefined);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, err instanceof Error ? err.message : String(err)),
);
}
},
"skills.detail": async ({ params, respond }) => {
if (!validateSkillsDetailParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid skills.detail params: ${formatValidationErrors(validateSkillsDetailParams.errors)}`,
),
);
return;
}
try {
const detail = await fetchClawHubSkillDetail({
slug: (params as { slug: string }).slug,
});
respond(true, detail, undefined);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, err instanceof Error ? err.message : String(err)),
);
}
},
"skills.install": async ({ params, respond }) => {
if (!validateSkillsInstallParams(params)) {
respond(