diff --git a/skill_handler.ts b/skill_handler.ts new file mode 100644 index 0000000..1616535 --- /dev/null +++ b/skill_handler.ts @@ -0,0 +1,130 @@ +import path from "path" +import { readdir, readFile, mkdir, writeFile, rm } from "fs/promises" +import { Effect } from "effect" +import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../api" +import { Config } from "@/config/config" + +const FORGEJO_BASE = "https://forge.wilddragon.net" + +function resolveUrl(input: string): string { + return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(input) + ? `${FORGEJO_BASE}/${input}/raw/branch/main/SKILL.md` + : input +} + +function parseField(content: string, field: string): string | undefined { + const m = content.match(new RegExp(`^---[\\s\\S]*?^${field}:\\s*(.+?)\\s*$`, "m")) + return m?.[1] +} + +async function listSkills( + dirs: string[], +): Promise> { + const skills: Array<{ name: string; description?: string; location: string; content: string }> = [] + for (const configDir of dirs) { + const skillsDir = path.join(configDir, "skills") + let entries: string[] + try { entries = await readdir(skillsDir) } catch { continue } + for (const entry of entries) { + const file = path.join(skillsDir, entry, "SKILL.md") + try { + const content = await readFile(file, "utf-8") + skills.push({ + name: parseField(content, "name") ?? entry, + description: parseField(content, "description"), + location: file, + content, + }) + } catch { /* skip unreadable */ } + } + } + return skills +} + +async function installSkill( + dirs: string[], + url: string, +): Promise<{ name: string; description?: string; location: string; content: string }> { + const res = await fetch(resolveUrl(url)) + if (!res.ok) throw new Error(`Fetch failed: ${res.status} ${res.statusText}`) + const content = await res.text() + const name = parseField(content, "name") + if (!name) throw new Error("SKILL.md is missing 'name' in frontmatter") + const dir = path.join(dirs[0], "skills", name) + const file = path.join(dir, "SKILL.md") + await mkdir(dir, { recursive: true }) + await writeFile(file, content, "utf-8") + return { name, description: parseField(content, "description"), location: file, content } +} + +async function uploadSkill( + dirs: string[], + inputName: string, + content: string, +): Promise<{ name: string; description?: string; location: string; content: string }> { + const name = parseField(content, "name") ?? inputName + const dir = path.join(dirs[0], "skills", name) + const file = path.join(dir, "SKILL.md") + await mkdir(dir, { recursive: true }) + await writeFile(file, content, "utf-8") + return { name, description: parseField(content, "description"), location: file, content } +} + +async function removeSkill(dirs: string[], skillName: string): Promise { + for (const configDir of dirs) { + const skillDir = path.join(configDir, "skills", skillName) + try { + await rm(skillDir, { recursive: true, force: true }) + return + } catch { /* try next */ } + } + throw new Error(`Skill '${skillName}' not found`) +} + +export const skillHandlers = HttpApiBuilder.group(InstanceHttpApi, "skill", (handlers) => + Effect.gen(function* () { + const config = yield* Config.Service + + const list = Effect.fn("SkillHttpApi.list")(function* () { + const dirs = yield* config.directories() + return yield* Effect.tryPromise({ + try: () => listSkills(dirs), + catch: (e) => new Error(String(e)), + }) + }) + + const install = Effect.fn("SkillHttpApi.install")(function* (ctx) { + const dirs = yield* config.directories() + if (!dirs.length) return yield* Effect.fail(HttpApiError.BadRequest.make("No config directories found")) + return yield* Effect.tryPromise({ + try: () => installSkill(dirs, ctx.payload.url), + catch: (e) => HttpApiError.BadRequest.make(String(e)), + }) + }) + + const upload = Effect.fn("SkillHttpApi.upload")(function* (ctx) { + const dirs = yield* config.directories() + if (!dirs.length) return yield* Effect.fail(HttpApiError.BadRequest.make("No config directories found")) + return yield* Effect.tryPromise({ + try: () => uploadSkill(dirs, ctx.payload.name, ctx.payload.content), + catch: (e) => HttpApiError.BadRequest.make(String(e)), + }) + }) + + const remove = Effect.fn("SkillHttpApi.remove")(function* (ctx) { + const dirs = yield* config.directories() + yield* Effect.tryPromise({ + try: () => removeSkill(dirs, ctx.pathParams.name), + catch: (e) => HttpApiError.NotFound.make(String(e)), + }) + return true as const + }) + + return handlers + .handle("list", list) + .handle("install", install) + .handle("upload", upload) + .handle("remove", remove) + }), +)