fix(desktop): try the skills CLI first, fall back to the GitHub download

The skills CLI stays the default so installs keep tracking Vercel's
tooling. When it fails for a GitHub-hosted skill, for any reason (no Node
or npx on the app's PATH, Node older than the CLI requires, no git, or
anything else), the skill is installed from the repository archive
instead. The failure is not classified: the CLI's output is localized and
changes between releases. If the fallback fails too, the error carries
both messages. Non-GitHub sources have no fallback and get a Node hint
next to the existing Git hint.
This commit is contained in:
Mikołaj Kondratek
2026-10-19 21:50:18 +02:00
parent c76454e0e7
commit 2aeb4615c6
2 changed files with 155 additions and 65 deletions
@@ -34,8 +34,8 @@ const GOAL_ENTRY = {
install: { args: ["goal"] },
};
// A non-GitHub source: GitHub sources install in-process and never reach the
// skills CLI, so only this path can produce the CLI's errors.
// A non-GitHub source: for those the skills CLI is the only path, so its
// errors reach the user unchanged (GitHub sources fall back to a download).
const SKILL_ENTRY = {
id: "example-skill",
type: "skill",
@@ -302,67 +302,124 @@ describe("marketplace skill install", () => {
install: { args: ["cline/skills", "--skill", "review-team"] },
};
it("installs GitHub skills in-process without spawning npx", async () => {
const spawnCommand = vi.fn();
it("uses the skills CLI when it works and does not touch GitHub", async () => {
const spawnCommand = vi.fn(async () => {
const installPath = join(tempHome, ".agents", "skills", "review-team");
await mkdir(installPath, { recursive: true });
await writeFile(join(installPath, "SKILL.md"), "# Review Team");
return { exitCode: 0, stdout: "installed", stderr: "" };
});
const result = await installMarketplaceEntry(
{ entry: REVIEW_TEAM_ENTRY },
{ spawnCommand },
);
expect(spawnCommand).toHaveBeenCalledWith(
"npx",
expect.arrayContaining([SKILLS_CLI_PACKAGE, "add", "cline/skills"]),
);
expect(installGitHubSkillMock).not.toHaveBeenCalled();
expect(result).toMatchObject({
status: "installed",
message: "Installed Review Team globally for Cline.",
});
});
it.each([
[
"npx missing from PATH",
() => Promise.reject(new Error("spawn npx ENOENT")),
],
[
"npx not recognized (Windows cmd)",
async () => ({
exitCode: 1,
stdout: "",
stderr:
"'npx' is not recognized as an internal or external command, operable program or batch file.",
}),
],
[
"Node too old for skills@latest",
async () => ({
exitCode: 1,
stdout: "",
stderr:
"npm warn EBADENGINE Unsupported engine { required: { node: '>=22.20.0' } }\nSyntaxError: The requested module 'node:zlib' does not provide an export named 'crc32'",
}),
],
[
"git missing",
async () => ({
exitCode: 1,
stdout: "",
stderr:
"Failed to clone https://github.com/cline/skills.git: Error: spawn git ENOENT",
}),
],
])("falls back to installing from GitHub when the skills CLI fails: %s", async (_case, spawn) => {
const spawnCommand = vi.fn(spawn);
const result = await installMarketplaceEntry(
{ entry: REVIEW_TEAM_ENTRY },
{ spawnCommand },
);
expect(spawnCommand).toHaveBeenCalledTimes(1);
// The accepted names are the ones the installed-check looks for, so the
// installer lands the skill where this entry will find it again.
expect(installGitHubSkillMock).toHaveBeenCalledWith(
{ owner: "cline", repo: "skills", skill: "review-team" },
{ acceptedNames: ["review-team"] },
);
expect(spawnCommand).not.toHaveBeenCalled();
expect(result).toMatchObject({
status: "installed",
message: "Installed Review Team globally for Cline.",
});
expect(result.output).toContain(
"Installed from the GitHub repository instead.",
);
expect(result.output).toContain("Skipped link: skills/review-team/linked");
});
it("surfaces download failures as a skill install error", async () => {
it("reports both failures when the CLI and the GitHub fallback fail", async () => {
const spawnCommand = vi.fn(async () => ({
exitCode: 1,
stdout: "",
stderr: "Failed to clone: Error: spawn git ENOENT",
}));
installGitHubSkillMock.mockRejectedValueOnce(
new Error("Could not download cline/skills from GitHub: HTTP 503"),
);
await expect(
installMarketplaceEntry({ entry: REVIEW_TEAM_ENTRY }),
installMarketplaceEntry({ entry: REVIEW_TEAM_ENTRY }, { spawnCommand }),
).rejects.toThrow(
"Skill install failed: Could not download cline/skills from GitHub: HTTP 503",
/Skill install failed\.\n\nskills CLI: Skill install failed with exit code 1:\nGit is required[\s\S]*\n\nDownload from GitHub: Could not download cline\/skills from GitHub: HTTP 503/,
);
});
it("falls back to the skills CLI for sources that are not on GitHub", async () => {
const spawnCommand = vi.fn(async () => {
const installPath = join(tempHome, ".agents", "skills", "local-skill");
await mkdir(installPath, { recursive: true });
await writeFile(join(installPath, "SKILL.md"), "# local");
return { exitCode: 0, stdout: "done", stderr: "" };
});
it("has no fallback for sources that are not on GitHub", async () => {
const spawnCommand = vi.fn(async () => ({
exitCode: 1,
stdout: "",
stderr:
"'npx' is not recognized as an internal or external command, operable program or batch file.",
}));
await installMarketplaceEntry(
{
entry: {
id: "local-skill",
type: "skill",
install: { args: ["https://gitlab.com/team/local-skill"] },
await expect(
installMarketplaceEntry(
{
entry: {
id: "local-skill",
type: "skill",
install: { args: ["https://gitlab.com/team/local-skill"] },
},
},
},
{ spawnCommand },
);
{ spawnCommand },
),
).rejects.toThrow(/The skills CLI needs Node\.js 22\.20 or newer on PATH/);
expect(installGitHubSkillMock).not.toHaveBeenCalled();
expect(spawnCommand).toHaveBeenCalledWith(
"npx",
expect.arrayContaining([
SKILLS_CLI_PACKAGE,
"add",
"https://gitlab.com/team/local-skill",
]),
);
});
});
@@ -783,10 +783,32 @@ async function installSkill(
};
}
ensureGlobalSkillsDirWritable();
// The skills CLI is the default so installs track Vercel's tooling. It
// needs Node (>=22.20, on the app's PATH) and git, which many Desktop users
// don't have; a GUI app can also inherit a stale PATH. When it fails for a
// GitHub-hosted skill, install from the repository archive ourselves. The
// failure isn't classified: the CLI's output is localized and changes
// between releases, and if the fallback fails too the user sees both.
const githubSource = parseGitHubSkillSource(entry.install.args ?? []);
const output = githubSource
? await installSkillFromGitHub(entry, githubSource)
: await installSkillWithSkillsCli(entry, spawnCommand);
let output: string | undefined;
try {
output = await installSkillWithSkillsCli(entry, spawnCommand);
} catch (cliError) {
if (!githubSource) throw cliError;
const cliMessage = errorMessage(cliError);
try {
output = [
cliMessage,
"",
"Installed from the GitHub repository instead.",
await installSkillFromGitHub(entry, githubSource),
].join("\n");
} catch (fallbackError) {
throw new Error(
`Skill install failed.\n\nskills CLI: ${cliMessage}\n\nDownload from GitHub: ${errorMessage(fallbackError)}`,
);
}
}
return {
id: entry.id,
type: entry.type,
@@ -796,55 +818,66 @@ async function installSkill(
};
}
// Catalog skills all live on GitHub, so they install in-process: shelling out
// to `npx skills` failed for users without Node on the app's PATH (a GUI app
// can inherit a stale or minimal one) or with a Node older than what
// skills@latest requires.
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function installSkillFromGitHub(
entry: MarketplaceInstallInput,
source: GitHubSkillSource,
): Promise<string> {
try {
const result = await installGitHubSkill(source, {
acceptedNames: getSkillInstallCandidates(entry),
});
return [
`Path: ${result.installPath}`,
...result.skippedPaths.map((path) => `Skipped link: ${path}`),
].join("\n");
} catch (error) {
throw new Error(
`Skill install failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
const result = await installGitHubSkill(source, {
acceptedNames: getSkillInstallCandidates(entry),
});
return [
`Path: ${result.installPath}`,
...result.skippedPaths.map((path) => `Skipped link: ${path}`),
].join("\n");
}
const MISSING_NODE_HINT =
"The skills CLI needs Node.js 22.20 or newer on PATH. Install or update Node.js, or restart Cline after installing it so the new PATH is picked up.\n";
async function installSkillWithSkillsCli(
entry: MarketplaceInstallInput,
spawnCommand: SpawnCommand,
): Promise<string | undefined> {
const result = await spawnCommand("npx", [
"-y",
SKILLS_CLI_PACKAGE,
"add",
...(entry.install.args ?? []),
"-g",
"-a",
"cline",
"-y",
]);
let result: SpawnResult;
try {
result = await spawnCommand("npx", [
"-y",
SKILLS_CLI_PACKAGE,
"add",
...(entry.install.args ?? []),
"-g",
"-a",
"cline",
"-y",
]);
} catch (error) {
throw new Error(
`Skill install failed: could not run the skills CLI (${errorMessage(error)}).\n${MISSING_NODE_HINT}`,
);
}
const output = commandOutput(result);
const missingGitHint = /\bspawn\s+git\s+ENOENT\b/i.test(output ?? "")
? "Git is required to install this skill. Install Git, make sure it is on PATH, and retry.\n"
: "";
// Best effort: cmd.exe's "not recognized" message is localized.
const missingNodeHint =
/not recognized as an internal or external command|npx: (command )?not found|EBADENGINE|Unsupported engine/i.test(
output ?? "",
)
? MISSING_NODE_HINT
: "";
if (result.exitCode !== 0) {
throw new Error(
`Skill install failed with exit code ${result.exitCode}${output ? `:\n${missingGitHint}${output}` : ""}`,
`Skill install failed with exit code ${result.exitCode}${output ? `:\n${missingGitHint}${missingNodeHint}${output}` : ""}`,
);
}
if (/\bFailed to install\b/i.test(output ?? "")) {
throw new Error(
`Skill install failed${output ? `:\n${missingGitHint}${output}` : ""}`,
`Skill install failed${output ? `:\n${missingGitHint}${missingNodeHint}${output}` : ""}`,
);
}
// The CLI picks the directory name itself, so confirm it landed where the