skillhub/skillhub-cli/src/core/api-client.ts
chenbaowang 6c0d4f4f16 feat(skillhub-cli): add skillhub CLI with interactive multi-agent support
- Interactive agent selection for install/uninstall/list
- Namespace search selection for unspecified namespace
- --skill-version option to skip interactive selection
- --from option for local/github source installation
- Remove deprecated add command (use install --from instead)
- Path-based grouping for uninstall results
2026-04-14 10:21:09 +08:00

131 lines
3.7 KiB
TypeScript

import { request, FormData as UndiciFormData } from "undici";
export interface ApiClientOptions {
baseUrl: string;
token?: string;
}
interface NativeApiResponse<T> {
code: number;
msg: string;
data: T;
timestamp: string;
}
export class ApiClient {
constructor(private options: ApiClientOptions) {}
/**
* Unwrap Native API response format:
* { code: 0, msg: "success", data: T } -> returns T
* { code: non-zero, msg: "error", data: null } -> throws ApiError
*
* Pass-through Compat API format (no code field):
* { user: {...} } -> returns as-is
* { results: [...] } -> returns as-is
*/
private unwrapResponse<T>(data: unknown): T {
// Check if it's a Native API response (has code field)
if (typeof data === "object" && data !== null && "code" in data) {
const native = data as NativeApiResponse<unknown>;
if (native.code !== 0) {
throw new ApiError(native.code, native);
}
return native.data as T;
}
// Otherwise it's a Compat API response, return as-is
return data as T;
}
async get<T>(path: string): Promise<T> {
const url = new URL(path, this.options.baseUrl);
const { statusCode, body } = await request(url.toString(), {
method: "GET",
headers: this.headers(),
});
const data = await body.json();
if (statusCode >= 400) {
throw new ApiError(statusCode, data);
}
return this.unwrapResponse<T>(data);
}
async postForm<T>(path: string, form: UndiciFormData, queryParams?: Record<string, string>): Promise<T> {
const url = new URL(path, this.options.baseUrl);
if (queryParams) {
for (const [k, v] of Object.entries(queryParams)) {
url.searchParams.set(k, v);
}
}
const { statusCode, body } = await request(url.toString(), {
method: "POST",
headers: {
...this.headers(),
},
body: form,
});
const data = await body.json();
if (statusCode >= 400) {
throw new ApiError(statusCode, data);
}
return this.unwrapResponse<T>(data);
}
async post<T>(path: string, opts?: { body?: string; headers?: Record<string, string> }): Promise<T> {
const url = new URL(path, this.options.baseUrl);
const { statusCode, body } = await request(url.toString(), {
method: "POST",
headers: { ...this.headers(), ...opts?.headers },
body: opts?.body,
});
const data = await body.json();
if (statusCode >= 400) {
throw new ApiError(statusCode, data);
}
return this.unwrapResponse<T>(data);
}
async put<T>(path: string, opts?: { body?: string; headers?: Record<string, string> }): Promise<T> {
const url = new URL(path, this.options.baseUrl);
const { statusCode, body } = await request(url.toString(), {
method: "PUT",
headers: { ...this.headers(), ...opts?.headers },
body: opts?.body,
});
const data = await body.json();
if (statusCode >= 400) {
throw new ApiError(statusCode, data);
}
return this.unwrapResponse<T>(data);
}
async delete<T>(path: string): Promise<T> {
const url = new URL(path, this.options.baseUrl);
const { statusCode, body } = await request(url.toString(), {
method: "DELETE",
headers: this.headers(),
});
const data = await body.json();
if (statusCode >= 400) {
throw new ApiError(statusCode, data);
}
return this.unwrapResponse<T>(data);
}
private headers(): Record<string, string> {
const h: Record<string, string> = {};
if (this.options.token) {
h["Authorization"] = `Bearer ${this.options.token}`;
}
return h;
}
}
export class ApiError extends Error {
constructor(
public statusCode: number,
public body: unknown,
) {
super(`API error ${statusCode}: ${JSON.stringify(body)}`);
}
}