Files
makelore/electron/opencode/skill-registry.ts
inman f4113a872f
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
收口 Makelore 客户端变更
2026-08-12 22:43:09 +08:00

112 lines
3.5 KiB
TypeScript

import { readdir, readFile } from 'node:fs/promises';
import { basename, dirname, join, relative, sep } from 'node:path';
import type { OpencodeSkillEntry, OpencodeSkillInfo } from './client';
const SKILL_ROOT_NAMES = ['skill', 'skills'] as const;
async function collectSkillFiles(root: string): Promise<string[]> {
let entries;
try {
entries = await readdir(root, { withFileTypes: true });
} catch (error) {
if (typeof error === 'object' && error && 'code' in error && error.code === 'ENOENT') {
return [];
}
throw error;
}
const files: string[] = [];
for (const entry of entries) {
const entryPath = join(root, entry.name);
if (entry.isFile() && entry.name === 'SKILL.md') {
files.push(entryPath);
continue;
}
if (entry.isDirectory()) {
files.push(...await collectSkillFiles(entryPath));
}
}
return files;
}
async function collectSkillEntries(root: string, current = root): Promise<OpencodeSkillEntry[]> {
let entries;
try {
entries = await readdir(current, { withFileTypes: true });
} catch (error) {
if (typeof error === 'object' && error && 'code' in error && error.code === 'ENOENT') {
return [];
}
throw error;
}
entries.sort((left, right) => {
const directoryOrder = Number(right.isDirectory()) - Number(left.isDirectory());
return directoryOrder || left.name.localeCompare(right.name);
});
const result: OpencodeSkillEntry[] = [];
for (const entry of entries) {
const entryPath = join(current, entry.name);
const relativePath = relative(root, entryPath).split(sep).join('/');
if (entry.isDirectory()) {
result.push({ path: relativePath, type: 'directory' });
result.push(...await collectSkillEntries(root, entryPath));
} else if (entry.isFile()) {
result.push({ path: relativePath, type: 'file' });
}
}
return result;
}
function unquoteYamlScalar(value: string): string {
const trimmed = value.trim();
if (
(trimmed.startsWith('"') && trimmed.endsWith('"'))
|| (trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
return trimmed.slice(1, -1).trim();
}
return trimmed;
}
function readFrontmatterScalar(content: string, key: string): string | undefined {
const match = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
if (!match) return undefined;
const keyPattern = new RegExp(`^${key}:\\s*(.*)$`);
for (const line of match[1].split(/\r?\n/)) {
const scalar = line.match(keyPattern);
if (!scalar) continue;
const value = unquoteYamlScalar(scalar[1] ?? '');
return value || undefined;
}
return undefined;
}
export async function listInstalledOpencodeSkills(managedConfigDir?: string | null): Promise<OpencodeSkillInfo[]> {
const configDir = managedConfigDir?.trim();
if (!configDir) return [];
const skillFiles = (
await Promise.all(SKILL_ROOT_NAMES.map((rootName) => collectSkillFiles(join(configDir, rootName))))
).flat().sort((left, right) => left.localeCompare(right));
const seen = new Set<string>();
const skills: OpencodeSkillInfo[] = [];
for (const location of skillFiles) {
const content = await readFile(location, 'utf8');
const name = readFrontmatterScalar(content, 'name') ?? basename(dirname(location));
if (!name || seen.has(name)) continue;
seen.add(name);
skills.push({
name,
description: readFrontmatterScalar(content, 'description'),
location,
content,
entries: await collectSkillEntries(dirname(location)),
});
}
return skills;
}