From 21c1ba2e33a5fa4f6078b6634b52832d5dc32c1f Mon Sep 17 00:00:00 2001 From: Lingxuan Zuo Date: Fri, 22 May 2026 22:31:19 +0800 Subject: [PATCH] fix(clawhub): search marketplace through official API (#1058) --- electron/gateway/clawhub.ts | 100 +++++++++++ .../tasks/fix-clawhub-cn-mirror-search.md | 27 +++ tests/unit/clawhub-service.test.ts | 166 ++++++++++++++++++ 3 files changed, 293 insertions(+) create mode 100644 harness/specs/tasks/fix-clawhub-cn-mirror-search.md create mode 100644 tests/unit/clawhub-service.test.ts diff --git a/electron/gateway/clawhub.ts b/electron/gateway/clawhub.ts index 9714d04..dca3769 100644 --- a/electron/gateway/clawhub.ts +++ b/electron/gateway/clawhub.ts @@ -40,12 +40,33 @@ export interface ClawHubInstalledSkillResult { baseDir?: string; } +interface ClawHubApiSearchEntry { + slug?: string; + displayName?: string; + summary?: string | null; + version?: string | null; + latestVersion?: { + version?: string | null; + } | null; +} + +interface ClawHubApiSearchResponse { + results?: ClawHubApiSearchEntry[]; +} + +interface ClawHubApiListResponse { + items?: ClawHubApiSearchEntry[]; +} + export interface MarketplaceProvider { getCapability(): Promise<{ mode: string; canSearch: boolean; canInstall: boolean; reason?: string }>; search(params: ClawHubSearchParams): Promise; install(params: ClawHubInstallParams): Promise; } +const CLAWHUB_MARKETPLACE_API_BASE_URL = 'https://clawhub.ai'; +const CLAWHUB_MARKETPLACE_TIMEOUT_MS = 5_000; + export class ClawHubService { private workDir: string; private cliPath: string; @@ -92,6 +113,73 @@ export class ClawHubService { return line.replace(this.ansiRegex, '').trim(); } + private mapMarketplaceEntry(entry: ClawHubApiSearchEntry): ClawHubSkillResult | null { + const slug = entry.slug?.trim(); + if (!slug) return null; + + const name = entry.displayName?.trim() || slug; + const description = entry.summary?.trim() || name; + const version = entry.version?.trim() || entry.latestVersion?.version?.trim() || 'latest'; + + return { + slug, + name, + description, + version, + }; + } + + private async fetchMarketplaceJson(url: URL): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), CLAWHUB_MARKETPLACE_TIMEOUT_MS); + + try { + const response = await fetch(url.toString(), { + method: 'GET', + headers: { Accept: 'application/json' }, + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error(`ClawHub marketplace HTTP ${response.status}`); + } + + const contentType = response.headers.get('content-type')?.toLowerCase() || ''; + if (!contentType.includes('application/json')) { + throw new Error(`ClawHub marketplace returned non-JSON content: ${contentType || 'unknown'}`); + } + + return await response.json() as T; + } finally { + clearTimeout(timeout); + } + } + + private async searchMarketplaceHttp(params: ClawHubSearchParams): Promise { + const url = new URL('/api/v1/search', CLAWHUB_MARKETPLACE_API_BASE_URL); + url.searchParams.set('q', params.query); + if (params.limit) { + url.searchParams.set('limit', String(params.limit)); + } + + const response = await this.fetchMarketplaceJson(url); + return (response.results || []) + .map((entry) => this.mapMarketplaceEntry(entry)) + .filter((entry): entry is ClawHubSkillResult => entry !== null); + } + + private async exploreMarketplaceHttp(params: { limit?: number } = {}): Promise { + const url = new URL('/api/v1/skills', CLAWHUB_MARKETPLACE_API_BASE_URL); + if (params.limit) { + url.searchParams.set('limit', String(params.limit)); + } + + const response = await this.fetchMarketplaceJson(url); + return (response.items || []) + .map((entry) => this.mapMarketplaceEntry(entry)) + .filter((entry): entry is ClawHubSkillResult => entry !== null); + } + private extractFrontmatterName(skillManifestPath: string): string | null { try { const raw = fs.readFileSync(skillManifestPath, 'utf8'); @@ -225,6 +313,12 @@ export class ClawHubService { return this.explore({ limit: params.limit }); } + try { + return await this.searchMarketplaceHttp(params); + } catch (httpError) { + console.warn('ClawHub HTTP search failed, falling back to CLI:', httpError); + } + const args = ['search', params.query]; if (params.limit) { args.push('--limit', String(params.limit)); @@ -288,6 +382,12 @@ export class ClawHubService { */ async explore(params: { limit?: number } = {}): Promise { try { + try { + return await this.exploreMarketplaceHttp(params); + } catch (httpError) { + console.warn('ClawHub HTTP explore failed, falling back to CLI:', httpError); + } + const args = ['explore']; if (params.limit) { args.push('--limit', String(params.limit)); diff --git a/harness/specs/tasks/fix-clawhub-cn-mirror-search.md b/harness/specs/tasks/fix-clawhub-cn-mirror-search.md new file mode 100644 index 0000000..81b8ec7 --- /dev/null +++ b/harness/specs/tasks/fix-clawhub-cn-mirror-search.md @@ -0,0 +1,27 @@ +--- +id: fix-clawhub-cn-mirror-search +title: Keep ClawHub marketplace search usable when cached mirror APIs fail +scenario: plugin-lifecycle-management +taskType: plugin-lifecycle +intent: Fix issue #960 so Skills marketplace search and explore do not fail when the ClawHub CLI is pointed at a static mirror or stale registry cache. +touchedAreas: + - electron/gateway/clawhub.ts + - tests/unit/clawhub-service.test.ts + - harness/specs/tasks/fix-clawhub-cn-mirror-search.md +expectedUserBehavior: + - Opening Skills Explore returns marketplace entries when the official ClawHub JSON API is reachable. + - Searching skills returns marketplace entries without depending on a cached static mirror registry. + - If the official JSON API is unavailable or returns non-JSON, ClawX falls back to the existing ClawHub CLI behavior. +requiredProfiles: + - fast +requiredTests: + - tests/unit/clawhub-service.test.ts +acceptance: + - Search uses the official ClawHub JSON API before invoking the CLI fallback. + - Explore uses the official ClawHub JSON API before invoking the CLI fallback. + - Non-JSON or failed HTTP responses do not break the existing CLI fallback path. +docs: + required: false +--- + +Use this task spec when changing ClawHub marketplace lookup behavior in the main process. diff --git a/tests/unit/clawhub-service.test.ts b/tests/unit/clawhub-service.test.ts new file mode 100644 index 0000000..ce58c63 --- /dev/null +++ b/tests/unit/clawhub-service.test.ts @@ -0,0 +1,166 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ClawHubService } from '@electron/gateway/clawhub'; + +describe('ClawHubService marketplace HTTP lookup', () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.stubGlobal('fetch', vi.fn()); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('uses the official ClawHub search API before the CLI fallback', async () => { + const service = new ClawHubService(); + const runCommand = vi.spyOn(service as unknown as { runCommand(args: string[]): Promise }, 'runCommand'); + const fetchMock = vi.mocked(fetch); + + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + results: [{ + slug: 'pdf', + displayName: 'PDF Tools', + summary: 'Read and transform PDFs', + version: '1.2.3', + score: 4.25, + }], + }), { + headers: { 'content-type': 'application/json' }, + })); + + const result = await service.search({ query: 'pdf', limit: 5 }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const requestedUrl = new URL(String(fetchMock.mock.calls[0][0])); + expect(requestedUrl.origin).toBe('https://clawhub.ai'); + expect(requestedUrl.pathname).toBe('/api/v1/search'); + expect(requestedUrl.searchParams.get('q')).toBe('pdf'); + expect(requestedUrl.searchParams.get('limit')).toBe('5'); + expect(runCommand).not.toHaveBeenCalled(); + expect(result).toEqual([{ + slug: 'pdf', + name: 'PDF Tools', + description: 'Read and transform PDFs', + version: '1.2.3', + }]); + }); + + it('uses the official ClawHub skills API for explore before the CLI fallback', async () => { + const service = new ClawHubService(); + const runCommand = vi.spyOn(service as unknown as { runCommand(args: string[]): Promise }, 'runCommand'); + const fetchMock = vi.mocked(fetch); + + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + items: [{ + slug: 'writer', + displayName: 'Writer', + summary: 'Draft structured documents', + latestVersion: { version: '2.0.0' }, + }], + }), { + headers: { 'content-type': 'application/json' }, + })); + + const result = await service.explore({ limit: 3 }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const requestedUrl = new URL(String(fetchMock.mock.calls[0][0])); + expect(requestedUrl.origin).toBe('https://clawhub.ai'); + expect(requestedUrl.pathname).toBe('/api/v1/skills'); + expect(requestedUrl.searchParams.get('limit')).toBe('3'); + expect(runCommand).not.toHaveBeenCalled(); + expect(result).toEqual([{ + slug: 'writer', + name: 'Writer', + description: 'Draft structured documents', + version: '2.0.0', + }]); + }); + + it('falls back to the CLI when the HTTP marketplace response is not JSON', async () => { + const service = new ClawHubService(); + const runCommand = vi + .spyOn(service as unknown as { runCommand(args: string[]): Promise }, 'runCommand') + .mockResolvedValueOnce('pdf v1.0.0 PDF toolkit (3.500)'); + const fetchMock = vi.mocked(fetch); + + fetchMock.mockResolvedValueOnce(new Response('', { + headers: { 'content-type': 'text/html' }, + })); + + const result = await service.search({ query: 'pdf', limit: 1 }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(runCommand).toHaveBeenCalledWith(['search', 'pdf', '--limit', '1']); + expect(result).toEqual([{ + slug: 'pdf', + name: 'pdf', + description: 'PDF toolkit', + version: '1.0.0', + }]); + }); + + it('falls back to the CLI when the HTTP marketplace request fails', async () => { + const service = new ClawHubService(); + const runCommand = vi + .spyOn(service as unknown as { runCommand(args: string[]): Promise }, 'runCommand') + .mockResolvedValueOnce('pdf v1.0.0 PDF toolkit'); + const fetchMock = vi.mocked(fetch); + + fetchMock.mockRejectedValueOnce(new Error('ECONNRESET')); + + const result = await service.search({ query: 'pdf' }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(runCommand).toHaveBeenCalledWith(['search', 'pdf']); + expect(result[0]).toMatchObject({ + slug: 'pdf', + description: 'PDF toolkit', + version: '1.0.0', + }); + }); + + it('falls back to the CLI when the HTTP marketplace request is aborted', async () => { + const service = new ClawHubService(); + const runCommand = vi + .spyOn(service as unknown as { runCommand(args: string[]): Promise }, 'runCommand') + .mockResolvedValueOnce('pdf v1.0.0 PDF toolkit'); + const fetchMock = vi.mocked(fetch); + + fetchMock.mockRejectedValueOnce(new DOMException('The operation was aborted', 'AbortError')); + + const result = await service.search({ query: 'pdf' }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(runCommand).toHaveBeenCalledWith(['search', 'pdf']); + expect(result[0]).toMatchObject({ + slug: 'pdf', + description: 'PDF toolkit', + version: '1.0.0', + }); + }); + + it('falls back to the CLI when the HTTP marketplace returns a server error', async () => { + const service = new ClawHubService(); + const runCommand = vi + .spyOn(service as unknown as { runCommand(args: string[]): Promise }, 'runCommand') + .mockResolvedValueOnce('pdf v1.0.0 PDF toolkit'); + const fetchMock = vi.mocked(fetch); + + fetchMock.mockResolvedValueOnce(new Response('temporary failure', { + status: 503, + headers: { 'content-type': 'text/plain' }, + })); + + const result = await service.search({ query: 'pdf' }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(runCommand).toHaveBeenCalledWith(['search', 'pdf']); + expect(result[0]).toMatchObject({ + slug: 'pdf', + description: 'PDF toolkit', + version: '1.0.0', + }); + }); +});