Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
- Skills/Web: centralize public visibility checks and keep `globalStats` skill counts in sync incrementally; remove duplicate `/skills` default-sort fallback and share browse test mocks (thanks @rknoche6, #76).
- Moderation: clear stale `flagged.suspicious` flags when VirusTotal rescans improve to clean verdicts (#418) (thanks @Phineas1500).
- CLI: respect `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` env vars for outbound registry requests, with troubleshooting docs (#363) (thanks @kerrypotter).
- CLI: preserve registry base paths when composing API URLs for search/inspect/moderation commands (#486) (thanks @Liknox).

## 0.6.1 - 2026-02-13

Expand Down
6 changes: 6 additions & 0 deletions packages/clawdhub/src/cli/commands/inspect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,15 @@ import type { GlobalOpts } from '../types'

const mockApiRequest = vi.fn()
const mockFetchText = vi.fn()
const mockRegistryUrl = vi.fn((path: string, registry: string) => {
const base = registry.endsWith('/') ? registry : `${registry}/`
const relative = path.startsWith('/') ? path.slice(1) : path
return new URL(relative, base)
})
vi.mock('../../http.js', () => ({
apiRequest: (...args: unknown[]) => mockApiRequest(...args),
fetchText: (...args: unknown[]) => mockFetchText(...args),
registryUrl: (...args: [string, string]) => mockRegistryUrl(...args),
}))

const mockGetRegistry = vi.fn(async () => 'https://clawhub.ai')
Expand Down
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
8 changes: 7 additions & 1 deletion packages/clawdhub/src/cli/commands/moderation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,15 @@ vi.mock('../registry.js', () => ({
}))

const mockApiRequest = vi.fn()
const mockRegistryUrl = vi.fn((path: string, registry: string) => {
const base = registry.endsWith('/') ? registry : `${registry}/`
const relative = path.startsWith('/') ? path.slice(1) : path
return new URL(relative, base)
})
vi.mock('../../http.js', () => ({
apiRequest: (registry: unknown, args: unknown, schema?: unknown) =>
mockApiRequest(registry, args, schema),
registryUrl: (...args: [string, string]) => mockRegistryUrl(...args),
}))

vi.mock('../ui.js', () => ({
Expand Down Expand Up @@ -116,7 +122,7 @@ describe('cmdBanUser', () => {
expect.anything(),
expect.objectContaining({
method: 'GET',
path: expect.stringContaining('/api/v1/users?'),
url: expect.stringContaining('/api/v1/users?'),
}),
expect.anything(),
)
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
6 changes: 6 additions & 0 deletions packages/clawdhub/src/cli/commands/skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,15 @@ import type { GlobalOpts } from '../types'

const mockApiRequest = vi.fn()
const mockDownloadZip = vi.fn()
const mockRegistryUrl = vi.fn((path: string, registry: string) => {
const base = registry.endsWith('/') ? registry : `${registry}/`
const relative = path.startsWith('/') ? path.slice(1) : path
return new URL(relative, base)
})
vi.mock('../../http.js', () => ({
apiRequest: (...args: unknown[]) => mockApiRequest(...args),
downloadZip: (...args: unknown[]) => mockDownloadZip(...args),
registryUrl: (...args: [string, string]) => mockRegistryUrl(...args),
}))

const mockGetRegistry = vi.fn(async () => 'https://clawhub.ai')
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
45 changes: 44 additions & 1 deletion packages/clawdhub/src/http.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
/* @vitest-environment node */

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

function mockImmediateTimeouts() {
Expand Down Expand Up @@ -65,6 +72,42 @@ describe('shouldUseProxyFromEnv', () => {
})
})

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
14 changes: 10 additions & 4 deletions packages/clawdhub/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ if (typeof process !== 'undefined' && process.versions?.node) {
}
}

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)
}

type RequestArgs =
| { method: 'GET' | 'POST' | 'DELETE'; path: string; token?: string; body?: unknown }
| { method: 'GET' | 'POST' | 'DELETE'; url: string; token?: string; body?: unknown }
Expand Down Expand Up @@ -84,7 +90,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 runWithRetries(
async () => {
if (isBun) {
Expand Down Expand Up @@ -128,7 +134,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 runWithRetries(
async () => {
if (isBun) {
Expand All @@ -155,7 +161,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 runWithRetries(
async () => {
if (isBun) {
Expand All @@ -178,7 +184,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 runWithRetries(
Expand Down