"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 "app";
if (scope === "project" || src === "project") return "project";
return "path";
}
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 */}
{label}
{displayPath(skill.filePath)}
onToggle(skill)}
/>
{saveError && (
{saveError}
)}
{skill.install?.skillsShUrl && (
)}
{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,
onInstalled,
}: {
cwd: string;
installedPackages: Record>;
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 = "global" as const;
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, cwd],
);
const installPath = "data/skills-home/.agents/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 */}
{/* 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 */}
);
})}
) : (
!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 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);
if (list.length > 0 && !selected) setSelected(list[0].filePath);
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,
),
);
} 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)}
{/* 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: "app / skills.sh",
matches: (skill: Skill) =>
sourceLabel(skill) === "app" &&
Boolean(skill.install?.skillsShUrl),
},
{
label: "app",
matches: (skill: Skill) =>
sourceLabel(skill) === "app" &&
!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 });
}
return groups.map(
({ label: grpLabel, skills: grpSkills }) => (
{grpLabel}
{grpSkills.map((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 (
↑
);
})()}
);
})}
),
);
})()
)}
{/* 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")}
)}
);
}