#!/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()