fix(clawhub): search marketplace through official API (#1058)

This commit is contained in:
Lingxuan Zuo
2026-05-22 22:31:19 +08:00
committed by GitHub
parent d596ed5b50
commit 21c1ba2e33
3 changed files with 293 additions and 0 deletions

View File

@@ -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<ClawHubSkillResult[]>;
install(params: ClawHubInstallParams): Promise<void>;
}
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<T>(url: URL): Promise<T> {
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<ClawHubSkillResult[]> {
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<ClawHubApiSearchResponse>(url);
return (response.results || [])
.map((entry) => this.mapMarketplaceEntry(entry))
.filter((entry): entry is ClawHubSkillResult => entry !== null);
}
private async exploreMarketplaceHttp(params: { limit?: number } = {}): Promise<ClawHubSkillResult[]> {
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<ClawHubApiListResponse>(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<ClawHubSkillResult[]> {
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));

View File

@@ -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.

View File

@@ -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<string> }, '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<string> }, '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<string> }, 'runCommand')
.mockResolvedValueOnce('pdf v1.0.0 PDF toolkit (3.500)');
const fetchMock = vi.mocked(fetch);
fetchMock.mockResolvedValueOnce(new Response('<!doctype html><html></html>', {
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<string> }, '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<string> }, '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<string> }, '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',
});
});
});