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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# lovstudio

## 0.5.1

### Patch Changes

- Keep `skills add` working when the GitHub API rate limit is exhausted. The catalog is read from the GitHub API (using `GITHUB_TOKEN` / `GH_TOKEN` when set, sent only to api.github.com), then falls back to raw.githubusercontent.com and jsDelivr on any failure such as `HTTP 403`. A mirror that answers with something other than a catalog no longer passes as an empty one, and installs still stop when no source returns the catalog.

## 0.5.0

### Minor Changes
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ automatically via their declared `install` command.
Under the hood:
- `account connect` → browser device confirmation on `lovstudio.ai`; the shared session is stored owner-only under `~/.lovstudio/` for local Agent tools
- `skills add <name>` → resolves the product slug in `skills.yaml`, then passes its exact `runtime_name` to `npx -y skills@latest add lovstudio/skills --skill <runtime_name>` (vercel-labs/skills)
- `skills.yaml` is read from the GitHub API first (authenticated with `GITHUB_TOKEN` / `GH_TOKEN` when set; anonymous calls share 60 requests per hour per IP), then from raw.githubusercontent.com, then from jsDelivr. `LOVSTUDIO_SKILLS_CATALOG_URL` replaces the whole chain. If no source answers, nothing is installed
- `skills add skills` → resolves the unified catalog and passes every free Skill's exact `runtime_name`
- `license *` → `uvx lovstudio-skill-helper *` (pinned version)

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "lovstudio",
"version": "0.5.0",
"version": "0.5.1",
"description": "Lovstudio CLI — install free Skills directly and redeem paid Skills with Credits.",
"type": "module",
"packageManager": "npm@11.17.0",
Expand Down
67 changes: 48 additions & 19 deletions src/commands/skills/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,16 @@ const SKILLS_NPX_SPEC = "skills@latest";
const SKILL_PREFIX = "lov-";
const LEGACY_SKILL_PREFIX = "lovstudio-";
const ALL_SKILLS_NAMES = new Set(["*", "all", "skills", GALLERY]);
const CATALOG_URL = process.env.LOVSTUDIO_SKILLS_CATALOG_URL ||
`https://api.github.com/repos/${GALLERY}/contents/skills.yaml?ref=main`;
const CATALOG_SOURCES = process.env.LOVSTUDIO_SKILLS_CATALOG_URL
? [process.env.LOVSTUDIO_SKILLS_CATALOG_URL]
: [
// The API reflects a push immediately but allows only 60 anonymous
// requests per hour per IP, which a shared NAT can exhaust. The CDN
// copies lag by minutes (raw) to hours (jsDelivr) and are not rate limited.
`https://api.github.com/repos/${GALLERY}/contents/skills.yaml?ref=main`,
`https://raw.githubusercontent.com/${GALLERY}/main/skills.yaml`,
`https://cdn.jsdelivr.net/gh/${GALLERY}@main/skills.yaml`,
];
const WEB_URL = (process.env.LOVSTUDIO_WEB_URL || "https://lovstudio.ai").replace(/\/$/, "");

function isAllSkillsName(name) {
Expand Down Expand Up @@ -58,27 +66,48 @@ async function requireAccountToken() {
}
}

async function loadCatalog() {
const response = await hfetch(CATALOG_URL, {
headers: { accept: "application/vnd.github+json, text/yaml, text/plain" },
function catalogRequestHeaders(url) {
// The raw media type makes the API return the file itself instead of base64
// JSON, which GitHub leaves empty once the file passes 1 MB.
const headers = { accept: "application/vnd.github.raw+json, text/yaml, text/plain" };
const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
// Only GitHub's API receives the token; mirrors and custom URLs never see it.
if (token && new URL(url).hostname === "api.github.com") {
headers.authorization = `Bearer ${token}`;
}
return headers;
}

async function fetchCatalogSource(url) {
const response = await hfetch(url, {
headers: catalogRequestHeaders(url),
signal: AbortSignal.timeout(15_000),
});
if (!response.ok) {
throw new Error(`catalog request failed: HTTP ${response.status}`);
}
const contentType = response.headers.get("content-type") || "";
let text;
if (contentType.includes("application/json")) {
const payload = await response.json();
if (typeof payload?.content !== "string") {
throw new Error("GitHub API catalog response has no encoded content");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = parseYaml(await response.text());
// A blocked or hijacked mirror can answer 200 with an HTML page; that must
// not pass as an empty catalog.
if (!Array.isArray(data?.skills)) throw new Error("response is not a skills catalog");
return data.skills.filter((skill) => !skill.test);
}

// Every source serves the same public file, so the first one that answers
// wins. Callers still refuse to install when all of them fail.
export async function loadCatalog(sources = CATALOG_SOURCES) {
const failures = [];
for (const url of sources) {
try {
const skills = await fetchCatalogSource(url);
if (failures.length) {
console.error(`Lovstudio Skills 目录改用 ${new URL(url).host} 读取(${failures.join("; ")})。`);
}
return skills;
} catch (error) {
const cause = error?.cause?.code ? ` (${error.cause.code})` : "";
failures.push(`${new URL(url).host} ${error instanceof Error ? error.message : String(error)}${cause}`);
}
text = Buffer.from(payload.content, "base64").toString("utf8");
} else {
text = await response.text();
}
const data = parseYaml(text) || {};
return Array.isArray(data.skills) ? data.skills.filter((skill) => !skill.test) : [];
throw new Error(`catalog request failed: ${failures.join("; ")}`);
}

export function canonicalSkillName(name) {
Expand Down
57 changes: 57 additions & 0 deletions test/skills-command.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,67 @@ import {
catalogSkillSelector,
canonicalSkillName,
findCatalogSkill,
loadCatalog,
locateExtractedSkill,
skillSelector,
} from "../src/commands/skills/index.mjs";

const API_CATALOG = "https://api.github.com/repos/lovstudio/skills/contents/skills.yaml?ref=main";
const RAW_CATALOG = "https://raw.githubusercontent.com/lovstudio/skills/main/skills.yaml";
const CDN_CATALOG = "https://cdn.jsdelivr.net/gh/lovstudio/skills@main/skills.yaml";

function withEnv(t, name, value) {
const previous = process.env[name];
process.env[name] = value;
t.after(() => {
if (previous === undefined) delete process.env[name];
else process.env[name] = previous;
});
}

test("loadCatalog falls back to the raw mirror when the GitHub API answers 403", async (t) => {
withEnv(t, "GITHUB_TOKEN", "test-token");
t.mock.method(console, "error", () => {});
const requests = [];
t.mock.method(globalThis, "fetch", async (url, init) => {
requests.push({ url, authorization: init.headers.authorization });
if (url === API_CATALOG) {
return new Response('{"message":"API rate limit exceeded"}', {
status: 403,
headers: { "content-type": "application/json" },
});
}
return new Response("version: 1\nskills:\n- name: wxmp-cli\n paid: false\n- name: smoke\n test: true\n", {
headers: { "content-type": "text/plain; charset=utf-8" },
});
});

const catalog = await loadCatalog([API_CATALOG, RAW_CATALOG, CDN_CATALOG]);

assert.deepEqual(catalog.map((skill) => skill.name), ["wxmp-cli"]);
assert.deepEqual(requests, [
{ url: API_CATALOG, authorization: "Bearer test-token" },
{ url: RAW_CATALOG, authorization: undefined },
]);
});

test("loadCatalog fails instead of returning an empty catalog when every source fails", async (t) => {
t.mock.method(globalThis, "fetch", async (url) => {
if (url === API_CATALOG) return new Response("", { status: 429 });
if (url === RAW_CATALOG) return new Response("<html>blocked</html>", { headers: { "content-type": "text/html" } });
throw new TypeError("fetch failed", { cause: Object.assign(new Error("getaddrinfo"), { code: "ENOTFOUND" }) });
});

await assert.rejects(
loadCatalog([API_CATALOG, RAW_CATALOG, CDN_CATALOG]),
{
message: "catalog request failed: api.github.com HTTP 429; " +
"raw.githubusercontent.com response is not a skills catalog; " +
"cdn.jsdelivr.net fetch failed (ENOTFOUND)",
},
);
});

test("catalogSkillDependencyClosure installs transitive Skill dependencies first", () => {
const catalog = [
{ name: "branding-consistency", runtime_name: "lov-branding-consistency" },
Expand Down
Loading