From fbf99ae98c3f2fddfd64be0298d53f22d3085a45 Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 15 May 2026 16:57:49 +0000 Subject: [PATCH] feat(skills): add CRUD management methods to SkillsClient Implements installSkill, listInstalledSkills, getInstalledSkill, toggleSkill, uninstallSkill, refreshSkill, and getMarketplace methods on SkillsClient to interface with the new agent-server skills management endpoints added in OpenHands/software-agent-sdk#3231. New types added to src/models/api.ts: - InstallSkillRequest - InstalledSkillInfo / InstalledSkillSummary / InstalledSkillsResponse - ToggleSkillResponse - SkillActionResponse - RefreshSkillResponse - MarketplaceSkill / MarketplaceResponse All new types are re-exported from src/index.ts. Tests added for all seven new methods including URL encoding. Closes #163 Co-authored-by: openhands --- src/__tests__/api-clients.test.ts | 126 ++++++++++++++++++++++++++++++ src/client/skills-client.ts | 57 +++++++++++++- src/index.ts | 9 +++ src/models/api.ts | 52 ++++++++++++ 4 files changed, 243 insertions(+), 1 deletion(-) diff --git a/src/__tests__/api-clients.test.ts b/src/__tests__/api-clients.test.ts index 757dab2..7b040ef 100644 --- a/src/__tests__/api-clients.test.ts +++ b/src/__tests__/api-clients.test.ts @@ -94,6 +94,132 @@ describe('Auxiliary API clients', () => { ); }); + it('SkillsClient CRUD methods map to the correct endpoints', async () => { + const installedSkill = { + name: 'my-skill', + version: '1.0.0', + description: 'A test skill', + enabled: true, + source: '/tmp/my-skill', + installed_at: '2026-05-12T12:00:00Z', + install_path: '/home/.openhands/skills/installed/my-skill', + }; + const installedList = { skills: [{ name: 'my-skill', version: '1.0.0', enabled: true }] }; + const toggleResponse = { name: 'my-skill', enabled: false }; + const uninstallResponse = { message: "Skill 'my-skill' uninstalled" }; + const refreshResponse = { + message: "Skill 'my-skill' updated", + skill: { name: 'my-skill', version: '1.0.0', enabled: true }, + }; + const marketplaceResponse = { + skills: [ + { name: 'my-skill', description: 'desc', source: 'github:org/repo', installed: false }, + ], + }; + + const responses = [ + installedSkill, + installedList, + installedSkill, + toggleResponse, + uninstallResponse, + refreshResponse, + marketplaceResponse, + ]; + global.fetch = jest.fn().mockImplementation(() => { + const body = responses.shift(); + return Promise.resolve( + new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ); + }) as typeof fetch; + + const client = new SkillsClient({ host: 'http://example.com' }); + + const installed = await client.installSkill({ source: '/tmp/my-skill', force: false }); + expect(installed.name).toBe('my-skill'); + expect(installed.enabled).toBe(true); + + const list = await client.listInstalledSkills(); + expect(list.skills).toHaveLength(1); + expect(list.skills[0].name).toBe('my-skill'); + + const got = await client.getInstalledSkill('my-skill'); + expect(got.name).toBe('my-skill'); + + const toggled = await client.toggleSkill('my-skill', false); + expect(toggled.enabled).toBe(false); + + const uninstalled = await client.uninstallSkill('my-skill'); + expect(uninstalled.message).toContain('uninstalled'); + + const refreshed = await client.refreshSkill('my-skill'); + expect(refreshed.message).toContain('updated'); + expect(refreshed.skill.name).toBe('my-skill'); + + const marketplace = await client.getMarketplace(); + expect(marketplace.skills).toHaveLength(1); + expect(marketplace.skills[0].installed).toBe(false); + + expect(global.fetch).toHaveBeenNthCalledWith( + 1, + 'http://example.com/api/skills/install', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ source: '/tmp/my-skill', force: false }), + }) + ); + expect(global.fetch).toHaveBeenNthCalledWith( + 2, + 'http://example.com/api/skills/installed', + expect.objectContaining({ method: 'GET' }) + ); + expect(global.fetch).toHaveBeenNthCalledWith( + 3, + 'http://example.com/api/skills/installed/my-skill', + expect.objectContaining({ method: 'GET' }) + ); + expect(global.fetch).toHaveBeenNthCalledWith( + 4, + 'http://example.com/api/skills/installed/my-skill', + expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ enabled: false }) }) + ); + expect(global.fetch).toHaveBeenNthCalledWith( + 5, + 'http://example.com/api/skills/installed/my-skill', + expect.objectContaining({ method: 'DELETE' }) + ); + expect(global.fetch).toHaveBeenNthCalledWith( + 6, + 'http://example.com/api/skills/installed/my-skill/update', + expect.objectContaining({ method: 'POST' }) + ); + expect(global.fetch).toHaveBeenNthCalledWith( + 7, + 'http://example.com/api/skills/marketplace', + expect.objectContaining({ method: 'GET' }) + ); + }); + + it('SkillsClient percent-encodes skill names with special characters', async () => { + global.fetch = jest.fn().mockResolvedValue( + new Response(JSON.stringify({ name: 'my skill', enabled: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ) as typeof fetch; + + const client = new SkillsClient({ host: 'http://example.com' }); + await client.getInstalledSkill('my skill'); + + expect(global.fetch).toHaveBeenCalledWith( + 'http://example.com/api/skills/installed/my%20skill', + expect.objectContaining({ method: 'GET' }) + ); + }); + it('BashClient.startCommand normalizes string requests', async () => { global.fetch = jest.fn().mockResolvedValue( new Response( diff --git a/src/client/skills-client.ts b/src/client/skills-client.ts index 945f258..d3af0ab 100644 --- a/src/client/skills-client.ts +++ b/src/client/skills-client.ts @@ -1,5 +1,16 @@ import { HttpClient } from './http-client'; -import { SkillsRequest, SkillsResponse, SyncResponse } from '../models/api'; +import type { + InstallSkillRequest, + InstalledSkillInfo, + InstalledSkillsResponse, + MarketplaceResponse, + RefreshSkillResponse, + SkillActionResponse, + SkillsRequest, + SkillsResponse, + SyncResponse, + ToggleSkillResponse, +} from '../models/api'; export interface SkillsClientOptions { host: string; @@ -32,6 +43,50 @@ export class SkillsClient { return response.data; } + async installSkill(request: InstallSkillRequest): Promise { + const response = await this.client.post('/api/skills/install', request); + return response.data; + } + + async listInstalledSkills(): Promise { + const response = await this.client.get('/api/skills/installed'); + return response.data; + } + + async getInstalledSkill(skillName: string): Promise { + const response = await this.client.get( + `/api/skills/installed/${encodeURIComponent(skillName)}` + ); + return response.data; + } + + async toggleSkill(skillName: string, enabled: boolean): Promise { + const response = await this.client.patch( + `/api/skills/installed/${encodeURIComponent(skillName)}`, + { enabled } + ); + return response.data; + } + + async uninstallSkill(skillName: string): Promise { + const response = await this.client.delete( + `/api/skills/installed/${encodeURIComponent(skillName)}` + ); + return response.data; + } + + async refreshSkill(skillName: string): Promise { + const response = await this.client.post( + `/api/skills/installed/${encodeURIComponent(skillName)}/update` + ); + return response.data; + } + + async getMarketplace(): Promise { + const response = await this.client.get('/api/skills/marketplace'); + return response.data; + } + close(): void { this.client.close(); } diff --git a/src/index.ts b/src/index.ts index d697a78..72417d3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -244,6 +244,15 @@ export type { SkillInfo, SkillsResponse, SyncResponse, + InstallSkillRequest, + InstalledSkillInfo, + InstalledSkillSummary, + InstalledSkillsResponse, + ToggleSkillResponse, + SkillActionResponse, + RefreshSkillResponse, + MarketplaceSkill, + MarketplaceResponse, DesktopUrlResponse, VSCodeUrlResponse, VSCodeStatusResponse, diff --git a/src/models/api.ts b/src/models/api.ts index b699ddc..80331ab 100644 --- a/src/models/api.ts +++ b/src/models/api.ts @@ -78,6 +78,58 @@ export interface SyncResponse { message: string; } +export interface InstallSkillRequest { + source: string; + force?: boolean; + ref?: string | null; + repo_path?: string | null; +} + +export interface InstalledSkillInfo { + name: string; + version?: string | null; + description?: string | null; + enabled: boolean; + source?: string | null; + installed_at?: string | null; + install_path?: string | null; +} + +export interface InstalledSkillSummary { + name: string; + version?: string | null; + enabled: boolean; +} + +export interface InstalledSkillsResponse { + skills: InstalledSkillSummary[]; +} + +export interface ToggleSkillResponse { + name: string; + enabled: boolean; +} + +export interface SkillActionResponse { + message: string; +} + +export interface RefreshSkillResponse { + message: string; + skill: InstalledSkillSummary; +} + +export interface MarketplaceSkill { + name: string; + description: string; + source: string; + installed: boolean; +} + +export interface MarketplaceResponse { + skills: MarketplaceSkill[]; +} + export interface DesktopUrlResponse { url: string | null; }