From a326d549a8720d33b40810be542f0526f6cd10a0 Mon Sep 17 00:00:00 2001 From: milan Date: Fri, 31 Jul 2026 21:35:10 +0000 Subject: [PATCH] feat(ui): allow editing an already registered Claude Code skill Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../_components/ClaudeCodePluginsPanel.tsx | 13 ++++ .../skills/_components/PluginTable.test.tsx | 13 +++- .../skills/_components/PluginTable.tsx | 14 +++- .../skills/_components/PluginTableColumns.tsx | 18 ++++- .../_components/add_plugin_form.test.tsx | 47 +++++++++++++ .../skills/_components/add_plugin_form.tsx | 69 +++++++++++++++---- .../claude_code_plugins/helpers.test.ts | 17 +++++ .../components/claude_code_plugins/helpers.ts | 16 +++++ 8 files changed, 187 insertions(+), 20 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx index 47fc8f41307..a4fc077eaa4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx @@ -25,6 +25,7 @@ interface ClaudeCodePluginsPanelProps { const ClaudeCodePluginsPanel: React.FC = ({ accessToken, userRole }) => { const [pluginsList, setPluginsList] = useState([]); const [isAddModalVisible, setIsAddModalVisible] = useState(false); + const [pluginToEdit, setPluginToEdit] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isDeleting, setIsDeleting] = useState(false); const [pluginToDelete, setPluginToDelete] = useState<{ @@ -105,6 +106,7 @@ const ClaudeCodePluginsPanel: React.FC = ({ accessT { @@ -122,6 +124,17 @@ const ClaudeCodePluginsPanel: React.FC = ({ accessT onSuccess={fetchPlugins} /> + {pluginToEdit && ( + setPluginToEdit(null)} + accessToken={accessToken} + onSuccess={fetchPlugins} + /> + )} + {pluginToDelete && ( { expect(mockOnDeleteClick).toHaveBeenCalledWith("newer-skill", "newer-skill"); }); + it("should edit a skill through the actions menu when admin", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId("plugin-actions-newer-skill")); + await user.click(await screen.findByTestId("plugin-action-edit")); + expect(mockOnEditClick).toHaveBeenCalledWith(mockPlugins[0]); + }); + it("should copy the skill ID through the actions menu", async () => { const user = userEvent.setup(); render(); @@ -103,11 +113,12 @@ describe("PluginTable", () => { expect(await window.navigator.clipboard.readText()).toBe("plugin-id-newer"); }); - it("should hide the delete action for non-admins but keep copy available", async () => { + it("should hide the edit and delete actions for non-admins but keep copy available", async () => { const user = userEvent.setup(); render(); await user.click(screen.getByTestId("plugin-actions-newer-skill")); expect(await screen.findByTestId("plugin-action-copy")).toBeInTheDocument(); + expect(screen.queryByTestId("plugin-action-edit")).not.toBeInTheDocument(); expect(screen.queryByTestId("plugin-action-delete")).not.toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTable.tsx index c581b0dfdeb..b87f9194be0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTable.tsx @@ -12,6 +12,7 @@ import { getPluginTableColumns } from "./PluginTableColumns"; interface PluginTableProps { pluginsList: Plugin[]; isLoading: boolean; + onEditClick: (plugin: Plugin) => void; onDeleteClick: (pluginName: string, displayName: string) => void; isAdmin: boolean; onPluginClick: (pluginId: string) => void; @@ -31,12 +32,19 @@ function EmptyState() { ); } -const PluginTable: React.FC = ({ pluginsList, isLoading, onDeleteClick, isAdmin, onPluginClick }) => { +const PluginTable: React.FC = ({ + pluginsList, + isLoading, + onEditClick, + onDeleteClick, + isAdmin, + onPluginClick, +}) => { const [sorting, setSorting] = useState(DEFAULT_SORTING); const columns = useMemo( - () => getPluginTableColumns({ isAdmin, onPluginClick, onDeleteClick }), - [isAdmin, onPluginClick, onDeleteClick], + () => getPluginTableColumns({ isAdmin, onPluginClick, onEditClick, onDeleteClick }), + [isAdmin, onPluginClick, onEditClick, onDeleteClick], ); return ( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTableColumns.tsx index 95c9924b375..e857328ab60 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTableColumns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTableColumns.tsx @@ -1,7 +1,7 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; -import { Copy, MoreHorizontal, Trash2 } from "lucide-react"; +import { Copy, MoreHorizontal, Pencil, Trash2 } from "lucide-react"; import { DataTableSortHeader } from "@/components/shared/DataTable"; import { DateCell, IdentityCell, StatusBadge } from "@/components/shared/table_cells"; @@ -43,10 +43,11 @@ function PluginCategoryBadge({ category }: { category?: string }) { interface PluginRowActionsProps { plugin: Plugin; isAdmin: boolean; + onEditClick: (plugin: Plugin) => void; onDeleteClick: (pluginName: string, displayName: string) => void; } -function PluginRowActions({ plugin, isAdmin, onDeleteClick }: PluginRowActionsProps) { +function PluginRowActions({ plugin, isAdmin, onEditClick, onDeleteClick }: PluginRowActionsProps) { return ( {isAdmin && ( <> + onEditClick(plugin)}> + + Edit + void; + onEditClick: (plugin: Plugin) => void; onDeleteClick: (pluginName: string, displayName: string) => void; } export const getPluginTableColumns = ({ isAdmin, onPluginClick, + onEditClick, onDeleteClick, }: PluginTableColumnsDeps): ColumnDef[] => [ { @@ -173,7 +180,12 @@ export const getPluginTableColumns = ({ enableHiding: false, cell: ({ row }) => (
- +
), }, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.test.tsx index 8ea4dfac32c..ae63ef519ea 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.test.tsx @@ -259,6 +259,53 @@ describe("AddPluginForm", () => { }); }); + describe("edit mode", () => { + const existingSkill = { + id: "plugin-id", + name: "my-skill", + version: "1.0.0", + description: "Does a thing", + source: { source: "git-subdir" as const, url: "https://gitlab.com/group/repo", path: "plugins/x" }, + keywords: ["search", "web"], + category: "Development", + enabled: true, + }; + + it("prefills the repository URL, subfolder, and metadata and locks the name", () => { + renderWithProviders(); + + expect((screen.getByPlaceholderText(URL_PLACEHOLDER) as HTMLInputElement).value).toBe( + "https://gitlab.com/group/repo", + ); + expect((screen.getByPlaceholderText(SUBPATH_PLACEHOLDER) as HTMLInputElement).value).toBe("plugins/x"); + expect((screen.getByPlaceholderText("my-skill") as HTMLInputElement).value).toBe("my-skill"); + expect(screen.getByPlaceholderText("my-skill")).toBeDisabled(); + expect((screen.getByPlaceholderText("search, web, api") as HTMLInputElement).value).toBe("search, web"); + }); + + it("submits the edited metadata under the same name", async () => { + renderWithProviders(); + + await act(async () => { + fireEvent.change(screen.getByPlaceholderText("1.0.0"), { target: { value: "2.0.0" } }); + }); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Save Changes" })); + }); + + await waitFor(() => { + expect(mockRegister).toHaveBeenCalledWith( + "sk-test", + expect.objectContaining({ + name: "my-skill", + version: "2.0.0", + source: { source: "git-subdir", url: "https://gitlab.com/group/repo", path: "plugins/x" }, + }), + ); + }); + }); + }); + it("surfaces the backend error message when registration fails", async () => { mockRegister.mockRejectedValueOnce(new Error("Plugin 'claude-code' already exists")); renderWithProviders(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.tsx index 04b8c88ae8d..3582a32ae29 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/add_plugin_form.tsx @@ -11,9 +11,11 @@ import { parseKeywords, parseSkillSource, isValidSubPath, + formatKeywords, + sourceToFormFields, SkillSourcePreview, } from "@/components/claude_code_plugins/helpers"; -import { PluginAuthor, PluginSource, SkillRegisterRequest } from "@/components/claude_code_plugins/types"; +import { Plugin, PluginAuthor, PluginSource, SkillRegisterRequest } from "@/components/claude_code_plugins/types"; const { TextArea } = Input; const { Option } = Select; @@ -23,6 +25,8 @@ interface AddPluginFormProps { onClose: () => void; accessToken: string | null; onSuccess: () => void; + /** When set, the form edits this skill instead of registering a new one */ + skill?: Plugin | null; } interface AddPluginFormValues { @@ -76,11 +80,31 @@ const PREDEFINED_CATEGORIES = [ "Documentation", ]; -const AddPluginForm: React.FC = ({ visible, onClose, accessToken, onSuccess }) => { +const toFormValues = (skill: Plugin): AddPluginFormValues => ({ + ...sourceToFormFields(skill.source), + name: skill.name, + version: skill.version, + description: skill.description, + authorName: skill.author?.name, + authorEmail: skill.author?.email ?? undefined, + homepage: skill.homepage, + category: skill.category, + keywords: formatKeywords(skill.keywords), + domain: skill.domain, + namespace: skill.namespace, +}); + +const AddPluginForm: React.FC = ({ visible, onClose, accessToken, onSuccess, skill }) => { const [form] = Form.useForm(); + const initialValues = skill ? toFormValues(skill) : undefined; const [isSubmitting, setIsSubmitting] = useState(false); - const [urlPreview, setUrlPreview] = useState(null); - const [urlEncodesSubdir, setUrlEncodesSubdir] = useState(false); + const [urlPreview, setUrlPreview] = useState(() => + initialValues ? parseSkillSource(initialValues.skillUrl ?? "", initialValues.subPath) : null, + ); + const [urlEncodesSubdir, setUrlEncodesSubdir] = useState( + () => parseSkillSource(initialValues?.skillUrl ?? "")?.parsed.source === "git-subdir", + ); + const isEditing = Boolean(skill); const recomputePreview = (skillUrl: string, subPath: string) => { const encodesSubdir = parseSkillSource(skillUrl)?.parsed.source === "git-subdir"; @@ -137,21 +161,29 @@ const AddPluginForm: React.FC = ({ visible, onClose, accessT setIsSubmitting(true); try { await registerClaudeCodePlugin(accessToken, buildRegisterRequest(values, urlPreview.parsed)); - MessageManager.success("Skill registered successfully"); + MessageManager.success(isEditing ? "Skill updated successfully" : "Skill registered successfully"); form.resetFields(); setUrlPreview(null); setUrlEncodesSubdir(false); onSuccess(); onClose(); } catch (error) { - console.error("Error registering skill:", error); - const reason = error instanceof Error && error.message ? error.message : "Failed to register skill"; - MessageManager.error(`Failed to register skill: ${reason}`); + console.error("Error saving skill:", error); + const action = isEditing ? "update" : "register"; + const reason = error instanceof Error && error.message ? error.message : `Failed to ${action} skill`; + MessageManager.error(`Failed to ${action} skill: ${reason}`); } finally { setIsSubmitting(false); } }; + const submitLabel = (() => { + if (isEditing) { + return isSubmitting ? "Saving..." : "Save Changes"; + } + return isSubmitting ? "Adding..." : "Add Skill"; + })(); + const handleCancel = () => { form.resetFields(); setUrlPreview(null); @@ -160,8 +192,15 @@ const AddPluginForm: React.FC = ({ visible, onClose, accessT }; return ( - -
+ + {/* Smart URL Input */} = ({ visible, onClose, accessT message: "Name must be kebab-case (lowercase, numbers, hyphens only)", }, ]} - tooltip="Unique identifier in kebab-case format (e.g., my-skill)" + tooltip={ + isEditing + ? "The name identifies the skill in the marketplace and cannot be changed" + : "Unique identifier in kebab-case format (e.g., my-skill)" + } > - + {/* Domain and Namespace — side by side */} @@ -300,7 +343,7 @@ const AddPluginForm: React.FC = ({ visible, onClose, accessT Cancel 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 c16eba24f7b..d3859b52261 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 @@ -18,6 +18,7 @@ import { parseSkillSource, isValidSubPath, buildMarketplaceSettingsSnippet, + sourceToFormFields, } from "./helpers"; import { MarketplacePluginEntry, PluginSource } from "./types"; @@ -36,6 +37,22 @@ describe("buildMarketplaceSettingsSnippet", () => { }); }); +describe("sourceToFormFields", () => { + it("round-trips every source shape back through parseSkillSource", () => { + const sources: PluginSource[] = [ + { source: "github", repo: "org/repo" }, + { source: "url", url: "https://gitlab.com/group/repo" }, + { source: "git-subdir", url: "https://gitlab.com/group/repo", path: "plugins/x" }, + { source: "git-subdir", url: "https://github.com/org/repo", path: "plugins/x" }, + ]; + + for (const source of sources) { + const { skillUrl, subPath } = sourceToFormFields(source); + expect(parseSkillSource(skillUrl, subPath)?.parsed).toEqual(source); + } + }); +}); + describe("formatInstallCommand", () => { it("formats github source with repo", () => { const source: PluginSource = { source: "github", repo: "org/repo" }; 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 a4e70f78af1..33dc2a97def 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts @@ -176,6 +176,22 @@ export const parseSkillSource = (rawUrl: string, subPath?: string): SkillSourceP return parseRawGitSource(url, subPath); }; +export interface SkillSourceFields { + skillUrl: string; + subPath: string; +} + +/** + * Inverse of parseSkillSource: turn a registered source back into the repository URL and + * subfolder the register form collects, so an existing skill can be loaded for editing. + */ +export const sourceToFormFields = (source: PluginSource): SkillSourceFields => { + if (source.source === "github" && source.repo) { + return { skillUrl: `https://github.com/${source.repo}`, subPath: "" }; + } + return { skillUrl: source.url ?? "", subPath: source.path ?? "" }; +}; + /** * Build the `~/.claude/settings.json` snippet that registers the proxy as a marketplace. * Claude Code expects `extraKnownMarketplaces..source` to be a source object, not a