diff --git a/CHANGELOG.md b/CHANGELOG.md index 75f3b5cef5c..2808121ec94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai - Matrix/exec approvals: add Matrix-native exec approval prompts with account-scoped approvers, channel-or-DM delivery, and room-thread aware resolution handling. (#58635) Thanks @gumadeiras. - Providers/StepFun: add the bundled StepFun provider plugin with standard and Step Plan endpoints, China/global onboarding choices, `step-3.5-flash` on both catalogs, and `step-3.5-flash-2603` currently exposed on Step Plan. (#60032) Thanks @hengm3467. - Providers/config: add `models.providers.*.request` overrides for headers and auth on model-provider paths, and full request transport overrides for media provider HTTP paths. +- Control UI/skills: add ClawHub search, detail, and install flows directly in the Skills panel. (#60134) Thanks @samzong. ### Fixes diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index 5e43ba5c884..409d76bc19a 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -69,6 +69,8 @@ const METHOD_SCOPE_GROUPS: Record = { "agents.list", "agent.identity.get", "skills.status", + "skills.search", + "skills.detail", "voicewake.get", "sessions.list", "sessions.get", diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 71df8293577..00bc4ae607a 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -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(SkillsBins export const validateSkillsInstallParams = ajv.compile(SkillsInstallParamsSchema); export const validateSkillsUpdateParams = ajv.compile(SkillsUpdateParamsSchema); +export const validateSkillsSearchParams = ajv.compile(SkillsSearchParamsSchema); +export const validateSkillsDetailParams = ajv.compile(SkillsDetailParamsSchema); export const validateCronListParams = ajv.compile(CronListParamsSchema); export const validateCronStatusParams = ajv.compile(CronStatusParamsSchema); export const validateCronAddParams = ajv.compile(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, diff --git a/src/gateway/protocol/schema/agents-models-skills.ts b/src/gateway/protocol/schema/agents-models-skills.ts index 9eafe5a1df5..0cb4247df8e 100644 --- a/src/gateway/protocol/schema/agents-models-skills.ts +++ b/src/gateway/protocol/schema/agents-models-skills.ts @@ -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), diff --git a/src/gateway/protocol/schema/protocol-schemas.ts b/src/gateway/protocol/schema/protocol-schemas.ts index 68321bf3a2b..cbfb7af2cb9 100644 --- a/src/gateway/protocol/schema/protocol-schemas.ts +++ b/src/gateway/protocol/schema/protocol-schemas.ts @@ -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, diff --git a/src/gateway/protocol/schema/types.ts b/src/gateway/protocol/schema/types.ts index a00dc6fed86..e5169fab8bd 100644 --- a/src/gateway/protocol/schema/types.ts +++ b/src/gateway/protocol/schema/types.ts @@ -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">; diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index 26831e525b9..5f7b690a8fd 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -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", diff --git a/src/gateway/server-methods/skills.search-detail.test.ts b/src/gateway/server-methods/skills.search-detail.test.ts new file mode 100644 index 00000000000..c81f223fb91 --- /dev/null +++ b/src/gateway/server-methods/skills.search-detail.test.ts @@ -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) { + 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(); + }); +}); diff --git a/src/gateway/server-methods/skills.ts b/src/gateway/server-methods/skills.ts index d82dbe43eca..9b9c759afeb 100644 --- a/src/gateway/server-methods/skills.ts +++ b/src/gateway/server-methods/skills.ts @@ -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( diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index c6f4f31d8e4..6a36dc598d2 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -80,9 +80,13 @@ import { loadNodes } from "./controllers/nodes.ts"; import { loadPresence } from "./controllers/presence.ts"; import { deleteSessionsAndRefresh, loadSessions, patchSession } from "./controllers/sessions.ts"; import { + closeClawHubDetail, + installFromClawHub, installSkill, + loadClawHubDetail, loadSkills, saveSkillApiKey, + searchClawHub, updateSkillEdit, updateSkillEnabled, } from "./controllers/skills.ts"; @@ -139,6 +143,8 @@ const lazyNodes = createLazy(() => import("./views/nodes.ts")); const lazySessions = createLazy(() => import("./views/sessions.ts")); const lazySkills = createLazy(() => import("./views/skills.ts")); +let clawhubSearchTimer: ReturnType | null = null; + function lazyRender(getter: () => M | null, render: (mod: M) => unknown) { const mod = getter(); return mod ? render(mod) : nothing; @@ -1313,6 +1319,16 @@ export function renderApp(state: AppViewState) { messages: state.skillMessages, busyKey: state.skillsBusyKey, detailKey: state.skillsDetailKey, + clawhubQuery: state.clawhubSearchQuery, + clawhubResults: state.clawhubSearchResults, + clawhubSearchLoading: state.clawhubSearchLoading, + clawhubSearchError: state.clawhubSearchError, + clawhubDetail: state.clawhubDetail, + clawhubDetailSlug: state.clawhubDetailSlug, + clawhubDetailLoading: state.clawhubDetailLoading, + clawhubDetailError: state.clawhubDetailError, + clawhubInstallSlug: state.clawhubInstallSlug, + clawhubInstallMessage: state.clawhubInstallMessage, onFilterChange: (next) => (state.skillsFilter = next), onStatusFilterChange: (next) => (state.skillsStatusFilter = next), onRefresh: () => loadSkills(state, { clearMessages: true }), @@ -1323,6 +1339,17 @@ export function renderApp(state: AppViewState) { installSkill(state, skillKey, name, installId), onDetailOpen: (key) => (state.skillsDetailKey = key), onDetailClose: () => (state.skillsDetailKey = null), + onClawHubQueryChange: (query) => { + state.clawhubSearchQuery = query; + state.clawhubInstallMessage = null; + if (clawhubSearchTimer) { + clearTimeout(clawhubSearchTimer); + } + clawhubSearchTimer = setTimeout(() => searchClawHub(state, query), 300); + }, + onClawHubDetailOpen: (slug) => loadClawHubDetail(state, slug), + onClawHubDetailClose: () => closeClawHubDetail(state), + onClawHubInstall: (slug) => installFromClawHub(state, slug), }), ) : nothing} diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index da586aee6ea..27775ec0228 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -4,7 +4,11 @@ import type { CronModelSuggestionsState, CronState } from "./controllers/cron.ts import type { DevicePairingList } from "./controllers/devices.ts"; import type { ExecApprovalRequest } from "./controllers/exec-approval.ts"; import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals.ts"; -import type { SkillMessage } from "./controllers/skills.ts"; +import type { + ClawHubSearchResult, + ClawHubSkillDetail, + SkillMessage, +} from "./controllers/skills.ts"; import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts"; import type { Tab } from "./navigation.ts"; import type { UiSettings } from "./storage.ts"; @@ -277,6 +281,16 @@ export type AppViewState = { skillMessages: Record; skillsBusyKey: string | null; skillsDetailKey: string | null; + clawhubSearchQuery: string; + clawhubSearchResults: ClawHubSearchResult[] | null; + clawhubSearchLoading: boolean; + clawhubSearchError: string | null; + clawhubDetail: ClawHubSkillDetail | null; + clawhubDetailSlug: string | null; + clawhubDetailLoading: boolean; + clawhubDetailError: string | null; + clawhubInstallSlug: string | null; + clawhubInstallMessage: { kind: "success" | "error"; text: string } | null; healthLoading: boolean; healthResult: HealthSummary | null; healthError: string | null; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 1d7fbb38406..e125e537902 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -63,7 +63,11 @@ import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./contro import type { DevicePairingList } from "./controllers/devices.ts"; import type { ExecApprovalRequest } from "./controllers/exec-approval.ts"; import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals.ts"; -import type { SkillMessage } from "./controllers/skills.ts"; +import type { + ClawHubSearchResult, + ClawHubSkillDetail, + SkillMessage, +} from "./controllers/skills.ts"; import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts"; import type { Tab } from "./navigation.ts"; import { loadSettings, type UiSettings } from "./storage.ts"; @@ -413,6 +417,16 @@ export class OpenClawApp extends LitElement { @state() skillsBusyKey: string | null = null; @state() skillMessages: Record = {}; @state() skillsDetailKey: string | null = null; + @state() clawhubSearchQuery = ""; + @state() clawhubSearchResults: ClawHubSearchResult[] | null = null; + @state() clawhubSearchLoading = false; + @state() clawhubSearchError: string | null = null; + @state() clawhubDetail: ClawHubSkillDetail | null = null; + @state() clawhubDetailSlug: string | null = null; + @state() clawhubDetailLoading = false; + @state() clawhubDetailError: string | null = null; + @state() clawhubInstallSlug: string | null = null; + @state() clawhubInstallMessage: { kind: "success" | "error"; text: string } | null = null; @state() healthLoading = false; @state() healthResult: HealthSummary | null = null; diff --git a/ui/src/ui/controllers/skills.test.ts b/ui/src/ui/controllers/skills.test.ts new file mode 100644 index 00000000000..1de87907415 --- /dev/null +++ b/ui/src/ui/controllers/skills.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from "vitest"; +import { searchClawHub, type SkillsState } from "./skills.ts"; + +function createState(): { state: SkillsState; request: ReturnType } { + const request = vi.fn(); + const state: SkillsState = { + client: { + request, + } as unknown as SkillsState["client"], + connected: true, + skillsLoading: false, + skillsReport: null, + skillsError: null, + skillsBusyKey: null, + skillEdits: {}, + skillMessages: {}, + clawhubSearchQuery: "github", + clawhubSearchResults: [ + { + score: 0.9, + slug: "github", + displayName: "GitHub", + summary: "Previous result", + version: "1.0.0", + }, + ], + clawhubSearchLoading: false, + clawhubSearchError: "old error", + clawhubDetail: null, + clawhubDetailSlug: null, + clawhubDetailLoading: false, + clawhubDetailError: null, + clawhubInstallSlug: null, + clawhubInstallMessage: null, + }; + return { state, request }; +} + +describe("searchClawHub", () => { + it("clears stale results as soon as a new search starts", async () => { + const { state, request } = createState(); + type SearchResponse = { results: SkillsState["clawhubSearchResults"] }; + let resolveRequest: (value: SearchResponse) => void = () => { + throw new Error("expected search request promise to be pending"); + }; + request.mockImplementation( + () => + new Promise((resolve) => { + resolveRequest = resolve; + }), + ); + + const pending = searchClawHub(state, "github"); + + expect(state.clawhubSearchResults).toBeNull(); + expect(state.clawhubSearchLoading).toBe(true); + expect(state.clawhubSearchError).toBeNull(); + + resolveRequest({ + results: [ + { + score: 0.95, + slug: "github-new", + displayName: "GitHub New", + summary: "Fresh result", + version: "2.0.0", + }, + ], + }); + await pending; + + expect(state.clawhubSearchResults).toEqual([ + { + score: 0.95, + slug: "github-new", + displayName: "GitHub New", + summary: "Fresh result", + version: "2.0.0", + }, + ]); + expect(state.clawhubSearchLoading).toBe(false); + }); + + it("clears stale results when the query is emptied", async () => { + const { state, request } = createState(); + + await searchClawHub(state, " "); + + expect(request).not.toHaveBeenCalled(); + expect(state.clawhubSearchResults).toBeNull(); + expect(state.clawhubSearchError).toBeNull(); + expect(state.clawhubSearchLoading).toBe(false); + }); +}); diff --git a/ui/src/ui/controllers/skills.ts b/ui/src/ui/controllers/skills.ts index 4c6d5079d10..628db56f717 100644 --- a/ui/src/ui/controllers/skills.ts +++ b/ui/src/ui/controllers/skills.ts @@ -1,6 +1,40 @@ import type { GatewayBrowserClient } from "../gateway.ts"; import type { SkillStatusReport } from "../types.ts"; +export type ClawHubSearchResult = { + score: number; + slug: string; + displayName: string; + summary?: string; + version?: string; + updatedAt?: number; +}; + +export type ClawHubSkillDetail = { + skill: { + slug: string; + displayName: string; + summary?: string; + tags?: Record; + createdAt: number; + updatedAt: number; + } | null; + latestVersion?: { + version: string; + createdAt: number; + changelog?: string; + } | null; + metadata?: { + os?: string[] | null; + systems?: string[] | null; + } | null; + owner?: { + handle?: string | null; + displayName?: string | null; + image?: string | null; + } | null; +}; + export type SkillsState = { client: GatewayBrowserClient | null; connected: boolean; @@ -10,6 +44,16 @@ export type SkillsState = { skillsBusyKey: string | null; skillEdits: Record; skillMessages: SkillMessageMap; + clawhubSearchQuery: string; + clawhubSearchResults: ClawHubSearchResult[] | null; + clawhubSearchLoading: boolean; + clawhubSearchError: string | null; + clawhubDetail: ClawHubSkillDetail | null; + clawhubDetailSlug: string | null; + clawhubDetailLoading: boolean; + clawhubDetailError: string | null; + clawhubInstallSlug: string | null; + clawhubInstallMessage: { kind: "success" | "error"; text: string } | null; }; export type SkillMessage = { @@ -157,3 +201,89 @@ export async function installSkill( state.skillsBusyKey = null; } } + +export async function searchClawHub(state: SkillsState, query: string) { + if (!state.client || !state.connected) { + return; + } + if (!query.trim()) { + state.clawhubSearchResults = null; + state.clawhubSearchError = null; + state.clawhubSearchLoading = false; + return; + } + // Clear stale entries as soon as a new search begins so the UI cannot act on + // results that no longer match the current query while the next request is in flight. + state.clawhubSearchResults = null; + state.clawhubSearchLoading = true; + state.clawhubSearchError = null; + try { + const res = await state.client.request<{ results: ClawHubSearchResult[] }>("skills.search", { + query, + limit: 20, + }); + if (query !== state.clawhubSearchQuery) { + return; + } + state.clawhubSearchResults = res?.results ?? []; + } catch (err) { + if (query !== state.clawhubSearchQuery) { + return; + } + state.clawhubSearchError = getErrorMessage(err); + } finally { + if (query === state.clawhubSearchQuery) { + state.clawhubSearchLoading = false; + } + } +} + +export async function loadClawHubDetail(state: SkillsState, slug: string) { + if (!state.client || !state.connected) { + return; + } + state.clawhubDetailSlug = slug; + state.clawhubDetailLoading = true; + state.clawhubDetailError = null; + state.clawhubDetail = null; + try { + const res = await state.client.request("skills.detail", { slug }); + if (slug !== state.clawhubDetailSlug) { + return; + } + state.clawhubDetail = res ?? null; + } catch (err) { + if (slug !== state.clawhubDetailSlug) { + return; + } + state.clawhubDetailError = getErrorMessage(err); + } finally { + if (slug === state.clawhubDetailSlug) { + state.clawhubDetailLoading = false; + } + } +} + +export function closeClawHubDetail(state: SkillsState) { + state.clawhubDetailSlug = null; + state.clawhubDetail = null; + state.clawhubDetailError = null; + state.clawhubDetailLoading = false; +} + +export async function installFromClawHub(state: SkillsState, slug: string) { + if (!state.client || !state.connected) { + return; + } + state.clawhubInstallSlug = slug; + state.clawhubInstallMessage = null; + try { + await state.client.request("skills.install", { source: "clawhub", slug }); + await loadSkills(state); + state.clawhubInstallMessage = { kind: "success", text: `Installed ${slug}` }; + } catch (err) { + state.clawhubInstallMessage = { kind: "error", text: getErrorMessage(err) }; + } finally { + state.clawhubInstallSlug = null; + } +} diff --git a/ui/src/ui/views/skills.test.ts b/ui/src/ui/views/skills.test.ts index b0a78e98f92..f222d884cf8 100644 --- a/ui/src/ui/views/skills.test.ts +++ b/ui/src/ui/views/skills.test.ts @@ -1,3 +1,5 @@ +/* @vitest-environment jsdom */ + import { render } from "lit"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { SkillStatusEntry, SkillStatusReport } from "../types.ts"; @@ -5,6 +7,10 @@ import { renderSkills, type SkillsProps } from "./skills.ts"; const dialogRestores: Array<() => void> = []; +function normalizeText(node: Element | DocumentFragment): string { + return node.textContent?.replace(/\s+/g, " ").trim() ?? ""; +} + function createSkill(overrides: Partial = {}): SkillStatusEntry { return { name: "Repo Skill", @@ -57,6 +63,16 @@ function createProps(overrides: Partial = {}): SkillsProps { busyKey: null, messages: {}, detailKey: null, + clawhubQuery: "", + clawhubResults: null, + clawhubSearchLoading: false, + clawhubSearchError: null, + clawhubDetail: null, + clawhubDetailSlug: null, + clawhubDetailLoading: false, + clawhubDetailError: null, + clawhubInstallSlug: null, + clawhubInstallMessage: null, onFilterChange: () => undefined, onStatusFilterChange: () => undefined, onRefresh: () => undefined, @@ -66,6 +82,10 @@ function createProps(overrides: Partial = {}): SkillsProps { onInstall: () => undefined, onDetailOpen: () => undefined, onDetailClose: () => undefined, + onClawHubQueryChange: () => undefined, + onClawHubDetailOpen: () => undefined, + onClawHubDetailClose: () => undefined, + onClawHubInstall: () => undefined, ...overrides, }; } @@ -126,6 +146,107 @@ describe("renderSkills", () => { expect(onDetailClose).toHaveBeenCalledTimes(1); }); + + it("renders ClawHub search results and routes detail/install actions", async () => { + const container = document.createElement("div"); + const onClawHubDetailOpen = vi.fn(); + const onClawHubInstall = vi.fn(); + + render( + renderSkills( + createProps({ + clawhubQuery: "git", + clawhubResults: [ + { + score: 0.95, + slug: "github", + displayName: "GitHub", + summary: "GitHub integration for OpenClaw", + version: "1.2.3", + }, + ], + onClawHubDetailOpen, + onClawHubInstall, + }), + ), + container, + ); + await Promise.resolve(); + + const text = normalizeText(container); + expect(text).toContain("GitHub"); + expect(text).toContain("GitHub integration for OpenClaw"); + expect(text).toContain("v1.2.3"); + + container.querySelector(".list-item")?.click(); + container + .querySelector(".list-item .btn.btn--sm") + ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(onClawHubDetailOpen).toHaveBeenCalledTimes(1); + expect(onClawHubDetailOpen).toHaveBeenCalledWith("github"); + expect(onClawHubInstall).toHaveBeenCalledTimes(1); + expect(onClawHubInstall).toHaveBeenCalledWith("github"); + }); + + it("opens the ClawHub detail dialog and renders install feedback", async () => { + const container = document.createElement("div"); + const showModal = vi.fn(function (this: HTMLDialogElement) { + this.setAttribute("open", ""); + }); + const onClawHubInstall = vi.fn(); + installDialogMethod("showModal", showModal); + + render( + renderSkills( + createProps({ + clawhubSearchError: "rate limited", + clawhubInstallMessage: { kind: "success", text: "Installed github" }, + clawhubDetailSlug: "github", + clawhubDetail: { + skill: { + slug: "github", + displayName: "GitHub", + summary: "GitHub integration for OpenClaw", + createdAt: 1_700_000_000, + updatedAt: 1_700_000_100, + }, + latestVersion: { + version: "1.2.3", + createdAt: 1_700_000_200, + changelog: "Added search support", + }, + metadata: { + os: ["macos", "linux"], + }, + owner: { + displayName: "OpenClaw", + handle: "openclaw", + }, + }, + onClawHubInstall, + }), + ), + container, + ); + await Promise.resolve(); + + expect(showModal).toHaveBeenCalledTimes(1); + const text = normalizeText(container); + expect(text).toContain("rate limited"); + expect(text).toContain("Installed github"); + expect(text).toContain("By OpenClaw (@openclaw)"); + expect(text).toContain("Latest: v1.2.3"); + expect(text).toContain("Platforms: macos, linux"); + expect(text).toContain("Added search support"); + + container + .querySelector(".md-preview-dialog__body .btn.primary") + ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(onClawHubInstall).toHaveBeenCalledTimes(1); + expect(onClawHubInstall).toHaveBeenCalledWith("github"); + }); }); function installDialogMethod( diff --git a/ui/src/ui/views/skills.ts b/ui/src/ui/views/skills.ts index fa310e839b6..c5bae2d5121 100644 --- a/ui/src/ui/views/skills.ts +++ b/ui/src/ui/views/skills.ts @@ -1,6 +1,10 @@ import { html, nothing } from "lit"; import { ref } from "lit/directives/ref.js"; -import type { SkillMessageMap } from "../controllers/skills.ts"; +import type { + ClawHubSearchResult, + ClawHubSkillDetail, + SkillMessageMap, +} from "../controllers/skills.ts"; import { clampText } from "../format.ts"; import { resolveSafeExternalUrl } from "../open-external-url.ts"; import type { SkillStatusEntry, SkillStatusReport } from "../types.ts"; @@ -31,6 +35,16 @@ export type SkillsProps = { busyKey: string | null; messages: SkillMessageMap; detailKey: string | null; + clawhubQuery: string; + clawhubResults: ClawHubSearchResult[] | null; + clawhubSearchLoading: boolean; + clawhubSearchError: string | null; + clawhubDetail: ClawHubSkillDetail | null; + clawhubDetailSlug: string | null; + clawhubDetailLoading: boolean; + clawhubDetailError: string | null; + clawhubInstallSlug: string | null; + clawhubInstallMessage: { kind: "success" | "error"; text: string } | null; onFilterChange: (next: string) => void; onStatusFilterChange: (next: SkillsStatusFilter) => void; onRefresh: () => void; @@ -40,6 +54,10 @@ export type SkillsProps = { onInstall: (skillKey: string, name: string, installId: string) => void; onDetailOpen: (skillKey: string) => void; onDetailClose: () => void; + onClawHubQueryChange: (query: string) => void; + onClawHubDetailOpen: (slug: string) => void; + onClawHubDetailClose: () => void; + onClawHubInstall: (slug: string) => void; }; type StatusTabDef = { id: SkillsStatusFilter; label: string }; @@ -140,19 +158,11 @@ export function renderSkills(props: SkillsProps) { class="filters" style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 12px;" > - Browse Skills Store