mirror of
https://github.com/xmanrui/OpenClaw-bot-review.git
synced 2026-07-27 22:25:52 +00:00
Add skill source viewer in dashboard
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getOpenclawSkillContent } from "@/lib/openclaw-skills";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const source = (searchParams.get("source") || "").trim();
|
||||
const id = (searchParams.get("id") || "").trim();
|
||||
|
||||
if (!source || !id) {
|
||||
return NextResponse.json({ error: "Missing source or id" }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = getOpenclawSkillContent(source, id);
|
||||
if (!result) {
|
||||
return NextResponse.json({ error: "Skill not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
id: result.skill.id,
|
||||
name: result.skill.name,
|
||||
source: result.skill.source,
|
||||
content: result.content,
|
||||
});
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
+2
-156
@@ -1,163 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { getOpenclawPackageCandidates, OPENCLAW_CONFIG_PATH, OPENCLAW_HOME } from "@/lib/openclaw-paths";
|
||||
|
||||
// Find OpenClaw package directory
|
||||
function findOpenClawPkg(): string {
|
||||
// Check common locations
|
||||
const candidates = getOpenclawPackageCandidates();
|
||||
for (const c of candidates) {
|
||||
if (fs.existsSync(path.join(c, "package.json"))) return c;
|
||||
}
|
||||
// Fallback: try to find via which
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
const OPENCLAW_PKG = findOpenClawPkg();
|
||||
|
||||
interface SkillInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
emoji: string;
|
||||
source: string; // "builtin" | "extension" | "custom"
|
||||
location: string;
|
||||
usedBy: string[]; // agent ids
|
||||
}
|
||||
|
||||
function parseFrontmatter(content: string): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
if (!content.startsWith("---")) return result;
|
||||
const parts = content.split("---", 3);
|
||||
if (parts.length < 3) return result;
|
||||
const fm = parts[1];
|
||||
|
||||
const nameMatch = fm.match(/^name:\s*(.+)/m);
|
||||
if (nameMatch) result.name = nameMatch[1].trim().replace(/^["']|["']$/g, "");
|
||||
|
||||
const descMatch = fm.match(/^description:\s*["']?(.+?)["']?\s*$/m);
|
||||
if (descMatch) result.description = descMatch[1].trim().replace(/^["']|["']$/g, "");
|
||||
|
||||
const emojiMatch = fm.match(/"emoji":\s*"([^"]+)"/);
|
||||
if (emojiMatch) result.emoji = emojiMatch[1];
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function scanSkillsDir(dir: string, source: string): SkillInfo[] {
|
||||
const skills: SkillInfo[] = [];
|
||||
if (!fs.existsSync(dir)) return skills;
|
||||
for (const name of fs.readdirSync(dir).sort()) {
|
||||
const skillMd = path.join(dir, name, "SKILL.md");
|
||||
if (!fs.existsSync(skillMd)) continue;
|
||||
const content = fs.readFileSync(skillMd, "utf-8");
|
||||
const fm = parseFrontmatter(content);
|
||||
skills.push({
|
||||
id: name,
|
||||
name: fm.name || name,
|
||||
description: fm.description || "",
|
||||
emoji: fm.emoji || "🔧",
|
||||
source,
|
||||
location: skillMd,
|
||||
usedBy: [],
|
||||
});
|
||||
}
|
||||
return skills;
|
||||
}
|
||||
|
||||
function getAgentSkillsFromSessions(): Record<string, Set<string>> {
|
||||
// Parse skillsSnapshot from session JSONL files
|
||||
const agentsDir = path.join(OPENCLAW_HOME, "agents");
|
||||
const result: Record<string, Set<string>> = {};
|
||||
if (!fs.existsSync(agentsDir)) return result;
|
||||
|
||||
for (const agentId of fs.readdirSync(agentsDir)) {
|
||||
const sessionsDir = path.join(agentsDir, agentId, "sessions");
|
||||
if (!fs.existsSync(sessionsDir)) continue;
|
||||
|
||||
const jsonlFiles = fs.readdirSync(sessionsDir)
|
||||
.filter(f => f.endsWith(".jsonl"))
|
||||
.sort();
|
||||
const skillNames = new Set<string>();
|
||||
|
||||
// Check the most recent session files for skillsSnapshot
|
||||
for (const file of jsonlFiles.slice(-3)) {
|
||||
const content = fs.readFileSync(path.join(sessionsDir, file), "utf-8");
|
||||
const idx = content.indexOf("skillsSnapshot");
|
||||
if (idx < 0) continue;
|
||||
const chunk = content.slice(idx, idx + 5000);
|
||||
// Match skill names in escaped JSON: \"name\":\"xxx\" or "name":"xxx"
|
||||
const matches = chunk.matchAll(/\\?"name\\?":\s*\\?"([^"\\]+)\\?"/g);
|
||||
for (const m of matches) {
|
||||
const name = m[1];
|
||||
// Filter out tool names and other non-skill entries
|
||||
if (!["exec","read","edit","write","process","message","web_search","web_fetch",
|
||||
"browser","tts","gateway","memory_search","memory_get","cron","nodes",
|
||||
"canvas","session_status","sessions_list","sessions_history","sessions_send",
|
||||
"sessions_spawn","agents_list"].includes(name) && name.length > 1) {
|
||||
skillNames.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (skillNames.size > 0) {
|
||||
result[agentId] = skillNames;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
import { listOpenclawSkills } from "@/lib/openclaw-skills";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// 1. Scan builtin skills
|
||||
const builtinDir = path.join(OPENCLAW_PKG, "skills");
|
||||
const builtinSkills = scanSkillsDir(builtinDir, "builtin");
|
||||
|
||||
// 2. Scan extension skills
|
||||
const extDir = path.join(OPENCLAW_PKG, "extensions");
|
||||
const extSkills: SkillInfo[] = [];
|
||||
if (fs.existsSync(extDir)) {
|
||||
for (const ext of fs.readdirSync(extDir)) {
|
||||
const skillsDir = path.join(extDir, ext, "skills");
|
||||
if (fs.existsSync(skillsDir)) {
|
||||
const skills = scanSkillsDir(skillsDir, `extension:${ext}`);
|
||||
extSkills.push(...skills);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Scan custom skills (~/.openclaw/skills)
|
||||
const customDir = path.join(OPENCLAW_HOME, "skills");
|
||||
const customSkills = scanSkillsDir(customDir, "custom");
|
||||
|
||||
const allSkills = [...builtinSkills, ...extSkills, ...customSkills];
|
||||
|
||||
// 4. Map agent usage from session data
|
||||
const agentSkills = getAgentSkillsFromSessions();
|
||||
for (const skill of allSkills) {
|
||||
for (const [agentId, skills] of Object.entries(agentSkills)) {
|
||||
if (skills.has(skill.id) || skills.has(skill.name)) {
|
||||
skill.usedBy.push(agentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Get agent info for display
|
||||
const configPath = OPENCLAW_CONFIG_PATH;
|
||||
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
||||
const agentList = config.agents?.list || [];
|
||||
const agentMap: Record<string, { name: string; emoji: string }> = {};
|
||||
for (const a of agentList) {
|
||||
const name = a.identity?.name || a.name || a.id;
|
||||
const emoji = a.identity?.emoji || "🤖";
|
||||
agentMap[a.id] = { name, emoji };
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
skills: allSkills,
|
||||
agents: agentMap,
|
||||
total: allSkills.length,
|
||||
});
|
||||
return NextResponse.json(listOpenclawSkills());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
|
||||
+117
-4
@@ -67,6 +67,10 @@ export default function SkillsPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<"all" | "builtin" | "extension" | "custom">("all");
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedSkill, setSelectedSkill] = useState<Skill | null>(null);
|
||||
const [skillContent, setSkillContent] = useState<Record<string, string>>({});
|
||||
const [contentLoading, setContentLoading] = useState(false);
|
||||
const [contentError, setContentError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -110,6 +114,62 @@ export default function SkillsPage() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSkill) return;
|
||||
|
||||
const cacheKey = `${selectedSkill.source}:${selectedSkill.id}`;
|
||||
if (skillContent[cacheKey]) {
|
||||
setContentError(null);
|
||||
setContentLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
setContentLoading(true);
|
||||
setContentError(null);
|
||||
|
||||
fetch(`/api/skills/content?source=${encodeURIComponent(selectedSkill.source)}&id=${encodeURIComponent(selectedSkill.id)}`, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(async (response) => {
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.error || `HTTP ${response.status}`);
|
||||
}
|
||||
return data;
|
||||
})
|
||||
.then((data) => {
|
||||
const content = typeof data?.content === "string" ? data.content : "";
|
||||
setSkillContent((prev) => ({ ...prev, [cacheKey]: content }));
|
||||
})
|
||||
.catch((err) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setContentError(err instanceof Error ? err.message : String(err));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) {
|
||||
setContentLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [selectedSkill, skillContent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSkill) return;
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setSelectedSkill(null);
|
||||
setContentError(null);
|
||||
setContentLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [selectedSkill]);
|
||||
|
||||
const filtered = skills.filter((skill) => {
|
||||
if (filter === "builtin" && skill.source !== "builtin") return false;
|
||||
if (filter === "extension" && !skill.source.startsWith("extension:")) return false;
|
||||
@@ -137,6 +197,9 @@ export default function SkillsPage() {
|
||||
return "bg-green-500/20 text-green-400";
|
||||
};
|
||||
|
||||
const selectedSkillCacheKey = selectedSkill ? `${selectedSkill.source}:${selectedSkill.id}` : "";
|
||||
const selectedSkillContent = selectedSkill ? skillContent[selectedSkillCacheKey] : "";
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
@@ -217,9 +280,11 @@ export default function SkillsPage() {
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((skill) => (
|
||||
<div
|
||||
<button
|
||||
key={`${skill.source}-${skill.id}`}
|
||||
className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 hover:border-[var(--accent)]/50 transition"
|
||||
type="button"
|
||||
onClick={() => setSelectedSkill(skill)}
|
||||
className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 hover:border-[var(--accent)]/50 transition text-left cursor-pointer"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2 gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
@@ -233,6 +298,9 @@ export default function SkillsPage() {
|
||||
<p className="text-xs text-[var(--text-muted)] line-clamp-2 mb-3 min-h-[2.5em]">
|
||||
{skill.description || t("skills.noDesc")}
|
||||
</p>
|
||||
<div className="mb-3 text-[10px] text-[var(--accent)]">
|
||||
{t("skills.viewSource")}
|
||||
</div>
|
||||
{skill.usedBy.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{skill.usedBy.map((agentId) => {
|
||||
@@ -248,10 +316,55 @@ export default function SkillsPage() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedSkill && (
|
||||
<div className="fixed inset-0 z-[70]">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 bg-black/60"
|
||||
aria-label={t("common.close")}
|
||||
onClick={() => {
|
||||
setSelectedSkill(null);
|
||||
setContentError(null);
|
||||
setContentLoading(false);
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-x-4 inset-y-6 md:inset-x-10 lg:inset-x-24 xl:inset-x-40 rounded-2xl border border-[var(--border)] bg-[var(--card)] shadow-2xl flex flex-col overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-[var(--border)] flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold truncate">{selectedSkill.emoji} {selectedSkill.name}</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">{t("skills.contentTitle")}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedSkill(null);
|
||||
setContentError(null);
|
||||
setContentLoading(false);
|
||||
}}
|
||||
className="px-3 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--bg)] text-sm hover:border-[var(--accent)] transition"
|
||||
>
|
||||
{t("common.close")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto bg-[var(--bg)]/40">
|
||||
{contentLoading && !selectedSkillContent ? (
|
||||
<div className="p-4 text-sm text-[var(--text-muted)]">{t("skills.loadingContent")}</div>
|
||||
) : contentError ? (
|
||||
<div className="p-4 text-sm text-red-400">{t("skills.contentLoadFailed")}: {contentError}</div>
|
||||
) : (
|
||||
<pre className="p-4 text-xs md:text-sm leading-6 whitespace-pre-wrap break-words text-[var(--text)] font-mono">
|
||||
{selectedSkillContent}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,6 +238,10 @@ const translations: Record<Locale, Record<string, string>> = {
|
||||
"skills.noDesc": "無描述",
|
||||
"skills.source.builtin": "內建",
|
||||
"skills.source.custom": "自訂",
|
||||
"skills.viewSource": "查看 SKILL.md",
|
||||
"skills.contentTitle": "SKILL.md 內容",
|
||||
"skills.loadingContent": "正在載入技能內容...",
|
||||
"skills.contentLoadFailed": "技能內容載入失敗",
|
||||
|
||||
// gateway status
|
||||
"gateway.healthy": "Gateway 運作正常",
|
||||
@@ -520,6 +524,10 @@ const translations: Record<Locale, Record<string, string>> = {
|
||||
"skills.noDesc": "无描述",
|
||||
"skills.source.builtin": "内置",
|
||||
"skills.source.custom": "自定义",
|
||||
"skills.viewSource": "查看 SKILL.md",
|
||||
"skills.contentTitle": "SKILL.md 内容",
|
||||
"skills.loadingContent": "正在加载技能内容...",
|
||||
"skills.contentLoadFailed": "技能内容加载失败",
|
||||
|
||||
// gateway status
|
||||
"gateway.healthy": "Gateway 运行正常",
|
||||
@@ -802,6 +810,10 @@ const translations: Record<Locale, Record<string, string>> = {
|
||||
"skills.noDesc": "No description",
|
||||
"skills.source.builtin": "Built-in",
|
||||
"skills.source.custom": "Custom",
|
||||
"skills.viewSource": "View SKILL.md",
|
||||
"skills.contentTitle": "SKILL.md",
|
||||
"skills.loadingContent": "Loading skill content...",
|
||||
"skills.contentLoadFailed": "Failed to load skill content",
|
||||
|
||||
// gateway status
|
||||
"gateway.healthy": "Gateway is running",
|
||||
|
||||
@@ -19,6 +19,7 @@ export function getOpenclawPackageCandidates(version = process.version): string[
|
||||
|
||||
return uniquePaths([
|
||||
process.env.OPENCLAW_PACKAGE_DIR,
|
||||
path.join(home, ".local", "lib", "node_modules", "openclaw"),
|
||||
npmPrefix ? path.join(npmPrefix, "node_modules", "openclaw") : undefined,
|
||||
path.join(home, ".nvm", "versions", "node", version, "lib", "node_modules", "openclaw"),
|
||||
path.join(home, ".fnm", "node-versions", version, "installation", "lib", "node_modules", "openclaw"),
|
||||
@@ -31,4 +32,4 @@ export function getOpenclawPackageCandidates(version = process.version): string[
|
||||
"/usr/local/lib/node_modules/openclaw",
|
||||
"/usr/lib/node_modules/openclaw",
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { getOpenclawPackageCandidates, OPENCLAW_CONFIG_PATH, OPENCLAW_HOME } from "@/lib/openclaw-paths";
|
||||
|
||||
export interface SkillInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
emoji: string;
|
||||
source: string;
|
||||
location: string;
|
||||
usedBy: string[];
|
||||
}
|
||||
|
||||
export interface SkillAgentInfo {
|
||||
name: string;
|
||||
emoji: string;
|
||||
}
|
||||
|
||||
function findOpenClawPkg(): string {
|
||||
const candidates = getOpenclawPackageCandidates();
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(path.join(candidate, "package.json"))) return candidate;
|
||||
}
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
const OPENCLAW_PKG = findOpenClawPkg();
|
||||
|
||||
function parseFrontmatter(content: string): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
if (!content.startsWith("---")) return result;
|
||||
const parts = content.split("---", 3);
|
||||
if (parts.length < 3) return result;
|
||||
const fm = parts[1];
|
||||
|
||||
const nameMatch = fm.match(/^name:\s*(.+)/m);
|
||||
if (nameMatch) result.name = nameMatch[1].trim().replace(/^["']|["']$/g, "");
|
||||
|
||||
const descMatch = fm.match(/^description:\s*["']?(.+?)["']?\s*$/m);
|
||||
if (descMatch) result.description = descMatch[1].trim().replace(/^["']|["']$/g, "");
|
||||
|
||||
const emojiMatch = fm.match(/"emoji":\s*"([^"]+)"/);
|
||||
if (emojiMatch) result.emoji = emojiMatch[1];
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function readSkillFile(skillMd: string, source: string, id = path.basename(path.dirname(skillMd))): SkillInfo | null {
|
||||
if (!fs.existsSync(skillMd)) return null;
|
||||
const content = fs.readFileSync(skillMd, "utf-8");
|
||||
const fm = parseFrontmatter(content);
|
||||
return {
|
||||
id,
|
||||
name: fm.name || id,
|
||||
description: fm.description || "",
|
||||
emoji: fm.emoji || "🔧",
|
||||
source,
|
||||
location: skillMd,
|
||||
usedBy: [],
|
||||
};
|
||||
}
|
||||
|
||||
function scanSkillsDir(dir: string, source: string): SkillInfo[] {
|
||||
const skills: SkillInfo[] = [];
|
||||
if (!fs.existsSync(dir)) return skills;
|
||||
for (const name of fs.readdirSync(dir).sort()) {
|
||||
const skill = readSkillFile(path.join(dir, name, "SKILL.md"), source, name);
|
||||
if (skill) skills.push(skill);
|
||||
}
|
||||
return skills;
|
||||
}
|
||||
|
||||
function getAgentSkillsFromSessions(): Record<string, Set<string>> {
|
||||
const agentsDir = path.join(OPENCLAW_HOME, "agents");
|
||||
const result: Record<string, Set<string>> = {};
|
||||
if (!fs.existsSync(agentsDir)) return result;
|
||||
|
||||
for (const agentId of fs.readdirSync(agentsDir)) {
|
||||
const sessionsDir = path.join(agentsDir, agentId, "sessions");
|
||||
if (!fs.existsSync(sessionsDir)) continue;
|
||||
|
||||
const jsonlFiles = fs.readdirSync(sessionsDir)
|
||||
.filter((file) => file.endsWith(".jsonl"))
|
||||
.sort();
|
||||
const skillNames = new Set<string>();
|
||||
|
||||
for (const file of jsonlFiles.slice(-3)) {
|
||||
const content = fs.readFileSync(path.join(sessionsDir, file), "utf-8");
|
||||
const idx = content.indexOf("skillsSnapshot");
|
||||
if (idx < 0) continue;
|
||||
const chunk = content.slice(idx, idx + 5000);
|
||||
const matches = chunk.matchAll(/\\?"name\\?":\s*\\?"([^"\\]+)\\?"/g);
|
||||
for (const match of matches) {
|
||||
const name = match[1];
|
||||
if (!["exec", "read", "edit", "write", "process", "message", "web_search", "web_fetch",
|
||||
"browser", "tts", "gateway", "memory_search", "memory_get", "cron", "nodes",
|
||||
"canvas", "session_status", "sessions_list", "sessions_history", "sessions_send",
|
||||
"sessions_spawn", "agents_list"].includes(name) && name.length > 1) {
|
||||
skillNames.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (skillNames.size > 0) result[agentId] = skillNames;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function listOpenclawSkills(): { skills: SkillInfo[]; agents: Record<string, SkillAgentInfo>; total: number } {
|
||||
const builtinSkills = scanSkillsDir(path.join(OPENCLAW_PKG, "skills"), "builtin");
|
||||
|
||||
const extDir = path.join(OPENCLAW_PKG, "extensions");
|
||||
const extSkills: SkillInfo[] = [];
|
||||
if (fs.existsSync(extDir)) {
|
||||
for (const ext of fs.readdirSync(extDir)) {
|
||||
const extSkill = readSkillFile(path.join(extDir, ext, "SKILL.md"), `extension:${ext}`, ext);
|
||||
if (extSkill) extSkills.push(extSkill);
|
||||
|
||||
const skillsDir = path.join(extDir, ext, "skills");
|
||||
if (fs.existsSync(skillsDir)) {
|
||||
extSkills.push(...scanSkillsDir(skillsDir, `extension:${ext}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const customSkills = scanSkillsDir(path.join(OPENCLAW_HOME, "skills"), "custom");
|
||||
const allSkills = [...builtinSkills, ...extSkills, ...customSkills];
|
||||
|
||||
const agentSkills = getAgentSkillsFromSessions();
|
||||
for (const skill of allSkills) {
|
||||
for (const [agentId, skills] of Object.entries(agentSkills)) {
|
||||
if (skills.has(skill.id) || skills.has(skill.name)) {
|
||||
skill.usedBy.push(agentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(OPENCLAW_CONFIG_PATH, "utf-8"));
|
||||
const agentList = config.agents?.list || [];
|
||||
const agents: Record<string, SkillAgentInfo> = {};
|
||||
for (const agent of agentList) {
|
||||
agents[agent.id] = {
|
||||
name: agent.identity?.name || agent.name || agent.id,
|
||||
emoji: agent.identity?.emoji || "🤖",
|
||||
};
|
||||
}
|
||||
|
||||
return { skills: allSkills, agents, total: allSkills.length };
|
||||
}
|
||||
|
||||
export function getOpenclawSkillContent(source: string, id: string): { skill: SkillInfo; content: string } | null {
|
||||
const { skills } = listOpenclawSkills();
|
||||
const skill = skills.find((entry) => entry.source === source && entry.id === id);
|
||||
if (!skill) return null;
|
||||
return {
|
||||
skill,
|
||||
content: fs.readFileSync(skill.location, "utf-8"),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user