Compare commits

...
Author SHA1 Message Date
Peter Steinberger ca70633b92 fix: harden explore limit + tests/docs (#14) (thanks @jdrhyne) 2026-01-18 16:25:18 +00:00
Jonathan Rhyne b8f2e4d7bf feat(cli): add explore command to browse latest updated skills
Adds a new `clawdhub explore` command that fetches the most recently
updated skills from the registry, sorted by updatedAt descending.

Usage:
  clawdhub explore           # Show latest 25 skills
  clawdhub explore --limit 10

Output includes slug, version, relative time since update, and summary.

The API endpoint already exists and returns skills sorted by updatedAt,
this just exposes it via the CLI.
2026-01-18 16:21:14 +00:00
5 changed files with 192 additions and 1 deletions
+1
View File
@@ -5,6 +5,7 @@
### Added
- Web: dynamic OG image cards for skills (name, description, version).
- CLI: auto-scan Clawdbot skill roots (per-agent workspaces, shared skills, extraDirs).
- CLI: add `explore` command for latest updates, with limit clamping + tests (thanks @jdrhyne, #14).
- Web: import skills from public GitHub URLs (auto-detect `SKILL.md`, smart file selection, provenance).
- Web/API: SoulHub (SOUL.md registry) with v1 endpoints and first-run auto-seed.
+7
View File
@@ -51,6 +51,13 @@ Stores your API token + cached registry URL.
- Calls `/api/v1/search?q=...`.
### `explore`
- Lists latest updated skills via `/api/v1/skills?limit=...` (sorted by `updatedAt` desc).
- Flags:
- `--limit <n>` (150, default: 25)
- Output: `<slug> v<version> <age> <summary>` (summary truncated to 50 chars).
### `install <slug>`
- Resolves latest version via `/api/v1/skills/<slug>`.
+17 -1
View File
@@ -7,7 +7,7 @@ import { resolveClawdbotDefaultWorkspace } from './cli/clawdbotConfig.js'
import { cmdLoginFlow, cmdLogout, cmdWhoami } from './cli/commands/auth.js'
import { cmdDeleteSkill, cmdUndeleteSkill } from './cli/commands/delete.js'
import { cmdPublish } from './cli/commands/publish.js'
import { cmdInstall, cmdList, cmdSearch, cmdUpdate } from './cli/commands/skills.js'
import { cmdExplore, cmdInstall, cmdList, cmdSearch, cmdUpdate } from './cli/commands/skills.js'
import { cmdSync } from './cli/commands/sync.js'
import { configureCommanderHelp, styleEnvBlock, styleTitle } from './cli/helpStyle.js'
import { DEFAULT_REGISTRY, DEFAULT_SITE } from './cli/registry.js'
@@ -183,6 +183,22 @@ program
await cmdList(opts)
})
program
.command('explore')
.description('Browse latest updated skills from the registry')
.option(
'--limit <n>',
'Number of skills to show (max 50)',
(value) => Number.parseInt(value, 10),
25,
)
.action(async (options) => {
const opts = await resolveGlobalOpts()
const limit =
typeof options.limit === 'number' && Number.isFinite(options.limit) ? options.limit : 25
await cmdExplore(opts, limit)
})
program
.command('publish')
.description('Publish skill from folder')
@@ -0,0 +1,98 @@
/* @vitest-environment node */
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { GlobalOpts } from '../types'
const mockApiRequest = vi.fn()
vi.mock('../../http.js', () => ({
apiRequest: (...args: unknown[]) => mockApiRequest(...args),
}))
const mockGetRegistry = vi.fn(async () => 'https://clawdhub.com')
vi.mock('../registry.js', () => ({
getRegistry: () => mockGetRegistry(),
}))
const mockSpinner = { stop: vi.fn(), fail: vi.fn() }
vi.mock('../ui.js', () => ({
createSpinner: vi.fn(() => mockSpinner),
formatError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
}))
const { clampLimit, cmdExplore, formatExploreLine } = await import('./skills')
const mockLog = vi.spyOn(console, 'log').mockImplementation(() => {})
function makeOpts(): GlobalOpts {
return {
workdir: '/work',
dir: '/work/skills',
site: 'https://clawdhub.com',
registry: 'https://clawdhub.com',
registrySource: 'default',
}
}
afterEach(() => {
vi.clearAllMocks()
})
describe('explore helpers', () => {
it('clamps explore limits and handles non-finite values', () => {
expect(clampLimit(-5)).toBe(1)
expect(clampLimit(0)).toBe(1)
expect(clampLimit(1)).toBe(1)
expect(clampLimit(50)).toBe(50)
expect(clampLimit(99)).toBe(50)
expect(clampLimit(Number.NaN)).toBe(25)
expect(clampLimit(Number.POSITIVE_INFINITY)).toBe(25)
expect(clampLimit(Number.NaN, 10)).toBe(10)
})
it('formats explore lines with relative time and truncation', () => {
const now = 4 * 60 * 60 * 1000
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(now)
const summary = 'a'.repeat(60)
const line = formatExploreLine({
slug: 'weather',
summary,
updatedAt: now - 2 * 60 * 60 * 1000,
latestVersion: null,
})
expect(line).toBe(`weather v? 2h ago ${'a'.repeat(49)}`)
nowSpy.mockRestore()
})
})
describe('cmdExplore', () => {
it('clamps limit and handles empty results', async () => {
mockApiRequest.mockResolvedValue({ items: [] })
await cmdExplore(makeOpts(), 0)
const [, args] = mockApiRequest.mock.calls[0] ?? []
const url = new URL(String(args?.url))
expect(url.searchParams.get('limit')).toBe('1')
expect(mockLog).toHaveBeenCalledWith('No skills found.')
})
it('prints formatted results', async () => {
const now = 10 * 60 * 1000
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(now)
const item = {
slug: 'gog',
summary: 'Google Workspace CLI for Gmail, Calendar, Drive and more.',
updatedAt: now - 90 * 1000,
latestVersion: { version: '1.2.3' },
}
mockApiRequest.mockResolvedValue({ items: [item] })
await cmdExplore(makeOpts(), 100)
const [, args] = mockApiRequest.mock.calls[0] ?? []
const url = new URL(String(args?.url))
expect(url.searchParams.get('limit')).toBe('50')
expect(mockLog).toHaveBeenCalledWith(formatExploreLine(item))
nowSpy.mockRestore()
})
})
@@ -5,6 +5,7 @@ import { apiRequest, downloadZip } from '../../http.js'
import {
ApiRoutes,
ApiV1SearchResponseSchema,
ApiV1SkillListResponseSchema,
ApiV1SkillResolveResponseSchema,
ApiV1SkillResponseSchema,
} from '../../schema/index.js'
@@ -241,6 +242,74 @@ export async function cmdList(opts: GlobalOpts) {
}
}
export async function cmdExplore(opts: GlobalOpts, limit = 25) {
const registry = await getRegistry(opts, { cache: true })
const spinner = createSpinner('Fetching latest skills')
try {
const url = new URL(ApiRoutes.skills, registry)
const boundedLimit = clampLimit(limit)
url.searchParams.set('limit', String(boundedLimit))
const result = await apiRequest(
registry,
{ method: 'GET', url: url.toString() },
ApiV1SkillListResponseSchema,
)
spinner.stop()
if (result.items.length === 0) {
console.log('No skills found.')
return
}
for (const item of result.items) {
console.log(formatExploreLine(item))
}
} catch (error) {
spinner.fail(formatError(error))
throw error
}
}
export function formatExploreLine(item: {
slug: string
summary?: string | null
updatedAt: number
latestVersion?: { version: string } | null
}) {
const version = item.latestVersion?.version ?? '?'
const age = formatRelativeTime(item.updatedAt)
const summary = item.summary ? ` ${truncate(item.summary, 50)}` : ''
return `${item.slug} v${version} ${age}${summary}`
}
export function clampLimit(limit: number, fallback = 25) {
if (!Number.isFinite(limit)) return fallback
return Math.min(Math.max(1, limit), 50)
}
function formatRelativeTime(timestamp: number): string {
const now = Date.now()
const diff = now - timestamp
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (days > 30) {
const months = Math.floor(days / 30)
return `${months}mo ago`
}
if (days > 0) return `${days}d ago`
if (hours > 0) return `${hours}h ago`
if (minutes > 0) return `${minutes}m ago`
return 'just now'
}
function truncate(str: string, maxLen: number): string {
if (str.length <= maxLen) return str
return `${str.slice(0, maxLen - 1)}`
}
async function resolveSkillVersion(registry: string, slug: string, hash: string) {
const url = new URL(ApiRoutes.resolve, registry)
url.searchParams.set('slug', slug)