mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-06 07:48:06 +00:00
feat(skills): add authenticated install and share commands (#50211)
This commit is contained in:
parent
79710a6ff1
commit
4bf75e5f51
7 changed files with 588 additions and 8 deletions
|
|
@ -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<SkillAgent>("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<unknown> = {
|
||||
queryKey: ["public-skill-install", gatewayUrl, archivePath],
|
||||
queryFn: ({ signal }) =>
|
||||
createApiClient({ getBaseUrl: () => gatewayUrl }).get<unknown>("/.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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90dvh] min-w-0 overflow-y-auto sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("skills.install.title", { name })}</DialogTitle>
|
||||
<DialogDescription>{t("skills.install.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid min-w-0 gap-5 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="skill-install-agent">{t("skills.install.agent")}</Label>
|
||||
<Select<SkillAgent> items={SKILL_AGENTS} value={agent} onValueChange={(value) => value && setAgent(value)}>
|
||||
<SelectTrigger id="skill-install-agent" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SKILL_AGENTS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">{t("skills.install.scope")}</p>
|
||||
<RadioGroup aria-label={t("skills.install.scope")} value={scope} onValueChange={setScope}>
|
||||
<Label>
|
||||
<RadioGroupItem value="project" />
|
||||
{t("skills.install.project")}
|
||||
</Label>
|
||||
<Label>
|
||||
<RadioGroupItem value="global" />
|
||||
{t("skills.install.global")}
|
||||
</Label>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
{publicSkill && (
|
||||
<RadioGroup
|
||||
aria-label={t("skills.install.method")}
|
||||
value={publicInstall ? "public" : "authenticated"}
|
||||
onValueChange={setMode}
|
||||
className="flex flex-wrap gap-5"
|
||||
>
|
||||
<Label>
|
||||
<RadioGroupItem value="authenticated" />
|
||||
{t("skills.install.authenticated")}
|
||||
</Label>
|
||||
<Label>
|
||||
<RadioGroupItem value="public" />
|
||||
{t("skills.install.public")}
|
||||
</Label>
|
||||
</RadioGroup>
|
||||
)}
|
||||
<div className="min-w-0 space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(publicInstall ? "skills.install.publicHint" : "skills.install.authenticatedHint")}
|
||||
</p>
|
||||
{command ? (
|
||||
<pre
|
||||
aria-label={t("skills.install.command")}
|
||||
className="max-h-72 overflow-auto rounded-lg border border-border bg-muted p-4 text-xs leading-relaxed"
|
||||
tabIndex={0}
|
||||
>
|
||||
<code>{command}</code>
|
||||
</pre>
|
||||
) : (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{t("skills.install.unavailable")}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(publicInstall ? "skills.install.publicRequirements" : "skills.install.authenticatedRequirements")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm text-muted-foreground">{t("skills.install.shareHint")}</p>
|
||||
<Button onClick={copyCommand} disabled={!command || copyPending}>
|
||||
{copied ? <Check className="size-4" /> : <Copy className="size-4" />}
|
||||
{t(copied ? "skills.install.copied" : "skills.install.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
{copyFailed && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{t("skills.install.copyFailed")}
|
||||
</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 </dev/tty
|
||||
export -n skill_key
|
||||
printf '\\n'
|
||||
printf '%s: Bearer %s\\n' ${shellQuote(options.authHeaderName)} "$skill_key" |
|
||||
curl -q --fail --silent --show-error --max-time 60 --header @- \\
|
||||
${shellQuote(archive.href)} --output "$skill_tmp/skill.zip"
|
||||
unset skill_key
|
||||
unzip -q "$skill_tmp/skill.zip" -d "$skill_tmp/skill"
|
||||
DISABLE_TELEMETRY=1 npx --yes ${skillsPackage} add "$skill_tmp/skill" --skill ${shellQuote(options.name)} -a ${options.agent} --copy --yes${scope}
|
||||
LITELLM_SKILL`;
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { renderWithProviders, testQueryClient } from "@/../tests/test-utils";
|
||||
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { focusManager } from "@tanstack/react-query";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { chooseSelectOption, renderWithProviders, testQueryClient } from "@/../tests/test-utils";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { getProxyBaseUrl, setGlobalLitellmHeaderName } from "@/components/networking";
|
||||
import SkillDetail, { skillHubDetail } from "./skill_detail";
|
||||
|
|
@ -47,6 +49,7 @@ const page = (
|
|||
beforeEach(() => {
|
||||
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<void>();
|
||||
const writeText = vi.spyOn(navigator.clipboard, "writeText").mockReturnValueOnce(pendingCopy.promise);
|
||||
setGlobalLitellmHeaderName("X-LiteLLM-Key");
|
||||
skillRequestMock.mockResolvedValue(Response.json({ detail: "Not Found" }, { status: 404 }));
|
||||
renderWithProviders(
|
||||
<SkillDetail
|
||||
skill={skillHubDetail({ ...skill, visibility: "private", download_url: "/skill-hub/skills/my-skill/archive" })}
|
||||
onBack={vi.fn()}
|
||||
accessToken="dashboard-token"
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<SkillDetail
|
||||
skill={skillHubDetail({ ...skill, visibility: "public", download_url: portablePath })}
|
||||
onBack={vi.fn()}
|
||||
accessToken="dashboard-token"
|
||||
/>,
|
||||
);
|
||||
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<Response>();
|
||||
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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<SkillDetailProps> = ({ skill, onBack, accessToken, o
|
|||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(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<SkillDetailProps> = ({ 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<SkillDetailProps> = ({ skill, onBack, accessToken, o
|
|||
|
||||
{/* Header */}
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<h1 className="m-0 text-[28px] font-normal leading-tight text-foreground">{skill.name}</h1>
|
||||
{onEditClick && (
|
||||
<Button variant="outline" onClick={onEditClick}>
|
||||
{t("skills.panel.editButton")}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{onEditClick && (
|
||||
<Button variant="outline" onClick={onEditClick}>
|
||||
{t("skills.panel.editButton")}
|
||||
</Button>
|
||||
)}
|
||||
{skill.portable && archivePath && (
|
||||
<Button onClick={() => setInstallOpen(true)}>
|
||||
<Download className="size-4" />
|
||||
{t("skills.install.action")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{skill.description && (
|
||||
<p className="mb-0 ml-0 mr-0 mt-2 text-sm leading-relaxed text-muted-foreground">{skill.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{installDialogPath && (
|
||||
<SkillInstallDialog
|
||||
key={`${skill.id}:${getProxyBaseUrl()}`}
|
||||
name={skill.name}
|
||||
archivePath={installDialogPath}
|
||||
open={installOpen}
|
||||
onOpenChange={setInstallOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="mb-7 mt-6 border-b border-border">
|
||||
<TabsList variant="line" className="h-auto p-0">
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue