feat: add skill pack management features including creation, editing, and syncing

This commit is contained in:
Abhimanyu Saharan
2026-02-14 02:05:11 +05:30
committed by Abhimanyu Saharan
parent 88565f4d69
commit a7e1e5cbf4
28 changed files with 4403 additions and 430 deletions

View File

@@ -6,13 +6,14 @@ import {
Activity,
BarChart3,
Bot,
Boxes,
CheckCircle2,
Folder,
Building2,
LayoutGrid,
Network,
Package,
Settings,
Store,
Tags,
} from "lucide-react";
@@ -165,6 +166,42 @@ export function DashboardSidebar() {
</div>
</div>
<div>
{isAdmin ? (
<>
<p className="px-3 text-[11px] font-semibold uppercase tracking-wider text-slate-400">
Skills
</p>
<div className="mt-1 space-y-1">
<Link
href="/skills/marketplace"
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
pathname === "/skills" || pathname.startsWith("/skills/marketplace")
? "bg-blue-100 text-blue-800 font-medium"
: "hover:bg-slate-100",
)}
>
<Store className="h-4 w-4" />
Marketplace
</Link>
<Link
href="/skills/packs"
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
pathname.startsWith("/skills/packs")
? "bg-blue-100 text-blue-800 font-medium"
: "hover:bg-slate-100",
)}
>
<Boxes className="h-4 w-4" />
Packs
</Link>
</div>
</>
) : null}
</div>
<div>
<p className="px-3 text-[11px] font-semibold uppercase tracking-wider text-slate-400">
Administration
@@ -196,20 +233,6 @@ export function DashboardSidebar() {
Gateways
</Link>
) : null}
{isAdmin ? (
<Link
href="/skills"
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
pathname.startsWith("/skills")
? "bg-blue-100 text-blue-800 font-medium"
: "hover:bg-slate-100",
)}
>
<Package className="h-4 w-4" />
Skills
</Link>
) : null}
{isAdmin ? (
<Link
href="/agents"

View File

@@ -8,13 +8,14 @@ import { clearLocalAuthToken, isLocalAuthMode } from "@/auth/localAuth";
import {
Activity,
Bot,
Boxes,
ChevronDown,
LayoutDashboard,
LogOut,
Package,
Plus,
Server,
Settings,
Store,
Trello,
} from "lucide-react";
@@ -156,7 +157,12 @@ export function UserMenu({
{ href: "/activity", label: "Activity", icon: Activity },
{ href: "/agents", label: "Agents", icon: Bot },
{ href: "/gateways", label: "Gateways", icon: Server },
{ href: "/skills", label: "Skills", icon: Package },
{
href: "/skills/marketplace",
label: "Skills marketplace",
icon: Store,
},
{ href: "/skills/packs", label: "Skill packs", icon: Boxes },
{ href: "/settings", label: "Settings", icon: Settings },
] as const
).map((item) => (

View File

@@ -0,0 +1,170 @@
import { useState } from "react";
import { ApiError } from "@/api/mutator";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
type MarketplaceSkillFormValues = {
sourceUrl: string;
name: string;
description: string;
};
type MarketplaceSkillFormProps = {
initialValues?: MarketplaceSkillFormValues;
sourceUrlReadOnly?: boolean;
sourceUrlHelpText?: string;
sourceLabel?: string;
sourcePlaceholder?: string;
nameLabel?: string;
namePlaceholder?: string;
descriptionLabel?: string;
descriptionPlaceholder?: string;
requiredUrlMessage?: string;
submitLabel: string;
submittingLabel: string;
isSubmitting: boolean;
onCancel: () => void;
onSubmit: (values: MarketplaceSkillFormValues) => Promise<void>;
};
const DEFAULT_VALUES: MarketplaceSkillFormValues = {
sourceUrl: "",
name: "",
description: "",
};
const extractErrorMessage = (error: unknown, fallback: string) => {
if (error instanceof ApiError) return error.message || fallback;
if (error instanceof Error) return error.message || fallback;
return fallback;
};
export function MarketplaceSkillForm({
initialValues,
sourceUrlReadOnly = false,
sourceUrlHelpText,
sourceLabel = "Skill URL",
sourcePlaceholder = "https://github.com/org/skill-repo",
nameLabel = "Name (optional)",
namePlaceholder = "Deploy Helper",
descriptionLabel = "Description (optional)",
descriptionPlaceholder = "Short summary shown in the marketplace.",
requiredUrlMessage = "Skill URL is required.",
submitLabel,
submittingLabel,
isSubmitting,
onCancel,
onSubmit,
}: MarketplaceSkillFormProps) {
const resolvedInitial = initialValues ?? DEFAULT_VALUES;
const [sourceUrl, setSourceUrl] = useState(resolvedInitial.sourceUrl);
const [name, setName] = useState(resolvedInitial.name);
const [description, setDescription] = useState(resolvedInitial.description);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const normalizedUrl = sourceUrl.trim();
if (!normalizedUrl) {
setErrorMessage(requiredUrlMessage);
return;
}
setErrorMessage(null);
try {
await onSubmit({
sourceUrl: normalizedUrl,
name: name.trim(),
description: description.trim(),
});
} catch (error) {
setErrorMessage(extractErrorMessage(error, "Unable to save skill."));
}
};
return (
<form
onSubmit={handleSubmit}
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
>
<div className="space-y-5">
<div className="space-y-2">
<label
htmlFor="source-url"
className="text-xs font-semibold uppercase tracking-wider text-slate-500"
>
{sourceLabel}
</label>
<Input
id="source-url"
type="url"
value={sourceUrl}
onChange={(event) => setSourceUrl(event.target.value)}
placeholder={sourcePlaceholder}
readOnly={sourceUrlReadOnly}
disabled={isSubmitting || sourceUrlReadOnly}
/>
{sourceUrlHelpText ? (
<p className="text-xs text-slate-500">{sourceUrlHelpText}</p>
) : null}
</div>
<div className="space-y-2">
<label
htmlFor="skill-name"
className="text-xs font-semibold uppercase tracking-wider text-slate-500"
>
{nameLabel}
</label>
<Input
id="skill-name"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder={namePlaceholder}
disabled={isSubmitting}
/>
</div>
<div className="space-y-2">
<label
htmlFor="skill-description"
className="text-xs font-semibold uppercase tracking-wider text-slate-500"
>
{descriptionLabel}
</label>
<Textarea
id="skill-description"
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder={descriptionPlaceholder}
className="min-h-[120px]"
disabled={isSubmitting}
/>
</div>
{errorMessage ? (
<div className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">
{errorMessage}
</div>
) : null}
</div>
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? submittingLabel : submitLabel}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,269 @@
import { useMemo, useState } from "react";
import Link from "next/link";
import {
type ColumnDef,
type OnChangeFn,
type SortingState,
type Updater,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import type { MarketplaceSkillCardRead } from "@/api/generated/model";
import { DataTable, type DataTableEmptyState } from "@/components/tables/DataTable";
import { dateCell } from "@/components/tables/cell-formatters";
import { Button, buttonVariants } from "@/components/ui/button";
import { truncateText as truncate } from "@/lib/formatters";
type MarketplaceSkillsTableProps = {
skills: MarketplaceSkillCardRead[];
isLoading?: boolean;
sorting?: SortingState;
onSortingChange?: OnChangeFn<SortingState>;
stickyHeader?: boolean;
disableSorting?: boolean;
canInstallActions: boolean;
isMutating?: boolean;
onSkillClick?: (skill: MarketplaceSkillCardRead) => void;
onUninstall: (skill: MarketplaceSkillCardRead) => void;
onDelete?: (skill: MarketplaceSkillCardRead) => void;
getEditHref?: (skill: MarketplaceSkillCardRead) => string;
emptyState?: Omit<DataTableEmptyState, "icon"> & {
icon?: DataTableEmptyState["icon"];
};
};
const DEFAULT_EMPTY_ICON = (
<svg
className="h-16 w-16 text-slate-300"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 7h16" />
<path d="M4 12h16" />
<path d="M4 17h16" />
<path d="M8 7v10" />
<path d="M16 7v10" />
</svg>
);
const toPackUrl = (sourceUrl: string): string => {
try {
const parsed = new URL(sourceUrl);
const treeMarker = "/tree/";
const markerIndex = parsed.pathname.indexOf(treeMarker);
if (markerIndex > 0) {
const repoPath = parsed.pathname.slice(0, markerIndex);
return `${parsed.origin}${repoPath}`;
}
return sourceUrl;
} catch {
return sourceUrl;
}
};
const toPackLabel = (packUrl: string): string => {
try {
const parsed = new URL(packUrl);
const segments = parsed.pathname.split("/").filter(Boolean);
if (segments.length >= 2) {
return `${segments[0]}/${segments[1]}`;
}
return parsed.host;
} catch {
return "Open pack";
}
};
const toPackDetailHref = (packUrl: string): string => {
const params = new URLSearchParams({ source_url: packUrl });
return `/skills/packs/detail?${params.toString()}`;
};
export function MarketplaceSkillsTable({
skills,
isLoading = false,
sorting,
onSortingChange,
stickyHeader = false,
disableSorting = false,
canInstallActions,
isMutating = false,
onSkillClick,
onUninstall,
onDelete,
getEditHref,
emptyState,
}: MarketplaceSkillsTableProps) {
const [internalSorting, setInternalSorting] = useState<SortingState>([
{ id: "name", desc: false },
]);
const resolvedSorting = sorting ?? internalSorting;
const handleSortingChange: OnChangeFn<SortingState> =
onSortingChange ??
((updater: Updater<SortingState>) => {
setInternalSorting(updater);
});
const columns = useMemo<ColumnDef<MarketplaceSkillCardRead>[]>(() => {
const baseColumns: ColumnDef<MarketplaceSkillCardRead>[] = [
{
accessorKey: "name",
header: "Skill",
cell: ({ row }) => (
<div>
{onSkillClick ? (
<button
type="button"
onClick={() => onSkillClick(row.original)}
className="text-sm font-medium text-blue-700 hover:text-blue-600 hover:underline"
>
{row.original.name}
</button>
) : (
<p className="text-sm font-medium text-slate-900">{row.original.name}</p>
)}
<p className="mt-1 line-clamp-2 text-xs text-slate-500">
{row.original.description || "No description provided."}
</p>
</div>
),
},
{
accessorKey: "source_url",
header: "Pack",
cell: ({ row }) => {
const packUrl = toPackUrl(row.original.source_url);
return (
<Link
href={toPackDetailHref(packUrl)}
className="inline-flex items-center gap-1 text-sm font-medium text-slate-700 hover:text-blue-600"
>
{truncate(toPackLabel(packUrl), 40)}
</Link>
);
},
},
{
accessorKey: "category",
header: "Category",
cell: ({ row }) => (
<span className="text-sm text-slate-700">
{row.original.category || "uncategorized"}
</span>
),
},
{
accessorKey: "risk",
header: "Risk",
cell: ({ row }) => (
<span className="text-sm text-slate-700">
{row.original.risk || "unknown"}
</span>
),
},
{
accessorKey: "source",
header: "Source",
cell: ({ row }) => (
<span className="text-sm text-slate-700" title={row.original.source || ""}>
{truncate(row.original.source || "unknown", 36)}
</span>
),
},
{
accessorKey: "updated_at",
header: "Updated",
cell: ({ row }) => dateCell(row.original.updated_at),
},
{
id: "actions",
header: "",
enableSorting: false,
cell: ({ row }) => (
<div className="flex justify-end gap-2">
{row.original.installed ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onUninstall(row.original)}
disabled={isMutating || !canInstallActions}
>
Uninstall
</Button>
) : null}
{getEditHref ? (
<Link
href={getEditHref(row.original)}
className={buttonVariants({ variant: "ghost", size: "sm" })}
>
Edit
</Link>
) : null}
{onDelete ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onDelete(row.original)}
disabled={isMutating}
>
Delete
</Button>
) : null}
</div>
),
},
];
return baseColumns;
}, [
canInstallActions,
getEditHref,
isMutating,
onDelete,
onSkillClick,
onUninstall,
]);
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data: skills,
columns,
enableSorting: !disableSorting,
state: {
...(!disableSorting ? { sorting: resolvedSorting } : {}),
},
...(disableSorting ? {} : { onSortingChange: handleSortingChange }),
getCoreRowModel: getCoreRowModel(),
...(disableSorting ? {} : { getSortedRowModel: getSortedRowModel() }),
});
return (
<DataTable
table={table}
isLoading={isLoading}
stickyHeader={stickyHeader}
rowClassName="transition hover:bg-slate-50"
cellClassName="px-6 py-4 align-top"
emptyState={
emptyState
? {
icon: emptyState.icon ?? DEFAULT_EMPTY_ICON,
title: emptyState.title,
description: emptyState.description,
actionHref: emptyState.actionHref,
actionLabel: emptyState.actionLabel,
}
: undefined
}
/>
);
}

View File

@@ -0,0 +1,188 @@
import { useMemo, useState } from "react";
import Link from "next/link";
import {
type ColumnDef,
type OnChangeFn,
type SortingState,
type Updater,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import type { SkillPackRead } from "@/api/generated/model";
import { DataTable, type DataTableEmptyState } from "@/components/tables/DataTable";
import { dateCell } from "@/components/tables/cell-formatters";
import { Button } from "@/components/ui/button";
import { truncateText as truncate } from "@/lib/formatters";
type SkillPacksTableProps = {
packs: SkillPackRead[];
isLoading?: boolean;
sorting?: SortingState;
onSortingChange?: OnChangeFn<SortingState>;
stickyHeader?: boolean;
canSync?: boolean;
syncingPackIds?: Set<string>;
onSync?: (pack: SkillPackRead) => void;
onDelete?: (pack: SkillPackRead) => void;
getEditHref?: (pack: SkillPackRead) => string;
emptyState?: Omit<DataTableEmptyState, "icon"> & {
icon?: DataTableEmptyState["icon"];
};
};
const DEFAULT_EMPTY_ICON = (
<svg
className="h-16 w-16 text-slate-300"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 7h16" />
<path d="M4 12h16" />
<path d="M4 17h16" />
<path d="M8 7v10" />
<path d="M16 7v10" />
</svg>
);
export function SkillPacksTable({
packs,
isLoading = false,
sorting,
onSortingChange,
stickyHeader = false,
canSync = false,
syncingPackIds,
onSync,
onDelete,
getEditHref,
emptyState,
}: SkillPacksTableProps) {
const [internalSorting, setInternalSorting] = useState<SortingState>([
{ id: "name", desc: false },
]);
const resolvedSorting = sorting ?? internalSorting;
const handleSortingChange: OnChangeFn<SortingState> =
onSortingChange ??
((updater: Updater<SortingState>) => {
setInternalSorting(updater);
});
const columns = useMemo<ColumnDef<SkillPackRead>[]>(() => {
const baseColumns: ColumnDef<SkillPackRead>[] = [
{
accessorKey: "name",
header: "Pack",
cell: ({ row }) => (
<div>
<p className="text-sm font-medium text-slate-900">{row.original.name}</p>
<p className="mt-1 line-clamp-2 text-xs text-slate-500">
{row.original.description || "No description provided."}
</p>
</div>
),
},
{
accessorKey: "source_url",
header: "Pack URL",
cell: ({ row }) => (
<Link
href={row.original.source_url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-sm font-medium text-slate-700 hover:text-blue-600"
>
{truncate(row.original.source_url, 48)}
</Link>
),
},
{
accessorKey: "skill_count",
header: "Skills",
cell: ({ row }) => (
<Link
href={`/skills/marketplace?packId=${encodeURIComponent(row.original.id)}`}
className="text-sm font-medium text-blue-700 hover:text-blue-600 hover:underline"
>
{row.original.skill_count ?? 0}
</Link>
),
},
{
accessorKey: "updated_at",
header: "Updated",
cell: ({ row }) => dateCell(row.original.updated_at),
},
{
id: "sync",
header: "",
enableSorting: false,
cell: ({ row }) => {
if (!onSync) return null;
const isThisPackSyncing = Boolean(syncingPackIds?.has(row.original.id));
return (
<div className="flex justify-end">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => onSync(row.original)}
disabled={isThisPackSyncing || !canSync}
>
{isThisPackSyncing ? "Syncing..." : "Sync"}
</Button>
</div>
);
},
},
];
return baseColumns;
}, [canSync, onSync, syncingPackIds]);
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data: packs,
columns,
state: {
sorting: resolvedSorting,
},
onSortingChange: handleSortingChange,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
return (
<DataTable
table={table}
isLoading={isLoading}
stickyHeader={stickyHeader}
rowClassName="transition hover:bg-slate-50"
cellClassName="px-6 py-4 align-top"
rowActions={
getEditHref || onDelete
? {
...(getEditHref ? { getEditHref } : {}),
...(onDelete ? { onDelete } : {}),
}
: undefined
}
emptyState={
emptyState
? {
icon: emptyState.icon ?? DEFAULT_EMPTY_ICON,
title: emptyState.title,
description: emptyState.description,
actionHref: emptyState.actionHref,
actionLabel: emptyState.actionLabel,
}
: undefined
}
/>
);
}