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
75 changes: 75 additions & 0 deletions convex/httpApiV1.handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import type { RateLimitArgs, RateLimitReturns } from "@convex-dev/rate-limiter";
import { gzipSync, strFromU8, unzipSync } from "fflate";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { parseArk } from "../packages/schema/src/ark";
import { ApiV1SkillListResponseSchema } from "../packages/schema/src/schemas";

// Route behavior assumes verified ingress; trust validation is covered by httpRateLimit.edge.test.ts.
vi.mock("./lib/verifiedClientIp", () => ({
Expand Down Expand Up @@ -2939,6 +2941,7 @@ describe("httpApiV1 handlers", () => {
return {
page: [
{
ownerHandle: "fixture-owner",
skill: {
_id: "skills:1",
slug: "demo",
Expand Down Expand Up @@ -2971,10 +2974,82 @@ describe("httpApiV1 handlers", () => {
);
expect(response.status).toBe(200);
const json = await response.json();
expect(json.items[0].ownerHandle).toBe("fixture-owner");
expect(json.items[0].tags.latest).toBe("1.0.0");
expect(json.items[0].topics).toEqual(["Automation", "Email"]);
});

it("preserves owner-qualified identities and nullable versions across cursor pages", async () => {
const fixtures = [
{ ownerHandle: "fixture-owner-a", slug: "shared-fixture-slug", version: "1.2.3+fixture.01" },
{ ownerHandle: "fixture-owner-b", slug: "shared-fixture-slug", version: "2.0.0" },
{ ownerHandle: "fixture-owner-c", slug: "third-fixture", version: "3.0.0" },
{ ownerHandle: "fixture-owner-d", slug: "no-public-version", version: null },
] as const;
let pageIndex = 0;
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("cursor" in args || "numItems" in args) {
const fixture = fixtures[pageIndex];
if (!fixture) return { page: [], nextCursor: null };
pageIndex += 1;
return {
page: [
{
ownerHandle: fixture.ownerHandle,
skill: {
_id: `skills:${pageIndex}`,
slug: fixture.slug,
displayName: `Fixture ${pageIndex}`,
summary: null,
tags: {},
stats: {},
createdAt: 1,
updatedAt: 2,
},
latestVersion: fixture.version
? { version: fixture.version, createdAt: 3, changelog: "fixture" }
: null,
},
],
nextCursor: pageIndex < fixtures.length ? `cursor-${pageIndex}` : null,
};
}
return [];
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const identities = new Set<string>();
let cursor: string | null = null;

do {
const url = new URL("https://example.com/api/v1/skills?sort=updated&limit=1");
if (cursor) url.searchParams.set("cursor", cursor);
const response = await __handlers.listSkillsV1Handler(
makeCtx({ runQuery, runMutation }),
new Request(url),
);
expect(response.status).toBe(200);
const json = parseArk(
ApiV1SkillListResponseSchema,
await response.json(),
"Skill list response",
);
const item = json.items[0];
expect(item).toBeDefined();
identities.add(`${item!.ownerHandle}/${item!.slug}`);
cursor = json.nextCursor;
} while (cursor);

expect(pageIndex).toBe(4);
expect(identities).toEqual(
new Set([
"fixture-owner-a/shared-fixture-slug",
"fixture-owner-b/shared-fixture-slug",
"fixture-owner-c/third-fixture",
"fixture-owner-d/no-public-version",
]),
);
});

it("lists skills with long description metadata and setup requirements", async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("cursor" in args || "numItems" in args) {
Expand Down
2 changes: 2 additions & 0 deletions convex/httpApiV1/skillsV1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ async function readRequestBodyWithinLimit(request: Request, maxBytes: number) {

type ListSkillsResult = {
items: Array<{
ownerHandle: string | null;
skill: {
_id: Id<"skills">;
slug: string;
Expand Down Expand Up @@ -1500,6 +1501,7 @@ export async function listSkillsV1Handler(ctx: ActionCtx, request: Request) {
);

const items = result.items.map((item, idx) => ({
ownerHandle: item.ownerHandle,
slug: item.skill.slug,
displayName: item.skill.displayName,
summary: item.skill.summary ?? null,
Expand Down
1 change: 1 addition & 0 deletions convex/skills.publicListCursor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1538,6 +1538,7 @@ describe("public skill list deterministic cursors", () => {

expect(result.items).toHaveLength(1);
expect(result.items[0]).toMatchObject({
ownerHandle: "owner",
skill: {
slug: "demo",
topics: ["Calendar", "Official"],
Expand Down
3 changes: 2 additions & 1 deletion convex/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6139,7 +6139,7 @@ async function buildPublicSkillApiListEntryFromDigest(
const publicSkill = toPublicSkill(hydratable);
if (!publicSkill) return null;
const ownerInfo = digestToOwnerInfo(digest);
if (!ownerInfo?.owner) return null;
if (!ownerInfo?.owner || !ownerInfo.ownerHandle) return null;
const latestVersion = await resolveDigestLatestVersionForSkill(ctx, digest);
if (isHostedSkillPendingPublicReview(hydratable) && !latestVersion) return null;

Expand All @@ -6156,6 +6156,7 @@ async function buildPublicSkillApiListEntryFromDigest(
updatedAt: publicSkill.updatedAt,
latestVersionId: publicSkill.latestVersionId,
},
ownerHandle: ownerInfo.ownerHandle,
latestVersion,
};
}
Expand Down
2 changes: 1 addition & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ See [CLI login](./auth.md#cli-login) for the approval steps and expiry guidance.
- `--limit <n>` (1-200, default: 25)
- `--sort newest|updated|rating|downloads|trending` (default: newest). Legacy install sort aliases still work for compatibility.
- `--json` (machine-readable output)
- Output: `<slug> v<version> <age> <summary>` (summary truncated to 50 chars).
- Output: `<ownerHandle>/<slug> v<version> <age> <summary>` (summary truncated to 50 chars).

### `inspect @owner/slug`

Expand Down
3 changes: 3 additions & 0 deletions docs/http-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,8 @@ Notes:
- `recommended` uses engagement and recency signals.
- `trending` ranks by installs in the last 7 days (telemetry-based).
- `createdAt` is stable for new-skill crawls; `updated` changes when existing skills are republished.
- Each item is identified by the owner-qualified pair `ownerHandle/slug`; slugs are not globally unique across publishers.
- `latestVersion` is always present. It is `null` when the skill has no public version.
- Prefix listing is complete across pages: keep following `nextCursor` until it is `null`.
- When `nonSuspiciousOnly=true`, cursor-based sorts may return fewer than `limit` items on a page because suspicious skills are filtered after page retrieval.
- Use `nextCursor` to continue pagination when present. A short page does not by itself mean end-of-results.
Expand All @@ -178,6 +180,7 @@ Response:
{
"items": [
{
"ownerHandle": "steipete",
"slug": "gifgrep",
"displayName": "GifGrep",
"summary": "…",
Expand Down
18 changes: 17 additions & 1 deletion packages/clawhub/src/cli/commands/skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,12 +166,27 @@ describe("explore helpers", () => {
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(now);
const summary = "a".repeat(60);
const line = formatExploreLine({
ownerHandle: "openclaw",
slug: "weather",
summary,
updatedAt: now - 2 * 60 * 60 * 1000,
latestVersion: null,
});
expect(line).toBe(`weather v? 2h ago ${"a".repeat(49)}…`);
expect(line).toBe(`openclaw/weather v? 2h ago ${"a".repeat(49)}…`);
nowSpy.mockRestore();
});

it("formats legacy registry results without an owner handle", () => {
const now = 4 * 60 * 60 * 1000;
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(now);

expect(
formatExploreLine({
slug: "weather",
updatedAt: now - 2 * 60 * 60 * 1000,
latestVersion: { version: "1.0.0" },
}),
).toBe("weather v1.0.0 2h ago");
nowSpy.mockRestore();
});
});
Expand Down Expand Up @@ -202,6 +217,7 @@ describe("cmdExplore", () => {
const now = 10 * 60 * 1000;
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(now);
const item = {
ownerHandle: "openclaw",
slug: "gog",
summary: "Google Workspace CLI for Gmail, Calendar, Drive and more.",
updatedAt: now - 90 * 1000,
Expand Down
4 changes: 3 additions & 1 deletion packages/clawhub/src/cli/commands/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1326,6 +1326,7 @@ export async function cmdExplore(
}

export function formatExploreLine(item: {
ownerHandle?: string;
slug: string;
summary?: string | null;
updatedAt: number;
Expand All @@ -1334,7 +1335,8 @@ export function formatExploreLine(item: {
const version = item.latestVersion?.version ?? "?";
const age = formatRelativeTime(item.updatedAt);
const summary = item.summary ? ` ${styleText(truncate(item.summary, 50), "muted")}` : "";
return `${styleText(item.slug, "brand")} ${styleText(`v${version}`, "muted")} ${styleText(
const qualifiedSlug = item.ownerHandle ? `${item.ownerHandle}/${item.slug}` : item.slug;
return `${styleText(qualifiedSlug, "brand")} ${styleText(`v${version}`, "muted")} ${styleText(
age,
"muted",
)}${summary}`;
Expand Down
56 changes: 56 additions & 0 deletions packages/clawhub/src/schema/schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,68 @@ import {
} from "./packages";
import {
ApiV1SearchResponseSchema,
ApiV1SkillListResponseSchema,
ApiV1SkillRescanResponseSchema,
ApiV1SkillVerifyResponseSchema,
ClawdisSkillMetadataSchema,
} from "./schemas";

describe("packages/clawhub skill metadata schema", () => {
it("parses owner-qualified skill list items without a public version", () => {
const response = parseArk(
ApiV1SkillListResponseSchema,
{
items: [
{
ownerHandle: "fixture-owner",
slug: "shared-fixture-slug",
displayName: "Fixture skill",
summary: null,
description: null,
tags: {},
stats: {},
createdAt: 1,
updatedAt: 2,
latestVersion: null,
metadata: null,
},
],
nextCursor: null,
},
"Skill list response",
);

expect(response.items[0]?.ownerHandle).toBe("fixture-owner");
expect(response.items[0]?.latestVersion).toBeNull();
});

it("parses legacy registry skill list items without newer fields", () => {
const response = parseArk(
ApiV1SkillListResponseSchema,
{
items: [
{
slug: "legacy-skill",
displayName: "Legacy skill",
summary: null,
description: null,
tags: {},
stats: {},
createdAt: 1,
updatedAt: 2,
metadata: null,
},
],
nextCursor: null,
},
"Skill list response",
);

expect(response.items[0]?.ownerHandle).toBeUndefined();
expect(response.items[0]?.latestVersion).toBeUndefined();
expect(response.items[0]?.slug).toBe("legacy-skill");
});

it("preserves optional env var declarations", () => {
const parsed = parseArk(
ClawdisSkillMetadataSchema,
Expand Down
5 changes: 4 additions & 1 deletion packages/clawhub/src/schema/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,7 @@ export const ApiV1SearchResponseSchema = type({

export const ApiV1SkillListResponseSchema = type({
items: type({
ownerHandle: "string?",
slug: "string",
displayName: "string",
summary: "string|null?",
Expand All @@ -521,7 +522,9 @@ export const ApiV1SkillListResponseSchema = type({
createdAt: "number",
changelog: "string",
license: '"MIT-0"|null?',
}).optional(),
})
.or("null")
.optional(),
metadata: type({
setup: type({
key: "string",
Expand Down
5 changes: 3 additions & 2 deletions packages/schema/dist/schemas.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ export declare const ApiV1SearchResponseSchema: import("arktype/internal/variant
}, {}>;
export declare const ApiV1SkillListResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
items: {
ownerHandle: string;
slug: string;
displayName: string;
summary?: string | null | undefined;
Expand All @@ -334,12 +335,12 @@ export declare const ApiV1SkillListResponseSchema: import("arktype/internal/vari
stats: unknown;
createdAt: number;
updatedAt: number;
latestVersion?: {
latestVersion: {
version: string;
createdAt: number;
changelog: string;
license?: "MIT-0" | null | undefined;
} | undefined;
} | null;
metadata?: {
setup: {
key: string;
Expand Down
3 changes: 2 additions & 1 deletion packages/schema/dist/schemas.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/schema/dist/schemas.js.map

Large diffs are not rendered by default.

29 changes: 29 additions & 0 deletions packages/schema/src/schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
ApiSearchResponseSchema,
ApiV1SkillHardDeleteResponseSchema,
ApiV1SkillInstallResolveResponseSchema,
ApiV1SkillListResponseSchema,
ApiV1SkillRescanResponseSchema,
ApiV1SearchResponseSchema,
ApiV1SkillVerifyResponseSchema,
Expand All @@ -26,6 +27,34 @@ import {
} from "./schemas";

describe("clawhub-schema", () => {
it("parses owner-qualified skill list items without a public version", () => {
const response = parseArk(
ApiV1SkillListResponseSchema,
{
items: [
{
ownerHandle: "fixture-owner",
slug: "shared-fixture-slug",
displayName: "Fixture skill",
summary: null,
description: null,
tags: {},
stats: {},
createdAt: 1,
updatedAt: 2,
latestVersion: null,
metadata: null,
},
],
nextCursor: null,
},
"Skill list response",
);

expect(response.items[0]?.ownerHandle).toBe("fixture-owner");
expect(response.items[0]?.latestVersion).toBeNull();
});

it("parses package hard-delete responses", () => {
const result = parseArk(
ApiV1PackageHardDeleteResponseSchema,
Expand Down
Loading
Loading