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:
milan 2026-07-31 21:35:10 +00:00 committed by Devin AI
parent 3083c55ffc
commit a326d549a8
8 changed files with 187 additions and 20 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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