621 lines
26 KiB
Python
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 & 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()
|