feat(skills): add authenticated install and share commands (#50211)

This commit is contained in:
tin-berri 2026-11-05 16:42:05 -08:00 • committed by GitHub
parent 79710a6ff1
commit 4bf75e5f51
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 588 additions and 8 deletions

View file

@ -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>
);
}

View file

@ -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")

View file

@ -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();
});
});

View file

@ -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`;
}

View file

@ -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");
});

View file

@ -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">

View file

@ -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.",