fix(clawhub): search marketplace through official API (#1058)
This commit is contained in:
@@ -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));
|
||||
|
||||
27
harness/specs/tasks/fix-clawhub-cn-mirror-search.md
Normal file
27
harness/specs/tasks/fix-clawhub-cn-mirror-search.md
Normal 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.
|
||||
166
tests/unit/clawhub-service.test.ts
Normal file
166
tests/unit/clawhub-service.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user