diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/SkillInstallDialog.tsx b/ui/litellm-dashboard/src/components/claude_code_plugins/SkillInstallDialog.tsx new file mode 100644 index 00000000000..4af2bd9a1cb --- /dev/null +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/SkillInstallDialog.tsx @@ -0,0 +1,164 @@ +import { useState } from "react"; +import { useQuery, type UseQueryOptions } from "@tanstack/react-query"; +import { Check, Copy } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { getProxyBaseUrl } from "@/components/networking"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { createApiClient } from "@/lib/http/client"; +import { getAuthHeaderName } from "@/lib/http/runtime"; +import { buildSkillInstallCommand, findPublicSkill, SKILL_AGENTS, type SkillAgent } from "./skillInstall"; + +interface SkillInstallDialogProps { + name: string; + archivePath: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export default function SkillInstallDialog({ name, archivePath, open, onOpenChange }: SkillInstallDialogProps) { + const { t } = useTranslation(); + const [agent, setAgent] = useState("codex"); + const [scope, setScope] = useState("project"); + const [mode, setMode] = useState("authenticated"); + const [copyState, setCopyState] = useState<{ + command: string; + status: "pending" | "copied" | "failed"; + } | null>(null); + const gatewayUrl = getProxyBaseUrl().replace(/\/+$/, ""); + const archiveUrl = `${gatewayUrl}${archivePath}`; + const indexOptions: UseQueryOptions = { + queryKey: ["public-skill-install", gatewayUrl, archivePath], + queryFn: ({ signal }) => + createApiClient({ getBaseUrl: () => gatewayUrl }).get("/.well-known/agent-skills/index.json", { + signal, + credentials: "omit", + redirect: "error", + }), + enabled: open, + retry: false, + staleTime: Infinity, + gcTime: 0, + refetchOnMount: "always", + }; + const index = useQuery(indexOptions); + const publicSkill = index.isSuccess && !index.isFetching ? findPublicSkill(index.data, archiveUrl) : undefined; + const publicInstall = mode === "public" && publicSkill !== undefined; + const commandOptions = { + gatewayUrl, + archiveUrl, + name, + agent, + global: scope === "global", + publicSkill: publicInstall ? publicSkill : undefined, + authHeaderName: getAuthHeaderName(), + }; + const command = buildSkillInstallCommand(commandOptions); + const copied = copyState?.command === command && copyState.status === "copied"; + const copyPending = copyState?.command === command && copyState.status === "pending"; + const copyFailed = copyState?.command === command && copyState.status === "failed"; + const copyCommand = async () => { + if (!command) return; + setCopyState({ command, status: "pending" }); + try { + await navigator.clipboard.writeText(command); + setCopyState({ command, status: "copied" }); + } catch { + setCopyState({ command, status: "failed" }); + } + }; + + return ( + + + + {t("skills.install.title", { name })} + {t("skills.install.description")} + +
+
+ + items={SKILL_AGENTS} value={agent} onValueChange={(value) => value && setAgent(value)}> + + + + + {SKILL_AGENTS.map((item) => ( + + {item.label} + + ))} + + +
+
+

{t("skills.install.scope")}

+ + + + +
+
+ {publicSkill && ( + + + + + )} +
+

+ {t(publicInstall ? "skills.install.publicHint" : "skills.install.authenticatedHint")} +

+ {command ? ( +
+              {command}
+            
+ ) : ( +

+ {t("skills.install.unavailable")} +

+ )} +

+ {t(publicInstall ? "skills.install.publicRequirements" : "skills.install.authenticatedRequirements")} +

+
+
+

{t("skills.install.shareHint")}

+ +
+ {copyFailed && ( +

+ {t("skills.install.copyFailed")} +

+ )} +
+
+ ); +} diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/skillInstall.fixture.py b/ui/litellm-dashboard/src/components/claude_code_plugins/skillInstall.fixture.py new file mode 100644 index 00000000000..d67718b9621 --- /dev/null +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/skillInstall.fixture.py @@ -0,0 +1,109 @@ +import errno +import json +import os +import pty +import select +import shutil +import signal +import sys +import tempfile +import time +import zipfile +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Final, TypeAlias, cast + +Environment: TypeAlias = Mapping[str, str] + + +def external_command(tool: str) -> None: + root: Final = Path(os.environ["SKILL_FIXTURE_ROOT"]) + args: Final = tuple(sys.argv[1:]) + header: Final = sys.stdin.read() if tool == "curl" and args[args.index("--header") + 1] == "@-" else "" + (root / f"{tool}.json").write_text(json.dumps({"args": args, "env": dict(os.environ), "header": header})) + if tool == "curl": + output: Final = Path(args[args.index("--output") + 1]) + (output.parent / ".skill-install-fixture").write_text(str(root)) + if os.environ["SKILL_FIXTURE_FAIL"] == "1": + sys.exit(22) + with zipfile.ZipFile(output, "w") as archive: + archive.writestr("SKILL.md", "fixture skill") + return + source: Final = Path(args[args.index("add") + 1]) + if "--copy" in args: + shutil.copytree(source, root / "installed") + else: + (root / "installed").symlink_to(source, target_is_directory=True) + + +def run(command: str, fail: bool) -> None: + secret: Final = "private-fixture-key" + with tempfile.TemporaryDirectory() as directory: + root: Final = Path(directory) + (root / "bin").mkdir() + (root / "tmp").mkdir() + for tool in ("curl", "npx"): + executable: Path = root / "bin" / tool + executable.write_text(f"#!{sys.executable}\n" + Path(__file__).read_text()) + executable.chmod(0o700) + environment: Final[Environment] = { + "PATH": f"{root / 'bin'}{os.pathsep}{os.defpath}", + "TMPDIR": str(root / "tmp"), + "SKILL_FIXTURE_ROOT": str(root), + "SKILL_FIXTURE_FAIL": "1" if fail else "0", + "skill_key": "previously-exported-value", + } + child, terminal = pty.fork() + if child == 0: + os.execve("/bin/bash", ["bash", "-c", command], environment) + output: bytes = b"" # rebind-ok: accumulate partial PTY reads + sent: bool = False # rebind-ok: send the key once after its prompt arrives + ended: bool = False # rebind-ok: distinguish PTY EOF from timeout cleanup + deadline: Final = time.monotonic() + 5 + try: + while True: + ready, _, _ = select.select([terminal], [], [], max(0, deadline - time.monotonic())) + if not ready: + raise TimeoutError("Install command did not finish") + try: + chunk: bytes = os.read(terminal, 65536) + except OSError as error: + if error.errno != errno.EIO: + raise + chunk = b"" + if not chunk: + ended = True + break + output += chunk + if not sent and b"LiteLLM API key: " in output: + os.write(terminal, f"{secret}\n".encode()) + sent = True + finally: + if not ended: + os.killpg(child, signal.SIGKILL) + os.close(terminal) + _, status = os.waitpid(child, 0) + curl: Final = cast(Mapping[str, object], json.loads((root / "curl.json").read_text())) # cast-ok: own JSON + record: Final = root / "npx.json" + npx: Final[Mapping[str, object] | None] = json.loads(record.read_text()) if record.exists() else None + curl_args: Final = cast(Sequence[str], curl["args"]) # cast-ok: fixture records argv + installed: Final = root / "installed" / "SKILL.md" + temporary: Final = Path(curl_args[curl_args.index("--output") + 1]).parent + sys.stdout.write(json.dumps({ + "status": os.waitstatus_to_exitcode(status), + "header": curl["header"], + "installArgs": npx["args"] if npx else None, + "telemetry": cast(Environment, npx["env"]).get("DISABLE_TELEMETRY") if npx else None, # cast-ok: own env + "secretLeaked": secret in json.dumps((output.decode(), curl_args, curl["env"], npx)), + "retained": installed.read_text() if installed.exists() else None, + "temporaryRemoved": not temporary.exists(), + })) + marker: Final = temporary / ".skill-install-fixture" + if marker.exists() and marker.read_text() == str(root): + shutil.rmtree(temporary) + + +if Path(sys.argv[0]).name in ("curl", "npx"): + external_command(Path(sys.argv[0]).name) +else: + run(sys.stdin.read(), sys.argv[1] == "fail") diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/skillInstall.test.ts b/ui/litellm-dashboard/src/components/claude_code_plugins/skillInstall.test.ts new file mode 100644 index 00000000000..183457af162 --- /dev/null +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/skillInstall.test.ts @@ -0,0 +1,107 @@ +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { buildSkillInstallCommand, findPublicSkill } from "./skillInstall"; + +const options = { + gatewayUrl: "https://gateway.example.test/proxy", + archiveUrl: "https://gateway.example.test/proxy/skill-hub/skills/review/archive", + name: "review", + agent: "codex" as const, + global: false, + authHeaderName: "X-LiteLLM-Key", +}; + +describe("skill install commands", () => { + it("quotes the public index name without executing shell substitutions", () => { + const name = "review' $(printf injected) `printf injected`"; + const command = buildSkillInstallCommand({ ...options, global: true, publicSkill: { name } }); + const result = spawnSync("bash", ["-c", `npx() { printf '%s\\n' "$DISABLE_TELEMETRY" "$@"; }; ${command}`], { + encoding: "utf8", + }); + expect(result.status).toBe(0); + expect(result.stdout.trim().split("\n")).toEqual([ + "1", + "skills@1.7.6", + "add", + options.gatewayUrl, + "--skill", + name, + "-a", + "codex", + "-g", + ]); + }); + + it.each(["success", "fail"])("executes a private install with safe credentials and cleanup on %s", (outcome) => { + const command = buildSkillInstallCommand({ ...options, agent: "claude-code", global: true })!; + const result = spawnSync( + "python3", + [fileURLToPath(new URL("./skillInstall.fixture.py", import.meta.url)), outcome], + { + input: command, + encoding: "utf8", + timeout: 10_000, + }, + ); + expect(result.status, result.stderr).toBe(0); + const expected = { + status: outcome === "success" ? 0 : 22, + header: "X-LiteLLM-Key: Bearer private-fixture-key\n", + secretLeaked: false, + temporaryRemoved: true, + retained: outcome === "success" ? "fixture skill" : null, + telemetry: outcome === "success" ? "1" : null, + }; + const receipt = JSON.parse(result.stdout) as typeof expected & { installArgs: string[] | null }; + expect(receipt).toMatchObject(expected); + expect(receipt.installArgs).toEqual( + outcome === "success" ? expect.arrayContaining(["--skill", "review", "-a", "claude-code", "-g"]) : null, + ); + }); + + it.each([ + "localhost", + "skills-install.localhost", + "127.0.0.2", + "[::1]", + "gateway.example.test", + "127.attacker.test", + "localhost.attacker.test", + ])("requires HTTPS for authenticated installs outside loopback at %s", (host) => { + const local = ["localhost", "skills-install.localhost", "127.0.0.2", "[::1]"].includes(host); + const httpOptions = { + ...options, + gatewayUrl: `http://${host}`, + archiveUrl: `http://${host}/skill-hub/skills/review/archive`, + }; + expect(buildSkillInstallCommand(httpOptions) === null).toBe(!local); + expect(buildSkillInstallCommand({ ...httpOptions, publicSkill: { name: "review" } })).not.toBeNull(); + }); + + it.each([ + { gatewayUrl: "https://user:password@gateway.example.test/proxy" }, + { archiveUrl: "https://other.example.test/skill.zip" }, + { archiveUrl: "https://gateway.example.test/proxy/skill-hub/submissions/pending/archive" }, + { authHeaderName: "Authorization\r\nX-Other: value" }, + { name: "review\nLITELLM_SKILL\nexit" }, + { gatewayUrl: "file:///private/skill" }, + ])("rejects credentials and values outside the managed archive contract", (invalid) => { + expect(buildSkillInstallCommand({ ...options, ...invalid })).toBeNull(); + }); + + it("uses archive identity instead of a colliding public skill name", () => { + const entry = { name: "review-2", type: "archive" as const, description: "Review", digest: "sha256:abc" }; + const wrong = { ...entry, name: "review", url: "https://other.example.test/skill.zip" }; + const right = { ...entry, url: `${options.archiveUrl}?digest=sha256:abc` }; + expect(findPublicSkill({ $schema: "", skills: [wrong, right] }, options.archiveUrl)).toMatchObject({ + name: right.name, + url: right.url, + }); + expect(findPublicSkill({ $schema: "", skills: [wrong] }, options.archiveUrl)).toBeUndefined(); + expect( + findPublicSkill({ skills: [{ ...right, url: `${right.url}&token=credential` }] }, options.archiveUrl), + ).toBeUndefined(); + expect(findPublicSkill({ skills: null }, options.archiveUrl)).toBeUndefined(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/skillInstall.ts b/ui/litellm-dashboard/src/components/claude_code_plugins/skillInstall.ts new file mode 100644 index 00000000000..343bc82961e --- /dev/null +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/skillInstall.ts @@ -0,0 +1,82 @@ +import { z } from "zod"; + +export const SKILL_AGENTS = [ + { value: "codex", label: "Codex" }, + { value: "claude-code", label: "Claude Code" }, + { value: "cursor", label: "Cursor" }, + { value: "github-copilot", label: "GitHub Copilot" }, + { value: "gemini-cli", label: "Gemini CLI" }, +] as const; + +export type SkillAgent = (typeof SKILL_AGENTS)[number]["value"]; +const skillsPackage = "skills@1.7.6"; +const publicIndexSchema = z.object({ + skills: z.array(z.object({ name: z.string(), type: z.literal("archive"), url: z.string() })), +}); + +const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\"'\"'")}'`; + +const parseUrl = (value: string): URL | null => { + try { + const url = new URL(value); + return /^https?:$/.test(url.protocol) && !url.username && !url.password ? url : null; + } catch { + return null; + } +}; + +export const findPublicSkill = (index: unknown, archiveUrl: string) => { + const parsed = publicIndexSchema.safeParse(index); + return parsed.success + ? parsed.data.skills.find((skill) => { + const url = parseUrl(skill.url); + if (!url || url.hash) return false; + return ( + `${url.origin}${url.pathname}` === archiveUrl && [...url.searchParams.keys()].every((key) => key === "digest") + ); + }) + : undefined; +}; + +interface SkillInstallOptions { + gatewayUrl: string; + archiveUrl: string; + name: string; + agent: SkillAgent; + global: boolean; + publicSkill?: { name: string }; + authHeaderName: string; +} + +export function buildSkillInstallCommand(options: SkillInstallOptions): string | null { + const gateway = parseUrl(options.gatewayUrl); + const archive = parseUrl(options.archiveUrl); + if (!gateway || gateway.search || gateway.hash) return null; + if (!archive || archive.origin !== gateway.origin) return null; + if (!/^[a-z0-9-]+$/.test(options.name)) return null; + if (!/^[!#$%&'*+.^_`|~\da-z-]+$/i.test(options.authHeaderName)) return null; + const gatewayUrl = gateway.href.replace(/\/+$/, ""); + if (archive.href !== `${gatewayUrl}/skill-hub/skills/${options.name}/archive`) return null; + const scope = options.global ? " -g" : ""; + if (options.publicSkill) { + return `DISABLE_TELEMETRY=1 npx ${skillsPackage} add ${shellQuote(gatewayUrl)} --skill ${shellQuote(options.publicSkill.name)} -a ${options.agent}${scope}`; + } + const loopback = + /^(?:localhost|\[::1\]|127(?:\.\d{1,3}){3})$/.test(gateway.hostname) || gateway.hostname.endsWith(".localhost"); + if (gateway.protocol !== "https:" && !loopback) return null; + return `bash <<'LITELLM_SKILL' +set -euo pipefail +set +x +skill_tmp=$(mktemp -d) +trap 'rm -rf "$skill_tmp"' EXIT +IFS= read -r -s -p 'LiteLLM API key: ' skill_key { vi.clearAllMocks(); testQueryClient.clear(); + testQueryClient.setQueryDefaults(["public-skill-install"], { refetchOnWindowFocus: true }); skillRequestMock.mockReset().mockRejectedValue(new Error("Unexpected skill request")); fetchMock.mockReset().mockImplementation((input, init) => { const requestUrl = input instanceof Request ? input.url : String(input); @@ -59,6 +62,7 @@ beforeEach(() => { }); afterEach(() => { + focusManager.setFocused(undefined); setGlobalLitellmHeaderName(); vi.restoreAllMocks(); vi.unstubAllGlobals(); @@ -187,3 +191,74 @@ it.each([skill.source, { source: "archive", url: `${getProxyBaseUrl()}/skill-hub expect(await screen.findByText(/No recorded releases yet/)).toBeInTheDocument(); }, ); + +it("copies the selected private install without sharing a session key and reports clipboard results", async () => { + const user = userEvent.setup(); + const pendingCopy = Promise.withResolvers(); + const writeText = vi.spyOn(navigator.clipboard, "writeText").mockReturnValueOnce(pendingCopy.promise); + setGlobalLitellmHeaderName("X-LiteLLM-Key"); + skillRequestMock.mockResolvedValue(Response.json({ detail: "Not Found" }, { status: 404 })); + renderWithProviders( + , + ); + expect(skillRequestMock).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Install / Share" })); + await chooseSelectOption(user, screen.getByRole("combobox", { name: "Coding agent" }), "Claude Code"); + fireEvent.click(screen.getByRole("radio", { name: "All projects" })); + const command = screen.getByLabelText("Install command"); + expect(command).toHaveTextContent("-a claude-code --copy --yes -g"); + expect(command).toHaveTextContent("X-LiteLLM-Key"); + expect(command).not.toHaveTextContent("dashboard-token"); + expect(screen.queryByRole("radio", { name: "Public command" })).not.toBeInTheDocument(); + expect(skillRequestMock).toHaveBeenCalledWith( + `${getProxyBaseUrl()}/.well-known/agent-skills/index.json`, + expect.objectContaining({ + credentials: "omit", + redirect: "error", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + }), + ); + fireEvent.click(screen.getByRole("button", { name: "Copy install command" })); + expect(writeText).toHaveBeenCalledWith(command.textContent); + expect(screen.queryByRole("button", { name: "Copied" })).not.toBeInTheDocument(); + pendingCopy.resolve(); + const copiedButton = await screen.findByRole("button", { name: "Copied" }); + writeText.mockRejectedValueOnce(new Error("Clipboard denied")); + fireEvent.click(copiedButton); + expect(await screen.findByRole("alert")).toHaveTextContent("Could not copy the command"); +}); + +it("offers the indexed public skill name and discards its availability when reopened", async () => { + const portablePath = "/skill-hub/skills/my-skill/archive"; + skillRequestMock.mockResolvedValueOnce( + Response.json({ + skills: [{ name: "my-skill-2", type: "archive", url: `${getProxyBaseUrl()}${portablePath}?digest=sha256:abc` }], + }), + ); + renderWithProviders( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Install / Share" })); + fireEvent.click(await screen.findByRole("radio", { name: "Public command" })); + focusManager.setFocused(false); + await act(async () => focusManager.setFocused(true)); + expect(skillRequestMock).toHaveBeenCalledTimes(1); + expect(screen.getByLabelText("Install command")).toHaveTextContent( + `DISABLE_TELEMETRY=1 npx skills@1.7.6 add '${getProxyBaseUrl()}' --skill 'my-skill-2' -a codex`, + ); + fireEvent.click(screen.getByRole("button", { name: "Close" })); + const refreshedIndex = Promise.withResolvers(); + skillRequestMock.mockReturnValueOnce(refreshedIndex.promise); + fireEvent.click(screen.getByRole("button", { name: "Install / Share" })); + expect(screen.queryByRole("radio", { name: "Public command" })).not.toBeInTheDocument(); + refreshedIndex.resolve(Response.json({ skills: [] })); + expect(screen.getByLabelText("Install command")).toHaveTextContent("LiteLLM API key"); +}); diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.tsx b/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.tsx index efdf4757c11..c519816f65f 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.tsx +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.tsx @@ -19,6 +19,7 @@ import { } from "./helpers"; import type { Plugin, SkillHubSkill } from "./types"; import SkillVersionHistory from "./SkillVersionHistory"; +import SkillInstallDialog from "./SkillInstallDialog"; type SkillDetailData = Pick< SkillHubSkill, @@ -128,6 +129,7 @@ const SkillDetail: React.FC = ({ skill, onBack, accessToken, o const { t } = useTranslation(); const [activeTab, setActiveTab] = useState("overview"); const [copiedKey, setCopiedKey] = useState(null); + const [installOpen, setInstallOpen] = useState(false); const marketplaceRequiresKey = useClaudeCodeMarketplaceRequiresKey(); const visibleTab = activeTab === "downloads" && !isAdmin ? "overview" : activeTab; const portableArchivePath = skill.downloadAvailable @@ -141,6 +143,7 @@ const SkillDetail: React.FC = ({ skill, onBack, accessToken, o getProxyBaseUrl(), typeof window !== "undefined" ? window.location.origin : undefined, ); + const installDialogPath = skill.portable && installOpen ? archivePath : null; const copyToClipboard = (text: string, key: string) => { navigator.clipboard.writeText(text); @@ -199,19 +202,37 @@ const SkillDetail: React.FC = ({ skill, onBack, accessToken, o {/* Header */}
-
+

{skill.name}

- {onEditClick && ( - - )} +
+ {onEditClick && ( + + )} + {skill.portable && archivePath && ( + + )} +
{skill.description && (

{skill.description}

)}
+ {installDialogPath && ( + + )} + {/* Tab bar */}
diff --git a/ui/litellm-dashboard/src/i18n/locales/en.json b/ui/litellm-dashboard/src/i18n/locales/en.json index c22930a8ec5..ec67f4dc09c 100644 --- a/ui/litellm-dashboard/src/i18n/locales/en.json +++ b/ui/litellm-dashboard/src/i18n/locales/en.json @@ -1670,6 +1670,28 @@ } }, "skills": { + "install": { + "action": "Install / Share", + "title": "Install {{name}}", + "description": "Choose your coding agent and copy the command to your terminal", + "agent": "Coding agent", + "scope": "Install location", + "project": "Current project", + "global": "All projects", + "method": "Install method", + "authenticated": "Authenticated", + "public": "Public command", + "authenticatedHint": "Enter your own LiteLLM key at the terminal prompt. The gateway checks your access before downloading the skill", + "publicHint": "This skill is listed in the gateway's public index and can be installed without a key", + "command": "Install command", + "authenticatedRequirements": "Requires macOS or Linux with Bash, curl, unzip, Node.js 22.20 or later, and npm. Run from your project directory for a project install", + "publicRequirements": "Requires macOS or Linux with Node.js 22.20 or later and npm. Run from your project directory for a project install", + "shareHint": "Share this command with teammates. It contains no key", + "copy": "Copy install command", + "copied": "Copied", + "copyFailed": "Could not copy the command. Select and copy it manually", + "unavailable": "An install command is unavailable for this gateway URL. Authenticated installs require HTTPS or a localhost gateway" + }, "panel": { "title": "Skills", "intro": "Upload or import portable skills. Review proposed changes before publishing them to the Skills Hub.",