feat(skills): add metadata and branch fields to skill packs and marketplace skills

This commit is contained in:
Abhimanyu Saharan
2026-02-14 12:26:45 +05:30
parent 5b9e81aa6d
commit 40dcf50f4b
17 changed files with 1049 additions and 51 deletions

View File

@@ -9,8 +9,10 @@
* Payload used to register a pack URL in the organization.
*/
export interface SkillPackCreate {
branch?: string;
description?: string | null;
name?: string | null;
metadata?: Record<string, object>;
/** @minLength 1 */
source_url: string;
}

View File

@@ -12,8 +12,10 @@ export interface SkillPackRead {
created_at: string;
description?: string | null;
id: string;
branch: string;
name: string;
organization_id: string;
metadata: Record<string, object>;
skill_count?: number;
source_url: string;
updated_at: string;

View File

@@ -11,6 +11,7 @@
export interface SkillPackSyncResponse {
created: number;
ok?: boolean;
warnings: string[];
pack_id: string;
synced: number;
updated: number;

View File

@@ -73,12 +73,17 @@ export default function EditSkillPackPage() {
sourceUrl: pack.source_url,
name: pack.name,
description: pack.description ?? "",
branch: pack.branch || "main",
}}
sourceLabel="Pack URL"
nameLabel="Pack name (optional)"
descriptionLabel="Pack description (optional)"
branchLabel="Pack branch (optional)"
branchPlaceholder="main"
showBranch
descriptionPlaceholder="Short summary shown in the packs list."
requiredUrlMessage="Pack URL is required."
invalidUrlMessage="Pack URL must be a GitHub repository URL (https://github.com/<owner>/<repo>)."
submitLabel="Save changes"
submittingLabel="Saving..."
isSubmitting={saveMutation.isPending}
@@ -90,6 +95,8 @@ export default function EditSkillPackPage() {
source_url: values.sourceUrl,
name: values.name || undefined,
description: values.description || undefined,
branch: values.branch || "main",
metadata: pack.metadata || {},
},
});
if (result.status !== 200) {

View File

@@ -36,7 +36,11 @@ export default function NewSkillPackPage() {
nameLabel="Pack name (optional)"
descriptionLabel="Pack description (optional)"
descriptionPlaceholder="Short summary shown in the packs list."
branchLabel="Pack branch (optional)"
branchPlaceholder="main"
showBranch
requiredUrlMessage="Pack URL is required."
invalidUrlMessage="Pack URL must be a GitHub repository URL (https://github.com/<owner>/<repo>)."
submitLabel="Add pack"
submittingLabel="Adding..."
isSubmitting={createMutation.isPending}
@@ -47,6 +51,8 @@ export default function NewSkillPackPage() {
source_url: values.sourceUrl,
name: values.name || undefined,
description: values.description || undefined,
branch: values.branch || "main",
metadata: {},
},
});
if (result.status !== 200) {

View File

@@ -24,7 +24,13 @@ import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
import { useOrganizationMembership } from "@/lib/use-organization-membership";
import { useUrlSorting } from "@/lib/use-url-sorting";
const PACKS_SORTABLE_COLUMNS = ["name", "source_url", "skill_count", "updated_at"];
const PACKS_SORTABLE_COLUMNS = [
"name",
"source_url",
"branch",
"skill_count",
"updated_at",
];
export default function SkillsPacksPage() {
const queryClient = useQueryClient();
@@ -32,6 +38,9 @@ export default function SkillsPacksPage() {
const { isAdmin } = useOrganizationMembership(isSignedIn);
const [deleteTarget, setDeleteTarget] = useState<SkillPackRead | null>(null);
const [syncingPackIds, setSyncingPackIds] = useState<Set<string>>(new Set());
const [isSyncingAll, setIsSyncingAll] = useState(false);
const [syncAllError, setSyncAllError] = useState<string | null>(null);
const [syncWarnings, setSyncWarnings] = useState<string[]>([]);
const { sorting, onSortingChange } = useUrlSorting({
allowedColumnIds: PACKS_SORTABLE_COLUMNS,
@@ -91,7 +100,9 @@ export default function SkillsPacksPage() {
};
const handleSyncPack = async (pack: SkillPackRead) => {
if (syncingPackIds.has(pack.id)) return;
if (isSyncingAll || syncingPackIds.has(pack.id)) return;
setSyncAllError(null);
setSyncWarnings([]);
setSyncingPackIds((previous) => {
const next = new Set(previous);
@@ -99,9 +110,10 @@ export default function SkillsPacksPage() {
return next;
});
try {
await syncMutation.mutateAsync({
const response = await syncMutation.mutateAsync({
packId: pack.id,
});
setSyncWarnings(response.data.warnings ?? []);
} finally {
setSyncingPackIds((previous) => {
const next = new Set(previous);
@@ -111,6 +123,54 @@ export default function SkillsPacksPage() {
}
};
const handleSyncAllPacks = async () => {
if (!isAdmin || isSyncingAll || syncingPackIds.size > 0 || packs.length === 0) {
return;
}
setSyncAllError(null);
setSyncWarnings([]);
setIsSyncingAll(true);
try {
let hasFailure = false;
for (const pack of packs) {
if (!pack.id) continue;
setSyncingPackIds((previous) => {
const next = new Set(previous);
next.add(pack.id);
return next;
});
try {
const response = await syncMutation.mutateAsync({ packId: pack.id });
setSyncWarnings((previous) => [
...previous,
...(response.data.warnings ?? []),
]);
} catch {
hasFailure = true;
} finally {
setSyncingPackIds((previous) => {
const next = new Set(previous);
next.delete(pack.id);
return next;
});
}
}
if (hasFailure) {
setSyncAllError("Some skill packs failed to sync. Please try again.");
}
} finally {
setIsSyncingAll(false);
await queryClient.invalidateQueries({
queryKey: packsQueryKey,
});
}
};
return (
<>
<DashboardPageLayout
@@ -122,12 +182,31 @@ export default function SkillsPacksPage() {
description={`${packs.length} pack${packs.length === 1 ? "" : "s"} configured.`}
headerActions={
isAdmin ? (
<Link
href="/skills/packs/new"
className={buttonVariants({ variant: "primary", size: "md" })}
>
Add pack
</Link>
<div className="flex items-center gap-2">
<button
type="button"
className={buttonVariants({
variant: "outline",
size: "md",
})}
disabled={
isSyncingAll ||
syncingPackIds.size > 0 ||
packs.length === 0
}
onClick={() => {
void handleSyncAllPacks();
}}
>
{isSyncingAll ? "Syncing all..." : "Sync all"}
</button>
<Link
href="/skills/packs/new"
className={buttonVariants({ variant: "primary", size: "md" })}
>
Add pack
</Link>
</div>
) : null
}
isAdmin={isAdmin}
@@ -167,6 +246,18 @@ export default function SkillsPacksPage() {
{syncMutation.error ? (
<p className="text-sm text-rose-600">{syncMutation.error.message}</p>
) : null}
{syncAllError ? (
<p className="text-sm text-rose-600">{syncAllError}</p>
) : null}
{syncWarnings.length > 0 ? (
<div className="space-y-1">
{syncWarnings.map((warning) => (
<p key={warning} className="text-sm text-amber-600">
{warning}
</p>
))}
</div>
) : null}
</div>
</DashboardPageLayout>

View File

@@ -9,6 +9,7 @@ type MarketplaceSkillFormValues = {
sourceUrl: string;
name: string;
description: string;
branch: string;
};
type MarketplaceSkillFormProps = {
@@ -21,9 +22,14 @@ type MarketplaceSkillFormProps = {
namePlaceholder?: string;
descriptionLabel?: string;
descriptionPlaceholder?: string;
branchLabel?: string;
branchPlaceholder?: string;
defaultBranch?: string;
requiredUrlMessage?: string;
invalidUrlMessage?: string;
submitLabel: string;
submittingLabel: string;
showBranch?: boolean;
isSubmitting: boolean;
onCancel: () => void;
onSubmit: (values: MarketplaceSkillFormValues) => Promise<void>;
@@ -33,6 +39,7 @@ const DEFAULT_VALUES: MarketplaceSkillFormValues = {
sourceUrl: "",
name: "",
description: "",
branch: "main",
};
const extractErrorMessage = (error: unknown, fallback: string) => {
@@ -51,7 +58,12 @@ export function MarketplaceSkillForm({
namePlaceholder = "Deploy Helper",
descriptionLabel = "Description (optional)",
descriptionPlaceholder = "Short summary shown in the marketplace.",
branchLabel = "Branch (optional)",
branchPlaceholder = "main",
defaultBranch = "main",
showBranch = false,
requiredUrlMessage = "Skill URL is required.",
invalidUrlMessage = "Skill URL must be a GitHub repository URL (https://github.com/<owner>/<repo>).",
submitLabel,
submittingLabel,
isSubmitting,
@@ -59,11 +71,30 @@ export function MarketplaceSkillForm({
onSubmit,
}: MarketplaceSkillFormProps) {
const resolvedInitial = initialValues ?? DEFAULT_VALUES;
const normalizedDefaultBranch = defaultBranch.trim() || "main";
const [sourceUrl, setSourceUrl] = useState(resolvedInitial.sourceUrl);
const [name, setName] = useState(resolvedInitial.name);
const [description, setDescription] = useState(resolvedInitial.description);
const [branch, setBranch] = useState(
resolvedInitial.branch?.trim() || normalizedDefaultBranch,
);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const isValidSourceUrl = (value: string) => {
try {
const parsed = new URL(value);
if (parsed.protocol !== "https:") return false;
if (parsed.hostname !== "github.com") return false;
const parts = parsed.pathname
.split("/")
.map((segment) => segment.trim())
.filter((segment) => segment.length > 0);
return parts.length >= 2;
} catch {
return false;
}
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const normalizedUrl = sourceUrl.trim();
@@ -72,6 +103,11 @@ export function MarketplaceSkillForm({
return;
}
if (!isValidSourceUrl(normalizedUrl)) {
setErrorMessage(invalidUrlMessage);
return;
}
setErrorMessage(null);
try {
@@ -79,6 +115,7 @@ export function MarketplaceSkillForm({
sourceUrl: normalizedUrl,
name: name.trim(),
description: description.trim(),
branch: branch.trim() || normalizedDefaultBranch,
});
} catch (error) {
setErrorMessage(extractErrorMessage(error, "Unable to save skill."));
@@ -112,6 +149,24 @@ export function MarketplaceSkillForm({
) : null}
</div>
{showBranch ? (
<div className="space-y-2">
<label
htmlFor="skill-branch"
className="text-xs font-semibold uppercase tracking-wider text-slate-500"
>
{branchLabel}
</label>
<Input
id="skill-branch"
value={branch}
onChange={(event) => setBranch(event.target.value)}
placeholder={branchPlaceholder}
disabled={isSubmitting}
/>
</div>
) : null}
<div className="space-y-2">
<label
htmlFor="skill-name"

View File

@@ -83,6 +83,11 @@ export function SkillPacksTable({
</Link>
),
},
{
accessorKey: "branch",
header: "Branch",
cell: ({ row }) => <p className="text-sm text-slate-900">{row.original.branch || "main"}</p>,
},
{
accessorKey: "skill_count",
header: "Skills",