opencode-patches/patch_skills_ui.py

621 lines
26 KiB
Python

#!/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 + <Route path="/skills"> 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<string> {{
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<string> {{
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<any>
}
export default function SkillsPage() {
const params = useParams()
const dir = () => params.dir ?? ""
const [skills, setSkills] = createSignal<Skill[]>([])
const [loading, setLoading] = createSignal(true)
const [loadError, setLoadError] = createSignal<string>()
const [expanded, setExpanded] = createSignal<string | null>(null)
const [installUrl, setInstallUrl] = createSignal("")
const [installing, setInstalling] = createSignal(false)
const [installError, setInstallError] = createSignal<string>()
const [installSuccess, setInstallSuccess] = createSignal<string>()
const [uploadError, setUploadError] = createSignal<string>()
const [uploadSuccess, setUploadSuccess] = createSignal<string>()
const [confirmRemove, setConfirmRemove] = createSignal<string | null>(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 (
<div class="flex flex-col gap-6 p-6 max-w-3xl mx-auto">
{/* Header */}
<div class="flex items-start justify-between gap-4">
<div>
<h1 class="text-18-medium text-text-strong">Skills</h1>
<p class="text-14-regular text-text-weak mt-1">
Manage SKILL.md files available to the agent. Skills installed here are shared
across all clients connected to this server.
</p>
</div>
<A
href={dir() ? `/${dir()}/session` : "/"}
class="shrink-0 text-14-regular text-text-weak hover:text-text-base transition-colors"
>
← Back
</A>
</div>
{/* Install from repo */}
<section class="flex flex-col gap-3 bg-surface-base rounded-lg p-4">
<h2 class="text-14-medium text-text-strong">Install from Repo</h2>
<p class="text-13-regular text-text-weak">
Enter <code class="font-mono bg-surface-raised-base px-1 rounded">owner/repo</code> (Forgejo) or a full
raw URL pointing directly at a SKILL.md file.
</p>
<div class="flex gap-2">
<input
class="flex-1 bg-surface-raised-base border border-border-base rounded-md px-3 py-2 text-14-regular text-text-base placeholder-text-weakest focus:outline-none focus:border-border-strong"
placeholder="zgaetano/my-skill or https://…/SKILL.md"
value={installUrl()}
onInput={(e) => setInstallUrl(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && void handleInstall()}
/>
<button
class="px-4 py-2 bg-accent-base text-text-on-accent rounded-md text-14-medium disabled:opacity-40 transition-opacity"
disabled={installing() || !installUrl()}
onClick={() => void handleInstall()}
>
{installing() ? "Installing…" : "Install"}
</button>
</div>
<Show when={installError()}>
<p class="text-13-regular text-red-500">{installError()}</p>
</Show>
<Show when={installSuccess()}>
<p class="text-13-regular text-green-600">{installSuccess()}</p>
</Show>
</section>
{/* Upload */}
<section class="flex flex-col gap-3 bg-surface-base rounded-lg p-4">
<h2 class="text-14-medium text-text-strong">Upload SKILL.md</h2>
<label
class="flex flex-col items-center justify-center gap-2 border-2 border-dashed border-border-base rounded-lg p-6 cursor-pointer hover:border-border-strong transition-colors"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault()
const file = e.dataTransfer?.files[0]
if (file) void handleUpload(file)
}}
>
<span class="text-14-regular text-text-weak">
Drag &amp; drop a SKILL.md here, or click to browse
</span>
<input
type="file"
accept=".md,text/markdown"
class="hidden"
onChange={(e) => {
const file = e.currentTarget.files?.[0]
if (file) void handleUpload(file)
}}
/>
</label>
<Show when={uploadError()}>
<p class="text-13-regular text-red-500">{uploadError()}</p>
</Show>
<Show when={uploadSuccess()}>
<p class="text-13-regular text-green-600">{uploadSuccess()}</p>
</Show>
</section>
{/* Installed skills list */}
<section class="flex flex-col gap-3">
<h2 class="text-14-medium text-text-strong">Installed Skills</h2>
<Show when={loading()}>
<p class="text-14-regular text-text-weak">Loading…</p>
</Show>
<Show when={loadError()}>
<p class="text-14-regular text-red-500">{loadError()}</p>
</Show>
<Show when={!loading() && skills().length === 0 && !loadError()}>
<p class="text-14-regular text-text-weak">No skills installed yet.</p>
</Show>
<For each={skills()}>
{(skill) => (
<div class="bg-surface-base rounded-lg border border-border-weaker-base overflow-hidden">
<div class="flex items-start justify-between gap-3 p-4">
<div class="flex-1 min-w-0">
<span class="text-14-medium text-text-strong font-mono">{skill.name}</span>
<Show when={skill.description}>
<p class="text-13-regular text-text-weak mt-0.5">{skill.description}</p>
</Show>
<p class="text-11-regular text-text-weakest mt-1 truncate">{skill.location}</p>
</div>
<div class="flex items-center gap-3 shrink-0">
<button
class="text-13-regular text-text-weak hover:text-text-base transition-colors"
onClick={() => setExpanded(expanded() === skill.name ? null : skill.name)}
>
{expanded() === skill.name ? "Hide" : "View"}
</button>
<button
class="text-13-regular text-red-500 hover:text-red-600 transition-colors"
onClick={() => setConfirmRemove(skill.name)}
>
Remove
</button>
</div>
</div>
<Show when={expanded() === skill.name}>
<pre class="text-12-regular text-text-base bg-surface-raised-base p-4 overflow-x-auto border-t border-border-weaker-base whitespace-pre-wrap break-words">
{skill.content}
</pre>
</Show>
</div>
)}
</For>
</section>
{/* Remove confirmation dialog */}
<Show when={confirmRemove()}>
{(name) => (
<div
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={() => setConfirmRemove(null)}
>
<div
class="bg-background-base rounded-lg p-6 max-w-sm w-full mx-4 flex flex-col gap-4"
onClick={(e) => e.stopPropagation()}
>
<h3 class="text-16-medium text-text-strong">Remove skill?</h3>
<p class="text-14-regular text-text-weak">
Remove{" "}
<code class="font-mono bg-surface-base px-1 rounded">{name()}</code>? This
deletes the SKILL.md from the server and cannot be undone.
</p>
<div class="flex gap-3 justify-end">
<button
class="px-4 py-2 text-14-regular text-text-base hover:text-text-strong transition-colors"
onClick={() => setConfirmRemove(null)}
>
Cancel
</button>
<button
class="px-4 py-2 bg-red-600 text-white rounded-md text-14-medium hover:bg-red-700 transition-colors"
onClick={() => void handleRemove(name())}
>
Remove
</button>
</div>
</div>
</div>
)}
</Show>
</div>
)
}
"""
# ═════════════════════════════════════════════════════════════════════════════
# 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,
'<Route path="/session/:id?" component={SessionRoute} />',
'<Route path="/session/:id?" component={SessionRoute} />\n <Route path="/skills" component={Skills} />',
)
print("\n── done ─────────────────────────────────────────────────────────────")
print("Skills UI patch applied successfully.")
print()
print("To navigate to the Skills page, visit:")
print(" http://<opencode-host>/<base64-dir>/skills")
print()
print("Or add a link from the home/sidebar by inserting:")
print(' <A href={`/${base64dir}/skills`}>Manage Skills</A>')
if __name__ == "__main__":
apply()