Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions packages/clawdhub/src/cli/commands/inspect.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { apiRequest, fetchText } from '../../http.js'
import { apiRequest, fetchText, registryUrl } from '../../http.js'
import {
ApiRoutes,
ApiV1SkillResponseSchema,
Expand Down Expand Up @@ -78,7 +78,7 @@ export async function cmdInspect(opts: GlobalOpts, slug: string, options: Inspec
let versionsList: { items?: unknown[]; nextCursor?: string | null } | null = null
if (options.versions) {
const limit = clampLimit(options.limit ?? 25, 25)
const url = new URL(`${ApiRoutes.skills}/${encodeURIComponent(trimmed)}/versions`, registry)
const url = registryUrl(`${ApiRoutes.skills}/${encodeURIComponent(trimmed)}/versions`, registry)
url.searchParams.set('limit', String(limit))
spinner.text = `Fetching versions (${limit})`
versionsList = await apiRequest(
Expand All @@ -90,7 +90,7 @@ export async function cmdInspect(opts: GlobalOpts, slug: string, options: Inspec

let fileContent: string | null = null
if (options.file) {
const url = new URL(`${ApiRoutes.skills}/${encodeURIComponent(trimmed)}/file`, registry)
const url = registryUrl(`${ApiRoutes.skills}/${encodeURIComponent(trimmed)}/file`, registry)
url.searchParams.set('path', options.file)
if (options.version) {
url.searchParams.set('version', options.version)
Expand Down
6 changes: 3 additions & 3 deletions packages/clawdhub/src/cli/commands/moderation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { isCancel, select } from '@clack/prompts'
import { apiRequest } from '../../http.js'
import { apiRequest, registryUrl } from '../../http.js'
import {
ApiRoutes,
ApiV1BanUserResponseSchema,
Expand Down Expand Up @@ -192,12 +192,12 @@ async function resolveUserIdentifier(
}

async function searchUsers(registry: string, token: string, query: string) {
const url = new URL(ApiRoutes.users, registry)
const url = registryUrl(ApiRoutes.users, registry)
url.searchParams.set('q', query.trim())
url.searchParams.set('limit', '10')
const result = await apiRequest(
registry,
{ method: 'GET', path: `${url.pathname}?${url.searchParams.toString()}`, token },
{ method: 'GET', url: url.toString(), token },
ApiV1UserSearchResponseSchema,
)
return parseArk(ApiV1UserSearchResponseSchema, result, 'User search response')
Expand Down
8 changes: 4 additions & 4 deletions packages/clawdhub/src/cli/commands/skills.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { mkdir, rm, stat } from 'node:fs/promises'
import { join } from 'node:path'
import semver from 'semver'
import { apiRequest, downloadZip } from '../../http.js'
import { apiRequest, downloadZip, registryUrl } from '../../http.js'
import {
ApiRoutes,
ApiV1SearchResponseSchema,
Expand Down Expand Up @@ -43,7 +43,7 @@ export async function cmdSearch(opts: GlobalOpts, query: string, limit?: number)
const registry = await getRegistry(opts, { cache: true })
const spinner = createSpinner('Searching')
try {
const url = new URL(ApiRoutes.search, registry)
const url = registryUrl(ApiRoutes.search, registry)
url.searchParams.set('q', query)
if (typeof limit === 'number' && Number.isFinite(limit)) {
url.searchParams.set('limit', String(limit))
Expand Down Expand Up @@ -363,7 +363,7 @@ export async function cmdExplore(
const registry = await getRegistry(opts, { cache: true })
const spinner = createSpinner('Fetching latest skills')
try {
const url = new URL(ApiRoutes.skills, registry)
const url = registryUrl(ApiRoutes.skills, registry)
const boundedLimit = clampLimit(options.limit ?? 25)
const { apiSort } = resolveExploreSort(options.sort)
url.searchParams.set('limit', String(boundedLimit))
Expand Down Expand Up @@ -465,7 +465,7 @@ function resolveExploreSort(raw?: string): { sort: ExploreSort; apiSort: ApiExpl
}

async function resolveSkillVersion(registry: string, slug: string, hash: string, token?: string) {
const url = new URL(ApiRoutes.resolve, registry)
const url = registryUrl(ApiRoutes.resolve, registry)
url.searchParams.set('slug', slug)
url.searchParams.set('hash', hash)
return apiRequest(
Expand Down
38 changes: 37 additions & 1 deletion packages/clawdhub/src/http.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* @vitest-environment node */

import { describe, expect, it, vi } from 'vitest'
import { apiRequest, apiRequestForm, downloadZip, fetchText } from './http'
import { apiRequest, apiRequestForm, downloadZip, fetchText, registryUrl } from './http'
import { ApiV1WhoamiResponseSchema } from './schema/index.js'

function mockImmediateTimeouts() {
Expand Down Expand Up @@ -36,6 +36,42 @@ function createAbortingFetchMock() {
})
}

describe('registryUrl', () => {
it('works with a plain-origin registry (no base path)', () => {
expect(registryUrl('/api/v1/skills', 'https://clawhub.ai').toString()).toBe(
'https://clawhub.ai/api/v1/skills',
)
})

it('preserves the registry base path', () => {
const base = 'http://localhost:8081/custom/registry/path'
expect(registryUrl('/api/v1/skills', base).toString()).toBe(
'http://localhost:8081/custom/registry/path/api/v1/skills',
)
})

it('handles a trailing slash on the registry', () => {
const base = 'http://localhost:8081/custom/registry/path/'
expect(registryUrl('/api/v1/skills', base).toString()).toBe(
'http://localhost:8081/custom/registry/path/api/v1/skills',
)
})

it('handles paths without a leading slash', () => {
expect(registryUrl('api/v1/skills', 'https://clawhub.ai').toString()).toBe(
'https://clawhub.ai/api/v1/skills',
)
})

it('handles compound paths with encoded segments', () => {
const base = 'http://localhost:8081/base'
const path = `/api/v1/skills/${encodeURIComponent('my-skill')}/versions`
expect(registryUrl(path, base).toString()).toBe(
'http://localhost:8081/base/api/v1/skills/my-skill/versions',
)
})
})

describe('apiRequest', () => {
it('adds bearer token and parses json', async () => {
const fetchMock = vi.fn().mockResolvedValue({
Expand Down
21 changes: 17 additions & 4 deletions packages/clawdhub/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ import { Agent, setGlobalDispatcher } from 'undici'
import type { ArkValidator } from './schema/index.js'
import { ApiRoutes, parseArk } from './schema/index.js'

/**
* Joins a path onto a registry base URL, preserving the base's path component.
*
* `new URL('/api/v1/skills', 'http://host/base')` discards `/base` because
* the path is absolute. This helper normalises both sides so the result is
* `http://host/base/api/v1/skills`.
*/
export function registryUrl(path: string, registry: string): URL {
const base = registry.endsWith('/') ? registry : `${registry}/`
const relative = path.startsWith('/') ? path.slice(1) : path
return new URL(relative, base)
}

const REQUEST_TIMEOUT_MS = 15_000
const REQUEST_TIMEOUT_SECONDS = Math.ceil(REQUEST_TIMEOUT_MS / 1000)
const isBun = typeof process !== 'undefined' && Boolean(process.versions?.bun)
Expand Down Expand Up @@ -38,7 +51,7 @@ export async function apiRequest<T>(
args: RequestArgs,
schema?: ArkValidator<T>,
): Promise<T> {
const url = 'url' in args ? args.url : new URL(args.path, registry).toString()
const url = 'url' in args ? args.url : registryUrl(args.path, registry).toString()
const json = await pRetry(
async () => {
if (isBun) {
Expand Down Expand Up @@ -83,7 +96,7 @@ export async function apiRequestForm<T>(
args: FormRequestArgs,
schema?: ArkValidator<T>,
): Promise<T> {
const url = 'url' in args ? args.url : new URL(args.path, registry).toString()
const url = 'url' in args ? args.url : registryUrl(args.path, registry).toString()
const json = await pRetry(
async () => {
if (isBun) {
Expand Down Expand Up @@ -111,7 +124,7 @@ export async function apiRequestForm<T>(
type TextRequestArgs = { path: string; token?: string } | { url: string; token?: string }

export async function fetchText(registry: string, args: TextRequestArgs): Promise<string> {
const url = 'url' in args ? args.url : new URL(args.path, registry).toString()
const url = 'url' in args ? args.url : registryUrl(args.path, registry).toString()
return pRetry(
async () => {
if (isBun) {
Expand All @@ -135,7 +148,7 @@ export async function downloadZip(
registry: string,
args: { slug: string; version?: string; token?: string },
) {
const url = new URL(ApiRoutes.download, registry)
const url = registryUrl(ApiRoutes.download, registry)
url.searchParams.set('slug', args.slug)
if (args.version) url.searchParams.set('version', args.version)
return pRetry(
Expand Down