131 lines
4.7 KiB
TypeScript
131 lines
4.7 KiB
TypeScript
|
|
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)
|
||
|
|
}),
|
||
|
|
)
|