opencode-patches/skill_handler.ts

131 lines
4.7 KiB
TypeScript
Raw Normal View History

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<Array<{ name: string; description?: string; location: string; content: string }>> {
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<void> {
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)
}),
)