feat: add skills management UI patch
This commit is contained in:
parent
2019ca75f3
commit
547f794e47
1 changed files with 621 additions and 0 deletions
621
patch_skills_ui.py
Normal file
621
patch_skills_ui.py
Normal file
|
|
@ -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 + <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()
|
||||
Loading…
Reference in a new issue