merge(F8): move skills→omni-skills + split into 8 components + inspector

This commit is contained in:
diegosouzapw
2026-05-27 21:26:38 -03:00
11 changed files with 1667 additions and 872 deletions

View File

@@ -0,0 +1,384 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { Card } from "@/shared/components";
import { SkillsConceptCard } from "@/shared/components";
import type { SkillsProvider } from "@/lib/skills/providerSettings";
import { OmniSkillsList } from "./components/OmniSkillsList";
import { OmniExecutionsTab } from "./components/OmniExecutionsTab";
import { OmniSandboxTab } from "./components/OmniSandboxTab";
import { OmniMarketplaceTab } from "./components/OmniMarketplaceTab";
import type { OmniSkill } from "./components/OmniSkillCard";
interface Execution {
id: string;
skillId: string;
skillName: string;
status: string;
duration: number;
createdAt: string;
}
export function OmniSkillsPageClient(): JSX.Element {
const [skills, setSkills] = useState<OmniSkill[]>([]);
const [executions, setExecutions] = useState<Execution[]>([]);
const [loading, setLoading] = useState(true);
const [skillsPage, setSkillsPage] = useState(1);
const [skillsTotal, setSkillsTotal] = useState(0);
const [skillsTotalPages, setSkillsTotalPages] = useState(1);
const [popularDefaults, setPopularDefaults] = useState<string[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [modeFilter, setModeFilter] = useState<"all" | "on" | "off" | "auto">("all");
const [selectedSkillId, setSelectedSkillId] = useState<string | null>(null);
const [execPage, setExecPage] = useState(1);
const [execTotal, setExecTotal] = useState(0);
const [execTotalPages, setExecTotalPages] = useState(1);
const [activeTab, setActiveTab] = useState<"skills" | "executions" | "sandbox" | "marketplace">(
"skills"
);
const [showInstallModal, setShowInstallModal] = useState(false);
const [installJson, setInstallJson] = useState("");
const [installStatus, setInstallStatus] = useState<{
type: "success" | "error";
message: string;
} | null>(null);
const [installing, setInstalling] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [skillsProvider, setSkillsProvider] = useState<SkillsProvider>("skillsmp");
const t = useTranslations("skills");
// NOTE: commonT is intentionally unused here but retained for potential future use
useTranslations("common");
const fetchSkills = async (page: number) => {
const params = new URLSearchParams({ page: String(page), limit: "20" });
if (searchTerm.trim()) params.set("q", searchTerm.trim());
if (modeFilter !== "all") params.set("mode", modeFilter);
const res = await fetch(`/api/skills?${params.toString()}`).then((r) => r.json());
setSkills((res as { data?: OmniSkill[] }).data || []);
setSkillsTotal((res as { total?: number }).total || 0);
setSkillsTotalPages((res as { totalPages?: number }).totalPages || 1);
setPopularDefaults(
Array.isArray((res as { popularDefaults?: string[] }).popularDefaults)
? (res as { popularDefaults: string[] }).popularDefaults
: []
);
};
const fetchExecutions = async (page: number) => {
const res = await fetch(`/api/skills/executions?page=${page}&limit=20`).then((r) => r.json());
setExecutions((res as { data?: Execution[] }).data || []);
setExecTotal((res as { total?: number }).total || 0);
setExecTotalPages((res as { totalPages?: number }).totalPages || 1);
};
useEffect(() => {
Promise.all([
fetch("/api/skills?page=1&limit=20").then((r) => r.json()),
fetch("/api/skills/executions?page=1&limit=20").then((r) => r.json()),
fetch("/api/settings").then((r) => (r.ok ? r.json() : null)),
])
.then(([skillsData, executionsData, settingsData]) => {
setSkills((skillsData as { data?: OmniSkill[] }).data || []);
setSkillsTotal((skillsData as { total?: number }).total || 0);
setSkillsTotalPages((skillsData as { totalPages?: number }).totalPages || 1);
setPopularDefaults(
Array.isArray((skillsData as { popularDefaults?: string[] }).popularDefaults)
? (skillsData as { popularDefaults: string[] }).popularDefaults
: []
);
setExecutions((executionsData as { data?: Execution[] }).data || []);
setExecTotal((executionsData as { total?: number }).total || 0);
setExecTotalPages((executionsData as { totalPages?: number }).totalPages || 1);
const sd = settingsData as { skillsProvider?: SkillsProvider } | null;
if (sd?.skillsProvider === "skillsmp" || sd?.skillsProvider === "skillssh") {
setSkillsProvider(sd.skillsProvider);
}
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
const refreshSkills = async () => {
setSkillsPage(1);
await fetchSkills(1);
};
const setSkillMode = async (skillId: string, mode: "on" | "off" | "auto") => {
await fetch(`/api/skills/${skillId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode }),
});
setSkills(skills.map((s) => (s.id === skillId ? { ...s, mode, enabled: mode !== "off" } : s)));
};
const deleteSkill = async (skillId: string) => {
const res = await fetch(`/api/skills/${skillId}`, { method: "DELETE" });
if (res.ok) {
setSkills(skills.filter((s) => s.id !== skillId));
if (selectedSkillId === skillId) setSelectedSkillId(null);
}
};
const handleInstall = async () => {
setInstalling(true);
setInstallStatus(null);
try {
const manifest = JSON.parse(installJson) as Record<string, unknown>;
const res = await fetch("/api/skills/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(manifest),
});
const data = await res.json();
if (res.ok && (data as { success?: boolean }).success) {
setInstallStatus({
type: "success",
message: t("installSuccess", { id: (data as { id?: string }).id || "" }),
});
setInstallJson("");
await refreshSkills();
} else {
setInstallStatus({
type: "error",
message:
(data as { error?: string; message?: string }).error ||
(data as { error?: string; message?: string }).message ||
t("installError"),
});
}
} catch (err) {
setInstallStatus({
type: "error",
message: err instanceof Error ? err.message : t("invalidJson"),
});
} finally {
setInstalling(false);
}
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
setInstallJson((ev.target?.result as string) || "");
};
reader.readAsText(file);
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-text-muted">{t("loading")}...</div>
</div>
);
}
const enabledCount = skills.filter((s) => s.enabled).length;
const execSuccessCount = executions.filter((e) => e.status === "success").length;
const successRate =
executions.length > 0 ? Math.round((execSuccessCount / executions.length) * 100) : 0;
const tabs: { id: "skills" | "executions" | "sandbox" | "marketplace"; labelKey: string }[] = [
{ id: "skills", labelKey: "skillsTab" },
{ id: "executions", labelKey: "executionsTab" },
{ id: "sandbox", labelKey: "sandboxTab" },
{ id: "marketplace", labelKey: "marketplaceTab" },
];
return (
<div className="flex flex-col gap-6">
{/* Concept card */}
<SkillsConceptCard variant="omni" />
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card className="p-4">
<p className="text-xs text-text-muted uppercase tracking-wide">{t("totalSkills")}</p>
<p className="text-2xl font-bold text-text-main mt-1">{skillsTotal}</p>
</Card>
<Card className="p-4">
<p className="text-xs text-text-muted uppercase tracking-wide">{t("enabledSkills")}</p>
<p className="text-2xl font-bold text-emerald-400 mt-1">{enabledCount}</p>
</Card>
<Card className="p-4">
<p className="text-xs text-text-muted uppercase tracking-wide">
{t("totalExecutions")}
</p>
<p className="text-2xl font-bold text-violet-400 mt-1">{execTotal}</p>
</Card>
<Card className="p-4">
<p className="text-xs text-text-muted uppercase tracking-wide">{t("successRate")}</p>
<p className="text-2xl font-bold text-amber-400 mt-1">{successRate}%</p>
</Card>
</div>
<div className="flex justify-end">
<button
onClick={() => setShowInstallModal(true)}
className="px-4 py-2 text-sm font-medium rounded-lg bg-violet-500 text-white hover:bg-violet-600 transition-colors"
>
{t("installSkillButton")}
</button>
</div>
{/* Tab bar */}
<div className="flex gap-2 border-b border-border">
{tabs.map(({ id, labelKey }) => (
<button
key={id}
onClick={() => setActiveTab(id)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === id
? "border-violet-500 text-violet-400"
: "border-transparent text-text-muted hover:text-text-main"
}`}
>
{t(labelKey)}
</button>
))}
</div>
{/* Tab content */}
{activeTab === "skills" && (
<OmniSkillsList
skills={skills}
skillsTotal={skillsTotal}
skillsPage={skillsPage}
skillsTotalPages={skillsTotalPages}
popularDefaults={popularDefaults}
searchTerm={searchTerm}
modeFilter={modeFilter}
selectedSkillId={selectedSkillId}
onSearchTermChange={setSearchTerm}
onModeFilterChange={setModeFilter}
onApplyFilters={() => {
setSkillsPage(1);
void fetchSkills(1);
}}
onPagePrev={() => {
const p = Math.max(1, skillsPage - 1);
setSkillsPage(p);
void fetchSkills(p);
}}
onPageNext={() => {
const p = Math.min(skillsTotalPages, skillsPage + 1);
setSkillsPage(p);
void fetchSkills(p);
}}
onSelectSkill={setSelectedSkillId}
onSetMode={setSkillMode}
onUninstall={deleteSkill}
/>
)}
{activeTab === "executions" && (
<OmniExecutionsTab
executions={executions}
execPage={execPage}
execTotalPages={execTotalPages}
execTotal={execTotal}
onPagePrev={() => {
const p = Math.max(1, execPage - 1);
setExecPage(p);
void fetchExecutions(p);
}}
onPageNext={() => {
const p = Math.min(execTotalPages, execPage + 1);
setExecPage(p);
void fetchExecutions(p);
}}
/>
)}
{activeTab === "sandbox" && <OmniSandboxTab />}
{activeTab === "marketplace" && (
<OmniMarketplaceTab skillsProvider={skillsProvider} onRefreshSkills={refreshSkills} />
)}
{/* Install modal */}
{showInstallModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-surface border border-border rounded-xl p-6 w-full max-w-lg mx-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">{t("installSkillModalTitle")}</h2>
<button
onClick={() => {
setShowInstallModal(false);
setInstallStatus(null);
setInstallJson("");
}}
className="text-text-muted hover:text-text-main"
>
X
</button>
</div>
<p className="text-sm text-text-muted mb-4">{t("installSkillModalDesc")}</p>
<textarea
value={installJson}
onChange={(e) => setInstallJson(e.target.value)}
placeholder={t("installJsonPlaceholder")}
className="w-full h-48 p-3 rounded-lg bg-background border border-border text-sm font-mono resize-none focus:outline-none focus:ring-1 focus:ring-violet-500"
/>
<div className="flex items-center gap-3 mt-3">
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleFileUpload}
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
className="px-3 py-1.5 text-sm rounded-lg border border-border text-text-muted hover:text-text-main transition-colors"
>
{t("uploadJson")}
</button>
<div className="flex-1" />
<button
onClick={() => {
setShowInstallModal(false);
setInstallStatus(null);
setInstallJson("");
}}
className="px-3 py-1.5 text-sm rounded-lg border border-border text-text-muted hover:text-text-main transition-colors"
>
{t("cancel")}
</button>
<button
onClick={handleInstall}
disabled={installing || !installJson.trim()}
className="px-4 py-1.5 text-sm font-medium rounded-lg bg-violet-500 text-white hover:bg-violet-600 disabled:opacity-50 transition-colors"
>
{installing ? t("installing") : t("installSkillButton")}
</button>
</div>
{installStatus && (
<div
className={`mt-3 p-3 rounded-lg text-sm ${
installStatus.type === "success"
? "bg-emerald-500/10 text-emerald-400"
: "bg-red-500/10 text-red-400"
}`}
>
{installStatus.message}
</div>
)}
</div>
</div>
)}
</div>
);
}
export default OmniSkillsPageClient;

View File

@@ -0,0 +1,106 @@
"use client";
import { useTranslations } from "next-intl";
import { Card } from "@/shared/components";
interface Execution {
id: string;
skillId: string;
skillName: string;
status: string;
duration: number;
createdAt: string;
}
interface OmniExecutionsTabProps {
executions: Execution[];
execPage: number;
execTotalPages: number;
execTotal: number;
onPagePrev: () => void;
onPageNext: () => void;
}
export function OmniExecutionsTab({
executions,
execPage,
execTotalPages,
execTotal,
onPagePrev,
onPageNext,
}: OmniExecutionsTabProps): JSX.Element {
const t = useTranslations("skills");
return (
<Card>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="text-left text-sm text-text-muted border-b border-border">
<th className="pb-3 font-medium">{t("skill")}</th>
<th className="pb-3 font-medium">{t("status")}</th>
<th className="pb-3 font-medium">{t("duration")}</th>
<th className="pb-3 font-medium">{t("time")}</th>
</tr>
</thead>
<tbody>
{executions.length === 0 ? (
<tr>
<td colSpan={4} className="py-8 text-center text-text-muted">
{t("noExecutions")}
</td>
</tr>
) : (
executions.map((exec) => (
<tr key={exec.id} className="border-b border-border/50">
<td className="py-3 font-medium">{exec.skillName}</td>
<td className="py-3">
<span
className={`text-xs px-2 py-1 rounded ${
exec.status === "success"
? "bg-emerald-500/10 text-emerald-400"
: exec.status === "error"
? "bg-red-500/10 text-red-400"
: "bg-amber-500/10 text-amber-400"
}`}
>
{exec.status}
</span>
</td>
<td className="py-3 text-text-muted">{exec.duration}ms</td>
<td className="py-3 text-text-muted text-sm">
{new Date(exec.createdAt).toLocaleString()}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<div className="flex items-center justify-between mt-4 pt-4 border-t border-border">
<span className="text-sm text-text-muted">
{t("pageInfo", { page: execPage, totalPages: execTotalPages, total: execTotal }) ||
`Page ${execPage} of ${execTotalPages} (${execTotal} total)`}
</span>
<div className="flex gap-2">
<button
onClick={onPagePrev}
disabled={execPage === 1}
className="px-3 py-1 text-sm rounded border border-border text-text-muted hover:text-text-main disabled:opacity-40 transition-colors"
>
{t("previous") || "Prev"}
</button>
<button
onClick={onPageNext}
disabled={execPage === execTotalPages || execTotalPages === 0}
className="px-3 py-1 text-sm rounded border border-border text-text-muted hover:text-text-main disabled:opacity-40 transition-colors"
>
{t("next") || "Next"}
</button>
</div>
</div>
</Card>
);
}
export default OmniExecutionsTab;

View File

@@ -0,0 +1,244 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Card } from "@/shared/components";
import type { SkillsProvider } from "@/lib/skills/providerSettings";
interface MarketplaceSkill {
name: string;
description: string;
skillMdContent?: string;
version?: string;
sourceUrl?: string;
}
interface SkillsShSkill {
id: string;
skillId: string;
name: string;
installs: number;
source: string;
}
interface OmniMarketplaceTabProps {
skillsProvider: SkillsProvider;
onRefreshSkills: () => Promise<void>;
}
export function OmniMarketplaceTab({
skillsProvider,
onRefreshSkills,
}: OmniMarketplaceTabProps): JSX.Element {
const t = useTranslations("skills");
const [mpQuery, setMpQuery] = useState("");
const [mpResults, setMpResults] = useState<MarketplaceSkill[]>([]);
const [mpLoading, setMpLoading] = useState(false);
const [mpError, setMpError] = useState("");
const [mpInstallingId, setMpInstallingId] = useState<string | null>(null);
const [shQuery, setShQuery] = useState("");
const [shResults, setShResults] = useState<SkillsShSkill[]>([]);
const [shLoading, setShLoading] = useState(false);
const [shError, setShError] = useState("");
const [shInstallingId, setShInstallingId] = useState<string | null>(null);
const searchMarketplace = async () => {
setMpLoading(true);
setMpError("");
setMpResults([]);
try {
const res = await fetch(`/api/skills/marketplace?q=${encodeURIComponent(mpQuery)}`);
const data = await res.json();
if (!res.ok) {
setMpError((data as { error?: string }).error || t("marketplaceError"));
} else {
setMpResults(Array.isArray(data) ? data : (data as { skills?: MarketplaceSkill[] }).skills || []);
}
} catch (err) {
setMpError(err instanceof Error ? err.message : t("marketplaceError"));
} finally {
setMpLoading(false);
}
};
const installFromMarketplace = async (skill: MarketplaceSkill) => {
setMpInstallingId(skill.name);
try {
const res = await fetch("/api/skills/marketplace/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: skill.name,
description: skill.description,
skillMdContent: skill.skillMdContent || skill.description,
version: skill.version || "1.0.0",
sourceUrl: skill.sourceUrl,
}),
});
const data = await res.json();
if (res.ok && (data as { success?: boolean }).success) {
await onRefreshSkills();
setMpInstallingId(null);
} else {
setMpError((data as { error?: string }).error || t("installError"));
setMpInstallingId(null);
}
} catch (err) {
setMpError(err instanceof Error ? err.message : t("installError"));
setMpInstallingId(null);
}
};
const searchSkillsSh = async () => {
setShLoading(true);
setShError("");
setShResults([]);
try {
const res = await fetch(`/api/skills/skillssh?q=${encodeURIComponent(shQuery)}`);
const data = await res.json();
if (!res.ok) {
setShError((data as { error?: string }).error || t("marketplaceError"));
} else {
setShResults((data as { skills?: SkillsShSkill[] }).skills || []);
}
} catch (err) {
setShError(err instanceof Error ? err.message : t("marketplaceError"));
} finally {
setShLoading(false);
}
};
const installFromSkillsSh = async (skill: SkillsShSkill) => {
setShInstallingId(skill.id);
try {
const res = await fetch("/api/skills/skillssh/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: skill.name,
description: `Installed from skills.sh (${skill.source})`,
source: skill.source,
skillId: skill.skillId,
}),
});
const data = await res.json();
if (res.ok && (data as { success?: boolean }).success) {
await onRefreshSkills();
setShInstallingId(null);
} else {
setShError((data as { error?: string }).error || t("installError"));
setShInstallingId(null);
}
} catch (err) {
setShError(err instanceof Error ? err.message : t("installError"));
setShInstallingId(null);
}
};
return (
<div className="grid gap-4">
<Card>
<h3 className="font-semibold mb-2">{t("skillsMarketplace")}</h3>
<p className="text-sm text-text-muted mb-4">
{t("activeProvider")}{" "}
<span className="font-medium">
{skillsProvider === "skillsmp" ? "SkillsMP" : "skills.sh"}
</span>
. {t("changeInSettings")}
</p>
<div className="flex gap-2 mb-4">
<input
type="text"
value={skillsProvider === "skillsmp" ? mpQuery : shQuery}
onChange={(e) =>
skillsProvider === "skillsmp" ? setMpQuery(e.target.value) : setShQuery(e.target.value)
}
onKeyDown={(e) =>
e.key === "Enter" &&
(skillsProvider === "skillsmp" ? searchMarketplace() : searchSkillsSh())
}
placeholder={t("searchMarketplacePlaceholder")}
className="flex-1 px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
/>
<button
onClick={() => (skillsProvider === "skillsmp" ? searchMarketplace() : searchSkillsSh())}
disabled={skillsProvider === "skillsmp" ? mpLoading : shLoading}
className="px-4 py-2 text-sm font-medium rounded-lg bg-violet-500 text-white hover:bg-violet-600 disabled:opacity-50 transition-colors"
>
{skillsProvider === "skillsmp"
? mpLoading
? t("searching")
: t("searchMarketplace")
: shLoading
? t("searching")
: t("searchMarketplace")}
</button>
</div>
{(skillsProvider === "skillsmp" ? mpError : shError) && (
<div className="p-3 rounded-lg bg-red-500/10 text-red-400 text-sm mb-4">
{skillsProvider === "skillsmp" ? mpError : shError}
</div>
)}
</Card>
{skillsProvider === "skillsmp" && mpResults.length > 0 && (
<div className="grid gap-3">
{mpResults.map((skill) => (
<Card key={skill.name}>
<div className="flex items-center justify-between">
<div>
<h4 className="font-semibold">{skill.name}</h4>
<p className="text-sm text-text-muted mt-1">{skill.description}</p>
</div>
<button
onClick={() => installFromMarketplace(skill)}
disabled={mpInstallingId === skill.name}
className="px-4 py-1.5 text-sm font-medium rounded-lg bg-violet-500 text-white hover:bg-violet-600 disabled:opacity-50 transition-colors"
>
{mpInstallingId === skill.name ? t("installing") : t("installSkillButton")}
</button>
</div>
</Card>
))}
</div>
)}
{skillsProvider === "skillssh" && shResults.length > 0 && (
<div className="grid gap-3">
{shResults.map((skill) => (
<Card key={skill.id}>
<div className="flex items-center justify-between">
<div>
<h4 className="font-semibold">{skill.name}</h4>
<p className="text-sm text-text-muted mt-1">
{skill.source} · {skill.installs.toLocaleString()} {t("installs")}
</p>
</div>
<button
onClick={() => installFromSkillsSh(skill)}
disabled={shInstallingId === skill.id}
className="px-4 py-1.5 text-sm font-medium rounded-lg bg-violet-500 text-white hover:bg-violet-600 disabled:opacity-50 transition-colors"
>
{shInstallingId === skill.id ? t("installing") : t("installSkillButton")}
</button>
</div>
</Card>
))}
</div>
)}
{skillsProvider === "skillsmp" && !mpLoading && mpResults.length === 0 && !mpError && (
<Card>
<div className="text-center py-8 text-text-muted">{t("marketplaceSkillsMpHint")}</div>
</Card>
)}
{skillsProvider === "skillssh" && !shLoading && shResults.length === 0 && !shError && (
<Card>
<div className="text-center py-8 text-text-muted">{t("marketplaceSkillsShHint")}</div>
</Card>
)}
</div>
);
}
export default OmniMarketplaceTab;

View File

@@ -0,0 +1,48 @@
"use client";
import { useTranslations } from "next-intl";
import { Card } from "@/shared/components";
export function OmniSandboxTab(): JSX.Element {
const t = useTranslations("skills");
return (
<div className="grid gap-4">
<Card>
<h3 className="font-semibold mb-4">{t("sandboxConfig")}</h3>
<div className="grid gap-4">
<div className="flex items-center justify-between p-3 rounded-lg bg-surface/30">
<div>
<p className="font-medium">{t("cpuLimit")}</p>
<p className="text-xs text-text-muted">{t("cpuLimitDesc")}</p>
</div>
<span className="font-mono">100ms</span>
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-surface/30">
<div>
<p className="font-medium">{t("memoryLimit")}</p>
<p className="text-xs text-text-muted">{t("memoryLimitDesc")}</p>
</div>
<span className="font-mono">256MB</span>
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-surface/30">
<div>
<p className="font-medium">{t("timeout")}</p>
<p className="text-xs text-text-muted">{t("timeoutDesc")}</p>
</div>
<span className="font-mono">30s</span>
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-surface/30">
<div>
<p className="font-medium">{t("networkAccess")}</p>
<p className="text-xs text-text-muted">{t("networkAccessDesc")}</p>
</div>
<span className="text-text-muted">{t("disabled")}</span>
</div>
</div>
</Card>
</div>
);
}
export default OmniSandboxTab;

View File

@@ -0,0 +1,98 @@
"use client";
import { useTranslations } from "next-intl";
export interface OmniSkill {
id: string;
name: string;
version: string;
description: string;
enabled: boolean;
mode?: "on" | "off" | "auto";
sourceProvider?: "skillsmp" | "skillssh" | "local";
tags?: string[];
installCount?: number;
createdAt: string;
}
interface OmniSkillCardProps {
skill: OmniSkill;
selected: boolean;
onClick: () => void;
}
export function OmniSkillCard({ skill, selected, onClick }: OmniSkillCardProps): JSX.Element {
const t = useTranslations("skills");
const effectiveMode = skill.mode || (skill.enabled ? "on" : "off");
const modeColor =
effectiveMode === "on"
? "text-emerald-400"
: effectiveMode === "auto"
? "text-amber-400"
: "text-text-muted";
const modeDot =
effectiveMode === "on"
? "bg-emerald-400"
: effectiveMode === "auto"
? "bg-amber-400"
: "bg-border";
return (
<button
role="button"
onClick={onClick}
aria-pressed={selected}
className={`w-full text-left rounded-lg border p-3 transition-all focus:outline-none focus:ring-2 focus:ring-violet-500/50 ${
selected
? "border-violet-500 bg-violet-500/5"
: "border-border bg-surface/30 hover:border-border/80 hover:bg-surface/50"
}`}
>
<div className="flex items-start gap-2">
<div
className={`flex items-center justify-center size-8 rounded-md shrink-0 ${
selected ? "bg-violet-500/20" : "bg-surface/60"
}`}
>
<span className="material-symbols-outlined text-[18px] text-text-muted">
auto_fix_high
</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-sm font-semibold text-text-main truncate">{skill.name}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface/60 text-text-muted shrink-0">
v{skill.version}
</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface/60 text-text-muted shrink-0">
{(skill.sourceProvider || "local").toUpperCase()}
</span>
</div>
<p className="text-xs text-text-muted mt-0.5 line-clamp-2">{skill.description}</p>
{Array.isArray(skill.tags) && skill.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{skill.tags.slice(0, 3).map((tag) => (
<span
key={`${skill.id}-${tag}`}
className="text-[10px] px-1.5 py-0.5 rounded bg-violet-500/10 text-violet-300"
>
{tag}
</span>
))}
</div>
)}
</div>
<div className="flex items-center gap-1.5 shrink-0">
<span className={`inline-block size-2 rounded-full ${modeDot}`} />
<span className={`text-[10px] font-medium ${modeColor}`}>
{t(effectiveMode === "on" ? "onMode" : effectiveMode === "auto" ? "autoMode" : "offMode")}
</span>
</div>
</div>
</button>
);
}
export default OmniSkillCard;

View File

@@ -0,0 +1,159 @@
"use client";
import { useTranslations } from "next-intl";
import { Card } from "@/shared/components";
import { OmniSkillCard } from "./OmniSkillCard";
import { SkillInspectorPane } from "./SkillInspectorPane";
import type { OmniSkill } from "./OmniSkillCard";
interface OmniSkillsListProps {
skills: OmniSkill[];
skillsTotal: number;
skillsPage: number;
skillsTotalPages: number;
popularDefaults: string[];
searchTerm: string;
modeFilter: "all" | "on" | "off" | "auto";
selectedSkillId: string | null;
onSearchTermChange: (v: string) => void;
onModeFilterChange: (v: "all" | "on" | "off" | "auto") => void;
onApplyFilters: () => void;
onPagePrev: () => void;
onPageNext: () => void;
onSelectSkill: (id: string) => void;
onSetMode: (skillId: string, mode: "on" | "off" | "auto") => void;
onUninstall: (skillId: string) => void;
}
export function OmniSkillsList({
skills,
skillsTotal,
skillsPage,
skillsTotalPages,
popularDefaults,
searchTerm,
modeFilter,
selectedSkillId,
onSearchTermChange,
onModeFilterChange,
onApplyFilters,
onPagePrev,
onPageNext,
onSelectSkill,
onSetMode,
onUninstall,
}: OmniSkillsListProps): JSX.Element {
const t = useTranslations("skills");
const selectedSkill = selectedSkillId
? (skills.find((s) => s.id === selectedSkillId) ?? null)
: null;
return (
<div className="grid grid-cols-12 gap-4">
{/* Left: grid + filters */}
<div className="col-span-12 lg:col-span-7 flex flex-col gap-4">
<Card>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 items-center">
<input
type="text"
value={searchTerm}
onChange={(e) => onSearchTermChange(e.target.value)}
placeholder={t("filterSkillsPlaceholder")}
className="px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
/>
<select
value={modeFilter}
onChange={(e) => onModeFilterChange(e.target.value as "all" | "on" | "off" | "auto")}
className="px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
>
<option value="all">{t("allModes")}</option>
<option value="on">{t("onMode")}</option>
<option value="auto">{t("autoMode")}</option>
<option value="off">{t("offMode")}</option>
</select>
<button
onClick={onApplyFilters}
className="px-4 py-2 text-sm font-medium rounded-lg bg-violet-500 text-white hover:bg-violet-600 transition-colors"
>
{t("applyFilters")}
</button>
</div>
{popularDefaults.length > 0 && (
<div className="mt-3">
<p className="text-xs text-text-muted mb-2">{t("popularDefaultsLabel")}</p>
<div className="flex flex-wrap gap-2">
{popularDefaults.map((name) => (
<span
key={name}
className="text-xs px-2 py-1 rounded bg-violet-500/10 text-violet-300 border border-violet-500/20"
>
{name}
</span>
))}
</div>
</div>
)}
</Card>
{skills.length === 0 ? (
<Card>
<div className="text-center py-8 text-text-muted">{t("noSkills")}</div>
</Card>
) : (
<div className="flex flex-col gap-2">
{skills.map((skill) => (
<OmniSkillCard
key={skill.id}
skill={skill}
selected={selectedSkillId === skill.id}
onClick={() => onSelectSkill(skill.id)}
/>
))}
</div>
)}
<div className="flex items-center justify-between pt-2 border-t border-border">
<span className="text-sm text-text-muted">
{t("pageInfo", {
page: skillsPage,
totalPages: skillsTotalPages,
total: skillsTotal,
})}
</span>
<div className="flex gap-2">
<button
onClick={onPagePrev}
disabled={skillsPage === 1}
className="px-3 py-1 text-sm rounded border border-border text-text-muted hover:text-text-main disabled:opacity-40 transition-colors"
>
{t("previous")}
</button>
<button
onClick={onPageNext}
disabled={skillsPage === skillsTotalPages || skillsTotalPages === 0}
className="px-3 py-1 text-sm rounded border border-border text-text-muted hover:text-text-main disabled:opacity-40 transition-colors"
>
{t("next")}
</button>
</div>
</div>
</div>
{/* Right: inspector pane */}
<div className="col-span-12 lg:col-span-5">
<div className="sticky top-4 rounded-xl border border-border bg-surface/30 overflow-hidden min-h-[400px]">
<SkillInspectorPane
selectedSkillId={selectedSkillId}
skill={selectedSkill}
onSetMode={onSetMode}
onUninstall={onUninstall}
/>
</div>
</div>
</div>
);
}
export default OmniSkillsList;

View File

@@ -0,0 +1,297 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import type { OmniSkill } from "./OmniSkillCard";
interface SkillDetail extends OmniSkill {
schema?: {
input?: Record<string, unknown>;
output?: Record<string, unknown>;
};
handler?: string;
}
interface SkillExecution {
id: string;
skillId: string;
skillName: string;
status: string;
duration: number;
createdAt: string;
}
type InspectorTab = "schema" | "handler" | "executions" | "sandbox";
interface SkillInspectorPaneProps {
selectedSkillId: string | null;
skill: OmniSkill | null;
onSetMode: (skillId: string, mode: "on" | "off" | "auto") => void;
onUninstall: (skillId: string) => void;
}
export function SkillInspectorPane({
selectedSkillId,
skill,
onSetMode,
onUninstall,
}: SkillInspectorPaneProps): JSX.Element {
const t = useTranslations("skills");
const [activeTab, setActiveTab] = useState<InspectorTab>("schema");
const [detail, setDetail] = useState<SkillDetail | null>(null);
const [executions, setExecutions] = useState<SkillExecution[]>([]);
const [loadingExecs, setLoadingExecs] = useState(false);
useEffect(() => {
let cancelled = false;
if (!selectedSkillId) {
// Reset via microtask to avoid synchronous setState-in-effect
Promise.resolve().then(() => {
if (!cancelled) {
setDetail(null);
setExecutions([]);
}
});
return () => {
cancelled = true;
};
}
// Fetch full skill detail for schema + handler
fetch(`/api/skills/${selectedSkillId}`)
.then((r) => (r.ok ? r.json() : null))
.then((data: SkillDetail | null) => {
if (!cancelled && data) setDetail(data);
})
.catch(() => null);
return () => {
cancelled = true;
};
}, [selectedSkillId]);
useEffect(() => {
if (!selectedSkillId || activeTab !== "executions") return;
let cancelled = false;
Promise.resolve().then(() => {
if (!cancelled) setLoadingExecs(true);
});
fetch(`/api/skills/executions?skillId=${selectedSkillId}&limit=20`)
.then((r) => (r.ok ? r.json() : { data: [] }))
.then((data: { data?: SkillExecution[] }) => {
if (!cancelled) {
setExecutions(data.data || []);
setLoadingExecs(false);
}
})
.catch(() => {
if (!cancelled) setLoadingExecs(false);
});
return () => {
cancelled = true;
};
}, [selectedSkillId, activeTab]);
if (!selectedSkillId || !skill) {
return (
<div className="flex flex-col items-center justify-center h-full min-h-[300px] text-text-muted text-sm text-center p-6">
<span className="material-symbols-outlined text-[40px] mb-3 opacity-30">
manage_search
</span>
<span>Selecione uma skill à esquerda para inspecionar.</span>
</div>
);
}
const effectiveMode = skill.mode || (skill.enabled ? "on" : "off");
const tabs: { id: InspectorTab; label: string }[] = [
{ id: "schema", label: "Schema" },
{ id: "handler", label: "Handler" },
{ id: "executions", label: t("executionsTab") },
{ id: "sandbox", label: t("sandboxTab") },
];
return (
<div className="flex flex-col h-full">
{/* Inspector header */}
<div className="px-4 pt-4 pb-2 border-b border-border">
<div className="flex items-center gap-2 mb-1">
<span className="material-symbols-outlined text-[18px] text-violet-400">
auto_fix_high
</span>
<h3 className="font-semibold text-text-main text-sm">{skill.name}</h3>
</div>
<p className="text-xs text-text-muted line-clamp-2">{skill.description}</p>
<div className="flex items-center gap-1.5 mt-1.5">
<span
className={`text-[10px] px-1.5 py-0.5 rounded ${
effectiveMode === "on"
? "bg-emerald-500/10 text-emerald-400"
: effectiveMode === "auto"
? "bg-amber-500/10 text-amber-400"
: "bg-surface/60 text-text-muted"
}`}
>
{t("mode")}: {effectiveMode}
</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface/60 text-text-muted">
{(skill.sourceProvider || "local").toUpperCase()}
</span>
</div>
</div>
{/* Inspector sub-tabs */}
<div className="flex gap-0 border-b border-border px-2">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-3 py-2 text-xs font-medium border-b-2 transition-colors ${
activeTab === tab.id
? "border-violet-500 text-violet-400"
: "border-transparent text-text-muted hover:text-text-main"
}`}
>
{tab.label}
</button>
))}
</div>
{/* Inspector content */}
<div className="flex-1 overflow-auto p-4">
{activeTab === "schema" && (
<div className="space-y-3">
<div>
<p className="text-xs font-medium text-text-muted uppercase tracking-wide mb-1.5">
Input Schema
</p>
<pre className="text-xs bg-surface/40 rounded-lg p-3 overflow-auto max-h-[200px] text-text-main font-mono whitespace-pre-wrap">
{JSON.stringify(detail?.schema?.input ?? {}, null, 2) || "{}"}
</pre>
</div>
<div>
<p className="text-xs font-medium text-text-muted uppercase tracking-wide mb-1.5">
Output Schema
</p>
<pre className="text-xs bg-surface/40 rounded-lg p-3 overflow-auto max-h-[200px] text-text-main font-mono whitespace-pre-wrap">
{JSON.stringify(detail?.schema?.output ?? {}, null, 2) || "{}"}
</pre>
</div>
</div>
)}
{activeTab === "handler" && (
<div>
<p className="text-xs font-medium text-text-muted uppercase tracking-wide mb-1.5">
Handler Code
</p>
<pre className="text-xs bg-surface/40 rounded-lg p-3 overflow-auto max-h-[400px] text-text-main font-mono whitespace-pre-wrap">
{detail?.handler ?? "// Handler not available"}
</pre>
</div>
)}
{activeTab === "executions" && (
<div>
{loadingExecs ? (
<div className="text-xs text-text-muted">{t("loading")}...</div>
) : executions.length === 0 ? (
<div className="text-xs text-text-muted text-center py-6">{t("noExecutions")}</div>
) : (
<table className="w-full text-xs">
<thead>
<tr className="text-left text-text-muted border-b border-border">
<th className="pb-2 font-medium">{t("status")}</th>
<th className="pb-2 font-medium">{t("duration")}</th>
<th className="pb-2 font-medium">{t("time")}</th>
</tr>
</thead>
<tbody>
{executions.map((exec) => (
<tr key={exec.id} className="border-b border-border/40">
<td className="py-2">
<span
className={`px-1.5 py-0.5 rounded ${
exec.status === "success"
? "bg-emerald-500/10 text-emerald-400"
: exec.status === "error"
? "bg-red-500/10 text-red-400"
: "bg-amber-500/10 text-amber-400"
}`}
>
{exec.status}
</span>
</td>
<td className="py-2 text-text-muted">{exec.duration}ms</td>
<td className="py-2 text-text-muted">
{new Date(exec.createdAt).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
{activeTab === "sandbox" && (
<div className="space-y-2">
<p className="text-xs font-medium text-text-muted uppercase tracking-wide mb-1.5">
{t("sandboxConfig")}
</p>
{[
{ label: t("cpuLimit"), desc: t("cpuLimitDesc"), value: "100ms" },
{ label: t("memoryLimit"), desc: t("memoryLimitDesc"), value: "256MB" },
{ label: t("timeout"), desc: t("timeoutDesc"), value: "30s" },
{ label: t("networkAccess"), desc: t("networkAccessDesc"), value: t("disabled") },
].map((item) => (
<div
key={item.label}
className="flex items-center justify-between p-2.5 rounded-lg bg-surface/30"
>
<div>
<p className="text-xs font-medium">{item.label}</p>
<p className="text-[10px] text-text-muted">{item.desc}</p>
</div>
<span className="text-xs font-mono text-text-muted">{item.value}</span>
</div>
))}
<button className="w-full mt-3 px-3 py-2 text-xs font-medium rounded-lg border border-border text-text-muted hover:text-text-main hover:border-violet-500/50 transition-colors">
Run test (placeholder)
</button>
</div>
)}
</div>
{/* Inspector action buttons */}
<div className="px-4 py-3 border-t border-border flex items-center gap-1.5">
{(["on", "auto", "off"] as const).map((mode) => (
<button
key={mode}
onClick={() => onSetMode(skill.id, mode)}
aria-label={`Set mode ${mode}`}
className={`flex-1 text-xs px-2 py-1.5 rounded border transition-colors ${
effectiveMode === mode
? mode === "on"
? "border-emerald-500 text-emerald-400 bg-emerald-500/5"
: mode === "auto"
? "border-amber-500 text-amber-400 bg-amber-500/5"
: "border-red-500 text-red-400 bg-red-500/5"
: "border-border text-text-muted hover:border-border/80"
}`}
>
{mode === "on" ? t("onMode") : mode === "auto" ? t("autoMode") : t("offMode")}
</button>
))}
<button
onClick={() => onUninstall(skill.id)}
aria-label="Uninstall skill"
className="flex-1 text-xs px-2 py-1.5 rounded border border-border text-red-400 hover:bg-red-500/10 transition-colors"
>
{t("delete")}
</button>
</div>
</div>
);
}
export default SkillInspectorPane;

View File

@@ -0,0 +1,5 @@
import { OmniSkillsPageClient } from "./OmniSkillsPageClient";
export default function Page() {
return <OmniSkillsPageClient />;
}

View File

@@ -1,871 +0,0 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { Card } from "@/shared/components";
import { useTranslations } from "next-intl";
import type { SkillsProvider } from "@/lib/skills/providerSettings";
interface Skill {
id: string;
name: string;
version: string;
description: string;
enabled: boolean;
mode?: "on" | "off" | "auto";
sourceProvider?: "skillsmp" | "skillssh" | "local";
tags?: string[];
installCount?: number;
createdAt: string;
}
interface Execution {
id: string;
skillId: string;
skillName: string;
status: string;
duration: number;
createdAt: string;
}
export default function SkillsPage() {
const [skills, setSkills] = useState<Skill[]>([]);
const [executions, setExecutions] = useState<Execution[]>([]);
const [loading, setLoading] = useState(true);
const [skillsPage, setSkillsPage] = useState(1);
const [skillsTotal, setSkillsTotal] = useState(0);
const [skillsTotalPages, setSkillsTotalPages] = useState(1);
const [popularDefaults, setPopularDefaults] = useState<string[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [modeFilter, setModeFilter] = useState<"all" | "on" | "off" | "auto">("all");
const [execPage, setExecPage] = useState(1);
const [execTotal, setExecTotal] = useState(0);
const [execTotalPages, setExecTotalPages] = useState(1);
const [activeTab, setActiveTab] = useState<"skills" | "executions" | "sandbox" | "marketplace">(
"skills"
);
const [showInstallModal, setShowInstallModal] = useState(false);
const [installJson, setInstallJson] = useState("");
const [installStatus, setInstallStatus] = useState<{
type: "success" | "error";
message: string;
} | null>(null);
const [installing, setInstalling] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [mpQuery, setMpQuery] = useState("");
const [mpResults, setMpResults] = useState<
{
name: string;
description: string;
skillMdContent?: string;
version?: string;
sourceUrl?: string;
}[]
>([]);
const [mpLoading, setMpLoading] = useState(false);
const [mpError, setMpError] = useState("");
const [mpInstallingId, setMpInstallingId] = useState<string | null>(null);
const [shQuery, setShQuery] = useState("");
const [shResults, setShResults] = useState<
{ id: string; skillId: string; name: string; installs: number; source: string }[]
>([]);
const [shLoading, setShLoading] = useState(false);
const [shError, setShError] = useState("");
const [shInstallingId, setShInstallingId] = useState<string | null>(null);
const [skillsProvider, setSkillsProvider] = useState<SkillsProvider>("skillsmp");
const t = useTranslations("skills");
const commonT = useTranslations("common");
const fetchSkills = async (page: number) => {
const params = new URLSearchParams({ page: String(page), limit: "20" });
if (searchTerm.trim()) params.set("q", searchTerm.trim());
if (modeFilter !== "all") params.set("mode", modeFilter);
const res = await fetch(`/api/skills?${params.toString()}`).then((r) => r.json());
setSkills(res.data || []);
setSkillsTotal(res.total || 0);
setSkillsTotalPages(res.totalPages || 1);
setPopularDefaults(Array.isArray(res.popularDefaults) ? res.popularDefaults : []);
};
const fetchExecutions = async (page: number) => {
const res = await fetch(`/api/skills/executions?page=${page}&limit=20`).then((r) => r.json());
setExecutions(res.data || []);
setExecTotal(res.total || 0);
setExecTotalPages(res.totalPages || 1);
};
useEffect(() => {
Promise.all([
fetch("/api/skills?page=1&limit=20").then((r) => r.json()),
fetch("/api/skills/executions?page=1&limit=20").then((r) => r.json()),
fetch("/api/settings").then((r) => (r.ok ? r.json() : null)),
])
.then(([skillsData, executionsData, settingsData]) => {
setSkills(skillsData.data || []);
setSkillsTotal(skillsData.total || 0);
setSkillsTotalPages(skillsData.totalPages || 1);
setPopularDefaults(
Array.isArray(skillsData.popularDefaults) ? skillsData.popularDefaults : []
);
setExecutions(executionsData.data || []);
setExecTotal(executionsData.total || 0);
setExecTotalPages(executionsData.totalPages || 1);
if (
settingsData?.skillsProvider === "skillsmp" ||
settingsData?.skillsProvider === "skillssh"
) {
setSkillsProvider(settingsData.skillsProvider);
}
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
const refreshSkills = async () => {
setSkillsPage(1);
await fetchSkills(1);
};
const toggleSkill = async (skillId: string, enabled: boolean) => {
await fetch(`/api/skills/${skillId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: !enabled }),
});
setSkills(skills.map((s) => (s.id === skillId ? { ...s, enabled: !enabled } : s)));
};
const setSkillMode = async (skillId: string, mode: "on" | "off" | "auto") => {
await fetch(`/api/skills/${skillId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode }),
});
setSkills(skills.map((s) => (s.id === skillId ? { ...s, mode, enabled: mode !== "off" } : s)));
};
const deleteSkill = async (skillId: string) => {
const res = await fetch(`/api/skills/${skillId}`, { method: "DELETE" });
if (res.ok) {
setSkills(skills.filter((s) => s.id !== skillId));
}
};
const handleInstall = async () => {
setInstalling(true);
setInstallStatus(null);
try {
const manifest = JSON.parse(installJson);
const res = await fetch("/api/skills/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(manifest),
});
const data = await res.json();
if (res.ok && data.success) {
setInstallStatus({ type: "success", message: t("installSuccess", { id: data.id }) });
setInstallJson("");
await refreshSkills();
} else {
setInstallStatus({
type: "error",
message: data.error || data.message || t("installError"),
});
}
} catch (err) {
setInstallStatus({
type: "error",
message: err instanceof Error ? err.message : t("invalidJson"),
});
} finally {
setInstalling(false);
}
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
setInstallJson((ev.target?.result as string) || "");
};
reader.readAsText(file);
};
const searchMarketplace = async () => {
setMpLoading(true);
setMpError("");
setMpResults([]);
try {
const res = await fetch(`/api/skills/marketplace?q=${encodeURIComponent(mpQuery)}`);
const data = await res.json();
if (!res.ok) {
setMpError(data.error || t("marketplaceError"));
} else {
setMpResults(Array.isArray(data) ? data : data.skills || []);
}
} catch (err) {
setMpError(err instanceof Error ? err.message : t("marketplaceError"));
} finally {
setMpLoading(false);
}
};
const installFromMarketplace = async (skill: {
name: string;
description: string;
skillMdContent?: string;
version?: string;
sourceUrl?: string;
}) => {
setMpInstallingId(skill.name);
try {
const res = await fetch("/api/skills/marketplace/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: skill.name,
description: skill.description,
skillMdContent: skill.skillMdContent || skill.description,
version: skill.version || "1.0.0",
sourceUrl: skill.sourceUrl,
}),
});
const data = await res.json();
if (res.ok && data.success) {
await refreshSkills();
setMpInstallingId(null);
} else {
setMpError(data.error || t("installError"));
setMpInstallingId(null);
}
} catch (err) {
setMpError(err instanceof Error ? err.message : t("installError"));
setMpInstallingId(null);
}
};
const searchSkillsSh = async () => {
setShLoading(true);
setShError("");
setShResults([]);
try {
const res = await fetch(`/api/skills/skillssh?q=${encodeURIComponent(shQuery)}`);
const data = await res.json();
if (!res.ok) {
setShError(data.error || t("marketplaceError"));
} else {
setShResults(data.skills || []);
}
} catch (err) {
setShError(err instanceof Error ? err.message : t("marketplaceError"));
} finally {
setShLoading(false);
}
};
const installFromSkillsSh = async (skill: {
id: string;
skillId: string;
name: string;
installs: number;
source: string;
}) => {
setShInstallingId(skill.id);
try {
const res = await fetch("/api/skills/skillssh/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: skill.name,
description: `Installed from skills.sh (${skill.source})`,
source: skill.source,
skillId: skill.skillId,
}),
});
const data = await res.json();
if (res.ok && data.success) {
await refreshSkills();
setShInstallingId(null);
} else {
setShError(data.error || t("installError"));
setShInstallingId(null);
}
} catch (err) {
setShError(err instanceof Error ? err.message : t("installError"));
setShInstallingId(null);
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-text-muted">{t("loading")}...</div>
</div>
);
}
// ── Stats computation ────────────────────────────────────────────────────
const enabledCount = skills.filter((s) => s.enabled).length;
const execSuccessCount = executions.filter((e) => e.status === "success").length;
const successRate =
executions.length > 0 ? Math.round((execSuccessCount / executions.length) * 100) : 0;
return (
<div className="flex flex-col gap-6">
{/* ── Stats Cards ─────────────────────────────────────────────────── */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card className="p-4">
<p className="text-xs text-text-muted uppercase tracking-wide">{t("totalSkills")}</p>
<p className="text-2xl font-bold text-text-main mt-1">{skillsTotal}</p>
</Card>
<Card className="p-4">
<p className="text-xs text-text-muted uppercase tracking-wide">{t("enabledSkills")}</p>
<p className="text-2xl font-bold text-emerald-400 mt-1">{enabledCount}</p>
</Card>
<Card className="p-4">
<p className="text-xs text-text-muted uppercase tracking-wide">{t("totalExecutions")}</p>
<p className="text-2xl font-bold text-violet-400 mt-1">{execTotal}</p>
</Card>
<Card className="p-4">
<p className="text-xs text-text-muted uppercase tracking-wide">{t("successRate")}</p>
<p className="text-2xl font-bold text-amber-400 mt-1">{successRate}%</p>
</Card>
</div>
<div className="flex justify-end">
<button
onClick={() => setShowInstallModal(true)}
className="px-4 py-2 text-sm font-medium rounded-lg bg-violet-500 text-white hover:bg-violet-600 transition-colors"
>
{t("installSkillButton")}
</button>
</div>
<div className="flex gap-2 border-b border-border">
<button
onClick={() => setActiveTab("skills")}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === "skills"
? "border-violet-500 text-violet-400"
: "border-transparent text-text-muted hover:text-text-main"
}`}
>
{t("skillsTab")}
</button>
<button
onClick={() => setActiveTab("executions")}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === "executions"
? "border-violet-500 text-violet-400"
: "border-transparent text-text-muted hover:text-text-main"
}`}
>
{t("executionsTab")}
</button>
<button
onClick={() => setActiveTab("sandbox")}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === "sandbox"
? "border-violet-500 text-violet-400"
: "border-transparent text-text-muted hover:text-text-main"
}`}
>
{t("sandboxTab")}
</button>
<button
onClick={() => setActiveTab("marketplace")}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === "marketplace"
? "border-violet-500 text-violet-400"
: "border-transparent text-text-muted hover:text-text-main"
}`}
>
{t("marketplaceTab")}
</button>
</div>
{activeTab === "skills" && (
<div className="grid gap-4">
<Card>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 items-center">
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder={t("filterSkillsPlaceholder")}
className="px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
/>
<select
value={modeFilter}
onChange={(e) => setModeFilter(e.target.value as "all" | "on" | "off" | "auto")}
className="px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
>
<option value="all">{t("allModes")}</option>
<option value="on">{t("onMode")}</option>
<option value="auto">{t("autoMode")}</option>
<option value="off">{t("offMode")}</option>
</select>
<button
onClick={() => {
setSkillsPage(1);
void fetchSkills(1);
}}
className="px-4 py-2 text-sm font-medium rounded-lg bg-violet-500 text-white hover:bg-violet-600 transition-colors"
>
{t("applyFilters")}
</button>
</div>
{popularDefaults.length > 0 && (
<div className="mt-3">
<p className="text-xs text-text-muted mb-2">{t("popularDefaultsLabel")}</p>
<div className="flex flex-wrap gap-2">
{popularDefaults.map((name) => (
<span
key={name}
className="text-xs px-2 py-1 rounded bg-violet-500/10 text-violet-300 border border-violet-500/20"
>
{name}
</span>
))}
</div>
</div>
)}
</Card>
{skills.length === 0 ? (
<Card>
<div className="text-center py-8 text-text-muted">{t("noSkills")}</div>
</Card>
) : (
skills.map((skill) => (
<Card key={skill.id}>
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold">{skill.name}</h3>
<span className="text-xs px-2 py-0.5 rounded bg-surface/50 text-text-muted">
v{skill.version}
</span>
<span className="text-xs px-2 py-0.5 rounded bg-surface/50 text-text-muted">
{(skill.sourceProvider || "local").toUpperCase()}
</span>
<span className="text-xs px-2 py-0.5 rounded bg-amber-500/10 text-amber-400">
{t("mode")}: {skill.mode || (skill.enabled ? "on" : "off")}
</span>
</div>
<p className="text-sm text-text-muted mt-1">{skill.description}</p>
{Array.isArray(skill.tags) && skill.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{skill.tags.map((tag) => (
<span
key={`${skill.id}-${tag}`}
className="text-[11px] px-1.5 py-0.5 rounded bg-surface/60 text-text-muted"
>
{tag}
</span>
))}
</div>
)}
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<button
onClick={() => setSkillMode(skill.id, "on")}
className={`text-xs px-2 py-1 rounded border ${
(skill.mode || (skill.enabled ? "on" : "off")) === "on"
? "border-emerald-500 text-emerald-400"
: "border-border text-text-muted"
}`}
>
{t("onMode")}
</button>
<button
onClick={() => setSkillMode(skill.id, "auto")}
className={`text-xs px-2 py-1 rounded border ${
(skill.mode || (skill.enabled ? "on" : "off")) === "auto"
? "border-amber-500 text-amber-400"
: "border-border text-text-muted"
}`}
>
{t("autoMode")}
</button>
<button
onClick={() => setSkillMode(skill.id, "off")}
className={`text-xs px-2 py-1 rounded border ${
(skill.mode || (skill.enabled ? "on" : "off")) === "off"
? "border-red-500 text-red-400"
: "border-border text-text-muted"
}`}
>
{t("offMode")}
</button>
</div>
<button
onClick={() => deleteSkill(skill.id)}
className="text-xs px-2 py-1 rounded text-red-400 hover:bg-red-500/10 transition-colors"
>
{t("delete")}
</button>
<button
onClick={() => toggleSkill(skill.id, skill.enabled)}
className={`relative w-11 h-6 rounded-full transition-colors ${
skill.enabled ? "bg-violet-500" : "bg-border"
}`}
role="switch"
aria-checked={skill.enabled}
>
<span
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform ${
skill.enabled ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
</div>
</div>
</Card>
))
)}
<div className="flex items-center justify-between mt-4 pt-4 border-t border-border">
<span className="text-sm text-text-muted">
{t("pageInfo", {
page: skillsPage,
totalPages: skillsTotalPages,
total: skillsTotal,
})}
</span>
<div className="flex gap-2">
<button
onClick={() => {
const p = Math.max(1, skillsPage - 1);
setSkillsPage(p);
fetchSkills(p);
}}
disabled={skillsPage === 1}
className="px-3 py-1 text-sm rounded border border-border text-text-muted hover:text-text-main disabled:opacity-40 transition-colors"
>
{t("previous")}
</button>
<button
onClick={() => {
const p = Math.min(skillsTotalPages, skillsPage + 1);
setSkillsPage(p);
fetchSkills(p);
}}
disabled={skillsPage === skillsTotalPages || skillsTotalPages === 0}
className="px-3 py-1 text-sm rounded border border-border text-text-muted hover:text-text-main disabled:opacity-40 transition-colors"
>
{t("next")}
</button>
</div>
</div>
</div>
)}
{activeTab === "executions" && (
<Card>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="text-left text-sm text-text-muted border-b border-border">
<th className="pb-3 font-medium">{t("skill")}</th>
<th className="pb-3 font-medium">{t("status")}</th>
<th className="pb-3 font-medium">{t("duration")}</th>
<th className="pb-3 font-medium">{t("time")}</th>
</tr>
</thead>
<tbody>
{executions.length === 0 ? (
<tr>
<td colSpan={4} className="py-8 text-center text-text-muted">
{t("noExecutions")}
</td>
</tr>
) : (
executions.map((exec) => (
<tr key={exec.id} className="border-b border-border/50">
<td className="py-3 font-medium">{exec.skillName}</td>
<td className="py-3">
<span
className={`text-xs px-2 py-1 rounded ${
exec.status === "success"
? "bg-emerald-500/10 text-emerald-400"
: exec.status === "error"
? "bg-red-500/10 text-red-400"
: "bg-amber-500/10 text-amber-400"
}`}
>
{exec.status}
</span>
</td>
<td className="py-3 text-text-muted">{exec.duration}ms</td>
<td className="py-3 text-text-muted text-sm">
{new Date(exec.createdAt).toLocaleString()}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<div className="flex items-center justify-between mt-4 pt-4 border-t border-border">
<span className="text-sm text-text-muted">
{t("pageInfo", { page: execPage, totalPages: execTotalPages, total: execTotal }) ||
`Page ${execPage} of ${execTotalPages} (${execTotal} total)`}
</span>
<div className="flex gap-2">
<button
onClick={() => {
const p = Math.max(1, execPage - 1);
setExecPage(p);
fetchExecutions(p);
}}
disabled={execPage === 1}
className="px-3 py-1 text-sm rounded border border-border text-text-muted hover:text-text-main disabled:opacity-40 transition-colors"
>
{t("previous") || "Prev"}
</button>
<button
onClick={() => {
const p = Math.min(execTotalPages, execPage + 1);
setExecPage(p);
fetchExecutions(p);
}}
disabled={execPage === execTotalPages || execTotalPages === 0}
className="px-3 py-1 text-sm rounded border border-border text-text-muted hover:text-text-main disabled:opacity-40 transition-colors"
>
{t("next") || "Next"}
</button>
</div>
</div>
</Card>
)}
{activeTab === "sandbox" && (
<div className="grid gap-4">
<Card>
<h3 className="font-semibold mb-4">{t("sandboxConfig")}</h3>
<div className="grid gap-4">
<div className="flex items-center justify-between p-3 rounded-lg bg-surface/30">
<div>
<p className="font-medium">{t("cpuLimit")}</p>
<p className="text-xs text-text-muted">{t("cpuLimitDesc")}</p>
</div>
<span className="font-mono">100ms</span>
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-surface/30">
<div>
<p className="font-medium">{t("memoryLimit")}</p>
<p className="text-xs text-text-muted">{t("memoryLimitDesc")}</p>
</div>
<span className="font-mono">256MB</span>
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-surface/30">
<div>
<p className="font-medium">{t("timeout")}</p>
<p className="text-xs text-text-muted">{t("timeoutDesc")}</p>
</div>
<span className="font-mono">30s</span>
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-surface/30">
<div>
<p className="font-medium">{t("networkAccess")}</p>
<p className="text-xs text-text-muted">{t("networkAccessDesc")}</p>
</div>
<span className="text-text-muted">{t("disabled")}</span>
</div>
</div>
</Card>
</div>
)}
{activeTab === "marketplace" && (
<div className="grid gap-4">
<Card>
<h3 className="font-semibold mb-2">{t("skillsMarketplace")}</h3>
<p className="text-sm text-text-muted mb-4">
{t("activeProvider")}{" "}
<span className="font-medium">
{skillsProvider === "skillsmp" ? "SkillsMP" : "skills.sh"}
</span>
. {t("changeInSettings")}
</p>
<div className="flex gap-2 mb-4">
<input
type="text"
value={skillsProvider === "skillsmp" ? mpQuery : shQuery}
onChange={(e) =>
skillsProvider === "skillsmp"
? setMpQuery(e.target.value)
: setShQuery(e.target.value)
}
onKeyDown={(e) =>
e.key === "Enter" &&
(skillsProvider === "skillsmp" ? searchMarketplace() : searchSkillsSh())
}
placeholder={t("searchMarketplacePlaceholder")}
className="flex-1 px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
/>
<button
onClick={() =>
skillsProvider === "skillsmp" ? searchMarketplace() : searchSkillsSh()
}
disabled={skillsProvider === "skillsmp" ? mpLoading : shLoading}
className="px-4 py-2 text-sm font-medium rounded-lg bg-violet-500 text-white hover:bg-violet-600 disabled:opacity-50 transition-colors"
>
{skillsProvider === "skillsmp"
? mpLoading
? t("searching")
: t("searchMarketplace")
: shLoading
? t("searching")
: t("searchMarketplace")}
</button>
</div>
{(skillsProvider === "skillsmp" ? mpError : shError) && (
<div className="p-3 rounded-lg bg-red-500/10 text-red-400 text-sm mb-4">
{skillsProvider === "skillsmp" ? mpError : shError}
</div>
)}
</Card>
{skillsProvider === "skillsmp" && mpResults.length > 0 && (
<div className="grid gap-3">
{mpResults.map((skill) => (
<Card key={skill.name}>
<div className="flex items-center justify-between">
<div>
<h4 className="font-semibold">{skill.name}</h4>
<p className="text-sm text-text-muted mt-1">{skill.description}</p>
</div>
<button
onClick={() => installFromMarketplace(skill)}
disabled={mpInstallingId === skill.name}
className="px-4 py-1.5 text-sm font-medium rounded-lg bg-violet-500 text-white hover:bg-violet-600 disabled:opacity-50 transition-colors"
>
{mpInstallingId === skill.name ? t("installing") : t("installSkillButton")}
</button>
</div>
</Card>
))}
</div>
)}
{skillsProvider === "skillssh" && shResults.length > 0 && (
<div className="grid gap-3">
{shResults.map((skill) => (
<Card key={skill.id}>
<div className="flex items-center justify-between">
<div>
<h4 className="font-semibold">{skill.name}</h4>
<p className="text-sm text-text-muted mt-1">
{skill.source} · {skill.installs.toLocaleString()} {t("installs")}
</p>
</div>
<button
onClick={() => installFromSkillsSh(skill)}
disabled={shInstallingId === skill.id}
className="px-4 py-1.5 text-sm font-medium rounded-lg bg-violet-500 text-white hover:bg-violet-600 disabled:opacity-50 transition-colors"
>
{shInstallingId === skill.id ? t("installing") : t("installSkillButton")}
</button>
</div>
</Card>
))}
</div>
)}
{skillsProvider === "skillsmp" && !mpLoading && mpResults.length === 0 && !mpError && (
<Card>
<div className="text-center py-8 text-text-muted">{t("marketplaceSkillsMpHint")}</div>
</Card>
)}
{skillsProvider === "skillssh" && !shLoading && shResults.length === 0 && !shError && (
<Card>
<div className="text-center py-8 text-text-muted">{t("marketplaceSkillsShHint")}</div>
</Card>
)}
</div>
)}
{showInstallModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-surface border border-border rounded-xl p-6 w-full max-w-lg mx-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">{t("installSkillModalTitle")}</h2>
<button
onClick={() => {
setShowInstallModal(false);
setInstallStatus(null);
setInstallJson("");
}}
className="text-text-muted hover:text-text-main"
>
X
</button>
</div>
<p className="text-sm text-text-muted mb-4">{t("installSkillModalDesc")}</p>
<textarea
value={installJson}
onChange={(e) => setInstallJson(e.target.value)}
placeholder={t("installJsonPlaceholder")}
className="w-full h-48 p-3 rounded-lg bg-background border border-border text-sm font-mono resize-none focus:outline-none focus:ring-1 focus:ring-violet-500"
/>
<div className="flex items-center gap-3 mt-3">
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleFileUpload}
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
className="px-3 py-1.5 text-sm rounded-lg border border-border text-text-muted hover:text-text-main transition-colors"
>
{t("uploadJson")}
</button>
<div className="flex-1" />
<button
onClick={() => {
setShowInstallModal(false);
setInstallStatus(null);
setInstallJson("");
}}
className="px-3 py-1.5 text-sm rounded-lg border border-border text-text-muted hover:text-text-main transition-colors"
>
{t("cancel")}
</button>
<button
onClick={handleInstall}
disabled={installing || !installJson.trim()}
className="px-4 py-1.5 text-sm font-medium rounded-lg bg-violet-500 text-white hover:bg-violet-600 disabled:opacity-50 transition-colors"
>
{installing ? t("installing") : t("installSkillButton")}
</button>
</div>
{installStatus && (
<div
className={`mt-3 p-3 rounded-lg text-sm ${
installStatus.type === "success"
? "bg-emerald-500/10 text-emerald-400"
: "bg-red-500/10 text-red-400"
}`}
>
{installStatus.message}
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -137,7 +137,7 @@ test.describe("Skills marketplace", () => {
});
});
await gotoDashboardRoute(page, "/dashboard/skills", {
await gotoDashboardRoute(page, "/dashboard/omni-skills", {
timeoutMs: NAVIGATION_TIMEOUT_MS,
});

View File

@@ -0,0 +1,325 @@
/**
* Unit tests for OmniSkillsPageClient and its sub-components.
* These are structural/file-system tests — they verify the correct component
* split, file structure, source patterns, and exported symbols without DOM rendering.
*
* Run:
* node --import tsx/esm --test tests/unit/omni-skills-page.test.tsx
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { resolve, join } from "node:path";
const cwd = process.cwd();
const base = resolve(join(cwd, "src/app/(dashboard)/dashboard/omni-skills"));
// ─── File structure ──────────────────────────────────────────────────────────
describe("File structure — omni-skills directory", () => {
it("old /dashboard/skills directory does not exist", () => {
const oldPath = resolve(join(cwd, "src/app/(dashboard)/dashboard/skills"));
assert.ok(!existsSync(oldPath), `Old skills/ directory must be absent (found at ${oldPath})`);
});
it("new /dashboard/omni-skills directory exists", () => {
assert.ok(existsSync(base), `omni-skills/ directory must exist at ${base}`);
});
const expectedFiles = [
"page.tsx",
"OmniSkillsPageClient.tsx",
"components/OmniSkillCard.tsx",
"components/SkillInspectorPane.tsx",
"components/OmniSkillsList.tsx",
"components/OmniExecutionsTab.tsx",
"components/OmniSandboxTab.tsx",
"components/OmniMarketplaceTab.tsx",
];
for (const file of expectedFiles) {
it(`file exists: omni-skills/${file}`, () => {
assert.ok(
existsSync(resolve(join(base, file))),
`Expected omni-skills/${file} to exist`
);
});
}
});
// ─── page.tsx — server component ─────────────────────────────────────────────
describe("page.tsx — server component", () => {
const src = readFileSync(resolve(join(base, "page.tsx")), "utf-8");
it("is a server component (no 'use client' directive)", () => {
assert.ok(
!src.includes('"use client"') && !src.includes("'use client'"),
"page.tsx must not have 'use client'"
);
});
it("imports and renders OmniSkillsPageClient", () => {
assert.ok(src.includes("OmniSkillsPageClient"), "page.tsx must reference OmniSkillsPageClient");
});
it("has a default export named Page", () => {
assert.ok(
src.includes("export default function Page"),
"page.tsx must have 'export default function Page'"
);
});
});
// ─── OmniSkillsPageClient.tsx ─────────────────────────────────────────────────
describe("OmniSkillsPageClient.tsx", () => {
const src = readFileSync(resolve(join(base, "OmniSkillsPageClient.tsx")), "utf-8");
it("starts with 'use client'", () => {
assert.ok(src.startsWith('"use client"'), "OmniSkillsPageClient must start with 'use client'");
});
it("has all 4 tab IDs", () => {
for (const tabId of ["skills", "executions", "sandbox", "marketplace"]) {
assert.ok(
src.includes(`id: "${tabId}"`),
`OmniSkillsPageClient must have tab id="${tabId}"`
);
}
});
it("renders SkillsConceptCard with variant='omni'", () => {
assert.ok(
src.includes('variant="omni"'),
"OmniSkillsPageClient must render <SkillsConceptCard variant=\"omni\" />"
);
});
it("imports SkillsConceptCard from shared components", () => {
assert.ok(
src.includes("SkillsConceptCard"),
"OmniSkillsPageClient must import SkillsConceptCard"
);
});
it("has selectedSkillId state", () => {
assert.ok(
src.includes("selectedSkillId"),
"OmniSkillsPageClient must maintain selectedSkillId state"
);
});
it("wires OmniSkillsList with inspector props", () => {
assert.ok(
src.includes("OmniSkillsList"),
"OmniSkillsPageClient must render OmniSkillsList"
);
assert.ok(
src.includes("onSelectSkill"),
"OmniSkillsPageClient must pass onSelectSkill to OmniSkillsList"
);
});
it("renders all 4 tab components", () => {
for (const component of [
"OmniSkillsList",
"OmniExecutionsTab",
"OmniSandboxTab",
"OmniMarketplaceTab",
]) {
assert.ok(src.includes(component), `OmniSkillsPageClient must render <${component}>`);
}
});
it("has install modal with hardcoded 'X' close button (preserved behavior)", () => {
assert.ok(src.includes("showInstallModal"), "must preserve showInstallModal state");
});
});
// ─── OmniSkillCard.tsx ────────────────────────────────────────────────────────
describe("OmniSkillCard.tsx", () => {
const src = readFileSync(resolve(join(base, "components/OmniSkillCard.tsx")), "utf-8");
it("starts with 'use client'", () => {
assert.ok(src.startsWith('"use client"'), "must be a client component");
});
it("accepts skill, selected, onClick props", () => {
assert.ok(src.includes("OmniSkillCardProps"), "must define OmniSkillCardProps");
assert.ok(src.includes("selected:"), "must have selected prop");
assert.ok(src.includes("onClick:"), "must have onClick prop");
});
it("has role='button' for accessibility", () => {
assert.ok(src.includes('role="button"'), "must have role='button' for accessibility");
});
it("exports OmniSkillCard", () => {
assert.ok(
src.includes("export function OmniSkillCard") || src.includes("export { OmniSkillCard }"),
"must export OmniSkillCard"
);
});
it("exports OmniSkill interface", () => {
assert.ok(
src.includes("export interface OmniSkill"),
"must export OmniSkill interface for other components"
);
});
});
// ─── SkillInspectorPane.tsx ───────────────────────────────────────────────────
describe("SkillInspectorPane.tsx", () => {
const src = readFileSync(resolve(join(base, "components/SkillInspectorPane.tsx")), "utf-8");
it("starts with 'use client'", () => {
assert.ok(src.startsWith('"use client"'), "must be a client component");
});
it("has all 4 sub-tab IDs", () => {
for (const tabId of ["schema", "handler", "executions", "sandbox"]) {
assert.ok(
src.includes(`"${tabId}"`),
`SkillInspectorPane must include sub-tab "${tabId}"`
);
}
});
it("has empty state text when no skill selected", () => {
assert.ok(
src.includes("Selecione uma skill"),
"must have empty state message"
);
});
it("fetches /api/skills/[id] for skill detail", () => {
assert.ok(
src.includes("/api/skills/${selectedSkillId}") ||
src.includes("`/api/skills/${selectedSkillId}`"),
"must fetch /api/skills/${selectedSkillId} for detail"
);
});
it("fetches /api/skills/executions for the executions tab", () => {
assert.ok(
src.includes("api/skills/executions?skillId=") ||
src.includes("api/skills/executions"),
"must fetch executions for the selected skill"
);
});
it("has ON / AUTO / OFF / Uninstall buttons", () => {
assert.ok(src.includes("onSetMode"), "must call onSetMode for mode buttons");
assert.ok(src.includes("onUninstall"), "must call onUninstall");
});
});
// ─── OmniSkillsList.tsx ───────────────────────────────────────────────────────
describe("OmniSkillsList.tsx", () => {
const src = readFileSync(resolve(join(base, "components/OmniSkillsList.tsx")), "utf-8");
it("uses grid-cols-12 split layout", () => {
assert.ok(src.includes("grid-cols-12"), "must use 12-column grid for split layout");
});
it("renders OmniSkillCard for each skill", () => {
assert.ok(src.includes("OmniSkillCard"), "must render OmniSkillCard per skill");
});
it("renders SkillInspectorPane on the right", () => {
assert.ok(src.includes("SkillInspectorPane"), "must include SkillInspectorPane in right col");
});
it("has onSelectSkill prop to control inspector state", () => {
assert.ok(src.includes("onSelectSkill"), "must accept onSelectSkill prop");
});
});
// ─── OmniExecutionsTab.tsx ────────────────────────────────────────────────────
describe("OmniExecutionsTab.tsx", () => {
const src = readFileSync(resolve(join(base, "components/OmniExecutionsTab.tsx")), "utf-8");
it("starts with 'use client'", () => {
assert.ok(src.startsWith('"use client"'), "must be a client component");
});
it("renders a table with skill/status/duration/time columns", () => {
assert.ok(src.includes("{t(\"skill\")}"), "must have skill column");
assert.ok(src.includes("{t(\"status\")}"), "must have status column");
assert.ok(src.includes("{t(\"duration\")}"), "must have duration column");
});
it("has pagination buttons", () => {
assert.ok(src.includes("onPagePrev"), "must accept onPagePrev handler");
assert.ok(src.includes("onPageNext"), "must accept onPageNext handler");
});
});
// ─── OmniSandboxTab.tsx ───────────────────────────────────────────────────────
describe("OmniSandboxTab.tsx", () => {
const src = readFileSync(resolve(join(base, "components/OmniSandboxTab.tsx")), "utf-8");
it("starts with 'use client'", () => {
assert.ok(src.startsWith('"use client"'), "must be a client component");
});
it("shows sandbox config values", () => {
assert.ok(src.includes("100ms"), "must show 100ms CPU limit");
assert.ok(src.includes("256MB"), "must show 256MB memory limit");
assert.ok(src.includes("30s"), "must show 30s timeout");
});
});
// ─── OmniMarketplaceTab.tsx ───────────────────────────────────────────────────
describe("OmniMarketplaceTab.tsx", () => {
const src = readFileSync(resolve(join(base, "components/OmniMarketplaceTab.tsx")), "utf-8");
it("starts with 'use client'", () => {
assert.ok(src.startsWith('"use client"'), "must be a client component");
});
it("has marketplace search logic", () => {
assert.ok(
src.includes("/api/skills/marketplace"),
"must call /api/skills/marketplace endpoint"
);
});
it("has skills.sh search logic", () => {
assert.ok(src.includes("/api/skills/skillssh"), "must call /api/skills/skillssh endpoint");
});
it("accepts skillsProvider and onRefreshSkills props", () => {
assert.ok(src.includes("skillsProvider"), "must accept skillsProvider prop");
assert.ok(src.includes("onRefreshSkills"), "must accept onRefreshSkills prop");
});
});
// ─── E2E test update ──────────────────────────────────────────────────────────
describe("E2E spec path", () => {
const src = readFileSync(
resolve(join(cwd, "tests/e2e/skills-marketplace.spec.ts")),
"utf-8"
);
it("uses /dashboard/omni-skills (not /dashboard/skills)", () => {
assert.ok(
src.includes("/dashboard/omni-skills"),
"E2E spec must navigate to /dashboard/omni-skills"
);
assert.ok(
!src.includes('"/dashboard/skills"') && !src.includes("'/dashboard/skills'"),
"E2E spec must not have the old /dashboard/skills path in gotoDashboardRoute call"
);
});
});