Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/agents/agent-skills.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ Xum reads one MCP file: an `mcpServers` path replaces `.mcp.json` instead of add

#### Skills repositories

**Settings → Plugins** also installs a repository of plain skills (`SKILL.md` folders, without a plugin manifest) as a managed plugin. Xum finds the skills with the rules of the [skills CLI](https://github.com/vercel-labs/skills) 1.7.4: a valid `SKILL.md` at the root, or skill folders directly below the root and up to three levels below `skills/`, `.agents/skills/`, `.claude/skills/`, `.github/skills/` and the other folders that the CLI searches. Xum writes a `plugin.json` for them (named after the repository or the plugin folder), and the install gets the same review, component selection and updates as any plugin. A skills install loads skills only: no hooks, agents, workflows or MCP servers. Xum tries the install methods in this order: Agent Plugin (`plugin.json` or `.codex-plugin/plugin.json`), Claude Code plugin, then Skills. When more than one method fits, the preview shows an **Install as** choice. Skills never replace a repository's own `plugin.json` or `.codex-plugin/plugin.json`. The **Skills CLI** choice shows the command of the pinned skills CLI for the checked skills, for you to copy and run in a terminal. Xum does not run the CLI itself: the CLI clones the repository again and follows symlinks when it copies a skill, so its run can copy files that the preview did not show. The command uses `--global --agent codex`, which copies the skills into `~/.agents/skills` (with `--agent mux`, as above, the CLI writes `~/.mux/skills` instead), and the CLI asks before it installs. Xum does not review, update or remove skills that the CLI installs. The choice is offered only for a source made of letters, digits and `@ _ + = : , . / -`, so the command is safe to paste into any shell.
**Settings → Plugins** also installs a repository of plain skills (`SKILL.md` folders, without a plugin manifest) as a managed plugin. Xum finds the skills with the rules of the [skills CLI](https://github.com/vercel-labs/skills) 1.7.4: a valid `SKILL.md` at the root, or skill folders directly below the root and up to three levels below `skills/`, `.agents/skills/`, `.claude/skills/`, `.github/skills/` and the other folders that the CLI searches. Xum writes a `plugin.json` for them (named after the repository or the plugin folder), and the install gets the same review, component selection and updates as any plugin. A skills install loads skills only: no hooks, agents, workflows or MCP servers. Xum tries the install methods in this order: Agent Plugin (`plugin.json` or `.codex-plugin/plugin.json`), Claude Code plugin, then Skills. When more than one method fits, the preview shows an **Install as** choice. Skills never replace a repository's own `plugin.json` or `.codex-plugin/plugin.json`. The **Skills CLI** choice shows the command of the pinned skills CLI for the checked skills, for you to copy and run in a terminal. Xum does not run the CLI itself: the CLI clones the repository again and follows symlinks when it copies a skill, so its run can copy files that the preview did not show. The command uses `--global --agent codex`, which copies the skills into `~/.agents/skills` (with `--agent mux`, as above, the CLI writes `~/.mux/skills` instead), and the CLI asks before it installs. Xum does not review, update or remove skills that the CLI installs. The choice is offered only for a source made of letters, digits and `@ _ + = : , . / -`, so the command is safe to paste into any shell. Run it from your home folder: npx prefers a `skills` package from the current folder's `node_modules`. On macOS and Linux the command starts with `cd ~ &&` for this. On Windows it has no such prefix, because no single form works in cmd.exe, PowerShell and Git Bash.

#### Component review

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,10 @@ export const SkillsCliInstall: React.FC<{
/>
{cli.command !== null && (
<div className="space-y-1">
<p className="text-muted text-xs">Run this command in a terminal:</p>
<p className="text-muted text-xs">
Run this command in a terminal, from your home folder. Inside a project, npx would
prefer a skills package from that project&apos;s node_modules.
</p>
<div className="bg-modal-bg border-border-medium flex items-start gap-2 rounded border px-2 py-1.5">
<code className="text-foreground min-w-0 flex-1 font-mono text-[11px] break-all">
{cli.command}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1318,7 +1318,7 @@ export const AddPluginSkillsCliCommand: Story = {
await userEvent.click(canvas.getByRole("checkbox", { name: "frontend-design" }));
await userEvent.click(canvas.getByRole("checkbox", { name: "skill-creator" }));
await userEvent.click(canvas.getByRole("button", { name: "Show command (1)" }));
await canvas.findByText("Run this command in a terminal:");
await canvas.findByText(/Run this command in a terminal, from your home folder/);
await canvas.findByText(/--skill pdf --global --agent codex --copy$/);
await canvas.findByRole("button", { name: /copy/i });
},
Expand Down
2 changes: 1 addition & 1 deletion src/browser/stories/mocks/orpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1444,7 +1444,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
success: true as const,
data: {
command: [
"npx --yes skills@1.7.4 add",
"cd ~ && npx --yes skills@1.7.4 add",
input.source,
...input.skills.map((skill) => `--skill ${skill}`),
"--global --agent codex --copy",
Expand Down
53 changes: 51 additions & 2 deletions src/node/services/agentPlugins/skillsCli.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
import { describe, expect, test } from "bun:test";
import { execFile } from "node:child_process";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { promisify } from "node:util";

import { SKILLS_CLI_PACKAGE, skillsCliArgs, skillsCliCommandResult } from "./skillsCli";
import {
SKILLS_CLI_PACKAGE,
skillsCliArgs,
skillsCliCommand,
skillsCliCommandResult,
} from "./skillsCli";

const execFileAsync = promisify(execFile);

const REPO = "https://github.com/anthropics/skills.git";

Expand Down Expand Up @@ -62,7 +74,7 @@ test("the command holds only validated arguments; invalid input is the error bra
).toEqual({
success: true,
data: {
command: `npx --yes ${SKILLS_CLI_PACKAGE} add git@github.com:anthropics/skills.git --skill pdf --global --agent codex --copy`,
command: `${process.platform === "win32" ? "" : "cd ~ && "}npx --yes ${SKILLS_CLI_PACKAGE} add git@github.com:anthropics/skills.git --skill pdf --global --agent codex --copy`,
},
});
for (const input of [
Expand All @@ -73,3 +85,40 @@ test("the command holds only validated arguments; invalid input is the error bra
expect(skillsCliCommandResult(input).success).toBe(false);
}
});

// Windows terminals differ (cmd.exe, PowerShell 5 and 7, Git Bash), and no folder-change prefix
// works in all of them, so the Windows command has none: Settings asks the user to run it from
// the home folder instead.
test("the Windows command has no shell-specific prefix", () => {
const command = skillsCliCommand({ source: REPO, skills: ["pdf"] }, "win32");
expect(command.startsWith("npx --yes ")).toBe(true);
expect(skillsCliCommand({ source: REPO, skills: ["pdf"] }, "darwin")).toBe(`cd ~ && ${command}`);
});

// npx prefers a matching package in the working folder's node_modules over the registry, so the
// shown command must leave the folder the user runs it from before npx starts.
test.skipIf(process.platform === "win32")(
"the shown command starts npx in the home folder, not in the current folder",
async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "skills-cli-cwd-"));
try {
const home = path.join(root, "home");
const checkout = path.join(root, "checkout");
const bin = path.join(root, "bin");
await fs.mkdir(home);
await fs.mkdir(path.join(checkout, "node_modules", "skills"), { recursive: true });
await fs.mkdir(bin);
// A stand-in npx that reports the folder it starts in.
await fs.writeFile(path.join(bin, "npx"), "#!/bin/sh\npwd -P\n", { mode: 0o755 });
const command = skillsCliCommand({ source: REPO, skills: ["pdf"] }, "linux");
const { stdout } = await execFileAsync("/bin/sh", ["-c", command], {
cwd: checkout,
env: { PATH: `${bin}:/usr/bin:/bin`, HOME: home },
encoding: "utf8",
});
expect(stdout.trim()).toBe(await fs.realpath(home));
} finally {
await fs.rm(root, { recursive: true, force: true });
}
}
);
20 changes: 17 additions & 3 deletions src/node/services/agentPlugins/skillsCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,19 +91,33 @@ function formatCommand(argv: string[]): string {
return argv.join(" ");
}

/**
* SECURITY: `npx skills@<version>` prefers a matching package in the working folder's
* node_modules over the registry, so the copied command run inside an attacker's checkout would
* start the attacker's copy. On macOS and Linux the command changes to the home folder first
* (`&&` stops it when that fails); `cd ~ &&` works in bash, zsh and fish. Windows has no prefix
* that works in cmd.exe, Windows PowerShell 5, PowerShell 7 and Git Bash alike, so there the
* command has none and Settings tells the user to run it from the home folder.
*/
const SKILLS_CLI_POSIX_CWD_PREFIX = "cd ~ &&";

/**
* The command for `input`: validated source and skill names only. Throws a user-facing error for
* invalid input.
* invalid input. `platform` is the user's machine (the terminal runs where the Xum backend runs).
*/
export function skillsCliCommand(input: { source: string; skills: string[] }): string {
export function skillsCliCommand(
input: { source: string; skills: string[] },
platform: NodeJS.Platform = process.platform
): string {
const args = skillsCliArgs(input.source, input.skills);
// skillsCliSourceProblem and the skill-name grammar admit only safe characters. Check what Xum
// shows again: an unsafe argument throws here, it is never quoted.
assert(
args.every((arg) => SKILLS_CLI_SAFE_ARG_PATTERN.test(arg)),
"skillsCliCommand: every argument must be safe to show unquoted in any shell"
);
return formatCommand(["npx", ...args]);
const command = formatCommand(["npx", ...args]);
return platform === "win32" ? command : `${SKILLS_CLI_POSIX_CWD_PREFIX} ${command}`;
}

/** The ORPC shape: invalid input becomes the error branch. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2420,7 +2420,7 @@ export const BUILTIN_SKILL_FILES: Record<string, Record<string, string>> = {
"",
"#### Skills repositories",
"",
"**Settings → Plugins** also installs a repository of plain skills (`SKILL.md` folders, without a plugin manifest) as a managed plugin. Xum finds the skills with the rules of the [skills CLI](https://github.com/vercel-labs/skills) 1.7.4: a valid `SKILL.md` at the root, or skill folders directly below the root and up to three levels below `skills/`, `.agents/skills/`, `.claude/skills/`, `.github/skills/` and the other folders that the CLI searches. Xum writes a `plugin.json` for them (named after the repository or the plugin folder), and the install gets the same review, component selection and updates as any plugin. A skills install loads skills only: no hooks, agents, workflows or MCP servers. Xum tries the install methods in this order: Agent Plugin (`plugin.json` or `.codex-plugin/plugin.json`), Claude Code plugin, then Skills. When more than one method fits, the preview shows an **Install as** choice. Skills never replace a repository's own `plugin.json` or `.codex-plugin/plugin.json`. The **Skills CLI** choice shows the command of the pinned skills CLI for the checked skills, for you to copy and run in a terminal. Xum does not run the CLI itself: the CLI clones the repository again and follows symlinks when it copies a skill, so its run can copy files that the preview did not show. The command uses `--global --agent codex`, which copies the skills into `~/.agents/skills` (with `--agent mux`, as above, the CLI writes `~/.mux/skills` instead), and the CLI asks before it installs. Xum does not review, update or remove skills that the CLI installs. The choice is offered only for a source made of letters, digits and `@ _ + = : , . / -`, so the command is safe to paste into any shell.",
"**Settings → Plugins** also installs a repository of plain skills (`SKILL.md` folders, without a plugin manifest) as a managed plugin. Xum finds the skills with the rules of the [skills CLI](https://github.com/vercel-labs/skills) 1.7.4: a valid `SKILL.md` at the root, or skill folders directly below the root and up to three levels below `skills/`, `.agents/skills/`, `.claude/skills/`, `.github/skills/` and the other folders that the CLI searches. Xum writes a `plugin.json` for them (named after the repository or the plugin folder), and the install gets the same review, component selection and updates as any plugin. A skills install loads skills only: no hooks, agents, workflows or MCP servers. Xum tries the install methods in this order: Agent Plugin (`plugin.json` or `.codex-plugin/plugin.json`), Claude Code plugin, then Skills. When more than one method fits, the preview shows an **Install as** choice. Skills never replace a repository's own `plugin.json` or `.codex-plugin/plugin.json`. The **Skills CLI** choice shows the command of the pinned skills CLI for the checked skills, for you to copy and run in a terminal. Xum does not run the CLI itself: the CLI clones the repository again and follows symlinks when it copies a skill, so its run can copy files that the preview did not show. The command uses `--global --agent codex`, which copies the skills into `~/.agents/skills` (with `--agent mux`, as above, the CLI writes `~/.mux/skills` instead), and the CLI asks before it installs. Xum does not review, update or remove skills that the CLI installs. The choice is offered only for a source made of letters, digits and `@ _ + = : , . / -`, so the command is safe to paste into any shell. Run it from your home folder: npx prefers a `skills` package from the current folder's `node_modules`. On macOS and Linux the command starts with `cd ~ &&` for this. On Windows it has no such prefix, because no single form works in cmd.exe, PowerShell and Git Bash.",
"",
"#### Component review",
"",
Expand Down
Loading