feat: rank skills by installs and dedupe install telemetry (#2535)

This commit is contained in:
Jesse Merhi
2026-06-11 14:22:11 +10:00
committed by GitHub
parent a1c91b265e
commit d6500794c6
41 changed files with 1152 additions and 584 deletions
+1 -1
View File
@@ -74,7 +74,7 @@ Docs: [`docs/quickstart.md`](docs/quickstart.md), [`docs/cli.md`](docs/cli.md).
## Telemetry
ClawHub tracks minimal **install telemetry** (to compute install counts) when you run `clawhub sync` while logged in.
ClawHub tracks minimal **install telemetry** (to compute install counts) when you run `clawhub install` while logged in.
Disable via:
```bash
-7
View File
@@ -9,7 +9,6 @@ import {
cliSkillDeleteHttp,
cliSkillUndeleteHttp,
cliTelemetryInstallHttp,
cliTelemetrySyncHttp,
cliUploadUrlHttp,
cliWhoamiHttp,
getSkillHttp,
@@ -399,12 +398,6 @@ http.route({
handler: cliTelemetryInstallHttp,
});
http.route({
path: LegacyApiRoutes.cliTelemetrySync,
method: "POST",
handler: cliTelemetrySyncHttp,
});
http.route({
path: LegacyApiRoutes.cliSkillDelete,
method: "POST",
+17 -64
View File
@@ -267,7 +267,7 @@ describe("httpApi handlers", () => {
expect(json.user.handle).toBe("p");
});
it("cliTelemetryInstallHttp forwards roots and returns ok", async () => {
it("cliTelemetryInstallHttp forwards one install event and returns ok", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: "users:1" } as never);
const runMutation = vi.fn().mockResolvedValue(null);
const response = await __handlers.cliTelemetryInstallHandler(
@@ -276,56 +276,31 @@ describe("httpApi handlers", () => {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
roots: [
{
rootId: "abc",
label: "~/skills",
skills: [{ slug: "weather", version: null }],
},
],
event: "install",
slug: "weather",
version: "1.0.0",
rootId: "abc",
rootLabel: "~/skills",
}),
}),
);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ ok: true });
expect(runMutation).toHaveBeenCalledTimes(1);
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
userId: "users:1",
slug: "weather",
version: "1.0.0",
rootId: "abc",
rootLabel: "~/skills",
});
});
it("cliTelemetrySyncHttp remains a backwards-compatible alias", async () => {
it("cliTelemetryInstallHttp rejects sync-shaped roots snapshots", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: "users:1" } as never);
const runMutation = vi.fn().mockResolvedValue(null);
const response = await __handlers.cliTelemetrySyncHandler(
const response = await __handlers.cliTelemetryInstallHandler(
makeCtx({ runMutation }),
new Request("https://x/api/cli/telemetry/sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ roots: [] }),
}),
);
expect(response.status).toBe(200);
expect(runMutation).toHaveBeenCalledTimes(1);
});
it("cliTelemetrySyncHttp returns 400 on invalid payload", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: "users:1" } as never);
const response = await __handlers.cliTelemetrySyncHandler(
makeCtx({ runMutation: vi.fn() }),
new Request("https://x/api/cli/telemetry/sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ roots: "nope" }),
}),
);
expect(response.status).toBe(400);
});
it("cliTelemetrySyncHttp forwards skill versions when provided", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: "users:1" } as never);
const runMutation = vi.fn().mockResolvedValue(null);
await __handlers.cliTelemetrySyncHandler(
makeCtx({ runMutation }),
new Request("https://x/api/cli/telemetry/sync", {
new Request("https://x/api/cli/telemetry/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -339,31 +314,9 @@ describe("httpApi handlers", () => {
}),
}),
);
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
userId: "users:1",
roots: [
{ rootId: "abc", label: "~/skills", skills: [{ slug: "weather", version: "1.0.0" }] },
],
});
});
it("cliTelemetrySyncHttp returns 400 on invalid json", async () => {
const request = new Request("https://x/api/cli/telemetry/sync", { method: "POST", body: "{" });
const response = await __handlers.cliTelemetrySyncHandler(makeCtx({}), request);
expect(response.status).toBe(400);
});
it("cliTelemetrySyncHttp returns 401 when unauthorized", async () => {
vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error("Unauthorized"));
const response = await __handlers.cliTelemetrySyncHandler(
makeCtx({}),
new Request("https://x/api/cli/telemetry/sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ roots: [] }),
}),
);
expect(response.status).toBe(401);
expect(runMutation).not.toHaveBeenCalled();
});
it("cliDeviceCodeHttp rate limits and creates a device code", async () => {
+9 -16
View File
@@ -1,9 +1,9 @@
import {
ApiCliSkillDeleteResponseSchema,
ApiCliTelemetrySyncResponseSchema,
ApiCliTelemetryInstallResponseSchema,
CliTelemetryInstallRequestSchema,
CliPublishRequestSchema,
CliSkillDeleteRequestSchema,
CliTelemetrySyncRequestSchema,
parseArk,
} from "clawhub-schema";
import { api, internal } from "./_generated/api";
@@ -237,19 +237,15 @@ async function cliTelemetryInstallHandler(ctx: ActionCtx, request: Request) {
try {
const { userId } = await requireApiTokenUser(ctx, request);
const args = parseArk(CliTelemetrySyncRequestSchema, body, "Telemetry payload");
await ctx.runMutation(internal.telemetry.reportCliSyncInternal, {
const args = parseArk(CliTelemetryInstallRequestSchema, body, "Install telemetry payload");
await ctx.runMutation(internal.telemetry.reportCliInstallInternal, {
userId,
roots: args.roots.map((root) => ({
rootId: root.rootId,
label: root.label,
skills: root.skills.map((skill) => ({
slug: skill.slug,
version: skill.version ?? undefined,
})),
})),
slug: args.slug,
version: args.version,
rootId: args.rootId,
rootLabel: args.rootLabel,
});
const ok = parseArk(ApiCliTelemetrySyncResponseSchema, { ok: true }, "Telemetry response");
const ok = parseArk(ApiCliTelemetryInstallResponseSchema, { ok: true }, "Telemetry response");
return json(ok);
} catch (error) {
const message = error instanceof Error ? error.message : "Telemetry failed";
@@ -258,9 +254,7 @@ async function cliTelemetryInstallHandler(ctx: ActionCtx, request: Request) {
}
}
const cliTelemetrySyncHandler = cliTelemetryInstallHandler;
export const cliTelemetryInstallHttp = httpAction(cliTelemetryInstallHandler);
export const cliTelemetrySyncHttp = httpAction(cliTelemetryInstallHandler);
async function cliDeviceCodeHandler(ctx: ActionCtx, request: Request) {
if (request.method !== "POST") return text("Method not allowed", 405);
@@ -395,7 +389,6 @@ export const __handlers = {
cliPublishHandler,
cliSkillDeleteHandler,
cliTelemetryInstallHandler,
cliTelemetrySyncHandler,
cliDeviceCodeHandler,
cliDeviceTokenHandler,
};
+68
View File
@@ -213,6 +213,74 @@ describe("publisher abuse scoring", () => {
expect(score.reasonCodes).toContain("temporal_sustained_downloads_flat_installs");
});
it("flags high-volume installs that track downloads too closely", () => {
const todayDay = 100;
const score = computeCurrentSkillTemporalAbuseScore({
todayDay,
dailyStats: dailyRange(94, 7, { downloads: 200, installs: 180 }),
});
expect(score.nearConversion).toBe(true);
expect(score.recent7Downloads).toBe(1_400);
expect(score.recent7Installs).toBe(1_260);
expect(score.installDownloadRatio7).toBeCloseTo(0.9);
expect(score.reasonCodes).toContain("temporal_installs_track_downloads");
});
it("keeps low-volume one-to-one install traffic below close-ratio thresholds", () => {
const todayDay = 100;
const score = computeCurrentSkillTemporalAbuseScore({
todayDay,
dailyStats: dailyRange(94, 7, { downloads: 1, installs: 1 }),
});
expect(score.nearConversion).toBe(false);
expect(score.reasonCodes).not.toContain("temporal_installs_track_downloads");
});
it("keeps observed high-end install ratios below close-ratio thresholds", () => {
const todayDay = 100;
const score = computeCurrentSkillTemporalAbuseScore({
todayDay,
dailyStats: dailyRange(94, 7, { downloads: 20, installs: 1 }),
});
expect(score.recent7Downloads).toBe(140);
expect(score.recent7Installs).toBe(7);
expect(score.installDownloadRatio7).toBeCloseTo(0.05);
expect(score.nearConversion).toBe(false);
expect(score.reasonCodes).not.toContain("temporal_installs_track_downloads");
});
it("requires installs to be close to downloads, not just statistically elevated", () => {
const todayDay = 100;
const score = computeCurrentSkillTemporalAbuseScore({
todayDay,
dailyStats: dailyRange(94, 7, { downloads: 300, installs: 15 }),
});
expect(score.recent7Downloads).toBe(2_100);
expect(score.recent7Installs).toBe(105);
expect(score.installDownloadRatio7).toBeCloseTo(0.05);
expect(score.installDownloadExcessZScore7).toBeGreaterThan(10);
expect(score.nearConversion).toBe(false);
expect(score.reasonCodes).not.toContain("temporal_installs_track_downloads");
});
it("reports a 30-day close-ratio window when the 7-day threshold is not met", () => {
const todayDay = 100;
const score = computeCurrentSkillTemporalAbuseScore({
todayDay,
dailyStats: dailyRange(71, 30, { downloads: 100, installs: 80 }),
});
expect(score.nearConversion).toBe(true);
expect(score.installDownloadRatio7).toBeCloseTo(0.8);
expect(score.installDownloadRatio30).toBeCloseTo(0.8);
expect(score.nearConversionWindowStartDay).toBe(71);
expect(score.nearConversionWindowEndDay).toBe(100);
});
it("keeps ordinary steady download traffic below temporal thresholds", () => {
const todayDay = 100;
const score = computeCurrentSkillTemporalAbuseScore({
+138 -14
View File
@@ -60,6 +60,7 @@ export type SkillTemporalAbuseDailyStat = {
export type SkillTemporalAbuseScore = {
spike: boolean;
sustained: boolean;
nearConversion: boolean;
pressure: number;
recent7Downloads: number;
recent7Installs: number;
@@ -73,10 +74,16 @@ export type SkillTemporalAbuseScore = {
spikeMultiplierCohortBand?: "p95" | "p99";
downloads30dVsPeerP95?: number;
spikeMultiplierVsPeerP95?: number;
installDownloadRatio7: number;
installDownloadRatio30: number;
installDownloadExcessZScore7: number;
installDownloadExcessZScore30: number;
spikeWindowStartDay?: number;
spikeWindowEndDay?: number;
sustainedWindowStartDay?: number;
sustainedWindowEndDay?: number;
nearConversionWindowStartDay?: number;
nearConversionWindowEndDay?: number;
reasonCodes: string[];
};
@@ -116,6 +123,12 @@ const TEMPORAL_SUSTAINED_DAYS = 30;
const TEMPORAL_MAX_SPIKE_INSTALLS = 2;
const TEMPORAL_MAX_SUSTAINED_INSTALLS = 5;
const TEMPORAL_MIN_BASELINE_7_DOWNLOADS = 100;
const TEMPORAL_MIN_NEAR_CONVERSION_7_DOWNLOADS = 1_000;
const TEMPORAL_MIN_NEAR_CONVERSION_30_DOWNLOADS = 2_000;
const TEMPORAL_MIN_NEAR_CONVERSION_INSTALLS = 500;
const TEMPORAL_EXPECTED_INSTALL_DOWNLOAD_RATIO = 0.012;
const TEMPORAL_MIN_INSTALL_DOWNLOAD_RATIO = 0.5;
const TEMPORAL_MIN_INSTALL_DOWNLOAD_EXCESS_Z_SCORE = 50;
export function labelForPublisherAbuseZScore(
zScore: number,
@@ -291,6 +304,7 @@ export function computeHistoricalSkillTemporalAbuseScore(input: {
const maxDay = Math.max(...days);
let bestSpike = emptySkillTemporalAbuseScore();
let bestSustained = emptySkillTemporalAbuseScore();
let bestNearConversion = emptySkillTemporalAbuseScore();
for (let startDay = minDay; startDay <= maxDay; startDay += 1) {
if (startDay + TEMPORAL_SPIKE_RECENT_DAYS - 1 <= maxDay) {
@@ -305,6 +319,13 @@ export function computeHistoricalSkillTemporalAbuseScore(input: {
if (score.spike && score.spikeMultiplier > bestSpike.spikeMultiplier) {
bestSpike = score;
}
if (
score.nearConversion &&
score.nearConversionWindowEndDay === startDay + TEMPORAL_SPIKE_RECENT_DAYS - 1 &&
score.installDownloadRatio7 > bestNearConversion.installDownloadRatio7
) {
bestNearConversion = score;
}
}
if (startDay + TEMPORAL_SUSTAINED_DAYS - 1 <= maxDay) {
@@ -319,10 +340,17 @@ export function computeHistoricalSkillTemporalAbuseScore(input: {
if (score.sustained && score.recent30Downloads > bestSustained.recent30Downloads) {
bestSustained = score;
}
if (
score.nearConversion &&
score.nearConversionWindowEndDay === startDay + TEMPORAL_SUSTAINED_DAYS - 1 &&
score.installDownloadRatio30 > bestNearConversion.installDownloadRatio30
) {
bestNearConversion = score;
}
}
}
return mergeTemporalAbuseWindowScores(bestSpike, bestSustained);
return mergeTemporalAbuseWindowScores(bestSpike, bestSustained, bestNearConversion);
}
export function labelForTemporalPublisherAbuse(input: {
@@ -379,15 +407,25 @@ export function classifySkillTemporalAbuseScore(
: undefined;
const spike = Boolean(spikeMultiplierCohortBand);
const sustained = Boolean(downloads30dCohortBand);
const nearConversion = score.nearConversion;
const nearConversionPressure = nearConversion
? Math.max(score.installDownloadExcessZScore7, score.installDownloadExcessZScore30)
: 0;
const reasonCodes: string[] = [];
if (spike) reasonCodes.push("temporal_download_spike_flat_installs");
if (sustained) reasonCodes.push("temporal_sustained_downloads_flat_installs");
if (nearConversion) reasonCodes.push("temporal_installs_track_downloads");
return {
...score,
spike,
sustained,
pressure: Math.max(spike ? spikeMultiplierVsPeerP95 : 0, sustained ? downloads30dVsPeerP95 : 0),
nearConversion,
pressure: Math.max(
spike ? spikeMultiplierVsPeerP95 : 0,
sustained ? downloads30dVsPeerP95 : 0,
nearConversionPressure,
),
downloads30dCohortBand,
spikeMultiplierCohortBand,
downloads30dVsPeerP95,
@@ -396,6 +434,8 @@ export function classifySkillTemporalAbuseScore(
spikeWindowEndDay: spike ? score.spikeWindowEndDay : undefined,
sustainedWindowStartDay: sustained ? score.sustainedWindowStartDay : undefined,
sustainedWindowEndDay: sustained ? score.sustainedWindowEndDay : undefined,
nearConversionWindowStartDay: nearConversion ? score.nearConversionWindowStartDay : undefined,
nearConversionWindowEndDay: nearConversion ? score.nearConversionWindowEndDay : undefined,
reasonCodes,
};
}
@@ -449,10 +489,37 @@ function computeSkillTemporalAbuseScoreForWindows(input: {
);
const spikeMultiplier = baseline7Downloads > 0 ? recent7.downloads / baseline7Downloads : 0;
const downloadInstallRatio30 = recent30.downloads / Math.max(1, recent30.installs);
const installDownloadRatio7 = recent7.installs / Math.max(1, recent7.downloads);
const installDownloadRatio30 = recent30.installs / Math.max(1, recent30.downloads);
const installDownloadExcessZScore7 = installDownloadExcessZScore({
downloads: recent7.downloads,
installs: recent7.installs,
});
const installDownloadExcessZScore30 = installDownloadExcessZScore({
downloads: recent30.downloads,
installs: recent30.installs,
});
const nearConversion7 =
recent7.downloads >= TEMPORAL_MIN_NEAR_CONVERSION_7_DOWNLOADS &&
recent7.installs >= TEMPORAL_MIN_NEAR_CONVERSION_INSTALLS &&
installDownloadRatio7 >= TEMPORAL_MIN_INSTALL_DOWNLOAD_RATIO &&
installDownloadExcessZScore7 >= TEMPORAL_MIN_INSTALL_DOWNLOAD_EXCESS_Z_SCORE;
const nearConversion30 =
recent30.downloads >= TEMPORAL_MIN_NEAR_CONVERSION_30_DOWNLOADS &&
recent30.installs >= TEMPORAL_MIN_NEAR_CONVERSION_INSTALLS &&
installDownloadRatio30 >= TEMPORAL_MIN_INSTALL_DOWNLOAD_RATIO &&
installDownloadExcessZScore30 >= TEMPORAL_MIN_INSTALL_DOWNLOAD_EXCESS_Z_SCORE;
const nearConversion = nearConversion7 || nearConversion30;
const reasonCodes: string[] = [];
if (nearConversion) reasonCodes.push("temporal_installs_track_downloads");
return {
spike: false,
sustained: false,
pressure: 0,
nearConversion,
pressure: nearConversion
? Math.max(installDownloadExcessZScore7, installDownloadExcessZScore30)
: 0,
recent7Downloads: recent7.downloads,
recent7Installs: recent7.installs,
previous30Downloads: previous30.downloads,
@@ -461,35 +528,74 @@ function computeSkillTemporalAbuseScoreForWindows(input: {
recent30Downloads: recent30.downloads,
recent30Installs: recent30.installs,
downloadInstallRatio30,
installDownloadRatio7,
installDownloadRatio30,
installDownloadExcessZScore7,
installDownloadExcessZScore30,
spikeWindowStartDay: input.spikeStartDay,
spikeWindowEndDay: spikeEndDay,
sustainedWindowStartDay: input.sustainedStartDay,
sustainedWindowEndDay: sustainedEndDay,
reasonCodes: [],
nearConversionWindowStartDay: nearConversion7
? input.spikeStartDay
: nearConversion30
? input.sustainedStartDay
: undefined,
nearConversionWindowEndDay: nearConversion7
? spikeEndDay
: nearConversion30
? sustainedEndDay
: undefined,
reasonCodes,
};
}
function mergeTemporalAbuseWindowScores(
bestSpike: SkillTemporalAbuseScore,
bestSustained: SkillTemporalAbuseScore,
bestNearConversion: SkillTemporalAbuseScore,
): SkillTemporalAbuseScore {
if (!bestSpike.spike && !bestSustained.sustained) return emptySkillTemporalAbuseScore();
if (!bestSpike.spike && !bestSustained.sustained && !bestNearConversion.nearConversion) {
return emptySkillTemporalAbuseScore();
}
const reasonCodes: string[] = [];
if (bestSpike.spike) reasonCodes.push("temporal_download_spike_flat_installs");
if (bestSustained.sustained) reasonCodes.push("temporal_sustained_downloads_flat_installs");
if (bestNearConversion.nearConversion) reasonCodes.push("temporal_installs_track_downloads");
return {
spike: bestSpike.spike,
sustained: bestSustained.sustained,
pressure: Math.max(bestSpike.pressure, bestSustained.pressure),
recent7Downloads: bestSpike.recent7Downloads,
recent7Installs: bestSpike.recent7Installs,
previous30Downloads: bestSpike.previous30Downloads,
baseline7Downloads: bestSpike.baseline7Downloads,
spikeMultiplier: bestSpike.spikeMultiplier,
recent30Downloads: bestSustained.recent30Downloads,
recent30Installs: bestSustained.recent30Installs,
downloadInstallRatio30: bestSustained.downloadInstallRatio30,
nearConversion: bestNearConversion.nearConversion,
pressure: Math.max(bestSpike.pressure, bestSustained.pressure, bestNearConversion.pressure),
recent7Downloads: bestSpike.spike
? bestSpike.recent7Downloads
: bestNearConversion.recent7Downloads,
recent7Installs: bestSpike.spike
? bestSpike.recent7Installs
: bestNearConversion.recent7Installs,
previous30Downloads: bestSpike.spike
? bestSpike.previous30Downloads
: bestNearConversion.previous30Downloads,
baseline7Downloads: bestSpike.spike
? bestSpike.baseline7Downloads
: bestNearConversion.baseline7Downloads,
spikeMultiplier: bestSpike.spike
? bestSpike.spikeMultiplier
: bestNearConversion.spikeMultiplier,
recent30Downloads: bestSustained.sustained
? bestSustained.recent30Downloads
: bestNearConversion.recent30Downloads,
recent30Installs: bestSustained.sustained
? bestSustained.recent30Installs
: bestNearConversion.recent30Installs,
downloadInstallRatio30: bestSustained.sustained
? bestSustained.downloadInstallRatio30
: bestNearConversion.downloadInstallRatio30,
installDownloadRatio7: bestNearConversion.installDownloadRatio7,
installDownloadRatio30: bestNearConversion.installDownloadRatio30,
installDownloadExcessZScore7: bestNearConversion.installDownloadExcessZScore7,
installDownloadExcessZScore30: bestNearConversion.installDownloadExcessZScore30,
downloads30dCohortBand: bestSustained.downloads30dCohortBand,
spikeMultiplierCohortBand: bestSpike.spikeMultiplierCohortBand,
downloads30dVsPeerP95: bestSustained.downloads30dVsPeerP95,
@@ -498,6 +604,8 @@ function mergeTemporalAbuseWindowScores(
spikeWindowEndDay: bestSpike.spikeWindowEndDay,
sustainedWindowStartDay: bestSustained.sustainedWindowStartDay,
sustainedWindowEndDay: bestSustained.sustainedWindowEndDay,
nearConversionWindowStartDay: bestNearConversion.nearConversionWindowStartDay,
nearConversionWindowEndDay: bestNearConversion.nearConversionWindowEndDay,
reasonCodes,
};
}
@@ -535,6 +643,7 @@ function emptySkillTemporalAbuseScore(): SkillTemporalAbuseScore {
return {
spike: false,
sustained: false,
nearConversion: false,
pressure: 0,
recent7Downloads: 0,
recent7Installs: 0,
@@ -544,10 +653,25 @@ function emptySkillTemporalAbuseScore(): SkillTemporalAbuseScore {
recent30Downloads: 0,
recent30Installs: 0,
downloadInstallRatio30: 0,
installDownloadRatio7: 0,
installDownloadRatio30: 0,
installDownloadExcessZScore7: 0,
installDownloadExcessZScore30: 0,
reasonCodes: [],
};
}
function installDownloadExcessZScore(input: { downloads: number; installs: number }) {
if (input.downloads <= 0) return 0;
const expected = input.downloads * TEMPORAL_EXPECTED_INSTALL_DOWNLOAD_RATIO;
const variance =
input.downloads *
TEMPORAL_EXPECTED_INSTALL_DOWNLOAD_RATIO *
(1 - TEMPORAL_EXPECTED_INSTALL_DOWNLOAD_RATIO);
const stdDev = Math.sqrt(Math.max(variance, 1));
return (input.installs - expected) / stdDev;
}
function nonNegative(value: number) {
return Number.isFinite(value) ? Math.max(0, value) : 0;
}
+109
View File
@@ -158,6 +158,7 @@ const persistTemporalHandler = (
temporalScore: {
spike: boolean;
sustained: boolean;
nearConversion: boolean;
pressure: number;
recent7Downloads: number;
recent7Installs: number;
@@ -167,6 +168,10 @@ const persistTemporalHandler = (
recent30Downloads: number;
recent30Installs: number;
downloadInstallRatio30: number;
installDownloadRatio7: number;
installDownloadRatio30: number;
installDownloadExcessZScore7: number;
installDownloadExcessZScore30: number;
spikeWindowStartDay?: number;
spikeWindowEndDay?: number;
sustainedWindowStartDay?: number;
@@ -3947,6 +3952,105 @@ describe("publisher abuse dry-run persistence", () => {
expect(ctx.db.get).toHaveBeenCalledWith("publishers:quiet");
});
it("keeps near-conversion-only temporal candidates", async () => {
const indexBuilder = {
eq: vi.fn(() => indexBuilder),
gte: vi.fn(() => indexBuilder),
lte: vi.fn(() => indexBuilder),
};
const publisher = {
_id: "publishers:pollyreach",
kind: "user",
handle: "pollyreach",
linkedUserId: "users:joel",
};
const ctx = {
db: {
get: vi.fn(async (id: string) => {
if (id === publisher._id) return publisher;
throw new Error(`unexpected get ${id}`);
}),
query: vi.fn((table: string) => {
if (table === "skills") {
return {
withIndex: (indexName: string, callback: (q: typeof indexBuilder) => unknown) => {
expect(indexName).toBe("by_active_stats_downloads");
callback(indexBuilder);
return {
order: () => ({
paginate: async () => ({
page: [
{
_id: "skills:tracked-installs",
ownerPublisherId: publisher._id,
slug: "tracked-installs",
displayName: "Tracked Installs",
softDeletedAt: undefined,
statsDownloads: 1_400,
statsInstallsAllTime: 1_190,
stats: {
downloads: 1_400,
stars: 0,
installsCurrent: 1_190,
installsAllTime: 1_190,
},
},
],
isDone: true,
continueCursor: "",
}),
}),
};
},
};
}
if (table === "skillDailyStats") {
return {
withIndex: (indexName: string, callback: (q: typeof indexBuilder) => unknown) => {
expect(indexName).toBe("by_skill_day");
callback(indexBuilder);
return {
take: async () =>
Array.from({ length: 7 }, (_, index) => ({
skillId: "skills:tracked-installs",
day: 94 + index,
downloads: 200,
installs: 170,
updatedAt: 1,
})),
};
},
};
}
throw new Error(`unexpected table ${table}`);
}),
},
};
await expect(
collectTemporalHandler(ctx, {
mode: "current",
batchSize: 1,
todayDay: 100,
}),
).resolves.toMatchObject({
cursor: undefined,
isDone: true,
scannedSkills: 1,
candidates: [
{
slug: "tracked-installs",
temporalScore: {
spike: false,
sustained: false,
nearConversion: true,
reasonCodes: ["temporal_installs_track_downloads"],
},
},
],
});
});
it("skips official org publishers during temporal candidate collection", async () => {
const indexBuilder = {
eq: vi.fn(() => indexBuilder),
@@ -4357,6 +4461,7 @@ function temporalCandidate(skillId: string, skill: { slug: string; displayName:
temporalScore: {
spike: true,
sustained: false,
nearConversion: false,
pressure: 20,
recent7Downloads: 2_000,
recent7Installs: 0,
@@ -4366,6 +4471,10 @@ function temporalCandidate(skillId: string, skill: { slug: string; displayName:
recent30Downloads: 2_000,
recent30Installs: 0,
downloadInstallRatio30: 2_000,
installDownloadRatio7: 0,
installDownloadRatio30: 0,
installDownloadExcessZScore7: 0,
installDownloadExcessZScore30: 0,
spikeWindowStartDay: 94,
spikeWindowEndDay: 100,
reasonCodes: ["temporal_download_spike_flat_installs"],
+20 -1
View File
@@ -151,6 +151,7 @@ const temporalAbuseCohortBenchmarkValidator = v.object({
const temporalScoreValidator = v.object({
spike: v.boolean(),
sustained: v.boolean(),
nearConversion: v.boolean(),
pressure: v.number(),
recent7Downloads: v.number(),
recent7Installs: v.number(),
@@ -164,10 +165,16 @@ const temporalScoreValidator = v.object({
spikeMultiplierCohortBand: v.optional(temporalCohortBandValidator),
downloads30dVsPeerP95: v.optional(v.number()),
spikeMultiplierVsPeerP95: v.optional(v.number()),
installDownloadRatio7: v.number(),
installDownloadRatio30: v.number(),
installDownloadExcessZScore7: v.number(),
installDownloadExcessZScore30: v.number(),
spikeWindowStartDay: v.optional(v.number()),
spikeWindowEndDay: v.optional(v.number()),
sustainedWindowStartDay: v.optional(v.number()),
sustainedWindowEndDay: v.optional(v.number()),
nearConversionWindowStartDay: v.optional(v.number()),
nearConversionWindowEndDay: v.optional(v.number()),
reasonCodes: v.array(v.string()),
});
@@ -1049,7 +1056,12 @@ export async function runTemporalPublisherAbuseScanInternalHandler(
...candidate,
temporalScore: classifySkillTemporalAbuseScore(candidate.temporalScore, benchmark),
}))
.filter((candidate) => candidate.temporalScore.spike || candidate.temporalScore.sustained);
.filter(
(candidate) =>
candidate.temporalScore.spike ||
candidate.temporalScore.sustained ||
candidate.temporalScore.nearConversion,
);
const flaggedPublishers = aggregateTemporalPublisherCandidates(highTemporalCandidates).length;
if (dryRun || (mode !== "current" && highTemporalCandidates.length === 0)) {
@@ -1267,6 +1279,7 @@ function temporalEvidenceFromCandidate(candidate: TemporalSkillCandidate) {
displayName: candidate.displayName,
spike: candidate.temporalScore.spike,
sustained: candidate.temporalScore.sustained,
nearConversion: candidate.temporalScore.nearConversion,
pressure: candidate.temporalScore.pressure,
recent7Downloads: candidate.temporalScore.recent7Downloads,
recent7Installs: candidate.temporalScore.recent7Installs,
@@ -1280,10 +1293,16 @@ function temporalEvidenceFromCandidate(candidate: TemporalSkillCandidate) {
spikeMultiplierCohortBand: candidate.temporalScore.spikeMultiplierCohortBand,
downloads30dVsPeerP95: candidate.temporalScore.downloads30dVsPeerP95,
spikeMultiplierVsPeerP95: candidate.temporalScore.spikeMultiplierVsPeerP95,
installDownloadRatio7: candidate.temporalScore.installDownloadRatio7,
installDownloadRatio30: candidate.temporalScore.installDownloadRatio30,
installDownloadExcessZScore7: candidate.temporalScore.installDownloadExcessZScore7,
installDownloadExcessZScore30: candidate.temporalScore.installDownloadExcessZScore30,
spikeWindowStartDay: candidate.temporalScore.spikeWindowStartDay,
spikeWindowEndDay: candidate.temporalScore.spikeWindowEndDay,
sustainedWindowStartDay: candidate.temporalScore.sustainedWindowStartDay,
sustainedWindowEndDay: candidate.temporalScore.sustainedWindowEndDay,
nearConversionWindowStartDay: candidate.temporalScore.nearConversionWindowStartDay,
nearConversionWindowEndDay: candidate.temporalScore.nearConversionWindowEndDay,
reasonCodes: candidate.temporalScore.reasonCodes,
};
}
+7 -2
View File
@@ -1098,7 +1098,6 @@ const skillSearchDigest = defineTable({
.index("by_active_recommended_rank", [
"softDeletedAt",
"statsStars",
"statsInstallsAllTime",
"statsDownloads",
"updatedAt",
])
@@ -1138,7 +1137,6 @@ const skillSearchDigest = defineTable({
"softDeletedAt",
"isSuspicious",
"statsStars",
"statsInstallsAllTime",
"statsDownloads",
"updatedAt",
])
@@ -2246,6 +2244,7 @@ const publisherAbuseScores = defineTable({
displayName: v.string(),
spike: v.boolean(),
sustained: v.boolean(),
nearConversion: v.optional(v.boolean()),
pressure: v.number(),
recent7Downloads: v.number(),
recent7Installs: v.number(),
@@ -2259,10 +2258,16 @@ const publisherAbuseScores = defineTable({
spikeMultiplierCohortBand: v.optional(v.union(v.literal("p95"), v.literal("p99"))),
downloads30dVsPeerP95: v.optional(v.number()),
spikeMultiplierVsPeerP95: v.optional(v.number()),
installDownloadRatio7: v.optional(v.number()),
installDownloadRatio30: v.optional(v.number()),
installDownloadExcessZScore7: v.optional(v.number()),
installDownloadExcessZScore30: v.optional(v.number()),
spikeWindowStartDay: v.optional(v.number()),
spikeWindowEndDay: v.optional(v.number()),
sustainedWindowStartDay: v.optional(v.number()),
sustainedWindowEndDay: v.optional(v.number()),
nearConversionWindowStartDay: v.optional(v.number()),
nearConversionWindowEndDay: v.optional(v.number()),
reasonCodes: v.array(v.string()),
}),
),
+11 -11
View File
@@ -1210,7 +1210,7 @@ describe("search helpers", () => {
expect(nameMatchScore).toBeGreaterThan(popularVectorScore);
});
it("adds a stars and installs popularity prior for equally relevant matches", () => {
it("adds stars and downloads popularity but ignores installs for equally relevant matches", () => {
const queryTokens = tokenize("notion");
const highDownloadsOnly = __test.scoreSkillResult(
queryTokens,
@@ -1219,22 +1219,22 @@ describe("search helpers", () => {
"notion-helper",
{ downloads: 1000, installsAllTime: 0, stars: 0 },
);
const trustedUsage = __test.scoreSkillResult(
const highInstallsOnly = __test.scoreSkillResult(
queryTokens,
0.5,
"Notion Helper",
"notion-helper",
{ downloads: 0, installsAllTime: 20, stars: 5 },
{ downloads: 0, installsAllTime: 1000, stars: 0 },
);
expect(trustedUsage).toBeGreaterThan(highDownloadsOnly);
expect(highDownloadsOnly).toBeGreaterThan(highInstallsOnly);
});
it("breaks capped popularity ties by stars and installs before downloads", async () => {
it("breaks capped popularity ties by stars and downloads before installs", async () => {
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
const trustedUsage = {
const installedOnly = {
skill: makePublicSkill({
id: "skills:trusted",
slug: "tool-trusted",
id: "skills:installed",
slug: "tool-installed",
displayName: "Tool",
downloads: 0,
installsAllTime: 1_000,
@@ -1251,7 +1251,7 @@ describe("search helpers", () => {
displayName: "Tool",
downloads: 1_000_000_000,
installsAllTime: 0,
stars: 0,
stars: 1_000,
}),
version: null,
ownerHandle: "owner",
@@ -1261,7 +1261,7 @@ describe("search helpers", () => {
.fn()
.mockResolvedValueOnce(null) // getExactSkillSlugMatch
.mockResolvedValueOnce([]) // directPrefixSkillMatches
.mockResolvedValueOnce([downloadedOnly, trustedUsage]); // lexicalFallbackSkills
.mockResolvedValueOnce([installedOnly, downloadedOnly]); // lexicalFallbackSkills
const result = await searchSkillsHandler(
{
@@ -1271,7 +1271,7 @@ describe("search helpers", () => {
{ query: "tool", limit: 2 },
);
expect(result.map((entry) => entry.skill.slug)).toEqual(["tool-trusted", "tool-downloaded"]);
expect(result.map((entry) => entry.skill.slug)).toEqual(["tool-downloaded", "tool-installed"]);
});
it("uses digest doc instead of full skill doc in hydrateResults but revalidates the owner", async () => {
+2 -8
View File
@@ -76,7 +76,6 @@ const SLUG_PREFIX_BOOST = 0.8;
const NAME_EXACT_BOOST = 1.1;
const NAME_PREFIX_BOOST = 0.6;
const STAR_POPULARITY_WEIGHT = 0.12;
const INSTALL_POPULARITY_WEIGHT = 0.04;
const DOWNLOAD_POPULARITY_WEIGHT = 0.005;
const MAX_POPULARITY_BOOST = 0.09;
const FALLBACK_SCAN_LIMIT = 2000;
@@ -124,6 +123,7 @@ function getLexicalBoost(queryTokens: string[], displayName: string, slug: strin
type PopularityStats = {
downloads: number;
/** Accepted for compatibility with existing callers, but ignored for ranking. */
installsAllTime?: number;
stars: number;
};
@@ -131,7 +131,6 @@ type PopularityStats = {
function getPopularityBoost(stats: PopularityStats) {
const rawBoost =
Math.log1p(Math.max(stats.stars, 0)) * STAR_POPULARITY_WEIGHT +
Math.log1p(Math.max(stats.installsAllTime ?? 0, 0)) * INSTALL_POPULARITY_WEIGHT +
Math.log1p(Math.max(stats.downloads, 0)) * DOWNLOAD_POPULARITY_WEIGHT;
return Math.min(rawBoost, MAX_POPULARITY_BOOST);
}
@@ -197,11 +196,7 @@ function classifySkillMatch(
}
function comparePopularityStats(a: PopularityStats, b: PopularityStats) {
return (
b.stars - a.stars ||
(b.installsAllTime ?? 0) - (a.installsAllTime ?? 0) ||
b.downloads - a.downloads
);
return b.stars - a.stars || b.downloads - a.downloads;
}
function mergeUniqueBySkillId(primary: SkillSearchEntry[], fallback: SkillSearchEntry[]) {
@@ -380,7 +375,6 @@ export const searchSkills: ReturnType<typeof action> = action({
entry.skill.slug,
{
downloads: entry.skill.stats.downloads,
installsAllTime: entry.skill.stats.installsAllTime,
stars: entry.skill.stats.stars,
},
),
+3 -6
View File
@@ -23,7 +23,6 @@ describe("skills.listPublicPageV4", () => {
expect(getSkillSearchDigestIndexFields("by_active_recommended_rank")).toEqual([
"softDeletedAt",
"statsStars",
"statsInstallsAllTime",
"statsDownloads",
"updatedAt",
]);
@@ -31,7 +30,6 @@ describe("skills.listPublicPageV4", () => {
"softDeletedAt",
"isSuspicious",
"statsStars",
"statsInstallsAllTime",
"statsDownloads",
"updatedAt",
]);
@@ -74,7 +72,7 @@ describe("skills.listPublicPageV4", () => {
).toBe("updated");
expect(
__test.resolveRecommendedPublicListSort({
decodedCursor: [undefined, 10, 20, 30, 123, 456, "skillSearchDigest:recommended"],
decodedCursor: [undefined, 10, 20, 123, 456, "skillSearchDigest:recommended"],
hasMissingRankStats: true,
}),
).toBe("recommended");
@@ -85,7 +83,6 @@ describe("skills.listPublicPageV4", () => {
false,
10,
20,
30,
123,
456,
"skillSearchDigest:nonsuspicious-recommended",
@@ -95,7 +92,7 @@ describe("skills.listPublicPageV4", () => {
).toBe("recommended");
});
it("sorts highlighted recommended results by stars, installs, downloads, then updatedAt", async () => {
it("sorts highlighted recommended results by stars, downloads, then updatedAt", async () => {
const result = await listPublicPageV4Handler(
makeHighlightedCtx([
makeDigest({
@@ -136,9 +133,9 @@ describe("skills.listPublicPageV4", () => {
expect(result.page.map((entry) => entry.skill.slug)).toEqual([
"stars-skill",
"installs-skill",
"downloads-skill",
"updated-skill",
"installs-skill",
]);
});
});
-2
View File
@@ -181,7 +181,6 @@ describe("public skill list deterministic cursors", () => {
expect(withIndex.mock.calls.map(([indexName]) => indexName)).toEqual([
"by_active_stats_stars",
"by_active_stats_installs_all_time",
"by_active_stats_downloads",
]);
expect(getPageMock).toHaveBeenCalledTimes(1);
@@ -204,7 +203,6 @@ describe("public skill list deterministic cursors", () => {
expect(withIndex.mock.calls.map(([indexName]) => indexName)).toEqual([
"by_nonsuspicious_stars",
"by_nonsuspicious_installs",
"by_nonsuspicious_downloads",
]);
expect(getPageMock).toHaveBeenCalledTimes(1);
+6 -23
View File
@@ -4931,7 +4931,7 @@ export const listPublicPageV3 = query({
type PublicListSort = keyof typeof SORT_INDEXES;
const SORT_INDEX_FIELD_COUNTS: Record<PublicListSort, number> = {
recommended: 5,
recommended: 4,
newest: 2,
updated: 2,
name: 2,
@@ -4941,7 +4941,7 @@ const SORT_INDEX_FIELD_COUNTS: Record<PublicListSort, number> = {
};
const NONSUSPICIOUS_SORT_INDEX_FIELD_COUNTS: Record<PublicListSort, number> = {
recommended: 6,
recommended: 5,
newest: 3,
updated: 3,
name: 3,
@@ -6131,22 +6131,13 @@ async function hasMissingRecommendedRankStats(
) {
if (decodedCursor) return false;
if (nonSuspiciousOnly) {
const [missingStars, missingInstalls, missingDownloads] = await Promise.all([
const [missingStars, missingDownloads] = await Promise.all([
ctx.db
.query("skillSearchDigest")
.withIndex("by_nonsuspicious_stars", (q) =>
q.eq("softDeletedAt", undefined).eq("isSuspicious", false).eq("statsStars", undefined),
)
.first(),
ctx.db
.query("skillSearchDigest")
.withIndex("by_nonsuspicious_installs", (q) =>
q
.eq("softDeletedAt", undefined)
.eq("isSuspicious", false)
.eq("statsInstallsAllTime", undefined),
)
.first(),
ctx.db
.query("skillSearchDigest")
.withIndex("by_nonsuspicious_downloads", (q) =>
@@ -6157,22 +6148,16 @@ async function hasMissingRecommendedRankStats(
)
.first(),
]);
return Boolean(missingStars || missingInstalls || missingDownloads);
return Boolean(missingStars || missingDownloads);
}
const [missingStars, missingInstalls, missingDownloads] = await Promise.all([
const [missingStars, missingDownloads] = await Promise.all([
ctx.db
.query("skillSearchDigest")
.withIndex("by_active_stats_stars", (q) =>
q.eq("softDeletedAt", undefined).eq("statsStars", undefined),
)
.first(),
ctx.db
.query("skillSearchDigest")
.withIndex("by_active_stats_installs_all_time", (q) =>
q.eq("softDeletedAt", undefined).eq("statsInstallsAllTime", undefined),
)
.first(),
ctx.db
.query("skillSearchDigest")
.withIndex("by_active_stats_downloads", (q) =>
@@ -6180,7 +6165,7 @@ async function hasMissingRecommendedRankStats(
)
.first(),
]);
return Boolean(missingStars || missingInstalls || missingDownloads);
return Boolean(missingStars || missingDownloads);
}
function readDigestRankStat(
@@ -6246,8 +6231,6 @@ async function fetchHighlightedPage(
case "recommended":
return (
(readDigestRankStat(a, "stars") - readDigestRankStat(b, "stars")) * multiplier ||
(readDigestRankStat(a, "installsAllTime") - readDigestRankStat(b, "installsAllTime")) *
multiplier ||
(readDigestRankStat(a, "downloads") - readDigestRankStat(b, "downloads")) * multiplier ||
(a.updatedAt - b.updatedAt) * multiplier
);
+214
View File
@@ -0,0 +1,214 @@
/* @vitest-environment node */
import { describe, expect, it, vi } from "vitest";
vi.mock("./functions", () => ({
internalAction: (def: { handler: unknown }) => ({ _handler: def.handler }),
internalMutation: (def: { handler: unknown }) => ({ _handler: def.handler }),
internalQuery: (def: { handler: unknown }) => ({ _handler: def.handler }),
mutation: (def: { handler: unknown }) => ({ _handler: def.handler }),
query: (def: { handler: unknown }) => ({ _handler: def.handler }),
}));
vi.mock("./_generated/api", () => ({
internal: {
skillStatEvents: {
processSkillStatEventsAction: Symbol("processSkillStatEventsAction"),
processSkillStatEventsInternal: Symbol("processSkillStatEventsInternal"),
},
},
}));
const { reportCliInstallInternal } = await import("./telemetry");
const reportCliInstallHandler = (
reportCliInstallInternal as unknown as {
_handler: (
ctx: unknown,
args: {
userId: string;
slug: string;
version?: string;
rootId?: string;
rootLabel?: string;
},
) => Promise<void>;
}
)._handler;
function makeIndexBuilder() {
const builder = {
eq: vi.fn(() => builder),
};
return builder;
}
describe("telemetry install events", () => {
it("records the first CLI install as an install stat event", async () => {
const skill = { _id: "skills:demo", slug: "demo" };
const insert = vi.fn();
const ctx = {
db: {
query: vi.fn((table: string) => ({
withIndex: vi.fn(
(indexName: string, callback: (q: ReturnType<typeof makeIndexBuilder>) => unknown) => {
callback(makeIndexBuilder());
if (table === "skills" && indexName === "by_slug") {
return { unique: async () => skill };
}
if (table === "userSyncRoots" && indexName === "by_user_root") {
return { unique: async () => null };
}
if (table === "userSkillRootInstalls" && indexName === "by_user_root_skill") {
return { unique: async () => null };
}
if (table === "userSkillInstalls" && indexName === "by_user_skill") {
return { unique: async () => null };
}
throw new Error(`unexpected query ${table}.${indexName}`);
},
),
})),
insert,
patch: vi.fn(),
},
};
await reportCliInstallHandler(ctx, {
userId: "users:one",
slug: "demo",
version: "1.0.0",
rootId: "root",
rootLabel: "~/skills",
});
expect(insert).toHaveBeenCalledWith(
"userSkillInstalls",
expect.objectContaining({
userId: "users:one",
skillId: "skills:demo",
activeRoots: 1,
lastVersion: "1.0.0",
}),
);
expect(insert).toHaveBeenCalledWith(
"userSyncRoots",
expect.objectContaining({
userId: "users:one",
rootId: "root",
label: "~/skills",
}),
);
expect(insert).toHaveBeenCalledWith(
"userSkillRootInstalls",
expect.objectContaining({
userId: "users:one",
rootId: "root",
skillId: "skills:demo",
lastVersion: "1.0.0",
}),
);
expect(insert).toHaveBeenCalledWith(
"skillStatEvents",
expect.objectContaining({
skillId: "skills:demo",
kind: "install_new",
}),
);
});
it("keeps repeated CLI install events idempotent per user and skill", async () => {
const skill = { _id: "skills:demo", slug: "demo" };
const existingInstall = {
_id: "userSkillInstalls:one",
userId: "users:one",
skillId: "skills:demo",
activeRoots: 1,
lastVersion: "1.0.0",
};
const insert = vi.fn();
const patch = vi.fn();
const ctx = {
db: {
query: vi.fn((table: string) => ({
withIndex: vi.fn(
(indexName: string, callback: (q: ReturnType<typeof makeIndexBuilder>) => unknown) => {
callback(makeIndexBuilder());
if (table === "skills" && indexName === "by_slug") {
return { unique: async () => skill };
}
if (table === "userSkillInstalls" && indexName === "by_user_skill") {
return { unique: async () => existingInstall };
}
throw new Error(`unexpected query ${table}.${indexName}`);
},
),
})),
insert,
patch,
},
};
await reportCliInstallHandler(ctx, {
userId: "users:one",
slug: "demo",
version: "1.0.1",
});
expect(patch).toHaveBeenCalledWith(
"userSkillInstalls:one",
expect.objectContaining({ activeRoots: 1, lastVersion: "1.0.1" }),
);
expect(insert).not.toHaveBeenCalledWith("skillStatEvents", expect.anything());
});
it("reactivates an inactive CLI install for current install counts", async () => {
const skill = { _id: "skills:demo", slug: "demo" };
const existingInstall = {
_id: "userSkillInstalls:one",
userId: "users:one",
skillId: "skills:demo",
activeRoots: 0,
lastVersion: "1.0.0",
};
const insert = vi.fn();
const patch = vi.fn();
const ctx = {
db: {
query: vi.fn((table: string) => ({
withIndex: vi.fn(
(indexName: string, callback: (q: ReturnType<typeof makeIndexBuilder>) => unknown) => {
callback(makeIndexBuilder());
if (table === "skills" && indexName === "by_slug") {
return { unique: async () => skill };
}
if (table === "userSkillInstalls" && indexName === "by_user_skill") {
return { unique: async () => existingInstall };
}
throw new Error(`unexpected query ${table}.${indexName}`);
},
),
})),
insert,
patch,
},
};
await reportCliInstallHandler(ctx, {
userId: "users:one",
slug: "demo",
version: "1.0.1",
});
expect(patch).toHaveBeenCalledWith(
"userSkillInstalls:one",
expect.objectContaining({ activeRoots: 1, lastVersion: "1.0.1" }),
);
expect(insert).toHaveBeenCalledWith(
"skillStatEvents",
expect.objectContaining({
skillId: "skills:demo",
kind: "install_reactivate",
}),
);
});
});
+105 -194
View File
@@ -1,53 +1,69 @@
import { getAuthUserId } from "@convex-dev/auth/server";
import { v } from "convex/values";
import type { Id } from "./_generated/dataModel";
import type { MutationCtx, QueryCtx } from "./_generated/server";
import type { MutationCtx } from "./_generated/server";
import { internalMutation, mutation, query } from "./functions";
import { requireUser } from "./lib/access";
import { insertStatEvent } from "./skillStatEvents";
const TELEMETRY_STALE_MS = 120 * 24 * 60 * 60 * 1000;
type RootPayload = {
rootId: string;
label: string;
skills: Array<{ slug: string; version?: string | null }>;
};
export const reportCliSyncInternal = internalMutation({
export const reportCliInstallInternal = internalMutation({
args: {
userId: v.id("users"),
roots: v.array(
v.object({
rootId: v.string(),
label: v.string(),
skills: v.array(
v.object({
slug: v.string(),
version: v.optional(v.string()),
}),
),
}),
),
slug: v.string(),
version: v.optional(v.string()),
rootId: v.optional(v.string()),
rootLabel: v.optional(v.string()),
},
handler: async (ctx, args) => {
const slug = args.slug.trim().toLowerCase();
if (!slug) return;
const skill = await ctx.db
.query("skills")
.withIndex("by_slug", (q) => q.eq("slug", slug))
.unique();
if (!skill || skill.softDeletedAt) return;
const now = Date.now();
const stalenessCutoff = now - TELEMETRY_STALE_MS;
await expireStaleRoots(ctx, { userId: args.userId, stalenessCutoff, now });
const roots = normalizeRoots(args.roots);
const skillsBySlug = await resolveSkillsBySlug(ctx, roots);
for (const root of roots) {
await upsertRoot(ctx, { userId: args.userId, rootId: root.rootId, now, label: root.label });
await applyRootReport(ctx, {
const rootId = args.rootId?.trim();
if (rootId) {
await upsertSingleRootInstall(ctx, {
userId: args.userId,
root,
skillsBySlug,
skillId: skill._id,
rootId,
label: args.rootLabel?.trim() || "Unknown",
now,
version: args.version,
});
return;
}
const existing = await ctx.db
.query("userSkillInstalls")
.withIndex("by_user_skill", (q) => q.eq("userId", args.userId).eq("skillId", skill._id))
.unique();
if (existing) {
const wasInactive = existing.activeRoots <= 0;
await ctx.db.patch(existing._id, {
lastSeenAt: now,
activeRoots: Math.max(1, existing.activeRoots),
lastVersion: args.version,
});
if (wasInactive) {
await insertStatEvent(ctx, { skillId: skill._id, kind: "install_reactivate" });
}
return;
}
await ctx.db.insert("userSkillInstalls", {
userId: args.userId,
skillId: skill._id,
firstSeenAt: now,
lastSeenAt: now,
activeRoots: 1,
lastVersion: args.version,
});
await insertStatEvent(ctx, { skillId: skill._id, kind: "install_new" });
},
});
@@ -199,26 +215,63 @@ async function clearTelemetryForUser(ctx: MutationCtx, params: { userId: Id<"use
}
}
function normalizeRoots(roots: RootPayload[]): RootPayload[] {
const seen = new Set<string>();
const unique: RootPayload[] = [];
for (const root of roots) {
const id = root.rootId.trim();
if (!id) continue;
if (seen.has(id)) continue;
seen.add(id);
unique.push({
rootId: id,
label: root.label.trim() || "Unknown",
skills: root.skills
.map((skill) => ({
slug: skill.slug.trim().toLowerCase(),
version: skill.version ?? null,
}))
.filter((skill) => Boolean(skill.slug)),
async function upsertSingleRootInstall(
ctx: MutationCtx,
params: {
userId: Id<"users">;
skillId: Id<"skills">;
rootId: string;
label: string;
now: number;
version?: string;
},
) {
await upsertRoot(ctx, {
userId: params.userId,
rootId: params.rootId,
label: params.label,
now: params.now,
});
const existing = await ctx.db
.query("userSkillRootInstalls")
.withIndex("by_user_root_skill", (q) =>
q.eq("userId", params.userId).eq("rootId", params.rootId).eq("skillId", params.skillId),
)
.unique();
if (existing) {
const wasRemoved = Boolean(existing.removedAt);
await ctx.db.patch(existing._id, {
lastSeenAt: params.now,
lastVersion: params.version ?? existing.lastVersion,
removedAt: undefined,
});
if (wasRemoved) {
await incrementActiveRoots(ctx, {
userId: params.userId,
skillId: params.skillId,
now: params.now,
version: params.version,
});
}
return;
}
return unique;
await ctx.db.insert("userSkillRootInstalls", {
userId: params.userId,
rootId: params.rootId,
skillId: params.skillId,
firstSeenAt: params.now,
lastSeenAt: params.now,
lastVersion: params.version,
});
await incrementActiveRoots(ctx, {
userId: params.userId,
skillId: params.skillId,
now: params.now,
version: params.version,
});
}
async function upsertRoot(
@@ -247,85 +300,6 @@ async function upsertRoot(
});
}
async function applyRootReport(
ctx: MutationCtx,
params: {
userId: Id<"users">;
root: RootPayload;
skillsBySlug: Map<string, { skillId: Id<"skills"> }>;
now: number;
},
) {
const expected = new Set<Id<"skills">>();
const versionsBySkill = new Map<Id<"skills">, string | undefined>();
for (const entry of params.root.skills) {
const resolved = params.skillsBySlug.get(entry.slug);
if (!resolved) continue;
expected.add(resolved.skillId);
const version = entry.version?.trim() || undefined;
if (version) versionsBySkill.set(resolved.skillId, version);
}
const previous = await ctx.db
.query("userSkillRootInstalls")
.withIndex("by_user_root", (q) =>
q.eq("userId", params.userId).eq("rootId", params.root.rootId),
)
.take(5000);
const active = previous.filter((entry) => !entry.removedAt);
for (const skillId of expected) {
const existing = await ctx.db
.query("userSkillRootInstalls")
.withIndex("by_user_root_skill", (q) =>
q.eq("userId", params.userId).eq("rootId", params.root.rootId).eq("skillId", skillId),
)
.unique();
const reportedVersion = versionsBySkill.get(skillId);
if (existing) {
const wasRemoved = Boolean(existing.removedAt);
await ctx.db.patch(existing._id, {
lastSeenAt: params.now,
lastVersion: reportedVersion ?? existing.lastVersion,
removedAt: undefined,
});
if (wasRemoved) {
await incrementActiveRoots(ctx, {
userId: params.userId,
skillId,
now: params.now,
version: reportedVersion,
});
}
continue;
}
await ctx.db.insert("userSkillRootInstalls", {
userId: params.userId,
rootId: params.root.rootId,
skillId,
firstSeenAt: params.now,
lastSeenAt: params.now,
lastVersion: reportedVersion,
});
await incrementActiveRoots(ctx, {
userId: params.userId,
skillId,
now: params.now,
version: reportedVersion,
});
}
for (const entry of active) {
if (expected.has(entry.skillId)) continue;
await ctx.db.patch(entry._id, { removedAt: params.now });
await decrementActiveRoots(ctx, { userId: params.userId, skillId: entry.skillId });
}
}
async function incrementActiveRoots(
ctx: MutationCtx,
params: { userId: Id<"users">; skillId: Id<"skills">; now: number; version?: string },
@@ -367,27 +341,6 @@ async function incrementActiveRoots(
}
}
async function decrementActiveRoots(
ctx: MutationCtx,
params: { userId: Id<"users">; skillId: Id<"skills"> },
) {
const existing = await ctx.db
.query("userSkillInstalls")
.withIndex("by_user_skill", (q) => q.eq("userId", params.userId).eq("skillId", params.skillId))
.unique();
if (!existing) return;
const nextActive = Math.max(0, (existing.activeRoots ?? 0) - 1);
await ctx.db.patch(existing._id, { activeRoots: nextActive });
if ((existing.activeRoots ?? 0) > 0 && nextActive === 0) {
await bumpSkillInstallCounts(ctx, {
skillId: params.skillId,
deltaAllTime: 0,
deltaCurrent: -1,
});
}
}
async function bumpSkillInstallCounts(
ctx: MutationCtx,
params: { skillId: Id<"skills">; deltaAllTime: number; deltaCurrent: number },
@@ -396,47 +349,5 @@ async function bumpSkillInstallCounts(
await insertStatEvent(ctx, { skillId: params.skillId, kind: "install_new" });
} else if (params.deltaAllTime === 0 && params.deltaCurrent === 1) {
await insertStatEvent(ctx, { skillId: params.skillId, kind: "install_reactivate" });
} else if (params.deltaAllTime === 0 && params.deltaCurrent === -1) {
await insertStatEvent(ctx, { skillId: params.skillId, kind: "install_deactivate" });
}
}
async function expireStaleRoots(
ctx: MutationCtx,
params: { userId: Id<"users">; stalenessCutoff: number; now: number },
) {
const roots = await ctx.db
.query("userSyncRoots")
.withIndex("by_user", (q) => q.eq("userId", params.userId))
.take(5000);
const stale = roots.filter((root) => !root.expiredAt && root.lastSeenAt < params.stalenessCutoff);
for (const root of stale) {
await ctx.db.patch(root._id, { expiredAt: params.now });
const installs = await ctx.db
.query("userSkillRootInstalls")
.withIndex("by_user_root", (q) => q.eq("userId", params.userId).eq("rootId", root.rootId))
.take(5000);
for (const entry of installs) {
if (entry.removedAt) continue;
await ctx.db.patch(entry._id, { removedAt: params.now });
await decrementActiveRoots(ctx, { userId: params.userId, skillId: entry.skillId });
}
}
}
async function resolveSkillsBySlug(ctx: QueryCtx | MutationCtx, roots: RootPayload[]) {
const slugs = new Set<string>();
for (const root of roots) {
for (const entry of root.skills) slugs.add(entry.slug);
}
const map = new Map<string, { skillId: Id<"skills"> }>();
for (const slug of slugs) {
const skill = await ctx.db
.query("skills")
.withIndex("by_slug", (q) => q.eq("slug", slug))
.unique();
if (skill && !skill.softDeletedAt) map.set(slug, { skillId: skill._id });
}
return map;
}
+1 -1
View File
@@ -30,7 +30,7 @@ Reading order:
6. `docs/skill-format.md`: skill bundle metadata and package shape.
7. `docs/soul-format.md`: SOUL.md bundle format.
8. `docs/auth.md`: GitHub OAuth, API tokens, and CLI login.
9. `docs/telemetry.md`: what `clawhub sync` reports and how to opt out.
9. `docs/telemetry.md`: install telemetry and how to opt out.
10. `docs/troubleshooting.md`: user-facing CLI, install, publish, sync, update, and API fixes.
Policy, API, and trust docs:
+1 -1
View File
@@ -89,7 +89,7 @@ Public read:
- Optional filter: `nonSuspiciousOnly=true`
- Legacy alias: `nonSuspicious=true`
- With `nonSuspiciousOnly=true`, cursor-based pages may contain fewer than `limit` items; use `nextCursor` to continue.
- `recommended` ranks by stars, then all-time installs, then downloads, then `updatedAt`.
- `recommended` ranks by stars, then downloads, then `updatedAt`.
- `GET /api/v1/skills/{slug}`
- `GET /api/v1/skills/{slug}/moderation`
- `GET /api/v1/skills/{slug}/versions?limit=&cursor=`
+4 -4
View File
@@ -14,7 +14,7 @@ sidebarTitle: "ClawHub"
ClawHub is the public registry for OpenClaw skills and plugins.
- Use native `openclaw` commands to search, install, and update skills and to install plugins from ClawHub.
- Use the separate `clawhub` CLI for registry auth, publishing, delete/undelete, and sync workflows.
- Use the separate `clawhub` CLI for registry auth, publishing, sync, and delete/undelete workflows.
Site: [clawhub.ai](https://clawhub.ai)
@@ -151,8 +151,8 @@ hide or restore content, and ban abusive accounts. See
## Telemetry and environment
When you run `clawhub sync` while logged in, the CLI sends a minimal snapshot so
ClawHub can compute install counts. Disable this with:
When you run `clawhub install` while logged in, the CLI may send a best-effort
install event so ClawHub can compute aggregate install counts. Disable this with:
```bash
export CLAWHUB_DISABLE_TELEMETRY=1
@@ -166,7 +166,7 @@ Useful environment overrides:
| `CLAWHUB_REGISTRY` | Override the registry API URL. |
| `CLAWHUB_CONFIG_PATH` | Override where the CLI stores token/config state. |
| `CLAWHUB_WORKDIR` | Override the default working directory. |
| `CLAWHUB_DISABLE_TELEMETRY=1` | Disable telemetry on `sync`. |
| `CLAWHUB_DISABLE_TELEMETRY=1` | Disable install telemetry. |
See [Telemetry](./telemetry.md), [HTTP API](./http-api.md), and
[Troubleshooting](./troubleshooting.md) for deeper reference material.
+11 -4
View File
@@ -1,5 +1,5 @@
---
summary: "CLI reference: commands, flags, config, lockfile, sync behavior."
summary: "CLI reference: commands, flags, config, lockfile, and sync behavior."
read_when:
- Using the ClawHub CLI
- Debugging install, update, publish, or sync
@@ -141,6 +141,8 @@ Stores your API token + cached registry URL.
### `uninstall <slug>`
- Removes `<workdir>/<dir>/<slug>` and deletes the lockfile entry.
- Sends best-effort telemetry while logged in so current install counts can be
deactivated.
- Interactive: asks for confirmation.
- Non-interactive (`--no-input`): requires `--yes`.
@@ -717,10 +719,15 @@ Notes:
- `--bump patch|minor|major` (default: patch)
- `--changelog <text>` (non-interactive)
- `--tags a,b,c` (default: latest)
- `--concurrency <n>` (default: 4)
- `--concurrency <n>`
- `--source-repo <repo>`, `--source-commit <sha>`, `--source-ref <ref>` for GitHub provenance
Telemetry:
`sync` does not report install telemetry.
- Sent during `sync` when logged in, unless `CLAWHUB_DISABLE_TELEMETRY=1` (legacy `CLAWDHUB_DISABLE_TELEMETRY=1`).
### Install telemetry
- Sent after `clawhub install <slug>` when logged in, unless
`CLAWHUB_DISABLE_TELEMETRY=1` is set.
- Reporting is best-effort. Install commands do not fail if telemetry is
unavailable.
- Details: `docs/telemetry.md`.
+4 -4
View File
@@ -123,10 +123,10 @@ Response:
Notes:
- Results are returned in relevance order (embedding similarity + exact slug/name token boosts + a small popularity prior from stars, all-time installs, and downloads).
- Results are returned in relevance order (embedding similarity + exact slug/name token boosts + a small popularity prior from stars and downloads).
- Relevance is stronger than popularity. A precise slug or display-name token match can outrank a looser match with many more downloads.
- ASCII text is tokenized on word and punctuation boundaries. For example, `personal-map` contains a standalone `map` token, while `amap-jsapi-skill` contains `amap`, `jsapi`, and `skill`; searching for `map` therefore gives `personal-map` a stronger lexical match than `amap-jsapi-skill`.
- Popularity is log-scaled and capped. Stars carry the strongest weight, all-time installs carry a smaller weight, and downloads are only a tiny fallback signal. High-download skills can rank lower when the query text is a weaker match.
- Popularity is log-scaled and capped. Stars carry the strongest weight, and downloads are the fallback popularity signal. High-download skills can rank lower when the query text is a weaker match.
- Suspicious or hidden moderation state can remove a skill from public search depending on caller filters and current moderation status.
Publisher discoverability guidance:
@@ -150,7 +150,7 @@ Invalid `sort` values return `400`.
Notes:
- `recommended` ranks by stars, then all-time installs, then downloads, then `updatedAt`.
- `recommended` ranks by stars, then downloads, then `updatedAt`.
- `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.
- When `nonSuspiciousOnly=true`, cursor-based sorts may return fewer than `limit` items on a page because suspicious skills are filtered after page retrieval.
@@ -1540,7 +1540,7 @@ Still supported for older CLI versions:
- `GET /api/cli/whoami`
- `POST /api/cli/upload-url`
- `POST /api/cli/publish`
- `POST /api/cli/telemetry/sync`
- `POST /api/cli/telemetry/install`
- `POST /api/cli/skill/delete`
- `POST /api/cli/skill/undelete`
+16 -8
View File
@@ -18,23 +18,31 @@ normal install and download surfaces until review finishes.
## Skills
The simplest publishing path is the CLI. Sign in, preview the sync plan, then
publish the new or changed skills:
The simplest publishing path is the CLI. Sign in, then publish a local skill
folder:
```bash
clawhub login
clawhub skill publish ./my-skill \
--slug my-skill \
--name "My Skill" \
--version 1.0.0 \
--owner <owner>
```
Use `--owner <handle>` when publishing to an org owner. Omit it to publish as
the authenticated user.
For catalog repos, use `sync` to scan folders containing `SKILL.md` and publish
new or changed skills:
```bash
clawhub sync --dry-run --owner <owner>
clawhub sync --all --owner <owner>
```
`sync` scans for folders containing `SKILL.md` and compares them with ClawHub.
When you run it without `--dry-run`, it publishes anything new or changed.
Use `--dry-run` first to see the plan without uploading.
Use `--owner <handle>` when publishing to an org owner. Omit it to publish as
the authenticated user.
### GitHub Actions for Skills
If you want to run skill publishing from CI, call ClawHub's reusable
-4
View File
@@ -138,10 +138,6 @@ jobs:
dry_run: true
```
When you are signed in, `sync` may also send a minimal install snapshot for
aggregate install counts. See [Telemetry](./telemetry.md) for what is reported
and how to opt out.
## Inspect before installing
Before installing, use the ClawHub web page or CLI detail commands to inspect
+16 -40
View File
@@ -1,5 +1,5 @@
---
summary: "Install telemetry collected via `clawhub sync` + opt-out."
summary: "Install telemetry collected by the ClawHub CLI and how to opt out."
read_when:
- Working on telemetry / privacy controls
- Questions about what data is collected
@@ -7,76 +7,52 @@ read_when:
# Telemetry
ClawHub uses **minimal telemetry** to compute **install counts** (whats actually in use) and to power better sorting/filtering.
This is based on the CLI `clawhub sync` command.
ClawHub uses minimal CLI telemetry to compute aggregate install counts.
## When telemetry is collected
Telemetry is only sent when:
- You are **logged in** in the CLI (we already require auth for sync/publish flows).
- You run `clawhub sync`.
- You are logged in in the CLI.
- You run `clawhub install <slug>`.
- Telemetry is **not disabled** (see “How to disable” below).
If you are not logged in, nothing is reported.
## What we collect
On each `clawhub sync`, the CLI reports a **full snapshot** of what it found, grouped by scan root (“folder/root”).
On each reported `clawhub install`, the CLI sends one best-effort install event.
For each root we store:
The event includes:
- `rootId`: a **SHA-256 hash** of the canonical root path (server never sees the raw path).
- `label`: a human-readable label derived from the last two path segments (home paths are shown with `~`).
- `firstSeenAt`, `lastSeenAt`, optional `expiredAt`.
For each skill found under a root we store:
- `skillId` (resolved by slug; only skills that exist in the registry are tracked).
- `firstSeenAt`, `lastSeenAt`.
- `lastVersion` (best-effort; currently the registry-matched version if known).
- optional `removedAt` when a previously-reported install disappears from a root.
- `rootLabel`: a short label derived from the last two path segments (home paths are shown with `~`).
- `slug`: the installed skill slug.
- `version`: the installed version, when known.
### What we do _not_ collect
- No raw absolute folder paths (only hashed `rootId` + a short display label).
- No file contents.
- No per-run logs, prompts, or other CLI output.
- No tracking for skills that arent uploaded to the registry (unknown slugs are ignored).
## Install counts
We maintain two counters per skill:
ClawHub maintains aggregate counters per skill:
- `installsCurrent`: unique users who currently have the skill installed in at least one active root.
- `installsAllTime`: unique users who have ever reported the skill installed.
### Multiple roots
If you sync from multiple folders, we treat each scan root independently. A skill is “currently installed” if it exists in **any** active root.
### Uninstall detection
Because `sync` reports the full set per root:
- If a skill disappears from a root on the next sync, we mark it removed for that root.
- If the skill is removed from all of your roots, it no longer counts toward `installsCurrent`.
- `installsAllTime` never decreases unless you delete telemetry (see below).
### Staleness (120 days)
Roots that dont report telemetry for **120 days** are marked stale and their installs stop counting toward `installsCurrent`.
This is evaluated lazily (on the next telemetry report) to avoid background jobs.
- `installsAllTime`: unique users who have reported at least one CLI install for the skill.
- `installsCurrent`: unique users who have reported an install and have not deleted their
telemetry.
## Transparency + user controls
ClawHub provides a private “Installed” tab on your own profile:
- Shows the exact roots + installed skills we store.
- Shows install telemetry associated with your account.
- Includes a **JSON export** view.
- Includes a **Delete telemetry** action to remove all stored telemetry for your account.
Everyone else only sees **aggregated install counters**; no one else can see your roots/folders.
Everyone else only sees **aggregated install counters**.
Deleting your account also deletes your telemetry data.
@@ -88,4 +64,4 @@ Set the environment variable:
export CLAWHUB_DISABLE_TELEMETRY=1
```
With this set, the CLI will not send telemetry during `clawhub sync`.
With this set, the CLI will not send install telemetry.
+15 -9
View File
@@ -806,17 +806,23 @@ registerCommand(program, ["sync"])
.option("--bump <type>", "Version bump for updates (patch|minor|major)", "patch")
.option("--changelog <text>", "Changelog to use for updates (non-interactive)")
.option("--tags <tags>", "Comma-separated tags", "latest")
.option("--concurrency <n>", "Concurrent registry checks (default: 4)", "4")
.option("--no-clawdbot-roots", "Only scan the configured workdir/dir and --root values")
.option("--source-repo <repo>", "GitHub repo (owner/repo or URL)")
.option("--source-commit <sha>", "Git commit SHA")
.option("--source-ref <ref>", "Git ref/tag/branch")
.option("--concurrency <n>", "Concurrent registry/file checks", (value) =>
Number.parseInt(value, 10),
)
.option("--source-repo <repo>", "GitHub repo URL or owner/name for source provenance")
.option("--source-commit <sha>", "Git commit SHA for source provenance")
.option("--source-ref <ref>", "Git ref for source provenance")
.addOption(
new Option("--clawdbot-roots", "Include Clawdbot-configured roots").default(true, "enabled"),
)
.addOption(new Option("--no-clawdbot-roots", "Disable Clawdbot-configured roots"))
.action(async (options) => {
const opts = await resolveGlobalOpts();
const bump = String(options.bump ?? "patch") as "patch" | "minor" | "major";
if (!["patch", "minor", "major"].includes(bump)) fail("--bump must be patch|minor|major");
const concurrencyRaw = Number(options.concurrency ?? 4);
const concurrency = Number.isFinite(concurrencyRaw) ? Math.round(concurrencyRaw) : 4;
const bump =
options.bump === "patch" || options.bump === "minor" || options.bump === "major"
? options.bump
: fail("--bump must be patch, minor, or major");
const concurrency = options.concurrency ?? 6;
if (concurrency < 1 || concurrency > 32) fail("--concurrency must be between 1 and 32");
await cmdSync(
opts,
@@ -970,13 +970,11 @@ describe("cmdInstall", () => {
path: LegacyApiRoutes.cliTelemetryInstall,
token: "tkn",
body: {
roots: [
{
rootId: expect.any(String),
label: expect.any(String),
skills: [{ slug: "demo", version: "1.0.0" }],
},
],
event: "install",
slug: "demo",
version: "1.0.0",
rootId: expect.any(String),
rootLabel: expect.any(String),
},
}),
expect.anything(),
+2 -1
View File
@@ -259,7 +259,8 @@ export async function cmdInstall(
token,
registry,
root: opts.dir,
skills: lock.skills,
slug: skillMeta.skill?.slug ?? trimmed,
version: resolvedVersion,
});
spinner.succeed(`OK. Installed ${trimmed} -> ${target}`);
} catch (error) {
@@ -779,9 +779,8 @@ describe("cmdSync", () => {
expect(mockCmdPublish).not.toHaveBeenCalled();
});
it("skips telemetry when CLAWHUB_DISABLE_TELEMETRY is set", async () => {
it("does not report install telemetry from sync", async () => {
interactive = false;
process.env.CLAWHUB_DISABLE_TELEMETRY = "1";
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
if (args.path === "/api/v1/whoami") return { user: { handle: "steipete" } };
if (args.path.startsWith("/api/v1/resolve?")) {
@@ -794,6 +793,5 @@ describe("cmdSync", () => {
expect(
mockApiRequest.mock.calls.some((call) => call[1]?.path === "/api/cli/telemetry/install"),
).toBe(false);
delete process.env.CLAWHUB_DISABLE_TELEMETRY;
});
});
-13
View File
@@ -21,10 +21,8 @@ import {
formatSyncedSummary,
getRegistryWithAuth,
mapWithConcurrency,
mergeScan,
normalizeConcurrency,
printSection,
reportTelemetryIfEnabled,
resolvePublishMeta,
scanRootsWithLabels,
selectToUpload,
@@ -54,7 +52,6 @@ export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllow
const spinner = jsonMode ? null : createSpinner("Scanning for local skills");
const primaryScan = await scanRootsWithLabels(combinedRoots, clawdbotRoots.labels);
let scan = primaryScan;
let telemetryScan = primaryScan;
if (primaryScan.skills.length === 0) {
if (!includeClawdbotRoots) {
fail("No skills found (checked configured roots)");
@@ -62,7 +59,6 @@ export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllow
const fallback = getFallbackSkillRoots(opts.workdir);
const fallbackScan = await scanRootsWithLabels(fallback);
spinner?.stop();
telemetryScan = mergeScan(primaryScan, fallbackScan);
scan = fallbackScan;
if (fallbackScan.skills.length === 0)
fail("No skills found (checked workdir and known Clawdis/Clawd locations)");
@@ -137,15 +133,6 @@ export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllow
candidatesSpinner?.stop();
}
if (token) {
await reportTelemetryIfEnabled({
token,
registry,
scan: telemetryScan,
candidates,
});
}
const synced = candidates.filter((candidate) => candidate.status === "synced");
const actionable = candidates.filter((candidate) => candidate.status !== "synced");
const bump = options.bump ?? "patch";
@@ -6,7 +6,7 @@ import semver from "semver";
import { resolveHome } from "../../homedir.js";
import { apiRequest, downloadZip } from "../../http.js";
import {
ApiCliTelemetrySyncResponseSchema,
ApiCliTelemetryInstallResponseSchema,
ApiRoutes,
ApiV1SkillResolveResponseSchema,
ApiV1SkillResponseSchema,
@@ -20,56 +20,16 @@ import type { GlobalOpts } from "../types.js";
import { fail, formatError } from "../ui.js";
import type { Candidate, LocalSkill } from "./syncTypes.js";
export async function reportTelemetryIfEnabled(params: {
token: string;
registry: string;
scan: { roots: string[]; skillsByRoot: Record<string, SkillFolder[]> };
candidates: Candidate[];
}) {
if (isTelemetryDisabled()) return;
const versionBySlug = new Map<string, string | null>();
for (const candidate of params.candidates) {
versionBySlug.set(candidate.slug, candidate.matchVersion ?? null);
}
const roots = params.scan.roots.map((root) => ({
rootId: rootTelemetryId(root),
label: formatRootLabel(root),
skills: (params.scan.skillsByRoot[root] ?? []).map((skill) => ({
slug: skill.slug,
version: versionBySlug.get(skill.slug) ?? null,
})),
}));
try {
await apiRequest(
params.registry,
{
method: "POST",
path: LegacyApiRoutes.cliTelemetryInstall,
token: params.token,
body: { roots },
},
ApiCliTelemetrySyncResponseSchema,
);
} catch {
// ignore telemetry failures
}
}
export async function reportInstalledSkillsTelemetryIfEnabled(params: {
token: string | undefined;
registry: string;
root: string;
skills: Record<string, { version?: string | null }>;
slug: string;
version?: string | null;
}) {
if (!params.token || isTelemetryDisabled()) return;
const skills = Object.entries(params.skills)
.map(([slug, entry]) => ({
slug,
version: entry.version ?? null,
}))
.filter((skill) => Boolean(skill.slug));
const slug = params.slug.trim();
if (!slug) return;
try {
await apiRequest(
@@ -79,16 +39,14 @@ export async function reportInstalledSkillsTelemetryIfEnabled(params: {
path: LegacyApiRoutes.cliTelemetryInstall,
token: params.token,
body: {
roots: [
{
rootId: rootTelemetryId(params.root),
label: formatRootLabel(params.root),
skills,
},
],
event: "install",
slug,
version: params.version ?? undefined,
rootId: rootTelemetryId(params.root),
rootLabel: formatRootLabel(params.root),
},
},
ApiCliTelemetrySyncResponseSchema,
ApiCliTelemetryInstallResponseSchema,
);
} catch {
// Install telemetry is best-effort; local installs must not fail because
@@ -242,37 +200,6 @@ export async function scanRootsWithLabels(roots: string[], labels?: Record<strin
};
}
export function mergeScan(
left: {
roots: string[];
skillsByRoot: Record<string, SkillFolder[]>;
skills: SkillFolder[];
rootsWithSkills: string[];
rootLabels: Record<string, string>;
},
right: {
roots: string[];
skillsByRoot: Record<string, SkillFolder[]>;
skills: SkillFolder[];
rootsWithSkills: string[];
rootLabels: Record<string, string>;
},
) {
const mergedRoots = Array.from(new Set([...left.roots, ...right.roots]));
const skillsByRoot: Record<string, SkillFolder[]> = {};
for (const root of mergedRoots) {
skillsByRoot[root] = right.skillsByRoot[root] ?? left.skillsByRoot[root] ?? [];
}
const rootLabels: Record<string, string> = { ...left.rootLabels, ...right.rootLabels };
const byFolder = new Map<string, SkillFolder>();
for (const entry of [...left.skills, ...right.skills]) {
byFolder.set(entry.folder, entry);
}
const skills = Array.from(byFolder.values());
const rootsWithSkills = mergedRoots.filter((root) => (skillsByRoot[root]?.length ?? 0) > 0);
return { roots: mergedRoots, skillsByRoot, skills, rootsWithSkills, rootLabels };
}
async function dedupeRoots(roots: string[]) {
const seen = new Set<string>();
const unique: string[] = [];
-1
View File
@@ -7,7 +7,6 @@ export const LegacyApiRoutes = {
cliUploadUrl: "/api/cli/upload-url",
cliPublish: "/api/cli/publish",
cliTelemetryInstall: "/api/cli/telemetry/install",
cliTelemetrySync: "/api/cli/telemetry/sync",
cliSkillDelete: "/api/cli/skill/delete",
cliSkillUndelete: "/api/cli/skill/undelete",
} as const;
+8 -11
View File
@@ -152,19 +152,16 @@ export const ApiV1SkillInstallResolveResponseSchema = type({
export type ApiV1SkillInstallResolveResponse =
(typeof ApiV1SkillInstallResolveResponseSchema)[inferred];
export const CliTelemetrySyncRequestSchema = type({
roots: type({
rootId: "string",
label: "string",
skills: type({
slug: "string",
version: "string|null?",
}).array(),
}).array(),
export const CliTelemetryInstallRequestSchema = type({
event: '"install"',
slug: "string",
version: "string?",
rootId: "string?",
rootLabel: "string?",
});
export type CliTelemetrySyncRequest = (typeof CliTelemetrySyncRequestSchema)[inferred];
export type CliTelemetryInstallRequest = (typeof CliTelemetryInstallRequestSchema)[inferred];
export const ApiCliTelemetrySyncResponseSchema = type({
export const ApiCliTelemetryInstallResponseSchema = type({
ok: "true",
});
@@ -1,9 +1,12 @@
/* @vitest-environment node */
import { spawnSync } from "node:child_process";
import { spawn, spawnSync } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { strToU8, zipSync } from "fflate";
import { afterEach, describe, expect, it } from "vitest";
const packageRoot = resolve(import.meta.dirname, "..");
@@ -12,6 +15,7 @@ const binPath = join(packageRoot, "bin", "clawdhub.js");
const distCliPath = join(packageRoot, "dist", "cli.js");
const tempDirs: string[] = [];
const servers: Server[] = [];
async function makeTmpDir(prefix: string) {
const dir = await mkdtemp(join(tmpdir(), prefix));
@@ -19,15 +23,44 @@ async function makeTmpDir(prefix: string) {
return dir;
}
function runNode(args: string[]) {
function runNode(args: string[], envOverrides: NodeJS.ProcessEnv = {}) {
const { FORCE_COLOR: _forceColor, ...env } = process.env;
return spawnSync("node", args, {
cwd: repoRoot,
encoding: "utf8",
env,
env: { ...env, ...envOverrides },
});
}
async function runNodeAsync(args: string[], envOverrides: NodeJS.ProcessEnv = {}) {
const { FORCE_COLOR: _forceColor, ...env } = process.env;
const child = spawn("node", args, {
cwd: repoRoot,
env: { ...env, ...envOverrides },
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
const timeout = setTimeout(() => child.kill("SIGTERM"), 20_000);
const result = await new Promise<{ status: number | null; signal: NodeJS.Signals | null }>(
(resolveExit, rejectExit) => {
child.on("error", rejectExit);
child.on("exit", (status, signal) => resolveExit({ status, signal }));
},
);
clearTimeout(timeout);
return { ...result, stdout, stderr };
}
function runGit(cwd: string, args: string[]) {
const result = spawnSync("git", ["-C", cwd, ...args], {
encoding: "utf8",
@@ -40,11 +73,155 @@ function runGit(cwd: string, args: string[]) {
}
afterEach(async () => {
while (servers.length > 0) {
await new Promise<void>((resolveClose, rejectClose) => {
const server = servers.pop()!;
server.closeAllConnections();
server.close((error) => (error ? rejectClose(error) : resolveClose()));
});
}
while (tempDirs.length > 0) {
await rm(tempDirs.pop()!, { recursive: true, force: true });
}
});
type RecordedRequest = {
method: string;
path: string;
authorization?: string;
body?: unknown;
};
async function readRequestBody(request: IncomingMessage) {
const chunks: Buffer[] = [];
for await (const chunk of request) chunks.push(Buffer.from(chunk));
return Buffer.concat(chunks).toString("utf8");
}
function writeJson(response: ServerResponse, status: number, body: unknown) {
response.writeHead(status, { "Content-Type": "application/json" });
response.end(JSON.stringify(body));
}
async function startLocalRegistry() {
const requests: RecordedRequest[] = [];
const skillZip = zipSync({
"SKILL.md": strToU8("# Demo\n\nA local registry fixture.\n"),
});
const server = createServer(async (request, response) => {
const url = new URL(request.url ?? "/", "http://127.0.0.1");
const bodyText = await readRequestBody(request);
const recorded: RecordedRequest = {
method: request.method ?? "GET",
path: `${url.pathname}${url.search}`,
authorization: request.headers.authorization,
};
if (bodyText) recorded.body = JSON.parse(bodyText) as unknown;
requests.push(recorded);
if (request.method === "GET" && url.pathname === "/api/v1/whoami") {
writeJson(response, 200, {
user: { handle: "artifact-user", displayName: "Artifact User", role: "user" },
});
return;
}
if (request.method === "GET" && url.pathname === "/api/v1/skills/demo") {
writeJson(response, 200, {
skill: {
slug: "demo",
displayName: "Demo",
summary: "Local fixture",
tags: {},
stats: {},
createdAt: 1,
updatedAt: 2,
},
latestVersion: {
version: "1.0.0",
createdAt: 2,
changelog: "Initial",
license: "MIT-0",
},
owner: null,
moderation: {
isSuspicious: false,
isMalwareBlocked: false,
verdict: "clean",
reasonCodes: [],
updatedAt: null,
engineVersion: null,
summary: null,
},
});
return;
}
if (request.method === "GET" && url.pathname === "/api/v1/skills/demo/versions/1.0.0") {
writeJson(response, 200, {
version: {
version: "1.0.0",
createdAt: 2,
changelog: "Initial",
changelogSource: "user",
license: "MIT-0",
files: [],
},
skill: {
slug: "demo",
displayName: "Demo",
},
});
return;
}
if (request.method === "GET" && url.pathname === "/api/v1/download") {
expect(url.searchParams.get("slug")).toBe("demo");
expect(url.searchParams.get("version")).toBe("1.0.0");
response.writeHead(200, { "Content-Type": "application/zip" });
response.end(Buffer.from(skillZip));
return;
}
if (request.method === "GET" && url.pathname === "/api/v1/resolve") {
writeJson(response, 200, {
match: { version: "1.0.0" },
latestVersion: { version: "1.0.0" },
});
return;
}
if (request.method === "POST" && url.pathname === "/api/cli/telemetry/install") {
writeJson(response, 200, { ok: true });
return;
}
writeJson(response, 404, { error: `Unhandled ${request.method} ${url.pathname}` });
});
await new Promise<void>((resolveListen, rejectListen) => {
server.once("error", rejectListen);
server.listen(0, "127.0.0.1", () => {
server.off("error", rejectListen);
resolveListen();
});
});
servers.push(server);
const address = server.address() as AddressInfo;
return {
registry: `http://127.0.0.1:${address.port}`,
requests,
};
}
async function writeConfigWithToken(root: string, registry: string) {
const configPath = join(root, "config.json");
await writeFile(configPath, JSON.stringify({ registry, token: "test-token" }), "utf8");
return configPath;
}
describe("built CLI artifact", () => {
it("runs help from the published bin entrypoint", async () => {
const result = runNode([binPath, "--help"]);
@@ -54,6 +231,45 @@ describe("built CLI artifact", () => {
expect(result.stdout).toContain("ClawHub CLI");
});
it("prints help by default", async () => {
const result = runNode([binPath]);
expect(result.status).toBe(0);
expect(result.stderr).toBe("");
expect(result.stdout).toContain("Usage: clawhub");
expect(result.stdout).toContain("sync");
});
it("runs sync for bare logged-in invocations", async () => {
const { registry, requests } = await startLocalRegistry();
const workdir = await makeTmpDir("clawhub-artifact-bare-sync-");
const configPath = await writeConfigWithToken(workdir, registry);
const skillDir = join(workdir, "skills", "demo");
await mkdir(skillDir, { recursive: true });
await writeFile(join(skillDir, "SKILL.md"), "# Demo\n\nA local sync fixture.\n", "utf8");
const result = await runNodeAsync(
[binPath, "--workdir", workdir, "--registry", registry, "--no-input"],
{ CLAWHUB_CONFIG_PATH: configPath },
);
expect(result.status).toBe(0);
expect(result.stdout).not.toContain("Usage: clawhub");
expect(requests.map((request) => request.path)).toContain("/api/v1/whoami");
expect(requests.map((request) => request.path)).toContainEqual(
expect.stringMatching(/^\/api\/v1\/resolve\?slug=demo&hash=/),
);
});
it("exposes sync help for reusable publishing workflows", async () => {
const result = runNode([binPath, "sync", "--help"]);
expect(result.status).toBe(0);
expect(result.stderr).toBe("");
expect(result.stdout).toContain("Usage: clawhub sync");
expect(result.stdout).toContain("Scan local skills and publish new/updated ones");
});
it("reports unknown top-level commands clearly", async () => {
const result = runNode([binPath, "nope"]);
@@ -70,6 +286,24 @@ describe("built CLI artifact", () => {
expect(result.stderr).not.toContain("too many arguments");
});
it("rejects invalid sync bump values before scanning", async () => {
const workdir = await makeTmpDir("clawhub-artifact-invalid-bump-");
const result = runNode([
binPath,
"--workdir",
workdir,
"sync",
"--bump",
"banana",
"--dry-run",
"--no-clawdbot-roots",
]);
expect(result.status).toBe(1);
expect(result.stderr).toContain("--bump must be patch, minor, or major");
expect(result.stderr).not.toContain("No skills found");
});
it("does not mask unknown global options", async () => {
const result = runNode([binPath, "--bad", "nope"]);
@@ -161,6 +395,91 @@ describe("built CLI artifact", () => {
expect(output.commit).toBeTypeOf("string");
});
it("sends one explicit install telemetry event from the built install command", async () => {
const { registry, requests } = await startLocalRegistry();
const workdir = await makeTmpDir("clawhub-artifact-install-");
const configPath = await writeConfigWithToken(workdir, registry);
const result = await runNodeAsync(
[
binPath,
"--workdir",
workdir,
"--registry",
registry,
"install",
"demo",
"--version",
"1.0.0",
],
{ CLAWHUB_CONFIG_PATH: configPath },
);
expect(result.status).toBe(0);
expect(result.stderr).toContain("OK. Installed demo");
const telemetryRequests = requests.filter(
(request) => request.path === "/api/cli/telemetry/install",
);
expect(telemetryRequests).toHaveLength(1);
expect(telemetryRequests[0]).toEqual({
method: "POST",
path: "/api/cli/telemetry/install",
authorization: "Bearer test-token",
body: {
event: "install",
slug: "demo",
version: "1.0.0",
rootId: expect.any(String),
rootLabel: expect.stringContaining("skills"),
},
});
expect(requests.map((request) => request.path)).toEqual([
"/api/v1/skills/demo",
"/api/v1/skills/demo/versions/1.0.0",
"/api/v1/download?slug=demo&version=1.0.0",
"/api/cli/telemetry/install",
]);
});
it("does not send install telemetry from the built sync command", async () => {
const { registry, requests } = await startLocalRegistry();
const workdir = await makeTmpDir("clawhub-artifact-sync-");
const configPath = await writeConfigWithToken(workdir, registry);
const skillDir = join(workdir, "skills", "demo");
await mkdir(skillDir, { recursive: true });
await writeFile(join(skillDir, "SKILL.md"), "# Demo\n\nA local sync fixture.\n", "utf8");
const result = await runNodeAsync(
[
binPath,
"--workdir",
workdir,
"--registry",
registry,
"sync",
"--dry-run",
"--json",
"--no-clawdbot-roots",
],
{ CLAWHUB_CONFIG_PATH: configPath },
);
expect(result.status).toBe(0);
expect(result.stderr).toBe("");
const output = JSON.parse(result.stdout.trim()) as { ok: boolean; dryRun: boolean };
expect(output).toMatchObject({ ok: true, dryRun: true });
expect(
requests.filter((request) => request.path.startsWith("/api/cli/telemetry/")),
).toHaveLength(0);
expect(requests.map((request) => request.path)).toEqual([
"/api/v1/whoami",
expect.stringMatching(/^\/api\/v1\/resolve\?slug=demo&hash=/),
]);
});
it("keeps the built dist free of compiled test files", async () => {
expect(dirname(distCliPath)).toBe(join(packageRoot, "dist"));
const result = runNode([
-1
View File
@@ -7,7 +7,6 @@ export declare const LegacyApiRoutes: {
readonly cliUploadUrl: "/api/cli/upload-url";
readonly cliPublish: "/api/cli/publish";
readonly cliTelemetryInstall: "/api/cli/telemetry/install";
readonly cliTelemetrySync: "/api/cli/telemetry/sync";
readonly cliSkillDelete: "/api/cli/skill/delete";
readonly cliSkillUndelete: "/api/cli/skill/undelete";
};
-1
View File
@@ -7,7 +7,6 @@ export const LegacyApiRoutes = {
cliUploadUrl: "/api/cli/upload-url",
cliPublish: "/api/cli/publish",
cliTelemetryInstall: "/api/cli/telemetry/install",
cliTelemetrySync: "/api/cli/telemetry/sync",
cliSkillDelete: "/api/cli/skill/delete",
cliSkillUndelete: "/api/cli/skill/undelete",
};
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;IAC9B,mBAAmB,EAAE,4BAA4B;IACjD,gBAAgB,EAAE,yBAAyB;IAC3C,cAAc,EAAE,uBAAuB;IACvC,gBAAgB,EAAE,yBAAyB;CACnC,CAAC;AAEX,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,gBAAgB,EAAE,4BAA4B;IAC9C,MAAM,EAAE,gBAAgB;IACxB,UAAU,EAAE,uBAAuB;IACnC,OAAO,EAAE,iBAAiB;IAC1B,aAAa,EAAE,wBAAwB;IACvC,QAAQ,EAAE,kBAAkB;IAC5B,WAAW,EAAE,sBAAsB;IACnC,aAAa,EAAE,wBAAwB;IACvC,KAAK,EAAE,eAAe;IACtB,SAAS,EAAE,mBAAmB;IAC9B,UAAU,EAAE,oBAAoB;IAChC,KAAK,EAAE,eAAe;IACtB,KAAK,EAAE,eAAe;IACtB,MAAM,EAAE,gBAAgB;IACxB,YAAY,EAAE,uBAAuB;CAC7B,CAAC"}
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;IAC9B,mBAAmB,EAAE,4BAA4B;IACjD,cAAc,EAAE,uBAAuB;IACvC,gBAAgB,EAAE,yBAAyB;CACnC,CAAC;AAEX,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,gBAAgB,EAAE,4BAA4B;IAC9C,MAAM,EAAE,gBAAgB;IACxB,UAAU,EAAE,uBAAuB;IACnC,OAAO,EAAE,iBAAiB;IAC1B,aAAa,EAAE,wBAAwB;IACvC,QAAQ,EAAE,kBAAkB;IAC5B,WAAW,EAAE,sBAAsB;IACnC,aAAa,EAAE,wBAAwB;IACvC,KAAK,EAAE,eAAe;IACtB,SAAS,EAAE,mBAAmB;IAC9B,UAAU,EAAE,oBAAoB;IAChC,KAAK,EAAE,eAAe;IACtB,KAAK,EAAE,eAAe;IACtB,MAAM,EAAE,gBAAgB;IACxB,YAAY,EAAE,uBAAuB;CAC7B,CAAC"}
+8 -11
View File
@@ -149,18 +149,15 @@ export declare const ApiV1SkillInstallResolveResponseSchema: import("arktype/int
status: number;
}, {}>;
export type ApiV1SkillInstallResolveResponse = (typeof ApiV1SkillInstallResolveResponseSchema)[inferred];
export declare const CliTelemetrySyncRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{
roots: {
rootId: string;
label: string;
skills: {
slug: string;
version?: string | null | undefined;
}[];
}[];
export declare const CliTelemetryInstallRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{
event: "install";
slug: string;
version?: string | undefined;
rootId?: string | undefined;
rootLabel?: string | undefined;
}, {}>;
export type CliTelemetrySyncRequest = (typeof CliTelemetrySyncRequestSchema)[inferred];
export declare const ApiCliTelemetrySyncResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
export type CliTelemetryInstallRequest = (typeof CliTelemetryInstallRequestSchema)[inferred];
export declare const ApiCliTelemetryInstallResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
ok: true;
}, {}>;
export declare const ApiV1WhoamiResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
+7 -10
View File
@@ -127,17 +127,14 @@ export const ApiV1SkillInstallResolveResponseSchema = type({
message: "string",
status: "number",
});
export const CliTelemetrySyncRequestSchema = type({
roots: type({
rootId: "string",
label: "string",
skills: type({
slug: "string",
version: "string|null?",
}).array(),
}).array(),
export const CliTelemetryInstallRequestSchema = type({
event: '"install"',
slug: "string",
version: "string?",
rootId: "string?",
rootLabel: "string?",
});
export const ApiCliTelemetrySyncResponseSchema = type({
export const ApiCliTelemetryInstallResponseSchema = type({
ok: "true",
});
export const ApiV1WhoamiResponseSchema = type({
File diff suppressed because one or more lines are too long
-1
View File
@@ -7,7 +7,6 @@ export const LegacyApiRoutes = {
cliUploadUrl: "/api/cli/upload-url",
cliPublish: "/api/cli/publish",
cliTelemetryInstall: "/api/cli/telemetry/install",
cliTelemetrySync: "/api/cli/telemetry/sync",
cliSkillDelete: "/api/cli/skill/delete",
cliSkillUndelete: "/api/cli/skill/undelete",
} as const;
+8 -11
View File
@@ -153,19 +153,16 @@ export const ApiV1SkillInstallResolveResponseSchema = type({
export type ApiV1SkillInstallResolveResponse =
(typeof ApiV1SkillInstallResolveResponseSchema)[inferred];
export const CliTelemetrySyncRequestSchema = type({
roots: type({
rootId: "string",
label: "string",
skills: type({
slug: "string",
version: "string|null?",
}).array(),
}).array(),
export const CliTelemetryInstallRequestSchema = type({
event: '"install"',
slug: "string",
version: "string?",
rootId: "string?",
rootLabel: "string?",
});
export type CliTelemetrySyncRequest = (typeof CliTelemetrySyncRequestSchema)[inferred];
export type CliTelemetryInstallRequest = (typeof CliTelemetryInstallRequestSchema)[inferred];
export const ApiCliTelemetrySyncResponseSchema = type({
export const ApiCliTelemetryInstallResponseSchema = type({
ok: "true",
});