= ({ visible, onClose, accessT
+ {/* Optional subfolder for monorepos */}
+
+ !value || isValidSubPath(value)
+ ? Promise.resolve()
+ : Promise.reject(
+ new Error(
+ "Subfolder must be a relative path like plugins/my-skill (letters, numbers, dots, hyphens, underscores)",
+ ),
+ ),
+ },
+ ]}
+ tooltip="Path within the repository where the skill lives (e.g., plugins/my-skill). Leave empty if the skill is at the repo root."
+ extra={urlEncodesSubdir ? "The URL already points to a subfolder, so this field is disabled" : undefined}
+ >
+
+
+
{/* Parsed preview */}
{urlPreview && (
diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.test.ts b/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.test.ts
index b3930d15718..4c84db2a97d 100644
--- a/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.test.ts
+++ b/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.test.ts
@@ -15,23 +15,34 @@ import {
isValidUrl,
parseKeywords,
formatKeywords,
+ parseSkillSource,
+ isValidSubPath,
} from "./helpers";
import { MarketplacePluginEntry, PluginSource } from "./types";
describe("formatInstallCommand", () => {
it("formats github source with repo", () => {
- const plugin = { name: "my-plugin", source: { source: "github" as const, repo: "org/repo" } };
- expect(formatInstallCommand(plugin)).toBe("/plugin marketplace add org/repo");
+ const source: PluginSource = { source: "github", repo: "org/repo" };
+ expect(formatInstallCommand({ name: "my-plugin", source })).toBe("/plugin marketplace add org/repo");
});
it("formats url source", () => {
- const plugin = { name: "my-plugin", source: { source: "url" as const, url: "https://example.com/plugin" } };
- expect(formatInstallCommand(plugin)).toBe("/plugin marketplace add https://example.com/plugin");
+ const source: PluginSource = { source: "url", url: "https://example.com/plugin" };
+ expect(formatInstallCommand({ name: "my-plugin", source })).toBe(
+ "/plugin marketplace add https://example.com/plugin",
+ );
+ });
+
+ it("formats git-subdir source using its url", () => {
+ const source: PluginSource = { source: "git-subdir", url: "https://github.com/org/repo", path: "plugins/x" };
+ expect(formatInstallCommand({ name: "my-plugin", source })).toBe(
+ "/plugin marketplace add https://github.com/org/repo",
+ );
});
it("falls back to plugin name when no repo or url", () => {
- const plugin = { name: "my-plugin", source: { source: "github" as const } };
- expect(formatInstallCommand(plugin)).toBe("/plugin marketplace add my-plugin");
+ const source: PluginSource = { source: "github" };
+ expect(formatInstallCommand({ name: "my-plugin", source })).toBe("/plugin marketplace add my-plugin");
});
});
@@ -91,6 +102,18 @@ describe("getSourceDisplayText", () => {
expect(getSourceDisplayText({ source: "url", url: "https://example.com" })).toBe("https://example.com");
});
+ it("shows git-subdir as url @ path for a github subdir", () => {
+ expect(getSourceDisplayText({ source: "git-subdir", url: "https://github.com/org/repo", path: "plugins/x" })).toBe(
+ "https://github.com/org/repo @ plugins/x",
+ );
+ });
+
+ it("shows git-subdir as url @ path for a gitlab subdir", () => {
+ expect(getSourceDisplayText({ source: "git-subdir", url: "https://gitlab.com/org/repo", path: "sub/dir" })).toBe(
+ "https://gitlab.com/org/repo @ sub/dir",
+ );
+ });
+
it("returns unknown for missing data", () => {
expect(getSourceDisplayText({ source: "github" })).toBe("Unknown source");
});
@@ -105,6 +128,18 @@ describe("getSourceLink", () => {
expect(getSourceLink({ source: "url", url: "https://example.com" })).toBe("https://example.com");
});
+ it("returns the repo url for a github git-subdir source", () => {
+ expect(getSourceLink({ source: "git-subdir", url: "https://github.com/org/repo", path: "plugins/x" })).toBe(
+ "https://github.com/org/repo",
+ );
+ });
+
+ it("returns the repo url for a gitlab git-subdir source", () => {
+ expect(getSourceLink({ source: "git-subdir", url: "https://gitlab.com/org/repo", path: "sub/dir" })).toBe(
+ "https://gitlab.com/org/repo",
+ );
+ });
+
it("returns null when no repo or url", () => {
expect(getSourceLink({ source: "github" })).toBeNull();
});
@@ -323,3 +358,216 @@ describe("formatKeywords", () => {
expect(formatKeywords(undefined)).toBe("");
});
});
+
+describe("parseSkillSource", () => {
+ it("parses a plain github repo", () => {
+ expect(parseSkillSource("github.com/org/repo")?.parsed).toEqual({ source: "github", repo: "org/repo" });
+ });
+
+ it("strips a .git suffix from the github repo shorthand", () => {
+ expect(parseSkillSource("https://github.com/org/repo.git")?.parsed).toEqual({
+ source: "github",
+ repo: "org/repo",
+ });
+ });
+
+ it("parses a github tree URL into a git-subdir", () => {
+ expect(parseSkillSource("github.com/org/repo/tree/main/plugins/x")?.parsed).toEqual({
+ source: "git-subdir",
+ url: "https://github.com/org/repo",
+ path: "plugins/x",
+ });
+ });
+
+ it("drops a trailing file segment from a github blob URL", () => {
+ expect(parseSkillSource("github.com/org/repo/blob/main/x/SKILL.md")?.parsed).toEqual({
+ source: "git-subdir",
+ url: "https://github.com/org/repo",
+ path: "x",
+ });
+ });
+
+ it("combines a github repo with an explicit subfolder", () => {
+ expect(parseSkillSource("github.com/org/repo", "plugins/x")?.parsed).toEqual({
+ source: "git-subdir",
+ url: "https://github.com/org/repo",
+ path: "plugins/x",
+ });
+ });
+
+ it("treats a gitlab repo as a raw url source", () => {
+ expect(parseSkillSource("gitlab.com/org/repo")?.parsed).toEqual({
+ source: "url",
+ url: "https://gitlab.com/org/repo",
+ });
+ });
+
+ it("keeps the .git suffix on raw urls", () => {
+ expect(parseSkillSource("https://gitlab.com/org/repo.git")?.parsed).toEqual({
+ source: "url",
+ url: "https://gitlab.com/org/repo.git",
+ });
+ });
+
+ it("combines a gitlab repo with an explicit subfolder", () => {
+ expect(parseSkillSource("gitlab.com/org/repo", "plugins/x")?.parsed).toEqual({
+ source: "git-subdir",
+ url: "https://gitlab.com/org/repo",
+ path: "plugins/x",
+ });
+ });
+
+ it("combines a self-hosted host with an explicit subfolder", () => {
+ expect(parseSkillSource("https://git.acme.com/team/repo", "sub/dir")?.parsed).toEqual({
+ source: "git-subdir",
+ url: "https://git.acme.com/team/repo",
+ path: "sub/dir",
+ });
+ });
+
+ it("lets a github URL-encoded subdir win over an also-provided subfolder", () => {
+ expect(parseSkillSource("github.com/org/repo/tree/main/plugins/x", "ignored/path")?.parsed).toEqual({
+ source: "git-subdir",
+ url: "https://github.com/org/repo",
+ path: "plugins/x",
+ });
+ });
+
+ it("rejects traversal, absolute, and double-slash subfolders", () => {
+ expect(parseSkillSource("gitlab.com/org/repo", "../etc")).toBeNull();
+ expect(parseSkillSource("gitlab.com/org/repo", "/abs")).toBeNull();
+ expect(parseSkillSource("gitlab.com/org/repo", "a//b")).toBeNull();
+ });
+
+ it("returns null for empty and garbage input", () => {
+ expect(parseSkillSource("")).toBeNull();
+ expect(parseSkillSource(" ")).toBeNull();
+ expect(parseSkillSource("not a url")).toBeNull();
+ });
+
+ it("suggests a kebab-friendly name from the last path segment", () => {
+ expect(parseSkillSource("github.com/org/my-awesome-skill")?.suggestedName).toBe("my-awesome-skill");
+ expect(parseSkillSource("github.com/org/repo/tree/main/plugins/cool-skill")?.suggestedName).toBe("cool-skill");
+ expect(parseSkillSource("gitlab.com/org/repo", "plugins/x")?.suggestedName).toBe("x");
+ });
+
+ it("rejects a bad explicit subfolder for a github repo", () => {
+ expect(parseSkillSource("github.com/org/repo", "../etc")).toBeNull();
+ expect(parseSkillSource("github.com/org/repo", "/abs")).toBeNull();
+ expect(parseSkillSource("github.com/org/repo", "a//b")).toBeNull();
+ });
+
+ it("treats a blob URL pointing at a root file as the plain repo", () => {
+ expect(parseSkillSource("github.com/org/repo/blob/main/SKILL.md")?.parsed).toEqual({
+ source: "github",
+ repo: "org/repo",
+ });
+ });
+
+ it("strips query strings and fragments before parsing", () => {
+ expect(parseSkillSource("github.com/org/repo?tab=readme")?.parsed).toEqual({ source: "github", repo: "org/repo" });
+ expect(parseSkillSource("github.com/org/repo#section")?.parsed).toEqual({ source: "github", repo: "org/repo" });
+ });
+
+ it("rejects a tree URL whose folder has a space or percent-encoded segment", () => {
+ expect(parseSkillSource("github.com/org/repo/tree/main/a b")).toBeNull();
+ expect(parseSkillSource("github.com/org/repo/tree/main/a%20b")).toBeNull();
+ });
+
+ it("routes uppercase and www github hosts through the github shorthand", () => {
+ expect(parseSkillSource("GitHub.com/org/repo/tree/main/x")?.parsed).toEqual({
+ source: "git-subdir",
+ url: "https://github.com/org/repo",
+ path: "x",
+ });
+ expect(parseSkillSource("www.github.com/org/repo")?.parsed).toEqual({ source: "github", repo: "org/repo" });
+ });
+
+ it("keeps a dotted folder name as the subdir path", () => {
+ expect(parseSkillSource("github.com/org/repo/blob/main/my.skill")?.parsed).toEqual({
+ source: "git-subdir",
+ url: "https://github.com/org/repo",
+ path: "my.skill",
+ });
+ });
+
+ it("falls back to the repo for a tree URL with a branch but no folder", () => {
+ expect(parseSkillSource("github.com/org/repo/tree/main")?.parsed).toEqual({ source: "github", repo: "org/repo" });
+ });
+
+ it("kebab-cases the suggested name from a mixed-case repo", () => {
+ expect(parseSkillSource("github.com/Org/My_Repo")?.suggestedName).toBe("my-repo");
+ });
+
+ it("rejects a bare host or single-segment raw git url", () => {
+ expect(parseSkillSource("gitlab.com")).toBeNull();
+ expect(parseSkillSource("gitlab.com/org")).toBeNull();
+ });
+});
+
+// Skill sources are served on the unauthenticated public feeds and cloned by clients, so the
+// parser must never publish an insecure, credentialed, internal, or malformed clone URL.
+describe("parseSkillSource — security boundary", () => {
+ it("rejects non-https schemes", () => {
+ for (const url of [
+ "http://gitlab.com/org/repo",
+ "HTTP://gitlab.com/org/repo",
+ "ssh://gitlab.com/org/repo",
+ "git://gitlab.com/org/repo",
+ "ftp://gitlab.com/org/repo",
+ "file:///etc/passwd",
+ "javascript:alert(1)",
+ "data:text/plain,hi",
+ "//gitlab.com/org/repo",
+ ]) {
+ expect(parseSkillSource(url)).toBeNull();
+ }
+ });
+
+ it("rejects URLs with embedded credentials", () => {
+ expect(parseSkillSource("https://user:token@gitlab.com/org/repo")).toBeNull();
+ expect(parseSkillSource("https://user@gitlab.com/org/repo")).toBeNull();
+ // userinfo confusion: the real host is evil.com, not github.com
+ expect(parseSkillSource("https://github.com@evil.com/org/repo")).toBeNull();
+ });
+
+ it("rejects IP-literal hosts (loopback, private, metadata, obfuscated, IPv6)", () => {
+ for (const url of [
+ "https://127.0.0.1/org/repo",
+ "https://10.0.0.5/org/repo",
+ "https://169.254.169.254/org/repo",
+ "https://2130706433/org/repo",
+ "https://[::ffff:127.0.0.1]/org/repo",
+ ]) {
+ expect(parseSkillSource(url)).toBeNull();
+ }
+ });
+
+ it("does not grant GitHub shorthand to a look-alike host", () => {
+ expect(parseSkillSource("https://github.com.evil.com/org/repo")?.parsed).toEqual({
+ source: "url",
+ url: "https://github.com.evil.com/org/repo",
+ });
+ });
+
+ it("rejects GitHub org/repo segments with illegal characters", () => {
+ expect(parseSkillSource("github.com/o@x/repo")).toBeNull();
+ expect(parseSkillSource("github.com/org/..%2f..%2fx")).toBeNull();
+ });
+});
+
+describe("isValidSubPath", () => {
+ it("accepts relative segment paths", () => {
+ expect(isValidSubPath("plugins/x")).toBe(true);
+ expect(isValidSubPath("sub/dir")).toBe(true);
+ expect(isValidSubPath("a.b-c_d")).toBe(true);
+ expect(isValidSubPath("plugins/x/")).toBe(true);
+ });
+
+ it("rejects empty, traversal, absolute, and double-slash paths", () => {
+ expect(isValidSubPath("")).toBe(false);
+ expect(isValidSubPath("../etc")).toBe(false);
+ expect(isValidSubPath("/abs")).toBe(false);
+ expect(isValidSubPath("a//b")).toBe(false);
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts b/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts
index d696a78b4cc..cab3c5cba3c 100644
--- a/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts
+++ b/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts
@@ -4,15 +4,189 @@
import { PluginSource, MarketplacePluginEntry } from "./types";
+export interface SkillSourcePreview {
+ parsed: PluginSource;
+ label: string;
+ suggestedName: string;
+}
+
+export const SUBDIR_PATH_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9._-]*(\/[a-zA-Z0-9][a-zA-Z0-9._-]*)*$/;
+
+export const normalizeSubPath = (subPath: string): string => subPath.trim().replace(/\/+$/, "");
+
+export const isValidSubPath = (subPath: string): boolean => {
+ const normalized = normalizeSubPath(subPath);
+ return normalized !== "" && SUBDIR_PATH_REGEX.test(normalized);
+};
+
+const GITHUB_HOST = "github.com";
+
+const SKILL_FILE_EXTENSION_REGEX = /\.(md|markdown|txt|json|ya?ml|toml)$/i;
+
+// WHATWG normalizes obfuscated IPv4 (e.g. 2130706433, 0x7f.0.0.1) to dotted-decimal, so this
+// catches every IPv4 form; bracketed IPv6 is rejected separately.
+const IPV4_HOST_REGEX = /^\d{1,3}(\.\d{1,3}){3}$/;
+
+const GITHUB_ORG_REGEX = /^[A-Za-z0-9-]+$/;
+const GITHUB_REPO_REGEX = /^[A-Za-z0-9._-]+$/;
+
+const buildRepoUrl = (url: URL): string => `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, "")}`;
+
+const pathSegments = (url: URL): string[] => url.pathname.split("/").filter((seg) => seg !== "");
+
+/**
+ * Validate and normalize a repository URL into a parsed URL, or null. Enforces https (rejects
+ * http/ssh/git/etc.), rejects embedded credentials, and requires a dotted host, so the public
+ * skill feeds never serve an insecure or credentialed clone URL. Everything downstream parses
+ * this normalized object rather than the raw string.
+ */
+const parseRepoUrl = (raw: string): URL | null => {
+ const trimmed = raw.trim();
+ if (trimmed === "" || trimmed.startsWith("//")) {
+ return null;
+ }
+ const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
+ let url: URL;
+ try {
+ url = new URL(withScheme);
+ } catch {
+ return null;
+ }
+ if (
+ url.protocol !== "https:" ||
+ url.username !== "" ||
+ url.password !== "" ||
+ !url.hostname.includes(".") ||
+ url.hostname.startsWith("[") ||
+ IPV4_HOST_REGEX.test(url.hostname)
+ ) {
+ return null;
+ }
+ return url;
+};
+
+const lastSegment = (path: string): string => {
+ const segments = path.split("/").filter((seg) => seg !== "");
+ return segments[segments.length - 1] ?? "";
+};
+
+const toKebabCase = (value: string): string =>
+ value
+ .toLowerCase()
+ .replace(/[^a-z0-9-]+/g, "-")
+ .replace(/-+/g, "-")
+ .replace(/^-+|-+$/g, "");
+
+const parseGitHubSource = (url: URL, subPath?: string): SkillSourcePreview | null => {
+ const parts = pathSegments(url);
+ if (parts.length < 2) {
+ return null;
+ }
+
+ const org = parts[0];
+ const repoBase = parts[1].replace(/\.git$/, "");
+ if (!GITHUB_ORG_REGEX.test(org) || !GITHUB_REPO_REGEX.test(repoBase)) {
+ return null;
+ }
+ const repoFull = `${org}/${repoBase}`;
+ const repoUrl = `https://github.com/${repoFull}`;
+ const repoPreview: SkillSourcePreview = {
+ parsed: { source: "github", repo: repoFull },
+ label: `GitHub repo — ${repoFull}`,
+ suggestedName: toKebabCase(repoBase),
+ };
+
+ const isTreeOrBlob = parts.length >= 4 && (parts[2] === "tree" || parts[2] === "blob");
+ if (isTreeOrBlob) {
+ const pathParts = parts.slice(4);
+ const last = lastSegment(pathParts.join("/"));
+ const effective = SKILL_FILE_EXTENSION_REGEX.test(last) ? pathParts.slice(0, -1) : pathParts;
+ if (effective.length === 0) {
+ return repoPreview;
+ }
+ const path = normalizeSubPath(effective.join("/"));
+ if (!SUBDIR_PATH_REGEX.test(path)) {
+ return null;
+ }
+ return {
+ parsed: { source: "git-subdir", url: repoUrl, path },
+ label: `GitHub subdir — ${repoFull} @ ${path}`,
+ suggestedName: toKebabCase(lastSegment(path)),
+ };
+ }
+
+ if (parts.length !== 2) {
+ return null;
+ }
+
+ const normalized = normalizeSubPath(subPath ?? "");
+ if (normalized !== "") {
+ if (!SUBDIR_PATH_REGEX.test(normalized)) {
+ return null;
+ }
+ return {
+ parsed: { source: "git-subdir", url: repoUrl, path: normalized },
+ label: `GitHub subdir — ${repoFull} @ ${normalized}`,
+ suggestedName: toKebabCase(lastSegment(normalized)),
+ };
+ }
+
+ return repoPreview;
+};
+
+const parseRawGitSource = (url: URL, subPath?: string): SkillSourcePreview | null => {
+ if (pathSegments(url).length < 2) {
+ return null;
+ }
+
+ const repoUrl = buildRepoUrl(url);
+
+ const normalized = normalizeSubPath(subPath ?? "");
+ if (normalized !== "") {
+ if (!SUBDIR_PATH_REGEX.test(normalized)) {
+ return null;
+ }
+ return {
+ parsed: { source: "git-subdir", url: repoUrl, path: normalized },
+ label: `Git subdir — ${repoUrl} @ ${normalized}`,
+ suggestedName: toKebabCase(lastSegment(normalized)),
+ };
+ }
+
+ return {
+ parsed: { source: "url", url: repoUrl },
+ label: `Git repo — ${repoUrl}`,
+ suggestedName: toKebabCase(lastSegment(url.pathname).replace(/\.git$/, "")),
+ };
+};
+
+/**
+ * Parse any git-accessible repository URL into a registerable skill source.
+ * GitHub URLs keep their `github`/`git-subdir` shorthand; every other host is
+ * treated as a raw repo URL, with an optional subfolder turning it into git-subdir.
+ */
+export const parseSkillSource = (rawUrl: string, subPath?: string): SkillSourcePreview | null => {
+ const url = parseRepoUrl(rawUrl);
+ if (!url) {
+ return null;
+ }
+ if (url.hostname.replace(/^www\./, "") === GITHUB_HOST) {
+ return parseGitHubSource(url, subPath);
+ }
+ return parseRawGitSource(url, subPath);
+};
+
/**
* Generate install command for Claude Code CLI
* Format: /plugin marketplace add org/repo OR /plugin marketplace add url
*/
export const formatInstallCommand = (plugin: { name: string; source: PluginSource }): string => {
- if (plugin.source.source === "github" && plugin.source.repo) {
- return `/plugin marketplace add ${plugin.source.repo}`;
- } else if (plugin.source.source === "url" && plugin.source.url) {
- return `/plugin marketplace add ${plugin.source.url}`;
+ const { source } = plugin;
+ if (source.source === "github" && source.repo) {
+ return `/plugin marketplace add ${source.repo}`;
+ }
+ if ((source.source === "url" || source.source === "git-subdir") && source.url) {
+ return `/plugin marketplace add ${source.url}`;
}
// Fallback to plugin name
return `/plugin marketplace add ${plugin.name}`;
@@ -55,7 +229,11 @@ export const validatePluginName = (name: string): boolean => {
export const getSourceDisplayText = (source: PluginSource): string => {
if (source.source === "github" && source.repo) {
return `GitHub: ${source.repo}`;
- } else if (source.source === "url" && source.url) {
+ }
+ if (source.source === "git-subdir" && source.url && source.path) {
+ return `${source.url} @ ${source.path}`;
+ }
+ if (source.source === "url" && source.url) {
return source.url;
}
return "Unknown source";
@@ -67,7 +245,8 @@ export const getSourceDisplayText = (source: PluginSource): string => {
export const getSourceLink = (source: PluginSource): string | null => {
if (source.source === "github" && source.repo) {
return `https://github.com/${source.repo}`;
- } else if (source.source === "url" && source.url) {
+ }
+ if ((source.source === "url" || source.source === "git-subdir") && source.url) {
return source.url;
}
return null;
diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/types.ts b/ui/litellm-dashboard/src/components/claude_code_plugins/types.ts
index fcb1146685d..d16c880749b 100644
--- a/ui/litellm-dashboard/src/components/claude_code_plugins/types.ts
+++ b/ui/litellm-dashboard/src/components/claude_code_plugins/types.ts
@@ -1,8 +1,12 @@
/**
* TypeScript types for Claude Code Marketplace
- * Matches backend API types from /litellm/types/proxy/claude_code_endpoints.py
+ * API request/response shapes are synced from the generated OpenAPI types in @/lib/http/schema.
*/
+import type { components } from "@/lib/http/schema";
+
+// Kept hand-written: the backend types `source` as Dict[str, str], so the generated type is a
+// loose string map; this discriminant union is what the parser and display helpers rely on.
export interface PluginSource {
source: "github" | "url" | "git-subdir";
repo?: string; // Format: "org/repo" for GitHub
@@ -10,10 +14,7 @@ export interface PluginSource {
path?: string; // Subdirectory path for git-subdir
}
-export interface PluginAuthor {
- name: string;
- email?: string;
-}
+export type PluginAuthor = components["schemas"]["PluginAuthor"];
export interface Plugin {
id: string;
@@ -56,24 +57,12 @@ export interface ListPluginsResponse {
count: number;
}
-export interface RegisterPluginRequest {
- name: string;
+// Request envelope synced from the OpenAPI spec, with `source` narrowed to our PluginSource
+// union and `version` kept optional (the backend supplies its default).
+export type SkillRegisterRequest = Omit & {
source: PluginSource;
version?: string;
- description?: string;
- author?: PluginAuthor;
- homepage?: string;
- keywords?: string[];
- category?: string;
- domain?: string;
- namespace?: string;
-}
-
-export interface RegisterPluginResponse {
- plugin: Plugin;
- action: "created" | "updated";
- message: string;
-}
+};
// Public marketplace types
export interface MarketplacePluginEntry {
@@ -104,20 +93,3 @@ export interface CategoryTab {
label: string;
count: number;
}
-
-export interface PluginFormData {
- name: string;
- sourceType: "github" | "url" | "git-subdir";
- repo: string;
- url: string;
- path: string;
- version: string;
- description: string;
- authorName: string;
- authorEmail: string;
- homepage: string;
- category: string;
- keywords: string; // Comma-separated string, will be split into array
- domain: string;
- namespace: string;
-}
diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx
index f5bae832e64..0bbf4d2a6e8 100644
--- a/ui/litellm-dashboard/src/components/networking.tsx
+++ b/ui/litellm-dashboard/src/components/networking.tsx
@@ -27,6 +27,7 @@ import { TagNewRequest, TagUpdateRequest, TagListResponse, TagInfoResponse } fro
import { Team } from "./key_team_helpers/key_list";
import { UserInfo } from "./view_users/types";
import { EmailEventSettingsResponse, EmailEventSettingsUpdateRequest } from "./email_events/types";
+import type { SkillRegisterRequest } from "./claude_code_plugins/types";
import { jsonFields } from "./common_components/check_openapi_schema";
import NotificationsManager from "./molecules/notifications_manager";
import type { MCPUserEnvVarsStatus } from "./mcp_tools/types";
@@ -7402,19 +7403,7 @@ export const getClaudeCodePluginDetails = async (accessToken: string, pluginName
* @param accessToken - Admin access token
* @param pluginData - Plugin registration data
*/
-export const registerClaudeCodePlugin = async (
- accessToken: string,
- pluginData: {
- name: string;
- source: { source: string; repo?: string; url?: string };
- version?: string;
- description?: string;
- author?: { name: string; email?: string };
- homepage?: string;
- keywords?: string[];
- category?: string;
- },
-) => {
+export const registerClaudeCodePlugin = async (accessToken: string, pluginData: SkillRegisterRequest) => {
try {
const proxyBaseUrl = getProxyBaseUrl();
const url = proxyBaseUrl ? `${proxyBaseUrl}/claude-code/plugins` : `/claude-code/plugins`;
@@ -7429,8 +7418,13 @@ export const registerClaudeCodePlugin = async (
});
if (!response.ok) {
- const errorData = await response.text();
- const errorMessage = deriveErrorMessage(JSON.parse(errorData));
+ const errorBody = await response.text();
+ let errorMessage: string;
+ try {
+ errorMessage = deriveErrorMessage(JSON.parse(errorBody));
+ } catch {
+ errorMessage = errorBody || `Request failed with status ${response.status}`;
+ }
handleError(errorMessage);
throw new Error(errorMessage);
}