diff --git a/patch_skills_ui.py b/patch_skills_ui.py new file mode 100644 index 0000000..36b365a --- /dev/null +++ b/patch_skills_ui.py @@ -0,0 +1,621 @@ +#!/usr/bin/env python3 +""" +patch_skills_ui.py — Add Skills management page to opencode web GUI + +Adds: + - Backend: /skill GET / POST install / POST upload / DELETE endpoints + - Frontend: /:dir/skills page (list, install-from-repo, upload, remove) + - App routing: lazy import + under /:dir + +Usage: + python patch_skills_ui.py [path-to-opencode-repo] + +If no path is given, the script walks upward from cwd looking for packages/. +""" + +import os +import sys + +FORGEJO_BASE = "https://forge.wilddragon.net" + +# ─── helpers ───────────────────────────────────────────────────────────────── + +def read(path): + with open(path, "r", encoding="utf-8") as f: + return f.read() + +def write(path, content): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + f.write(content) + print(f" [+] {path}") + +def patch(path, old, new, *, required=True): + content = read(path) + if old not in content: + if required: + print(f" [!] marker not found in {path}:\n {repr(old[:80])}") + return + write(path, content.replace(old, new, 1)) + +# ─── locate repo root ───────────────────────────────────────────────────────── + +def find_root(argv): + if len(argv) > 1: + root = os.path.abspath(argv[1]) + if os.path.isdir(os.path.join(root, "packages")): + return root + sys.exit(f"No 'packages/' directory found under {root}") + + # walk upward from cwd + cwd = os.path.abspath(os.getcwd()) + parts = cwd.split(os.sep) + for i in range(len(parts), 0, -1): + candidate = os.sep.join(parts[:i]) + if os.path.isdir(os.path.join(candidate, "packages")): + return candidate + + sys.exit("Cannot find opencode repo root (no 'packages/' directory in path). " + "Run from inside the repo or pass the root as an argument.") + +ROOT = find_root(sys.argv) + +def p(*parts): + return os.path.join(ROOT, *parts) + +# ═════════════════════════════════════════════════════════════════════════════ +# BACKEND — API group +# ═════════════════════════════════════════════════════════════════════════════ + +SKILL_GROUP_TS = """\ +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../middleware/authorization" +import { InstanceContextMiddleware } from "../middleware/instance-context" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" +import { described } from "./metadata" + +const root = "/skill" + +export const SkillInfo = Schema.Struct({ + name: Schema.String, + description: Schema.optional(Schema.String), + location: Schema.String, + content: Schema.String, +}).annotations({ identifier: "SkillInfo" }) + +export const SkillApi = HttpApi.make("skill") + .add( + HttpApiGroup.make("skill") + .add( + HttpApiEndpoint.get("list", root, { + query: WorkspaceRoutingQuery, + success: described(Schema.Array(SkillInfo), "List of installed skills"), + }).annotateMerge( + OpenApi.annotations({ identifier: "skill.list", summary: "List all installed skills" }), + ), + HttpApiEndpoint.post("install", `${root}/install`, { + query: WorkspaceRoutingQuery, + payload: Schema.Struct({ url: Schema.String }), + success: described(SkillInfo, "Installed skill"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ identifier: "skill.install", summary: "Install skill from URL or owner/repo" }), + ), + HttpApiEndpoint.post("upload", `${root}/upload`, { + query: WorkspaceRoutingQuery, + payload: Schema.Struct({ name: Schema.String, content: Schema.String }), + success: described(SkillInfo, "Uploaded skill"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ identifier: "skill.upload", summary: "Upload a SKILL.md file directly" }), + ), + HttpApiEndpoint.del("remove", `${root}/:name`, { + query: WorkspaceRoutingQuery, + success: described(Schema.Literal(true), "Skill removed"), + error: HttpApiError.NotFound, + }).annotateMerge( + OpenApi.annotations({ identifier: "skill.remove", summary: "Remove an installed skill" }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "skill", + description: "Skills management: list, install, upload, and remove SKILL.md files.", + }), + ) + .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) + .middleware(Authorization), + ) + .annotateMerge( + OpenApi.annotations({ + title: "opencode experimental HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) +""" + +# ═════════════════════════════════════════════════════════════════════════════ +# BACKEND — handler +# ═════════════════════════════════════════════════════════════════════════════ + +SKILL_HANDLER_TS = f"""\ +import path from "path" +import {{ Effect }} from "effect" +import {{ HttpApiBuilder, HttpApiError }} from "effect/unstable/httpapi" +import {{ InstanceHttpApi }} from "../api" +import {{ Config }} from "@/config/config" + +const FORGEJO_BASE = "{FORGEJO_BASE}" + +// Resolve "owner/repo" shorthand to a raw Forgejo SKILL.md URL. +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 getSkillsDir(configDir: string): Promise {{ + const {{ mkdir }} = await import("fs/promises") + const dir = path.join(configDir, "skills") + await mkdir(dir, {{ recursive: true }}) + return dir +}} + +async function writeSkill(skillsDir: string, name: string, content: string): Promise {{ + const {{ mkdir, writeFile }} = await import("fs/promises") + const dir = path.join(skillsDir, name) + const file = path.join(dir, "SKILL.md") + await mkdir(dir, {{ recursive: true }}) + await writeFile(file, content, "utf-8") + return file +}} + +export const skillHandlers = HttpApiBuilder.group(InstanceHttpApi, "skill", (handlers) => + Effect.gen(function* () {{ + const config = yield* Config.Service + + const firstConfigDir = Effect.fn("SkillHttpApi.firstConfigDir")(function* () {{ + const dirs = yield* config.directories() + if (!dirs.length) return yield* Effect.fail(HttpApiError.BadRequest.make("No opencode config directories found")) + return dirs[0] + }}) + + const list = Effect.fn("SkillHttpApi.list")(function* () {{ + const dirs = yield* config.directories() + const {{ readdir, readFile }} = await import("fs/promises") + 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 + }}) + + const install = Effect.fn("SkillHttpApi.install")(function* (ctx) {{ + const configDir = yield* firstConfigDir + const skillsDir = yield* Effect.tryPromise({{ try: () => getSkillsDir(configDir), catch: String }}) + const content = yield* Effect.tryPromise({{ + try: async () => {{ + const res = await fetch(resolveUrl(ctx.payload.url)) + if (!res.ok) throw new Error(`${{res.status}} ${{res.statusText}}`) + return res.text() + }}, + catch: (e) => HttpApiError.BadRequest.make(`Fetch failed: ${{e}}`), + }}) + const name = parseField(content, "name") + if (!name) return yield* Effect.fail(HttpApiError.BadRequest.make("SKILL.md is missing 'name' in frontmatter")) + const location = yield* Effect.tryPromise({{ try: () => writeSkill(skillsDir, name, content), catch: String }}) + return {{ name, description: parseField(content, "description"), location, content }} + }}) + + const upload = Effect.fn("SkillHttpApi.upload")(function* (ctx) {{ + const name = parseField(ctx.payload.content, "name") ?? ctx.payload.name + if (!name) return yield* Effect.fail(HttpApiError.BadRequest.make("SKILL.md is missing 'name' in frontmatter")) + const configDir = yield* firstConfigDir + const skillsDir = yield* Effect.tryPromise({{ try: () => getSkillsDir(configDir), catch: String }}) + const location = yield* Effect.tryPromise({{ + try: () => writeSkill(skillsDir, name, ctx.payload.content), + catch: String, + }}) + return {{ name, description: parseField(ctx.payload.content, "description"), location, content: ctx.payload.content }} + }}) + + const remove = Effect.fn("SkillHttpApi.remove")(function* (ctx) {{ + const dirs = yield* config.directories() + const {{ rm }} = await import("fs/promises") + for (const configDir of dirs) {{ + const skillDir = path.join(configDir, "skills", ctx.pathParams.name) + try {{ + await rm(skillDir, {{ recursive: true, force: true }}) + return true as const + }} catch {{ /* try next config dir */ }} + }} + return yield* Effect.fail(HttpApiError.NotFound.make(`Skill '${{ctx.pathParams.name}}' not found`)) + }}) + + return handlers + .handle("list", list) + .handle("install", install) + .handle("upload", upload) + .handle("remove", remove) + }}), +) +""" + +# ═════════════════════════════════════════════════════════════════════════════ +# FRONTEND — skills page +# ═════════════════════════════════════════════════════════════════════════════ + +SKILLS_PAGE_TSX = """\ +import { createSignal, For, Show, onMount } from "solid-js" +import { A, useParams } from "@solidjs/router" + +type Skill = { + name: string + description?: string + location: string + content: string +} + +async function apiFetch(path: string, init?: RequestInit) { + const res = await fetch(path, init) + if (!res.ok) throw new Error(await res.text()) + return res.json() as Promise +} + +export default function SkillsPage() { + const params = useParams() + const dir = () => params.dir ?? "" + + const [skills, setSkills] = createSignal([]) + const [loading, setLoading] = createSignal(true) + const [loadError, setLoadError] = createSignal() + const [expanded, setExpanded] = createSignal(null) + + const [installUrl, setInstallUrl] = createSignal("") + const [installing, setInstalling] = createSignal(false) + const [installError, setInstallError] = createSignal() + const [installSuccess, setInstallSuccess] = createSignal() + + const [uploadError, setUploadError] = createSignal() + const [uploadSuccess, setUploadSuccess] = createSignal() + + const [confirmRemove, setConfirmRemove] = createSignal(null) + + const load = async () => { + setLoading(true) + setLoadError(undefined) + try { + const data: Skill[] = await apiFetch(`/skill?directory=${encodeURIComponent(dir())}`) + setSkills(data) + } catch (e: any) { + setLoadError(String(e.message ?? e)) + } finally { + setLoading(false) + } + } + + onMount(load) + + const handleInstall = async () => { + if (!installUrl() || installing()) return + setInstalling(true) + setInstallError(undefined) + setInstallSuccess(undefined) + try { + const skill: Skill = await apiFetch( + `/skill/install?directory=${encodeURIComponent(dir())}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: installUrl() }), + }, + ) + setInstallSuccess(`Installed: ${skill.name}`) + setInstallUrl("") + await load() + } catch (e: any) { + setInstallError(String(e.message ?? e)) + } finally { + setInstalling(false) + } + } + + const handleUpload = async (file: File) => { + setUploadError(undefined) + setUploadSuccess(undefined) + try { + const content = await file.text() + const skill: Skill = await apiFetch( + `/skill/upload?directory=${encodeURIComponent(dir())}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: file.name.replace(/\\.md$/i, ""), content }), + }, + ) + setUploadSuccess(`Uploaded: ${skill.name}`) + await load() + } catch (e: any) { + setUploadError(String(e.message ?? e)) + } + } + + const handleRemove = async (name: string) => { + try { + await apiFetch( + `/skill/${encodeURIComponent(name)}?directory=${encodeURIComponent(dir())}`, + { method: "DELETE" }, + ) + setConfirmRemove(null) + await load() + } catch (e: any) { + setLoadError(String(e.message ?? e)) + } + } + + return ( +
+ {/* Header */} +
+
+

Skills

+

+ Manage SKILL.md files available to the agent. Skills installed here are shared + across all clients connected to this server. +

+
+ + ← Back + +
+ + {/* Install from repo */} +
+

Install from Repo

+

+ Enter owner/repo (Forgejo) or a full + raw URL pointing directly at a SKILL.md file. +

+
+ setInstallUrl(e.currentTarget.value)} + onKeyDown={(e) => e.key === "Enter" && void handleInstall()} + /> + +
+ +

{installError()}

+
+ +

{installSuccess()}

+
+
+ + {/* Upload */} +
+

Upload SKILL.md

+ + +

{uploadError()}

+
+ +

{uploadSuccess()}

+
+
+ + {/* Installed skills list */} +
+

Installed Skills

+ +

Loading…

+
+ +

{loadError()}

+
+ +

No skills installed yet.

+
+ + {(skill) => ( +
+
+
+ {skill.name} + +

{skill.description}

+
+

{skill.location}

+
+
+ + +
+
+ +
+                  {skill.content}
+                
+
+
+ )} +
+
+ + {/* Remove confirmation dialog */} + + {(name) => ( +
setConfirmRemove(null)} + > +
e.stopPropagation()} + > +

Remove skill?

+

+ Remove{" "} + {name()}? This + deletes the SKILL.md from the server and cannot be undone. +

+
+ + +
+
+
+ )} +
+
+ ) +} +""" + +# ═════════════════════════════════════════════════════════════════════════════ +# APPLY +# ═════════════════════════════════════════════════════════════════════════════ + +def apply(): + print(f"\nRepo root: {ROOT}\n") + + # ── new files ───────────────────────────────────────────────────────────── + print("── creating new files ───────────────────────────────────────────────") + write( + p("packages/opencode/src/server/routes/instance/httpapi/groups/skill.ts"), + SKILL_GROUP_TS, + ) + write( + p("packages/opencode/src/server/routes/instance/httpapi/handlers/skill.ts"), + SKILL_HANDLER_TS, + ) + write(p("packages/app/src/pages/skills.tsx"), SKILLS_PAGE_TSX) + + # ── api.ts ──────────────────────────────────────────────────────────────── + print("\n── patching api.ts ──────────────────────────────────────────────────") + API_FILE = p("packages/opencode/src/server/routes/instance/httpapi/api.ts") + patch( + API_FILE, + 'import { WorkspaceApi } from "./groups/workspace"', + 'import { WorkspaceApi } from "./groups/workspace"\nimport { SkillApi } from "./groups/skill"', + ) + patch( + API_FILE, + ".addHttpApi(WorkspaceApi)", + ".addHttpApi(WorkspaceApi)\n .addHttpApi(SkillApi)", + ) + + # ── httpapi server.ts ───────────────────────────────────────────────────── + print("\n── patching httpapi server.ts ───────────────────────────────────────") + SERVER_FILE = p("packages/opencode/src/server/routes/instance/httpapi/server.ts") + patch( + SERVER_FILE, + 'import { workspaceHandlers } from "./handlers/workspace"', + 'import { workspaceHandlers } from "./handlers/workspace"\nimport { skillHandlers } from "./handlers/skill"', + ) + patch( + SERVER_FILE, + "workspaceHandlers,", + "workspaceHandlers,\n skillHandlers,", + ) + + # ── app.tsx ─────────────────────────────────────────────────────────────── + print("\n── patching app.tsx ─────────────────────────────────────────────────") + APP_FILE = p("packages/app/src/app.tsx") + patch( + APP_FILE, + 'const Session = lazy(() => import("@/pages/session"))', + 'const Session = lazy(() => import("@/pages/session"))\nconst Skills = lazy(() => import("@/pages/skills"))', + ) + patch( + APP_FILE, + '', + '\n ', + ) + + print("\n── done ─────────────────────────────────────────────────────────────") + print("Skills UI patch applied successfully.") + print() + print("To navigate to the Skills page, visit:") + print(" http:////skills") + print() + print("Or add a link from the home/sidebar by inserting:") + print(' Manage Skills') + + +if __name__ == "__main__": + apply()