"use client"; import { useState, useEffect, useCallback, useRef } from "react"; import { useIsMobile } from "@/hooks/useIsMobile"; import { useI18n } from "@/hooks/useI18n"; import type { SkillInfo as Skill, SkillInstallScope, SkillSearchResult, SkillsResponse, SkillUpdateResult, } from "@/lib/api-types"; function shortenPath(p: string): string { // Match common home dir patterns: /Users/xxx, /home/xxx return p.replace(/^\/(?:Users|home)\/[^/]+/, "~"); } function sourceLabel(skill: Skill): string { const src = skill.sourceInfo?.source; const scope = skill.sourceInfo?.scope; if (scope === "user" || src === "user") return "global"; if (scope === "project" || src === "project") return "project"; return "path"; } function skillGroupLabel(skill: Skill): string { const source = sourceLabel(skill); if (source === "path") return source; return skill.install?.skillsShUrl ? `${source} / skills.sh` : source; } function updateKey(skill: Skill): string | null { return skill.install ? `${skill.install.scope}\0${skill.install.package}` : null; } function shortVersion(version?: string): string { return version ? version.slice(0, 8) : "unknown"; } function Toggle({ enabled, loading, onToggle, }: { enabled: boolean; loading: boolean; onToggle: () => void; }) { const { t } = useI18n(); return ( ); } function SkillDetail({ skill, cwd, onToggle, toggling, saveError, updateStatus, checkingUpdate, updating, updateError, onCheckUpdate, onUpdate, }: { skill: Skill; cwd: string; onToggle: (skill: Skill) => void; toggling: boolean; saveError: string | null; updateStatus?: SkillUpdateResult; checkingUpdate: boolean; updating: boolean; updateError: string | null; onCheckUpdate: () => void; onUpdate: () => void; }) { const { t } = useI18n(); const label = sourceLabel(skill); const enabled = !skill.disableModelInvocation; function displayPath(p: string): string { if (label === "project" && p.startsWith(cwd)) { const rel = p.slice(cwd.length).replace(/^[/\\]/, ""); return `./${rel}`; } return shortenPath(p); } return (
{/* Path + tag + toggle, with a stable status row below. */}
{label} {displayPath(skill.filePath)} onToggle(skill)} />
{!enabled && ( {t("i18n.hiddenButInvocable")} )} {saveError && ( {saveError} )}
{skill.install?.skillsShUrl && (
Source {skill.install.skillsShUrl.replace(/^https?:\/\//, "")} ↗
)} {skill.install && (
Version
{shortVersion(updateStatus?.currentVersion ?? skill.install.versionHash)} {skill.install.canCheckForUpdates && ( )} {updateStatus?.state === "update-available" && ( {shortVersion(updateStatus.latestVersion)} )} {(checkingUpdate || (updateStatus && updateStatus.state !== "update-available")) && ( {checkingUpdate ? t("i18n.checking") : updateStatus?.state === "up-to-date" ? t("i18n.upToDate") : updateStatus?.state === "unsupported" ? t("i18n.automaticChecksUnavailable") : updateStatus?.message || t("i18n.checkFailed")} )} {updateStatus?.state === "update-available" && ( )}
{updateError && ( {updateError} )}
)}
Name {skill.name}
Description {skill.description}
); } function AddSkillPanel({ cwd, installedPackages, projectResourcesLoaded, onInstalled, }: { cwd: string; installedPackages: Record>; projectResourcesLoaded: boolean; onInstalled: () => void; }) { const { t } = useI18n(); const [query, setQuery] = useState(""); const [results, setResults] = useState([]); const [searching, setSearching] = useState(false); const [searchError, setSearchError] = useState(null); const [installing, setInstalling] = useState(null); const [installError, setInstallError] = useState(null); const [newlyInstalledPkgs, setNewlyInstalledPkgs] = useState>( new Set(), ); const [scope, setScope] = useState<"global" | "project">("global"); const inputRef = useRef(null); useEffect(() => { inputRef.current?.focus(); }, []); const search = useCallback(async (q: string) => { if (!q.trim()) return; setSearching(true); setSearchError(null); setResults([]); try { const res = await fetch("/api/skills/search", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query: q.trim() }), }); const d = (await res.json()) as { results?: SkillSearchResult[]; error?: string; }; if (d.error) { setSearchError(d.error); return; } setResults(d.results ?? []); if ((d.results ?? []).length === 0) setSearchError("No skills found"); } catch (e) { setSearchError(String(e)); } finally { setSearching(false); } }, []); const install = useCallback( async (pkg: string) => { setInstalling(pkg); setInstallError(null); try { const res = await fetch("/api/skills/install", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ package: pkg, scope, cwd }), }); const d = (await res.json()) as { success?: boolean; error?: string }; if (!res.ok || d.error) { setInstallError(d.error ?? `HTTP ${res.status}`); return; } setNewlyInstalledPkgs((prev) => new Set(prev).add(`${scope}:${pkg}`), ); onInstalled(); } catch (e) { setInstallError(String(e)); } finally { setInstalling(null); } }, [onInstalled, scope, cwd], ); const installPath = scope === "global" ? "~/.pi/agent/skills/" : `${shortenPath(cwd)}/.pi/skills/`; return (
{/* ── Header area ── */}
{t("i18n.addSkill")}
{/* Search row */}
setQuery(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") search(query); }} placeholder={t("i18n.skillSearchPlaceholder")} style={{ flex: 1, padding: "7px 10px", fontSize: 13, background: "var(--bg-panel)", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text)", outline: "none", }} />
{/* Scope + install path row */}
{(["global", "project"] as const).map((s) => ( ))}
→ {installPath}
{/* Errors */} {searchError && (
{searchError}
)} {installError && (
{installError}
)}
{/* ── Results list ── */} {results.length > 0 ? (
{results.map((r) => { const isInstalled = installedPackages[scope].has(r.package) || newlyInstalledPkgs.has(`${scope}:${r.package}`); const isInstalling = installing === r.package; // split "owner/repo@skill" for cleaner display const atIdx = r.package.indexOf("@"); const repopart = atIdx > -1 ? r.package.slice(0, atIdx) : r.package; const skillpart = atIdx > -1 ? r.package.slice(atIdx + 1) : null; return (
{/* skill name prominent */}
{skillpart ?? repopart}
{/* repo + installs + link row */}
{repopart} {r.installs} {r.url && ( skills.sh ↗ )}
); })}
) : ( !searchError && !searching && (
Search{" "} skills.sh {" "} to discover and install skills for your agent.
) )}
); } export function SkillsConfig({ cwd, onClose, }: { cwd: string; onClose: () => void; }) { const isMobile = useIsMobile(); const { t } = useI18n(); const [skills, setSkills] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [selected, setSelected] = useState(null); const [toggling, setToggling] = useState>(new Set()); const [saveError, setSaveError] = useState(null); const [addMode, setAddMode] = useState(false); const [updateStatuses, setUpdateStatuses] = useState>({}); const [checkingUpdates, setCheckingUpdates] = useState>(new Set()); const [checkingAll, setCheckingAll] = useState(false); const [updatingSkill, setUpdatingSkill] = useState(null); const [updateError, setUpdateError] = useState(null); const [projectResourcesLoaded, setProjectResourcesLoaded] = useState(true); const [dormantGroupsOpen, setDormantGroupsOpen] = useState>({}); const loadSkills = useCallback(async () => { setLoading(true); setError(null); try { const res = await fetch(`/api/skills?cwd=${encodeURIComponent(cwd)}`); const d = (await res.json()) as Partial & { error?: string }; if (!res.ok || d.error) throw new Error(d.error ?? `HTTP ${res.status}`); const list = d.skills ?? []; setSkills(list); setProjectResourcesLoaded(d.projectResourcesLoaded ?? true); if (list.length > 0 && !selected) { const initialSkill = list.find((skill) => !skill.disableModelInvocation) ?? list[0]; setSelected(initialSkill.filePath); if (initialSkill.disableModelInvocation) { setDormantGroupsOpen((current) => ({ ...current, [skillGroupLabel(initialSkill)]: true, })); } } return list; } catch (e) { setError(String(e)); return []; } finally { setLoading(false); } }, [cwd, selected]); useEffect(() => { setUpdateStatuses({}); setUpdateError(null); void loadSkills(); }, [cwd]); // eslint-disable-line react-hooks/exhaustive-deps const checkForUpdates = useCallback(async (skill?: Skill) => { const targets = skill ? [skill] : skills.filter((item) => Boolean(item.install)); const keys = targets .map(updateKey) .filter((key): key is string => Boolean(key)); if (keys.length === 0) return; setUpdateError(null); setCheckingUpdates((current) => new Set([...current, ...keys])); if (!skill) setCheckingAll(true); try { const res = await fetch("/api/skills/check", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ cwd, package: skill?.install?.package, scope: skill?.install?.scope, }), }); const data = (await res.json()) as { updates?: SkillUpdateResult[]; error?: string; }; if (!res.ok || data.error) throw new Error(data.error ?? `HTTP ${res.status}`); setUpdateStatuses((current) => { const next = { ...current }; for (const update of data.updates ?? []) { next[`${update.scope}\0${update.package}`] = update; } return next; }); } catch (e) { setUpdateError(e instanceof Error ? e.message : String(e)); } finally { setCheckingUpdates((current) => { const next = new Set(current); for (const key of keys) next.delete(key); return next; }); if (!skill) setCheckingAll(false); } }, [cwd, skills]); const updateInstalledSkill = useCallback(async (skill: Skill) => { if (!skill.install) return; const key = updateKey(skill)!; setUpdatingSkill(key); setUpdateError(null); try { const res = await fetch("/api/skills/update", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ cwd, package: skill.install.package, scope: skill.install.scope, }), }); const data = (await res.json()) as { success?: boolean; skill?: Skill; error?: string; }; if (!res.ok || data.error || !data.success) { throw new Error(data.error ?? `HTTP ${res.status}`); } await loadSkills(); const versionHash = data.skill?.install?.versionHash; setUpdateStatuses((current) => ({ ...current, [key]: { package: skill.install!.package, scope: skill.install!.scope, state: "up-to-date", currentVersion: versionHash, latestVersion: versionHash, }, })); } catch (e) { setUpdateError(e instanceof Error ? e.message : String(e)); } finally { setUpdatingSkill(null); } }, [cwd, loadSkills]); const toggle = useCallback(async (skill: Skill) => { const next = !skill.disableModelInvocation; setToggling((s) => new Set(s).add(skill.filePath)); setSaveError(null); try { const res = await fetch("/api/skills", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ filePath: skill.filePath, disableModelInvocation: next, }), }); const d = (await res.json()) as { success?: boolean; error?: string }; if (!res.ok || d.error) { setSaveError(d.error ?? `HTTP ${res.status}`); return; } setSkills((prev) => prev.map((s) => s.filePath === skill.filePath ? { ...s, disableModelInvocation: next } : s, ), ); if (next) { setDormantGroupsOpen((current) => ({ ...current, [skillGroupLabel(skill)]: true, })); } } catch (e) { setSaveError(String(e)); } finally { setToggling((s) => { const n = new Set(s); n.delete(skill.filePath); return n; }); } }, []); const selectedSkill = skills.find((s) => s.filePath === selected) ?? null; return (
{ if (e.target === e.currentTarget) onClose(); }} >
{/* Header */}
{t("common.skills")} {shortenPath(cwd)}
{!projectResourcesLoaded && (
{t("trust.skillsNotLoaded")}
)} {/* Body */}
{/* Left: skill list */}
{loading ? (
{t("i18n.loading")}
) : error ? (
{error}
) : skills.length === 0 ? (
{t("i18n.noSkills")}
) : ( (() => { const groups: { label: string; skills: typeof skills }[] = []; const groupDefinitions = [ { label: "project / skills.sh", matches: (skill: Skill) => sourceLabel(skill) === "project" && Boolean(skill.install?.skillsShUrl), }, { label: "project", matches: (skill: Skill) => sourceLabel(skill) === "project" && !skill.install?.skillsShUrl, }, { label: "global / skills.sh", matches: (skill: Skill) => sourceLabel(skill) === "global" && Boolean(skill.install?.skillsShUrl), }, { label: "global", matches: (skill: Skill) => sourceLabel(skill) === "global" && !skill.install?.skillsShUrl, }, { label: "path", matches: (skill: Skill) => sourceLabel(skill) === "path", }, ]; for (const { label, matches } of groupDefinitions) { const grpSkills = skills.filter(matches); if (grpSkills.length > 0) groups.push({ label, skills: grpSkills }); } const renderSkillRow = (skill: Skill) => { const isSelected = !addMode && selected === skill.filePath; const disabled = skill.disableModelInvocation; return (
{ setSelected(skill.filePath); setAddMode(false); }} style={{ display: "flex", alignItems: "center", gap: 7, padding: "8px 8px", borderRadius: 5, cursor: "pointer", background: isSelected ? "var(--bg-selected)" : "none", }} onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.background = "var(--bg-hover)"; }} onMouseLeave={(e) => { if (!isSelected) e.currentTarget.style.background = "none"; }} > {skill.name} {(() => { const key = updateKey(skill); const status = key ? updateStatuses[key] : undefined; if (status?.state !== "update-available") return null; return ( ↑ ); })()}
); }; return groups.map( ({ label: grpLabel, skills: grpSkills }) => { const activeSkills = grpSkills.filter( (skill) => !skill.disableModelInvocation, ); const dormantSkills = grpSkills.filter( (skill) => skill.disableModelInvocation, ); const dormantOpen = dormantGroupsOpen[grpLabel] ?? false; return (
{grpLabel}
{activeSkills.map(renderSkillRow)} {dormantSkills.length > 0 && ( <>
setDormantGroupsOpen((current) => ({ ...current, [grpLabel]: !dormantOpen, })) } style={{ display: "flex", alignItems: "center", gap: 5, padding: "4px 8px 3px", fontSize: 10, fontWeight: 600, color: "var(--text-dim)", textTransform: "uppercase", letterSpacing: "0.06em", cursor: "pointer", userSelect: "none", }} > {dormantOpen ? "▾" : "▸"} {t("i18n.dormant")} ({dormantSkills.length})
{dormantOpen && dormantSkills.map(renderSkillRow)} )}
); }, ); })() )}
{/* Add skill button */}
setAddMode(true)} style={{ display: "flex", alignItems: "center", gap: 6, padding: "7px 8px", borderRadius: 5, cursor: "pointer", background: addMode ? "var(--bg-selected)" : "none", color: addMode ? "var(--accent)" : "var(--text-dim)", fontSize: 12, }} onMouseEnter={(e) => { if (!addMode) e.currentTarget.style.background = "var(--bg-hover)"; }} onMouseLeave={(e) => { if (!addMode) e.currentTarget.style.background = "none"; }} > {t("i18n.addSkill")}
{/* Right: detail or add panel */}
{addMode ? ( skill.install?.scope === "global") .map((skill) => skill.install!.package), ), project: new Set( skills .filter((skill) => skill.install?.scope === "project") .map((skill) => skill.install!.package), ), }} onInstalled={() => { void loadSkills(); }} /> ) : loading ? null : selectedSkill ? ( void checkForUpdates(selectedSkill)} onUpdate={() => void updateInstalledSkill(selectedSkill)} /> ) : (
{t("i18n.selectSkill")}
)}
{/* Footer */}
{skills.some((skill) => Boolean(skill.install)) && ( )} {Object.values(updateStatuses).filter( (status) => status.state === "update-available", ).length > 0 && ( { Object.values(updateStatuses).filter( (status) => status.state === "update-available", ).length }{" "} {Object.values(updateStatuses).filter( (status) => status.state === "update-available", ).length === 1 ? t("i18n.update") : t("i18n.updates")} )}
); }