536 lines
19 KiB
TypeScript
536 lines
19 KiB
TypeScript
import { randomBytes } from "node:crypto";
|
|
import {
|
|
existsSync,
|
|
lstatSync,
|
|
mkdirSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
readlinkSync,
|
|
rmSync,
|
|
symlinkSync
|
|
} from "node:fs";
|
|
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
import {
|
|
boundSkillText,
|
|
firstManifestField,
|
|
firstMarkdownSummary,
|
|
parseFrontmatter
|
|
} from "./codex-stdio-session-helpers.ts";
|
|
import { createHwlabAgentRunAssemblySummary } from "../agent/agentrun-dispatch.mjs";
|
|
|
|
export const DEFAULT_PREINSTALLED_SKILLS_DIR = "/app/skills";
|
|
export const DEFAULT_USER_SKILLS_DIR = "/data/user-skills";
|
|
export const USER_SKILL_METADATA_FILE = ".hwlab-skill.json";
|
|
export const USER_SKILLS_INDEX_FILE = ".hwlab-user-skills.json";
|
|
|
|
const DEFAULT_CODE_AGENT_WORKSPACE = "/workspace/hwlab";
|
|
|
|
export function configureSkillRuntime(env = process.env) {
|
|
const config = skillRuntimeConfig(env);
|
|
env.HWLAB_PREINSTALLED_SKILLS_DIR ||= config.preinstalledDir;
|
|
env.HWLAB_USER_SKILLS_DIR ||= config.userSkillsDir;
|
|
|
|
const configured = splitSkillDirs(env.HWLAB_CODE_AGENT_SKILLS_DIRS || env.UNIDESK_SKILLS_PATH || "");
|
|
const strict = env.HWLAB_CODE_AGENT_SKILLS_STRICT === "1";
|
|
const nextDirs = strict && configured.length > 0
|
|
? configured
|
|
: [...configured, config.preinstalledDir, config.userSkillsDir];
|
|
env.HWLAB_CODE_AGENT_SKILLS_DIRS = uniquePaths(nextDirs).join(path.delimiter);
|
|
return config;
|
|
}
|
|
|
|
export function skillRuntimeConfig(env = process.env) {
|
|
const workspace = path.resolve(String(env.HWLAB_CODE_AGENT_CODEX_WORKSPACE || env.HWLAB_CODE_AGENT_WORKSPACE || DEFAULT_CODE_AGENT_WORKSPACE));
|
|
const preinstalledDir = path.resolve(String(env.HWLAB_PREINSTALLED_SKILLS_DIR || DEFAULT_PREINSTALLED_SKILLS_DIR));
|
|
const userSkillsDir = path.resolve(String(env.HWLAB_USER_SKILLS_DIR || DEFAULT_USER_SKILLS_DIR));
|
|
return {
|
|
workspace,
|
|
preinstalledDir,
|
|
userSkillsDir,
|
|
codexSkillsDir: path.join(workspace, ".agents", "skills")
|
|
};
|
|
}
|
|
|
|
export function splitSkillDirs(value) {
|
|
return String(value ?? "")
|
|
.split(/[,;]/u)
|
|
.flatMap((part) => part.split(path.delimiter))
|
|
.map((dir) => dir.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
export async function listSkills(options = {}) {
|
|
const env = options.env ?? process.env;
|
|
const config = skillRuntimeConfig(env);
|
|
await mkdir(config.userSkillsDir, { recursive: true });
|
|
const [preinstalled, uploaded] = await Promise.all([
|
|
listPreinstalledSkills(config.preinstalledDir),
|
|
listUploadedSkills(config.userSkillsDir)
|
|
]);
|
|
ensureCodexSkillsAggregationSync(config);
|
|
const skills = [...preinstalled, ...uploaded].sort(compareSkillItems);
|
|
return {
|
|
serviceId: "hwlab-cloud-api",
|
|
route: "/v1/skills",
|
|
status: "ok",
|
|
roots: {
|
|
preinstalled: config.preinstalledDir,
|
|
uploaded: config.userSkillsDir,
|
|
codexAggregation: config.codexSkillsDir
|
|
},
|
|
agentRunAssembly: createHwlabAgentRunAssemblySummary({ env }),
|
|
count: skills.length,
|
|
skills
|
|
};
|
|
}
|
|
|
|
export async function uploadUserSkill(payload, options = {}) {
|
|
const env = options.env ?? process.env;
|
|
const config = skillRuntimeConfig(env);
|
|
const prepared = prepareUploadedFiles(payload?.files);
|
|
if (prepared.length === 0) {
|
|
throw httpError(400, "invalid_skill_upload", "Skill upload body must include files.");
|
|
}
|
|
|
|
await mkdir(config.userSkillsDir, { recursive: true });
|
|
const id = await allocateUserSkillId(config.userSkillsDir);
|
|
const rootDir = path.join(config.userSkillsDir, id);
|
|
await mkdir(rootDir, { recursive: false });
|
|
|
|
let sizeBytes = 0;
|
|
try {
|
|
for (const file of prepared) {
|
|
const target = safeJoin(rootDir, file.relativePath);
|
|
if (!target) {
|
|
throw httpError(400, "invalid_skill_path", `Skill upload file path is outside the skill root: ${file.relativePath}`);
|
|
}
|
|
await mkdir(path.dirname(target), { recursive: true });
|
|
await writeFile(target, file.content, { flag: "wx" });
|
|
sizeBytes += file.content.length;
|
|
}
|
|
|
|
const now = new Date().toISOString();
|
|
const manifest = await readSkillManifestSummary(path.join(rootDir, "SKILL.md"));
|
|
const metadata = {
|
|
id,
|
|
source: "uploaded",
|
|
name: nonEmptyString(payload?.name) ?? manifest?.name ?? id,
|
|
description: manifest?.description ?? "Uploaded skill",
|
|
version: manifest?.version ?? null,
|
|
commitId: manifest?.commitId ?? null,
|
|
rootDir,
|
|
manifestPath: existsSync(path.join(rootDir, "SKILL.md")) ? path.join(rootDir, "SKILL.md") : null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
fileCount: prepared.length,
|
|
sizeBytes
|
|
};
|
|
await writeJson(path.join(rootDir, USER_SKILL_METADATA_FILE), metadata);
|
|
await writeUserSkillsIndex(config.userSkillsDir);
|
|
ensureCodexSkillsAggregationSync(config);
|
|
return {
|
|
serviceId: "hwlab-cloud-api",
|
|
route: "/v1/skills/uploads",
|
|
status: "ok",
|
|
accepted: true,
|
|
skill: metadata
|
|
};
|
|
} catch (error) {
|
|
await rmIfEmptyOrCreated(rootDir);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function getSkillTree(id, options = {}) {
|
|
const skill = await resolveSkillById(id, options);
|
|
if (!skill) throw httpError(404, "skill_not_found", `Skill not found: ${id}`);
|
|
const tree = await walkSkillTree(skill.rootDir);
|
|
return {
|
|
serviceId: "hwlab-cloud-api",
|
|
route: `/v1/skills/${encodeURIComponent(id)}/tree`,
|
|
status: "ok",
|
|
skill,
|
|
tree,
|
|
count: tree.length
|
|
};
|
|
}
|
|
|
|
export async function readSkillTextFile(id, relativePath = "SKILL.md", options = {}) {
|
|
const skill = await resolveSkillById(id, options);
|
|
if (!skill) throw httpError(404, "skill_not_found", `Skill not found: ${id}`);
|
|
const safeRelativePath = nonEmptyString(relativePath) ?? "SKILL.md";
|
|
const filePath = safeJoin(skill.rootDir, safeRelativePath);
|
|
if (!filePath) throw httpError(400, "invalid_skill_path", "Requested skill file path is outside the skill root.");
|
|
let content;
|
|
try {
|
|
content = await readFile(filePath);
|
|
} catch {
|
|
throw httpError(404, "skill_file_not_found", `Skill file not found: ${safeRelativePath}`);
|
|
}
|
|
if (looksBinary(content)) {
|
|
throw httpError(415, "skill_file_binary", "Binary skill files are not returned inline.");
|
|
}
|
|
return {
|
|
serviceId: "hwlab-cloud-api",
|
|
route: `/v1/skills/${encodeURIComponent(id)}/files`,
|
|
status: "ok",
|
|
skill,
|
|
file: {
|
|
path: normalizeRelativePath(safeRelativePath),
|
|
sizeBytes: content.length,
|
|
contentType: "text/plain; charset=utf-8",
|
|
content: content.toString("utf8")
|
|
}
|
|
};
|
|
}
|
|
|
|
export async function resolveSkillById(id, options = {}) {
|
|
const skillId = nonEmptyString(id);
|
|
if (!skillId) return null;
|
|
const payload = await listSkills(options);
|
|
return payload.skills.find((skill) => skill.id === skillId) ?? null;
|
|
}
|
|
|
|
export async function listPreinstalledSkills(rootDir) {
|
|
return listSkillRoots(rootDir, "preinstalled");
|
|
}
|
|
|
|
export async function listUploadedSkills(rootDir) {
|
|
await mkdir(rootDir, { recursive: true });
|
|
return listSkillRoots(rootDir, "uploaded");
|
|
}
|
|
|
|
export function ensureCodexSkillsAggregationSync(input = process.env) {
|
|
const config = input?.codexSkillsDir ? input : skillRuntimeConfig(input);
|
|
try {
|
|
mkdirSync(config.codexSkillsDir, { recursive: true });
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
for (const record of skillRootRecordsSync(config.preinstalledDir, "preinstalled")) {
|
|
const linkName = `preinstalled-${safeLinkSegment(record.name)}`;
|
|
upsertSkillSymlink(path.join(config.codexSkillsDir, linkName), record.rootDir);
|
|
}
|
|
for (const record of skillRootRecordsSync(config.userSkillsDir, "uploaded")) {
|
|
const linkName = `uploaded-${safeLinkSegment(record.id)}`;
|
|
upsertSkillSymlink(path.join(config.codexSkillsDir, linkName), record.rootDir);
|
|
}
|
|
}
|
|
|
|
async function listSkillRoots(rootDir, source) {
|
|
const records = [];
|
|
const root = path.resolve(rootDir);
|
|
if (!existsSync(root)) return records;
|
|
if (existsSync(path.join(root, "SKILL.md"))) {
|
|
records.push(await skillItemFromRoot({ rootDir: root, source, idSeed: path.basename(root) || source }));
|
|
}
|
|
|
|
let entries = [];
|
|
try {
|
|
entries = await readdir(root, { withFileTypes: true });
|
|
} catch {
|
|
return records;
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
const childRoot = path.join(root, entry.name);
|
|
if (source === "preinstalled" && !existsSync(path.join(childRoot, "SKILL.md"))) continue;
|
|
if (source === "uploaded" && !isUploadedSkillRoot(childRoot, entry.name)) continue;
|
|
records.push(await skillItemFromRoot({ rootDir: childRoot, source, idSeed: entry.name }));
|
|
}
|
|
return records.filter(Boolean);
|
|
}
|
|
|
|
async function skillItemFromRoot({ rootDir, source, idSeed }) {
|
|
const manifestPath = path.join(rootDir, "SKILL.md");
|
|
const manifest = await readSkillManifestSummary(manifestPath);
|
|
const metadata = source === "uploaded" ? await readJson(path.join(rootDir, USER_SKILL_METADATA_FILE)) : null;
|
|
const id = source === "uploaded"
|
|
? nonEmptyString(metadata?.id) ?? path.basename(rootDir)
|
|
: `preinstalled-${safeLinkSegment(idSeed)}`;
|
|
const stats = await skillRootStats(rootDir);
|
|
return {
|
|
id,
|
|
source,
|
|
name: nonEmptyString(metadata?.name) ?? manifest?.name ?? path.basename(rootDir),
|
|
description: nonEmptyString(metadata?.description) ?? manifest?.description ?? "No description provided.",
|
|
version: nonEmptyString(metadata?.version) ?? manifest?.version ?? null,
|
|
commitId: nonEmptyString(metadata?.commitId) ?? manifest?.commitId ?? null,
|
|
rootDir,
|
|
manifestPath: existsSync(manifestPath) ? manifestPath : null,
|
|
createdAt: nonEmptyString(metadata?.createdAt) ?? null,
|
|
updatedAt: nonEmptyString(metadata?.updatedAt) ?? null,
|
|
fileCount: Number.isInteger(metadata?.fileCount) ? metadata.fileCount : stats.fileCount,
|
|
sizeBytes: Number.isFinite(Number(metadata?.sizeBytes)) ? Number(metadata.sizeBytes) : stats.sizeBytes
|
|
};
|
|
}
|
|
|
|
async function readSkillManifestSummary(manifestPath) {
|
|
try {
|
|
const text = await readFile(manifestPath, "utf8");
|
|
const frontmatter = parseFrontmatter(text);
|
|
const body = text.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/u, "");
|
|
const name = nonEmptyString(frontmatter.name) ?? path.basename(path.dirname(manifestPath));
|
|
return {
|
|
name,
|
|
description: boundSkillText(frontmatter.description ?? firstMarkdownSummary(body) ?? "No description provided."),
|
|
version: firstManifestField(frontmatter, ["version", "skillVersion"]),
|
|
commitId: firstManifestField(frontmatter, ["commitId", "commit", "gitCommit", "revision"])
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function prepareUploadedFiles(files) {
|
|
const rawFiles = Array.isArray(files) ? files : [];
|
|
const prepared = [];
|
|
for (const file of rawFiles) {
|
|
const relativePath = normalizeRelativePath(file?.relativePath ?? file?.path ?? file?.name);
|
|
if (!relativePath) continue;
|
|
const content = decodeUploadContent(file);
|
|
if (!content) continue;
|
|
prepared.push({ relativePath, content });
|
|
}
|
|
return stripSingleTopLevelSkillFolder(prepared);
|
|
}
|
|
|
|
function stripSingleTopLevelSkillFolder(files) {
|
|
if (files.some((file) => file.relativePath === "SKILL.md")) return files;
|
|
const firstParts = files.map((file) => file.relativePath.split("/")).filter((parts) => parts.length > 1);
|
|
if (firstParts.length !== files.length) return files;
|
|
const [top] = firstParts[0] ?? [];
|
|
if (!top || firstParts.some((parts) => parts[0] !== top)) return files;
|
|
if (!files.some((file) => file.relativePath === `${top}/SKILL.md`)) return files;
|
|
return files.map((file) => ({ ...file, relativePath: file.relativePath.slice(top.length + 1) }));
|
|
}
|
|
|
|
function decodeUploadContent(file) {
|
|
if (typeof file?.contentBase64 === "string") return Buffer.from(file.contentBase64, "base64");
|
|
if (typeof file?.contentText === "string") return Buffer.from(file.contentText, "utf8");
|
|
if (typeof file?.content === "string") return Buffer.from(file.content, "utf8");
|
|
return null;
|
|
}
|
|
|
|
async function allocateUserSkillId(rootDir) {
|
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
const id = `skill-${Date.now().toString(36)}-${randomBytes(4).toString("hex")}`;
|
|
if (!existsSync(path.join(rootDir, id))) return id;
|
|
}
|
|
throw httpError(500, "skill_id_allocation_failed", "Could not allocate uploaded skill id.");
|
|
}
|
|
|
|
async function writeUserSkillsIndex(rootDir) {
|
|
const skills = await listSkillRoots(rootDir, "uploaded");
|
|
await writeJson(path.join(rootDir, USER_SKILLS_INDEX_FILE), {
|
|
version: 1,
|
|
updatedAt: new Date().toISOString(),
|
|
skills: skills.map((skill) => ({
|
|
id: skill.id,
|
|
name: skill.name,
|
|
source: skill.source,
|
|
rootDir: skill.rootDir,
|
|
manifestPath: skill.manifestPath,
|
|
createdAt: skill.createdAt,
|
|
updatedAt: skill.updatedAt,
|
|
fileCount: skill.fileCount,
|
|
sizeBytes: skill.sizeBytes
|
|
}))
|
|
});
|
|
}
|
|
|
|
async function walkSkillTree(rootDir, options = {}) {
|
|
const limit = Number.isInteger(options.limit) ? options.limit : 500;
|
|
const result = [];
|
|
async function walk(current, relativeRoot, depth) {
|
|
if (result.length >= limit || depth > 8) return;
|
|
let entries = [];
|
|
try {
|
|
entries = await readdir(current, { withFileTypes: true });
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name, "en"))) {
|
|
if (entry.name === USER_SKILL_METADATA_FILE || entry.name === USER_SKILLS_INDEX_FILE) continue;
|
|
const relativePath = relativeRoot ? `${relativeRoot}/${entry.name}` : entry.name;
|
|
const fullPath = path.join(current, entry.name);
|
|
if (entry.isDirectory()) {
|
|
result.push({ path: relativePath, type: "directory" });
|
|
await walk(fullPath, relativePath, depth + 1);
|
|
} else if (entry.isFile()) {
|
|
const buffer = await readFile(fullPath).catch(() => null);
|
|
result.push({ path: relativePath, type: "file", sizeBytes: buffer?.length ?? null });
|
|
}
|
|
if (result.length >= limit) return;
|
|
}
|
|
}
|
|
await walk(rootDir, "", 0);
|
|
return result;
|
|
}
|
|
|
|
async function skillRootStats(rootDir) {
|
|
const tree = await walkSkillTree(rootDir, { limit: 500 });
|
|
return {
|
|
fileCount: tree.filter((entry) => entry.type === "file").length,
|
|
sizeBytes: tree.reduce((total, entry) => total + (Number(entry.sizeBytes) || 0), 0)
|
|
};
|
|
}
|
|
|
|
function skillRootRecordsSync(rootDir, source) {
|
|
const root = path.resolve(rootDir);
|
|
if (!existsSync(root)) return [];
|
|
const records = [];
|
|
if (existsSync(path.join(root, "SKILL.md"))) {
|
|
records.push(skillRootRecordSync(root, source, path.basename(root) || source));
|
|
}
|
|
let entries = [];
|
|
try {
|
|
entries = readdirSync(root, { withFileTypes: true });
|
|
} catch {
|
|
return records.filter(Boolean);
|
|
}
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
const childRoot = path.join(root, entry.name);
|
|
if (source === "preinstalled" && !existsSync(path.join(childRoot, "SKILL.md"))) continue;
|
|
if (source === "uploaded" && !isUploadedSkillRoot(childRoot, entry.name)) continue;
|
|
records.push(skillRootRecordSync(childRoot, source, entry.name));
|
|
}
|
|
return records.filter(Boolean);
|
|
}
|
|
|
|
function skillRootRecordSync(rootDir, source, idSeed) {
|
|
const metadata = source === "uploaded" ? readJsonSync(path.join(rootDir, USER_SKILL_METADATA_FILE)) : null;
|
|
const manifest = readSkillManifestSummarySync(path.join(rootDir, "SKILL.md"));
|
|
return {
|
|
id: source === "uploaded" ? nonEmptyString(metadata?.id) ?? path.basename(rootDir) : `preinstalled-${safeLinkSegment(idSeed)}`,
|
|
name: nonEmptyString(metadata?.name) ?? manifest?.name ?? path.basename(rootDir),
|
|
rootDir
|
|
};
|
|
}
|
|
|
|
function readSkillManifestSummarySync(manifestPath) {
|
|
try {
|
|
const text = readFileSync(manifestPath, "utf8");
|
|
const frontmatter = parseFrontmatter(text);
|
|
return {
|
|
name: nonEmptyString(frontmatter.name) ?? path.basename(path.dirname(manifestPath))
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isUploadedSkillRoot(rootDir, name) {
|
|
if (name === "lost+found") return false;
|
|
return name.startsWith("skill-") ||
|
|
existsSync(path.join(rootDir, USER_SKILL_METADATA_FILE)) ||
|
|
existsSync(path.join(rootDir, "SKILL.md"));
|
|
}
|
|
|
|
function upsertSkillSymlink(linkPath, target) {
|
|
try {
|
|
const current = lstatSync(linkPath);
|
|
if (!current.isSymbolicLink()) return;
|
|
const linked = path.resolve(path.dirname(linkPath), readlinkSync(linkPath));
|
|
if (linked === path.resolve(target)) return;
|
|
rmSync(linkPath, { force: true });
|
|
} catch {
|
|
// Missing links are created below.
|
|
}
|
|
try {
|
|
symlinkSync(target, linkPath, "dir");
|
|
} catch {
|
|
// Runtime health and API list expose source roots; symlink aggregation is best-effort.
|
|
}
|
|
}
|
|
|
|
function normalizeRelativePath(value) {
|
|
const text = String(value ?? "").replace(/\\/gu, "/").replace(/^\/+/, "").trim();
|
|
const parts = text.split("/").filter(Boolean);
|
|
if (parts.length === 0 || parts.some((part) => part === "." || part === "..")) return null;
|
|
return parts.join("/");
|
|
}
|
|
|
|
function safeJoin(rootDir, relativePath) {
|
|
const normalized = normalizeRelativePath(relativePath);
|
|
if (!normalized) return null;
|
|
const root = path.resolve(rootDir);
|
|
const target = path.resolve(root, normalized);
|
|
return target === root || target.startsWith(`${root}${path.sep}`) ? target : null;
|
|
}
|
|
|
|
function looksBinary(buffer) {
|
|
if (!Buffer.isBuffer(buffer)) return false;
|
|
return buffer.subarray(0, Math.min(buffer.length, 8000)).includes(0);
|
|
}
|
|
|
|
async function readJson(filePath) {
|
|
try {
|
|
return JSON.parse(await readFile(filePath, "utf8"));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function readJsonSync(filePath) {
|
|
try {
|
|
return JSON.parse(readFileSync(filePath, "utf8"));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function writeJson(filePath, value) {
|
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
}
|
|
|
|
async function rmIfEmptyOrCreated(rootDir) {
|
|
await rm(rootDir, { recursive: true, force: true }).catch(() => {});
|
|
}
|
|
|
|
function nonEmptyString(value) {
|
|
const text = typeof value === "string" ? value.trim() : "";
|
|
return text || null;
|
|
}
|
|
|
|
function uniquePaths(values) {
|
|
const seen = new Set();
|
|
const result = [];
|
|
for (const value of values) {
|
|
const text = String(value ?? "").trim();
|
|
if (!text) continue;
|
|
const resolved = path.resolve(text);
|
|
if (seen.has(resolved)) continue;
|
|
seen.add(resolved);
|
|
result.push(resolved);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function compareSkillItems(a, b) {
|
|
return String(a.name).localeCompare(String(b.name), "en") ||
|
|
String(a.source).localeCompare(String(b.source), "en") ||
|
|
String(a.id).localeCompare(String(b.id), "en");
|
|
}
|
|
|
|
function safeLinkSegment(value) {
|
|
return String(value ?? "skill")
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9._-]+/giu, "-")
|
|
.replace(/^-+|-+$/gu, "")
|
|
.slice(0, 80) || "skill";
|
|
}
|
|
|
|
function httpError(statusCode, code, message) {
|
|
const error = new Error(message);
|
|
Object.assign(error, { statusCode, code });
|
|
return error;
|
|
}
|