mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
feat(ui): allow editing an already registered Claude Code skill
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
3083c55ffc
commit
a326d549a8
8 changed files with 187 additions and 20 deletions
|
|
@ -25,6 +25,7 @@ interface ClaudeCodePluginsPanelProps {
|
|||
const ClaudeCodePluginsPanel: React.FC<ClaudeCodePluginsPanelProps> = ({ accessToken, userRole }) => {
|
||||
const [pluginsList, setPluginsList] = useState<Plugin[]>([]);
|
||||
const [isAddModalVisible, setIsAddModalVisible] = useState(false);
|
||||
const [pluginToEdit, setPluginToEdit] = useState<Plugin | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [pluginToDelete, setPluginToDelete] = useState<{
|
||||
|
|
@ -105,6 +106,7 @@ const ClaudeCodePluginsPanel: React.FC<ClaudeCodePluginsPanelProps> = ({ accessT
|
|||
<PluginTable
|
||||
pluginsList={pluginsList}
|
||||
isLoading={isLoading}
|
||||
onEditClick={setPluginToEdit}
|
||||
onDeleteClick={handleDeleteClick}
|
||||
isAdmin={isAdmin}
|
||||
onPluginClick={(id) => {
|
||||
|
|
@ -122,6 +124,17 @@ const ClaudeCodePluginsPanel: React.FC<ClaudeCodePluginsPanelProps> = ({ accessT
|
|||
onSuccess={fetchPlugins}
|
||||
/>
|
||||
|
||||
{pluginToEdit && (
|
||||
<AddPluginForm
|
||||
key={pluginToEdit.name}
|
||||
visible
|
||||
skill={pluginToEdit}
|
||||
onClose={() => setPluginToEdit(null)}
|
||||
accessToken={accessToken}
|
||||
onSuccess={fetchPlugins}
|
||||
/>
|
||||
)}
|
||||
|
||||
{pluginToDelete && (
|
||||
<AlertDialog
|
||||
open
|
||||
|
|
|
|||
|
|
@ -27,11 +27,13 @@ const mockPlugins: Plugin[] = [
|
|||
];
|
||||
|
||||
const mockOnDeleteClick = vi.fn();
|
||||
const mockOnEditClick = vi.fn();
|
||||
const mockOnPluginClick = vi.fn();
|
||||
|
||||
const defaultProps = {
|
||||
pluginsList: mockPlugins,
|
||||
isLoading: false,
|
||||
onEditClick: mockOnEditClick,
|
||||
onDeleteClick: mockOnDeleteClick,
|
||||
isAdmin: true,
|
||||
onPluginClick: mockOnPluginClick,
|
||||
|
|
@ -95,6 +97,14 @@ describe("PluginTable", () => {
|
|||
expect(mockOnDeleteClick).toHaveBeenCalledWith("newer-skill", "newer-skill");
|
||||
});
|
||||
|
||||
it("should edit a skill through the actions menu when admin", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PluginTable {...defaultProps} />);
|
||||
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(<PluginTable {...defaultProps} />);
|
||||
|
|
@ -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(<PluginTable {...defaultProps} isAdmin={false} />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<PluginTableProps> = ({ pluginsList, isLoading, onDeleteClick, isAdmin, onPluginClick }) => {
|
||||
const PluginTable: React.FC<PluginTableProps> = ({
|
||||
pluginsList,
|
||||
isLoading,
|
||||
onEditClick,
|
||||
onDeleteClick,
|
||||
isAdmin,
|
||||
onPluginClick,
|
||||
}) => {
|
||||
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
|
||||
|
||||
const columns = useMemo(
|
||||
() => getPluginTableColumns({ isAdmin, onPluginClick, onDeleteClick }),
|
||||
[isAdmin, onPluginClick, onDeleteClick],
|
||||
() => getPluginTableColumns({ isAdmin, onPluginClick, onEditClick, onDeleteClick }),
|
||||
[isAdmin, onPluginClick, onEditClick, onDeleteClick],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
|
|
@ -66,6 +67,10 @@ function PluginRowActions({ plugin, isAdmin, onDeleteClick }: PluginRowActionsPr
|
|||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<DropdownMenuItem data-testid="plugin-action-edit" onClick={() => onEditClick(plugin)}>
|
||||
<Pencil />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
|
|
@ -85,12 +90,14 @@ function PluginRowActions({ plugin, isAdmin, onDeleteClick }: PluginRowActionsPr
|
|||
interface PluginTableColumnsDeps {
|
||||
isAdmin: boolean;
|
||||
onPluginClick: (pluginId: string) => void;
|
||||
onEditClick: (plugin: Plugin) => void;
|
||||
onDeleteClick: (pluginName: string, displayName: string) => void;
|
||||
}
|
||||
|
||||
export const getPluginTableColumns = ({
|
||||
isAdmin,
|
||||
onPluginClick,
|
||||
onEditClick,
|
||||
onDeleteClick,
|
||||
}: PluginTableColumnsDeps): ColumnDef<Plugin>[] => [
|
||||
{
|
||||
|
|
@ -173,7 +180,12 @@ export const getPluginTableColumns = ({
|
|||
enableHiding: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end">
|
||||
<PluginRowActions plugin={row.original} isAdmin={isAdmin} onDeleteClick={onDeleteClick} />
|
||||
<PluginRowActions
|
||||
plugin={row.original}
|
||||
isAdmin={isAdmin}
|
||||
onEditClick={onEditClick}
|
||||
onDeleteClick={onDeleteClick}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(<AddPluginForm {...DEFAULT_PROPS} skill={existingSkill} />);
|
||||
|
||||
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(<AddPluginForm {...DEFAULT_PROPS} skill={existingSkill} />);
|
||||
|
||||
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(<AddPluginForm {...DEFAULT_PROPS} />);
|
||||
|
|
|
|||
|
|
@ -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<AddPluginFormProps> = ({ 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<AddPluginFormProps> = ({ visible, onClose, accessToken, onSuccess, skill }) => {
|
||||
const [form] = Form.useForm();
|
||||
const initialValues = skill ? toFormValues(skill) : undefined;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [urlPreview, setUrlPreview] = useState<SkillSourcePreview | null>(null);
|
||||
const [urlEncodesSubdir, setUrlEncodesSubdir] = useState(false);
|
||||
const [urlPreview, setUrlPreview] = useState<SkillSourcePreview | null>(() =>
|
||||
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<AddPluginFormProps> = ({ 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<AddPluginFormProps> = ({ visible, onClose, accessT
|
|||
};
|
||||
|
||||
return (
|
||||
<Modal title="Add New Skill" open={visible} onCancel={handleCancel} footer={null} width={700} className="top-8">
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit} className="mt-4">
|
||||
<Modal
|
||||
title={isEditing ? `Edit Skill: ${skill?.name}` : "Add New Skill"}
|
||||
open={visible}
|
||||
onCancel={handleCancel}
|
||||
footer={null}
|
||||
width={700}
|
||||
className="top-8"
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit} className="mt-4" initialValues={initialValues}>
|
||||
{/* Smart URL Input */}
|
||||
<Form.Item
|
||||
label="Repository URL"
|
||||
|
|
@ -221,9 +260,13 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ 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)"
|
||||
}
|
||||
>
|
||||
<Input placeholder="my-skill" className="rounded-lg" />
|
||||
<Input placeholder="my-skill" className="rounded-lg" disabled={isEditing} />
|
||||
</Form.Item>
|
||||
|
||||
{/* Domain and Namespace — side by side */}
|
||||
|
|
@ -300,7 +343,7 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
|
|||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={isSubmitting}>
|
||||
{isSubmitting ? "Adding..." : "Add Skill"}
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
|
|
|||
|
|
@ -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" };
|
||||
|
|
|
|||
|
|
@ -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.<name>.source` to be a source object, not a
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue