From ff85d28e4729f647c1994ca931fda9e0c812b8c3 Mon Sep 17 00:00:00 2001 From: EyJunge1 <149941075+EyJunge1@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:40:18 +0200 Subject: [PATCH 01/25] feat(skills): add shared editable skills with V2 bridge and GUI Persist team skills in skills.db, expose CRUD + HTTP catalog, register them with OpenCode via ctx.skill.transform, and edit in the web UI with CodeMirror markdown preview. Refs: #402 --- README.md | 22 +- src/services/api-handlers.ts | 145 +++++++++++ src/services/skills/skills-manager.ts | 238 ++++++++++++++++++ src/services/skills/types.ts | 37 +++ src/services/turso/schema-migrations.ts | 27 ++ src/services/web-server.ts | 82 ++++++ src/v2/adapter.ts | 72 ++++++ tests/skills-api-handlers.test.ts | 74 ++++++ tests/skills-manager.test.ts | 135 ++++++++++ tests/v2-skills-bridge.test.ts | 60 +++++ web/bun.lock | 58 +++++ web/package.json | 6 + web/src/App.svelte | 31 ++- .../lib/components/explorer/AppSidebar.svelte | 16 ++ .../components/explorer/SkillDialog.svelte | 118 +++++++++ .../lib/components/explorer/SkillsView.svelte | 105 ++++++++ .../lib/components/ui/markdown-editor.svelte | 94 +++++++ web/src/lib/i18n/translations.ts | 56 +++++ web/src/lib/routes.ts | 21 +- web/src/lib/stores/skills.svelte.ts | 166 ++++++++++++ web/vite.config.ts | 14 ++ 21 files changed, 1564 insertions(+), 13 deletions(-) create mode 100644 src/services/skills/skills-manager.ts create mode 100644 src/services/skills/types.ts create mode 100644 tests/skills-api-handlers.test.ts create mode 100644 tests/skills-manager.test.ts create mode 100644 tests/v2-skills-bridge.test.ts create mode 100644 web/src/lib/components/explorer/SkillDialog.svelte create mode 100644 web/src/lib/components/explorer/SkillsView.svelte create mode 100644 web/src/lib/components/ui/markdown-editor.svelte create mode 100644 web/src/lib/stores/skills.svelte.ts diff --git a/README.md b/README.md index d2b405f6..710cda5c 100644 --- a/README.md +++ b/README.md @@ -161,15 +161,23 @@ You do **not** need to ask OpenCode to “remember” things for the plugin to w Manual search/add/list still work even if auto-capture has no provider configured. Auto-capture and user profile learning need a provider that can return structured/tool-call output. -### Memory vs AGENTS.md / project docs +### Memory vs AGENTS.md vs Skills -| Store in **memory** | Store in **AGENTS.md** / static docs | -| -------------------------------------------------------------------- | ----------------------------------------------------------- | -| Project-specific decisions, bug patterns, “we tried X and it failed” | Stable rules and workflows that rarely change | -| User preferences discovered over sessions | Always-on coding conventions and process | -| Facts that should follow you across chats | Instructions every agent should see regardless of retrieval | +| Store in **memory** | Store in **AGENTS.md** / static docs | Store as **Skills** (web UI / `skills.db`) | +| -------------------------------------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------- | +| Project-specific decisions, bug patterns, “we tried X and it failed” | Stable rules and workflows that rarely change | Reusable procedures the agent loads on demand | +| User preferences discovered over sessions | Always-on coding conventions and process | Team-editable skill packs shared via the same storage | +| Facts that should follow you across chats | Instructions every agent should see regardless of retrieval | OpenCode-native `skill` tool (V2 plugin bridge) | -Rule of thumb: if it is a lasting project instruction, put it in AGENTS.md; if it is context that grows from real work, let memory (or auto-capture) hold it. +Rule of thumb: lasting always-on project instructions → `AGENTS.md`; growing work context → memory; reusable on-demand procedures → **Skills**. + +#### Shared Skills (V2) + +- Create/edit skills in the web explorer (**Skills** tab): Markdown editor with live preview. +- Persisted in `skills.db` under the plugin storage path (not as memory vectors). +- On OpenCode **V2**, the plugin registers enabled skills via `ctx.skill.transform` so the agent can load them with the native `skill` tool. +- Optional HTTP catalog for other OpenCode instances: add your web base URL to config, e.g. `"skills": ["http://127.0.0.1:4747/skills/"]` (serves `/skills/index.json` and `/skills//.md`). +- REST: `GET/POST /api/skills`, `GET/PUT/DELETE /api/skills/:id`. ### Intelligent prompt-based memory extraction diff --git a/src/services/api-handlers.ts b/src/services/api-handlers.ts index 050ab856..abf8cc55 100644 --- a/src/services/api-handlers.ts +++ b/src/services/api-handlers.ts @@ -1669,3 +1669,148 @@ export async function handleRunTagMigrationBatch( return { success: false, error: String(error) }; } } + +function skillToApi(skill: { + id: string; + name: string; + description: string; + content: string; + autoinvoke: boolean; + enabled: boolean; + containerTag: string | null; + createdAt: number; + updatedAt: number; +}) { + return { + id: skill.id, + name: skill.name, + description: skill.description, + content: skill.content, + autoinvoke: skill.autoinvoke, + enabled: skill.enabled, + containerTag: skill.containerTag, + createdAt: safeToISOString(skill.createdAt), + updatedAt: safeToISOString(skill.updatedAt), + }; +} + +export async function handleListSkills(options?: { + containerTag?: string; + enabledOnly?: boolean; +}): Promise[] }>> { + try { + const { skillsManager } = await import("./skills/skills-manager.js"); + const items = await skillsManager.list({ + containerTag: options?.containerTag, + enabledOnly: options?.enabledOnly, + }); + return { success: true, data: { items: items.map(skillToApi) } }; + } catch (error) { + log("handleListSkills: error", { error: String(error) }); + return { success: false, error: String(error) }; + } +} + +export async function handleGetSkill( + id: string +): Promise>> { + try { + const { skillsManager } = await import("./skills/skills-manager.js"); + const skill = await skillsManager.get(id); + if (!skill) return { success: false, error: `Skill not found: ${id}` }; + return { success: true, data: skillToApi(skill) }; + } catch (error) { + log("handleGetSkill: error", { error: String(error) }); + return { success: false, error: String(error) }; + } +} + +export async function handleCreateSkill(body: { + id?: string; + name?: string; + description?: string; + content?: string; + autoinvoke?: boolean; + enabled?: boolean; + containerTag?: string | null; +}): Promise>> { + try { + if (!body?.id || !body?.name || !body?.content) { + return { success: false, error: "id, name, and content are required" }; + } + const { skillsManager } = await import("./skills/skills-manager.js"); + const skill = await skillsManager.create({ + id: body.id, + name: body.name, + description: body.description, + content: body.content, + autoinvoke: body.autoinvoke, + enabled: body.enabled, + containerTag: body.containerTag, + }); + return { success: true, data: skillToApi(skill) }; + } catch (error) { + log("handleCreateSkill: error", { error: String(error) }); + return { success: false, error: String(error) }; + } +} + +export async function handleUpdateSkill( + id: string, + body: { + name?: string; + description?: string; + content?: string; + autoinvoke?: boolean; + enabled?: boolean; + containerTag?: string | null; + } +): Promise>> { + try { + const { skillsManager } = await import("./skills/skills-manager.js"); + const skill = await skillsManager.update(id, body); + return { success: true, data: skillToApi(skill) }; + } catch (error) { + log("handleUpdateSkill: error", { error: String(error) }); + return { success: false, error: String(error) }; + } +} + +export async function handleDeleteSkill(id: string): Promise> { + try { + const { skillsManager } = await import("./skills/skills-manager.js"); + const deleted = await skillsManager.delete(id); + if (!deleted) return { success: false, error: `Skill not found: ${id}` }; + return { success: true, data: { deleted: true } }; + } catch (error) { + log("handleDeleteSkill: error", { error: String(error) }); + return { success: false, error: String(error) }; + } +} + +export async function handleSkillsCatalogIndex(): Promise< + ApiResponse<{ skills: Array<{ name: string; version: string; files: string[] }> }> +> { + try { + const { skillsManager } = await import("./skills/skills-manager.js"); + const catalog = await skillsManager.toHttpCatalog(); + return { success: true, data: catalog }; + } catch (error) { + log("handleSkillsCatalogIndex: error", { error: String(error) }); + return { success: false, error: String(error) }; + } +} + +export async function handleSkillsCatalogFile( + id: string +): Promise> { + try { + const { skillsManager } = await import("./skills/skills-manager.js"); + const markdown = await skillsManager.toCatalogMarkdown(id); + if (!markdown) return { success: false, error: `Skill not found: ${id}` }; + return { success: true, data: { markdown } }; + } catch (error) { + log("handleSkillsCatalogFile: error", { error: String(error) }); + return { success: false, error: String(error) }; + } +} diff --git a/src/services/skills/skills-manager.ts b/src/services/skills/skills-manager.ts new file mode 100644 index 00000000..f65c104d --- /dev/null +++ b/src/services/skills/skills-manager.ts @@ -0,0 +1,238 @@ +import { join } from "node:path"; +import { tursoConnectionManager } from "../turso/connection-manager.js"; +import type { TursoDb } from "../turso/turso-db.js"; +import { CONFIG } from "../../config.js"; +import { log } from "../logger.js"; +import { isValidSkillId, type SkillInput, type SkillRecord, type SkillUpdate } from "./types.js"; + +const SKILLS_DB_NAME = "skills.db"; + +type SkillChangeListener = () => void; + +const skillChangeListeners = new Set(); + +export function onSkillsChanged(listener: SkillChangeListener): () => void { + skillChangeListeners.add(listener); + return () => skillChangeListeners.delete(listener); +} + +function notifySkillsChanged(): void { + for (const listener of skillChangeListeners) { + try { + listener(); + } catch (error) { + log("skills change listener failed", { error: String(error) }); + } + } +} + +function rowToSkill(row: Record): SkillRecord { + return { + id: String(row.id), + name: String(row.name), + description: String(row.description ?? ""), + content: String(row.content ?? ""), + autoinvoke: Number(row.autoinvoke ?? 1) === 1, + enabled: Number(row.enabled ?? 1) === 1, + containerTag: row.container_tag == null ? null : String(row.container_tag), + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at), + }; +} + +export class SkillsManager { + private db: TursoDb | null = null; + private dbPath: string; + private initPromise: Promise | null = null; + + constructor() { + this.dbPath = join(CONFIG.storagePath || "", SKILLS_DB_NAME); + } + + reset(): void { + this.db = null; + this.initPromise = null; + this.dbPath = join(CONFIG.storagePath || "", SKILLS_DB_NAME); + } + + private async initialize(): Promise { + if (this.initPromise) return this.initPromise; + this.initPromise = this.initDatabase(); + return this.initPromise; + } + + private async initDatabase(): Promise { + this.dbPath = join(CONFIG.storagePath || "", SKILLS_DB_NAME); + const db = await tursoConnectionManager.getConnection(this.dbPath); + const { applySchemaMigrations, SKILLS_MIGRATIONS } = + await import("../turso/schema-migrations.js"); + await applySchemaMigrations(db, SKILLS_MIGRATIONS, { + dbPath: this.dbPath, + label: "skills", + }); + this.db = db; + } + + private async getDb(): Promise { + await this.initialize(); + if (!this.db) throw new Error("Skills database not initialized"); + return this.db; + } + + /** Absolute synthetic SKILL.md path for OpenCode Skill.Info.path */ + skillPath(id: string): string { + return join(this.dbPath.replace(/skills\.db$/, "skills-content"), id, "SKILL.md"); + } + + async list(options?: { + containerTag?: string | null; + enabledOnly?: boolean; + }): Promise { + const db = await this.getDb(); + const clauses: string[] = []; + const args: Array = []; + + if (options?.enabledOnly) { + clauses.push(`enabled = 1`); + } + if (options?.containerTag !== undefined) { + if (options.containerTag === null) { + clauses.push(`container_tag IS NULL`); + } else { + clauses.push(`(container_tag IS NULL OR container_tag = ?)`); + args.push(options.containerTag); + } + } + + const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; + const rows = await db.all( + `SELECT * FROM skills ${where} ORDER BY updated_at DESC, id ASC`, + args + ); + return (rows as Record[]).map(rowToSkill); + } + + async get(id: string): Promise { + const db = await this.getDb(); + const row = await db.get(`SELECT * FROM skills WHERE id = ?`, [id]); + return row ? rowToSkill(row as Record) : null; + } + + async create(input: SkillInput): Promise { + const id = input.id.trim(); + if (!isValidSkillId(id)) { + throw new Error( + "Invalid skill id: use lowercase kebab-case (a-z, 0-9, single hyphens), 1–64 characters" + ); + } + const name = input.name.trim(); + if (!name) throw new Error("Skill name is required"); + const content = input.content; + if (typeof content !== "string" || !content.trim()) { + throw new Error("Skill content is required"); + } + + const existing = await this.get(id); + if (existing) throw new Error(`Skill already exists: ${id}`); + + const now = Date.now(); + const db = await this.getDb(); + await db.run( + `INSERT INTO skills ( + id, name, description, content, autoinvoke, enabled, container_tag, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + id, + name, + (input.description ?? "").trim(), + content, + input.autoinvoke === false ? 0 : 1, + input.enabled === false ? 0 : 1, + input.containerTag ?? null, + now, + now, + ] + ); + + const created = await this.get(id); + if (!created) throw new Error("Failed to create skill"); + notifySkillsChanged(); + return created; + } + + async update(id: string, update: SkillUpdate): Promise { + const existing = await this.get(id); + if (!existing) throw new Error(`Skill not found: ${id}`); + + const name = update.name !== undefined ? update.name.trim() : existing.name; + if (!name) throw new Error("Skill name is required"); + const content = update.content !== undefined ? update.content : existing.content; + if (typeof content !== "string" || !content.trim()) { + throw new Error("Skill content is required"); + } + + const description = + update.description !== undefined ? update.description.trim() : existing.description; + const autoinvoke = update.autoinvoke !== undefined ? update.autoinvoke : existing.autoinvoke; + const enabled = update.enabled !== undefined ? update.enabled : existing.enabled; + const containerTag = + update.containerTag !== undefined ? update.containerTag : existing.containerTag; + const now = Date.now(); + + const db = await this.getDb(); + await db.run( + `UPDATE skills SET + name = ?, description = ?, content = ?, autoinvoke = ?, enabled = ?, + container_tag = ?, updated_at = ? + WHERE id = ?`, + [name, description, content, autoinvoke ? 1 : 0, enabled ? 1 : 0, containerTag, now, id] + ); + + const updated = await this.get(id); + if (!updated) throw new Error("Failed to update skill"); + notifySkillsChanged(); + return updated; + } + + async delete(id: string): Promise { + const existing = await this.get(id); + if (!existing) return false; + const db = await this.getDb(); + await db.run(`DELETE FROM skills WHERE id = ?`, [id]); + notifySkillsChanged(); + return true; + } + + /** Catalog payload for OpenCode HTTP skills sources (`skills: ["http://host/skills/"]`). */ + async toHttpCatalog(): Promise<{ + skills: Array<{ name: string; version: string; files: string[] }>; + }> { + const enabled = await this.list({ enabledOnly: true }); + return { + skills: enabled.map((skill) => ({ + name: skill.id, + version: String(skill.updatedAt), + // OpenCode fetches `{base}/{name}/{file}` — named .md form → skill id = name. + files: [`${skill.id}.md`], + })), + }; + } + + async toCatalogMarkdown(id: string): Promise { + const skill = await this.get(id); + if (!skill || !skill.enabled) return null; + const frontmatter = [ + "---", + `name: ${JSON.stringify(skill.name)}`, + `description: ${JSON.stringify(skill.description)}`, + "metadata:", + ` opencode/autoinvoke: ${skill.autoinvoke ? '"true"' : '"false"'}`, + "---", + "", + skill.content, + ].join("\n"); + return frontmatter; + } +} + +export const skillsManager = new SkillsManager(); diff --git a/src/services/skills/types.ts b/src/services/skills/types.ts new file mode 100644 index 00000000..c18875a5 --- /dev/null +++ b/src/services/skills/types.ts @@ -0,0 +1,37 @@ +export interface SkillRecord { + id: string; + name: string; + description: string; + content: string; + autoinvoke: boolean; + enabled: boolean; + containerTag: string | null; + createdAt: number; + updatedAt: number; +} + +export interface SkillInput { + id: string; + name: string; + description?: string; + content: string; + autoinvoke?: boolean; + enabled?: boolean; + containerTag?: string | null; +} + +export interface SkillUpdate { + name?: string; + description?: string; + content?: string; + autoinvoke?: boolean; + enabled?: boolean; + containerTag?: string | null; +} + +/** OpenCode-compatible skill id: lowercase kebab-case, 1–64 chars. */ +export const SKILL_ID_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/; + +export function isValidSkillId(id: string): boolean { + return id.length >= 1 && id.length <= 64 && SKILL_ID_PATTERN.test(id); +} diff --git a/src/services/turso/schema-migrations.ts b/src/services/turso/schema-migrations.ts index 831db97e..ceeb270f 100644 --- a/src/services/turso/schema-migrations.ts +++ b/src/services/turso/schema-migrations.ts @@ -261,6 +261,33 @@ export const USER_PROFILES_MIGRATIONS: SchemaMigration[] = [ }, ]; +export const SKILLS_MIGRATIONS: SchemaMigration[] = [ + { + version: 1, + description: "Baseline shared skills table", + statements: [ + { + sql: ` + CREATE TABLE IF NOT EXISTS skills ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + content TEXT NOT NULL, + autoinvoke INTEGER NOT NULL DEFAULT 1, + enabled INTEGER NOT NULL DEFAULT 1, + container_tag TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `, + }, + { sql: `CREATE INDEX IF NOT EXISTS idx_skills_enabled ON skills(enabled)` }, + { sql: `CREATE INDEX IF NOT EXISTS idx_skills_container_tag ON skills(container_tag)` }, + { sql: `CREATE INDEX IF NOT EXISTS idx_skills_updated_at ON skills(updated_at DESC)` }, + ], + }, +]; + export const AI_SESSIONS_MIGRATIONS: SchemaMigration[] = [ { version: 1, diff --git a/src/services/web-server.ts b/src/services/web-server.ts index 056d8312..02b145a7 100644 --- a/src/services/web-server.ts +++ b/src/services/web-server.ts @@ -36,6 +36,13 @@ import { handleAICleanup, handleApplyCleanup, handleUpdateProfileItem, + handleListSkills, + handleGetSkill, + handleCreateSkill, + handleUpdateSkill, + handleDeleteSkill, + handleSkillsCatalogIndex, + handleSkillsCatalogFile, } from "./api-handlers.js"; /** @@ -703,6 +710,81 @@ export class WebServer { return this.jsonResponse(result); } + if (path === "/api/skills" && method === "GET") { + const containerTag = url.searchParams.get("tag") || undefined; + const enabledOnly = url.searchParams.get("enabledOnly") === "true"; + const result = await handleListSkills({ containerTag, enabledOnly }); + return this.jsonResponse(result); + } + + if (path === "/api/skills" && method === "POST") { + const body = (await req.json().catch(() => ({}))) as any; + const result = await handleCreateSkill(body); + return this.jsonResponse(result, result.success ? 200 : 400); + } + + if (path.startsWith("/api/skills/") && method === "GET") { + const id = decodeURIComponent(path.slice("/api/skills/".length)); + if (!id || id.includes("/")) { + return this.jsonResponse({ success: false, error: "Invalid skill id" }, 400); + } + const result = await handleGetSkill(id); + return this.jsonResponse(result, result.success ? 200 : 404); + } + + if (path.startsWith("/api/skills/") && method === "PUT") { + const id = decodeURIComponent(path.slice("/api/skills/".length)); + if (!id || id.includes("/")) { + return this.jsonResponse({ success: false, error: "Invalid skill id" }, 400); + } + const body = (await req.json().catch(() => ({}))) as any; + const result = await handleUpdateSkill(id, body); + return this.jsonResponse(result, result.success ? 200 : 400); + } + + if (path.startsWith("/api/skills/") && method === "DELETE") { + const id = decodeURIComponent(path.slice("/api/skills/".length)); + if (!id || id.includes("/")) { + return this.jsonResponse({ success: false, error: "Invalid skill id" }, 400); + } + const result = await handleDeleteSkill(id); + return this.jsonResponse(result, result.success ? 200 : 404); + } + + // OpenCode HTTP skill catalog (public path; prefer loopback / network auth). + if (path === "/skills/index.json" && method === "GET") { + const result = await handleSkillsCatalogIndex(); + if (!result.success || !result.data) { + return this.jsonResponse(result, 500); + } + return new Response(JSON.stringify(result.data), { + status: 200, + headers: { "Content-Type": "application/json; charset=utf-8" }, + }); + } + + // OpenCode catalog layout: /skills/{id}/{id}.md + if (path.startsWith("/skills/") && path.endsWith(".md") && method === "GET") { + const rest = path.slice("/skills/".length); + const parts = rest.split("/").filter(Boolean); + if (parts.length !== 2) { + return new Response("Not Found", { status: 404 }); + } + const id = decodeURIComponent(parts[0]!); + const filename = decodeURIComponent(parts[1]!); + if (!id || filename !== `${id}.md`) { + return new Response("Not Found", { status: 404 }); + } + const result = await handleSkillsCatalogFile(id); + if (!result.success || !result.data?.markdown) { + return new Response("Not Found", { status: 404 }); + } + return new Response(result.data.markdown, { + status: 200, + headers: { "Content-Type": "text/markdown; charset=utf-8" }, + }); + } + // SPA fallback for client routes (e.g. /profile) — serve the app shell. if (method === "GET" && !path.startsWith("/api/") && !extname(path)) { return this.serveStaticFile("index.html", "text/html"); diff --git a/src/v2/adapter.ts b/src/v2/adapter.ts index eaff7c0b..867f3266 100644 --- a/src/v2/adapter.ts +++ b/src/v2/adapter.ts @@ -1,5 +1,8 @@ +import { isAbsolute, resolve } from "node:path"; import type { Context } from "@opencode/plugin/promise/plugin"; import { log } from "../services/logger.js"; +import { onSkillsChanged, skillsManager } from "../services/skills/skills-manager.js"; +import { getTags } from "../services/tags.js"; import { eventBelongsToLocation, legacyToolResult, toLegacyEvent } from "./legacy-client.js"; interface MemoryContextHooks { @@ -52,6 +55,62 @@ export async function registerV2Adapter(ctx: Context, legacy: any) { const messageIDs = new Map(); const suppressed = new Set(); const memoryContext = legacy.memoryContext as MemoryContextHooks | undefined; + /** Skill IDs last registered from opencode-mem DB (so we can remove stale ones). */ + const registeredSkillIds = new Set(); + + async function registerDbSkills(): Promise { + if (!ctx.skill?.transform) return; + + const projectTag = getTags(ctx.location.directory).project.tag; + let skills: Awaited> = []; + try { + skills = await skillsManager.list({ + enabledOnly: true, + containerTag: projectTag, + }); + } catch (error) { + log("Failed to load skills for OpenCode registration", { error: String(error) }); + return; + } + + const nextIds = new Set(skills.map((skill) => skill.id)); + + await ctx.skill.transform((editor) => { + for (const id of registeredSkillIds) { + if (!nextIds.has(id) && editor.get(id)) { + editor.remove(id); + } + } + + for (const skill of skills) { + const path = skillsManager.skillPath(skill.id); + const absolutePath = isAbsolute(path) ? path : resolve(path); + const payload = { + id: skill.id, + name: skill.name, + description: skill.description || undefined, + autoinvoke: skill.autoinvoke, + path: absolutePath, + content: skill.content, + } as any; + + if (editor.get(skill.id)) { + editor.update(skill.id, (entry) => { + entry.name = payload.name; + entry.description = payload.description; + entry.autoinvoke = payload.autoinvoke; + entry.path = payload.path; + entry.content = payload.content; + }); + } else { + editor.add(payload); + } + } + }); + + registeredSkillIds.clear(); + for (const id of nextIds) registeredSkillIds.add(id); + } await ctx.tool.transform((editor) => editor.add({ @@ -74,6 +133,18 @@ export async function registerV2Adapter(ctx: Context, legacy: any) { } as any) ); + await registerDbSkills(); + const unsubscribeSkills = onSkillsChanged(() => { + void (async () => { + try { + await registerDbSkills(); + await ctx.skill?.reload?.(); + } catch (error) { + log("Failed to reload OpenCode skills after DB change", { error: String(error) }); + } + })(); + }); + await ctx.session.hook("prompt", async (event) => { messageIDs.set(event.sessionID, event.messageID); const original = { type: "text", text: event.prompt.text }; @@ -156,6 +227,7 @@ export async function registerV2Adapter(ctx: Context, legacy: any) { })(); return async () => { + unsubscribeSkills(); controller.abort(); await watcher; contexts.clear(); diff --git a/tests/skills-api-handlers.test.ts b/tests/skills-api-handlers.test.ts new file mode 100644 index 00000000..ba693a54 --- /dev/null +++ b/tests/skills-api-handlers.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { tursoConnectionManager } from "../src/services/turso/connection-manager.js"; + +let tmpDir: string; + +describe("skills API handlers", () => { + beforeEach(async () => { + tmpDir = mkdtempSync(join(tmpdir(), "opencode-mem-skills-api-")); + const { CONFIG } = await import("../src/config.js"); + CONFIG.storagePath = tmpDir; + const { skillsManager } = await import("../src/services/skills/skills-manager.js"); + skillsManager.reset(); + }); + + afterEach(async () => { + const { skillsManager } = await import("../src/services/skills/skills-manager.js"); + skillsManager.reset(); + await tursoConnectionManager.closeAll(); + await new Promise((r) => setTimeout(r, 50)); + try { + rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // ignore + } + }); + + it("supports CRUD through handlers", async () => { + const { + handleCreateSkill, + handleListSkills, + handleGetSkill, + handleUpdateSkill, + handleDeleteSkill, + handleSkillsCatalogIndex, + handleSkillsCatalogFile, + } = await import("../src/services/api-handlers.js"); + + const created = await handleCreateSkill({ + id: "ship-it", + name: "Ship It", + description: "Release flow", + content: "## Ship\n\n1. Build\n2. Tag", + }); + expect(created.success).toBe(true); + expect(created.data?.id).toBe("ship-it"); + + const listed = await handleListSkills(); + expect(listed.success).toBe(true); + expect(listed.data?.items).toHaveLength(1); + + const got = await handleGetSkill("ship-it"); + expect(got.success).toBe(true); + expect(got.data?.name).toBe("Ship It"); + + const updated = await handleUpdateSkill("ship-it", { name: "Ship It Now" }); + expect(updated.success).toBe(true); + expect(updated.data?.name).toBe("Ship It Now"); + + const catalog = await handleSkillsCatalogIndex(); + expect(catalog.success).toBe(true); + expect(catalog.data?.skills[0]?.name).toBe("ship-it"); + + const file = await handleSkillsCatalogFile("ship-it"); + expect(file.success).toBe(true); + expect(file.data?.markdown).toContain("## Ship"); + + const deleted = await handleDeleteSkill("ship-it"); + expect(deleted.success).toBe(true); + expect((await handleGetSkill("ship-it")).success).toBe(false); + }); +}); diff --git a/tests/skills-manager.test.ts b/tests/skills-manager.test.ts new file mode 100644 index 00000000..952c7b49 --- /dev/null +++ b/tests/skills-manager.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { tursoConnectionManager } from "../src/services/turso/connection-manager.js"; +import { isValidSkillId } from "../src/services/skills/types.js"; + +let tmpDir: string; + +async function makeManager() { + const { CONFIG } = await import("../src/config.js"); + CONFIG.storagePath = tmpDir; + const { SkillsManager } = await import("../src/services/skills/skills-manager.js"); + return new SkillsManager(); +} + +describe("isValidSkillId", () => { + it("accepts kebab-case ids", () => { + expect(isValidSkillId("git-release")).toBe(true); + expect(isValidSkillId("a")).toBe(true); + expect(isValidSkillId("pr-review-v2")).toBe(true); + }); + + it("rejects invalid ids", () => { + expect(isValidSkillId("Git-Release")).toBe(false); + expect(isValidSkillId("-bad")).toBe(false); + expect(isValidSkillId("bad-")).toBe(false); + expect(isValidSkillId("bad--id")).toBe(false); + expect(isValidSkillId("has space")).toBe(false); + }); +}); + +describe("SkillsManager", () => { + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "opencode-mem-skills-")); + }); + + afterEach(async () => { + await tursoConnectionManager.closeAll(); + await new Promise((r) => setTimeout(r, 50)); + try { + rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // ignore + } + }); + + it("creates, lists, updates, and deletes skills", async () => { + const mgr = await makeManager(); + const created = await mgr.create({ + id: "git-release", + name: "Git Release", + description: "Prepare releases", + content: "## Steps\n\n1. Tag\n2. Publish", + autoinvoke: true, + enabled: true, + }); + + expect(created.id).toBe("git-release"); + expect(created.name).toBe("Git Release"); + expect(created.autoinvoke).toBe(true); + + const listed = await mgr.list(); + expect(listed).toHaveLength(1); + + const updated = await mgr.update("git-release", { + content: "## Updated", + enabled: false, + }); + expect(updated.content).toBe("## Updated"); + expect(updated.enabled).toBe(false); + + const enabledOnly = await mgr.list({ enabledOnly: true }); + expect(enabledOnly).toHaveLength(0); + + const deleted = await mgr.delete("git-release"); + expect(deleted).toBe(true); + expect(await mgr.list()).toHaveLength(0); + }); + + it("rejects invalid and duplicate ids", async () => { + const mgr = await makeManager(); + await expect(mgr.create({ id: "Bad_ID", name: "x", content: "body" })).rejects.toThrow( + /Invalid skill id/ + ); + + await mgr.create({ id: "ok-skill", name: "Ok", content: "body" }); + await expect(mgr.create({ id: "ok-skill", name: "Dup", content: "body" })).rejects.toThrow( + /already exists/ + ); + }); + + it("builds an OpenCode HTTP catalog", async () => { + const mgr = await makeManager(); + await mgr.create({ + id: "review", + name: "Review", + description: "Review changes", + content: "Review carefully.", + }); + await mgr.create({ + id: "hidden", + name: "Hidden", + content: "off", + enabled: false, + }); + + const catalog = await mgr.toHttpCatalog(); + expect(catalog.skills).toHaveLength(1); + expect(catalog.skills[0]).toEqual({ + name: "review", + version: expect.any(String), + files: ["review.md"], + }); + + const md = await mgr.toCatalogMarkdown("review"); + expect(md).toContain('name: "Review"'); + expect(md).toContain("Review carefully."); + expect(await mgr.toCatalogMarkdown("hidden")).toBeNull(); + }); + + it("notifies listeners on change", async () => { + const mgr = await makeManager(); + const { onSkillsChanged } = await import("../src/services/skills/skills-manager.js"); + let hits = 0; + const stop = onSkillsChanged(() => { + hits++; + }); + await mgr.create({ id: "notify-me", name: "N", content: "c" }); + await mgr.update("notify-me", { name: "N2" }); + await mgr.delete("notify-me"); + stop(); + expect(hits).toBe(3); + }); +}); diff --git a/tests/v2-skills-bridge.test.ts b/tests/v2-skills-bridge.test.ts new file mode 100644 index 00000000..884ab0cd --- /dev/null +++ b/tests/v2-skills-bridge.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { tursoConnectionManager } from "../src/services/turso/connection-manager.js"; + +let tmpDir: string; + +describe("V2 skills bridge helpers", () => { + beforeEach(async () => { + tmpDir = mkdtempSync(join(tmpdir(), "opencode-mem-v2-skills-")); + const { CONFIG } = await import("../src/config.js"); + CONFIG.storagePath = tmpDir; + const { skillsManager } = await import("../src/services/skills/skills-manager.js"); + skillsManager.reset(); + }); + + afterEach(async () => { + const { skillsManager } = await import("../src/services/skills/skills-manager.js"); + skillsManager.reset(); + await tursoConnectionManager.closeAll(); + await new Promise((r) => setTimeout(r, 50)); + try { + rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // ignore + } + }); + + it("exposes absolute skill paths for OpenCode Skill.Info", async () => { + const { skillsManager } = await import("../src/services/skills/skills-manager.js"); + await skillsManager.create({ + id: "bridge-demo", + name: "Bridge Demo", + description: "demo", + content: "Do the thing.", + }); + const path = skillsManager.skillPath("bridge-demo"); + expect(path.startsWith("/")).toBe(true); + expect(path.endsWith("bridge-demo/SKILL.md")).toBe(true); + }); + + it("lists only enabled skills for registration filtering", async () => { + const { skillsManager } = await import("../src/services/skills/skills-manager.js"); + await skillsManager.create({ + id: "on", + name: "On", + content: "a", + enabled: true, + }); + await skillsManager.create({ + id: "off", + name: "Off", + content: "b", + enabled: false, + }); + const enabled = await skillsManager.list({ enabledOnly: true }); + expect(enabled.map((s) => s.id)).toEqual(["on"]); + }); +}); diff --git a/web/bun.lock b/web/bun.lock index 59c29c2a..a0748e56 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -5,9 +5,15 @@ "": { "name": "web", "dependencies": { + "@codemirror/lang-markdown": "^6.5.2", + "@codemirror/state": "^6.7.6", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.43.14", + "codemirror": "^6.0.2", "dompurify": "^3.4.16", "jsonrepair": "^3.15.0", "marked": "^18.1.0", + "svelte-codemirror-editor": "^2.1.0", "zod": "^4.6.5", }, "devDependencies": { @@ -32,6 +38,32 @@ }, }, "packages": { + "@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g=="], + + "@codemirror/commands": ["@codemirror/commands@6.11.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.7.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg=="], + + "@codemirror/lang-css": ["@codemirror/lang-css@6.3.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.2", "@lezer/css": "^1.1.7" } }, "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg=="], + + "@codemirror/lang-html": ["@codemirror/lang-html@6.4.12", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/css": "^1.1.0", "@lezer/html": "^1.3.12" } }, "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w=="], + + "@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.5", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A=="], + + "@codemirror/lang-markdown": ["@codemirror/lang-markdown@6.5.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.7.1", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.3.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/markdown": "^1.0.0" } }, "sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw=="], + + "@codemirror/language": ["@codemirror/language@6.13.1", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/streamparser": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw=="], + + "@codemirror/lint": ["@codemirror/lint@6.9.7", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.42.0", "crelt": "^1.0.5" } }, "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg=="], + + "@codemirror/search": ["@codemirror/search@6.7.2", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA=="], + + "@codemirror/state": ["@codemirror/state@6.7.6", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w=="], + + "@codemirror/streamparser": ["@codemirror/streamparser@6.0.0", "", {}, "sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w=="], + + "@codemirror/theme-one-dark": ["@codemirror/theme-one-dark@6.1.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/highlight": "^1.0.0" } }, "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA=="], + + "@codemirror/view": ["@codemirror/view@6.43.14", "", { "dependencies": { "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ=="], + "@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="], "@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="], @@ -52,8 +84,24 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@lezer/common": ["@lezer/common@1.5.3", "", {}, "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q=="], + + "@lezer/css": ["@lezer/css@1.3.9", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-ZmzgvTOBdJWGIsY/ItAfN7UW8DV802oZG+jeG6E508SwEzz5YhuAajptpWPICvIyqYAD2+VjMdOS40GgGkyEOQ=="], + + "@lezer/highlight": ["@lezer/highlight@1.2.5", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w=="], + + "@lezer/html": ["@lezer/html@1.3.13", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg=="], + + "@lezer/javascript": ["@lezer/javascript@1.5.6", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.0" } }, "sha512-sEvOqpl4vJQca0NugSQK02vQPbc5KrvoojnYGoqqFgFO0MtcYhwGiHKVEB+LuXYJnfyXo4QPA8ztbrEa4Fw5NA=="], + + "@lezer/lr": ["@lezer/lr@1.4.11", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw=="], + + "@lezer/markdown": ["@lezer/markdown@1.8.0", "", { "dependencies": { "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0" } }, "sha512-X6w3A2NpyhdtGqTWRJB8gR4QDXBaFMbTOs780ZdZ15KKCfBXOQTKcB3Vr+W6tqJHVlealt53tLmHRvOqPTQgYA=="], + "@lucide/svelte": ["@lucide/svelte@1.52.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-bQ8t3IcZxWqAdmdMZPyZEv8Qf+MDgBAf7n43eRQoXyBeLWr8HWToiMIO6vKlHxC/lnMiCDdTzm09hjy92ch46A=="], + "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.4", "", {}, "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ=="], + "@oxc-project/types": ["@oxc-project/types@0.152.0", "", {}, "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw=="], "@resvg/resvg-js": ["@resvg/resvg-js@2.6.2", "", { "optionalDependencies": { "@resvg/resvg-js-android-arm-eabi": "2.6.2", "@resvg/resvg-js-android-arm64": "2.6.2", "@resvg/resvg-js-darwin-arm64": "2.6.2", "@resvg/resvg-js-darwin-x64": "2.6.2", "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2", "@resvg/resvg-js-linux-arm64-gnu": "2.6.2", "@resvg/resvg-js-linux-arm64-musl": "2.6.2", "@resvg/resvg-js-linux-x64-gnu": "2.6.2", "@resvg/resvg-js-linux-x64-musl": "2.6.2", "@resvg/resvg-js-win32-arm64-msvc": "2.6.2", "@resvg/resvg-js-win32-ia32-msvc": "2.6.2", "@resvg/resvg-js-win32-x64-msvc": "2.6.2" } }, "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q=="], @@ -170,6 +218,10 @@ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "codemirror": ["codemirror@6.0.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw=="], + + "crelt": ["crelt@1.0.7", "", {}, "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA=="], + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], @@ -258,12 +310,16 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "style-mod": ["style-mod@4.1.4", "", {}, "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw=="], + "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], "svelte": ["svelte@5.57.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "@jridgewell/sourcemap-codec": "^1.6.0", "@sveltejs/acorn-typescript": "^1.0.13", "@types/estree": "^1.0.9", "acorn": "^8.18.0", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.9.2", "esm-env": "^1.2.1", "esrap": "^2.3.6", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-Uqj49lWKB+iSSnneuwiYYJ7MZgkB+eXr0LXBhv4uDuAkXqnWmq65Sxflfvp0Lc6MdKjMUxGaeOKWJqz5SNiVIA=="], "svelte-check": ["svelte-check@4.7.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "@sveltejs/load-config": "^0.2.3", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.0.0 || ^6.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw=="], + "svelte-codemirror-editor": ["svelte-codemirror-editor@2.1.0", "", { "peerDependencies": { "codemirror": "^6.0.0", "svelte": "^5.0.0" } }, "sha512-WGkSsIYNpVcOVxaQPkmdBQhaGyKLmg6pgaS/b+7guRb4eikrbXYtvNFuW2AzKJi8ZbLhSngrf3SRiZOuwuskrQ=="], + "svelte-toolbelt": ["svelte-toolbelt@0.10.6", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.35.1", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ=="], "tabbable": ["tabbable@6.5.0", "", {}, "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA=="], @@ -290,6 +346,8 @@ "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], + "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], + "zimmerframe": ["zimmerframe@1.1.5", "", {}, "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA=="], "zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], diff --git a/web/package.json b/web/package.json index 6e58a522..6fe3ee2b 100644 --- a/web/package.json +++ b/web/package.json @@ -11,9 +11,15 @@ "check": "svelte-check --tsconfig ./tsconfig.app.json --threshold error" }, "dependencies": { + "@codemirror/lang-markdown": "^6.5.2", + "@codemirror/state": "^6.7.6", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.43.14", + "codemirror": "^6.0.2", "dompurify": "^3.4.16", "jsonrepair": "^3.15.0", "marked": "^18.1.0", + "svelte-codemirror-editor": "^2.1.0", "zod": "^4.6.5" }, "devDependencies": { diff --git a/web/src/App.svelte b/web/src/App.svelte index 9c6f8b53..d8cc465d 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -13,6 +13,8 @@ import EditMemoryDialog from "$lib/components/explorer/EditMemoryDialog.svelte"; import MemoryList from "$lib/components/explorer/MemoryList.svelte"; import ProfileView from "$lib/components/explorer/ProfileView.svelte"; + import SkillDialog from "$lib/components/explorer/SkillDialog.svelte"; + import SkillsView from "$lib/components/explorer/SkillsView.svelte"; import TagMigrationDialog from "$lib/components/explorer/TagMigrationDialog.svelte"; import TagFilter from "$lib/components/explorer/TagFilter.svelte"; import Alert from "$lib/components/ui/alert.svelte"; @@ -28,6 +30,7 @@ import { initRouter, navigate, ROUTES, shouldHandleSpaClick } from "$lib/router"; import { createRouter } from "$lib/router.svelte"; import { createMemoriesExplorer } from "$lib/stores/memories-explorer.svelte"; + import { createSkillsStore } from "$lib/stores/skills.svelte"; import { createUserProfile } from "$lib/stores/user-profile.svelte"; import { GAP, @@ -56,6 +59,7 @@ const router = createRouter(); const explorer = createMemoriesExplorer(); const profile = createUserProfile(); + const skills = createSkillsStore(); let sidebarOpen = $state(false); let sidebarCollapsed = $state(readCollapsed()); @@ -63,7 +67,11 @@ let headerScrolled = $state(false); const pageTitle = $derived( - router.view === "project" ? i18n.t("tab-project") : i18n.t("tab-profile") + router.view === "project" + ? i18n.t("tab-project") + : router.view === "skills" + ? i18n.t("tab-skills") + : i18n.t("tab-profile") ); $effect(() => { @@ -108,6 +116,9 @@ if (router.view === "profile") { void profile.loadUserProfile(); } + if (router.view === "skills") { + void skills.loadSkills(); + } }); function onLangToggle() { @@ -115,6 +126,7 @@ void explorer.loadMemories(); void explorer.loadStats(); if (router.view === "profile") void profile.loadUserProfile(); + if (router.view === "skills") void skills.loadSkills(); } function onHomeClick(event: MouseEvent) { @@ -136,6 +148,7 @@ brand={i18n.t("brand")} projectLabel={i18n.t("tab-project")} profileLabel={i18n.t("tab-profile")} + skillsLabel={i18n.t("tab-skills")} {langLabel} languageLabel={i18n.t("nav-language")} themeLabel={i18n.t("nav-theme")} @@ -334,6 +347,14 @@ onTagsChange={explorer.updateMemoryTags} knownTags={explorer.knownTags} /> + {:else if router.view === "skills"} + {:else} + (skills.dialogOpen = open)} + onSave={skills.saveSkill} +/> + (explorer.addOpen = open)} diff --git a/web/src/lib/components/explorer/AppSidebar.svelte b/web/src/lib/components/explorer/AppSidebar.svelte index 9e767454..78f0903d 100644 --- a/web/src/lib/components/explorer/AppSidebar.svelte +++ b/web/src/lib/components/explorer/AppSidebar.svelte @@ -3,6 +3,7 @@ import Folder from "@lucide/svelte/icons/folder"; import Languages from "@lucide/svelte/icons/languages"; import Moon from "@lucide/svelte/icons/moon"; + import Sparkles from "@lucide/svelte/icons/sparkles"; import Sun from "@lucide/svelte/icons/sun"; import User from "@lucide/svelte/icons/user"; import X from "@lucide/svelte/icons/x"; @@ -30,6 +31,7 @@ brand: string; projectLabel: string; profileLabel: string; + skillsLabel: string; langLabel: string; languageLabel: string; themeLabel: string; @@ -45,6 +47,7 @@ brand, projectLabel, profileLabel, + skillsLabel, langLabel, languageLabel, themeLabel, @@ -163,6 +166,19 @@ {profileLabel} {/if} + onNavClick(e, ROUTES.skills)} + > +
diff --git a/web/src/lib/components/explorer/SkillDialog.svelte b/web/src/lib/components/explorer/SkillDialog.svelte new file mode 100644 index 00000000..3a4c1bf9 --- /dev/null +++ b/web/src/lib/components/explorer/SkillDialog.svelte @@ -0,0 +1,118 @@ + + + +
{ + e.preventDefault(); + submit(); + }} + > +
+ + +

{i18n.t("hint-skill-id")}

+
+ + + +
+ + + + + +
+ + +
+ + + + +
+ + {#snippet footer()} + handleOpenChange(false)} + primaryLabel={i18n.t("btn-save")} + onPrimary={submit} + /> + {/snippet} +
diff --git a/web/src/lib/components/explorer/SkillsView.svelte b/web/src/lib/components/explorer/SkillsView.svelte new file mode 100644 index 00000000..6a464354 --- /dev/null +++ b/web/src/lib/components/explorer/SkillsView.svelte @@ -0,0 +1,105 @@ + + +
+
+
+

+ {i18n.t("section-skills", { count: skills.length })} +

+

{i18n.t("skill-hint")}

+
+ +
+ + {#if loading} +
+
+ {:else if skills.length === 0} +
+
+
+ {i18n.t("skill-empty")} +
+ +
+
+ {:else} +
    + {#each skills as skill (skill.id)} +
  • +
    +
    +
    +

    {skill.name}

    + {skill.id} + {#if !skill.enabled} + {i18n.t("skill-disabled")} + {/if} + {#if skill.autoinvoke} + auto + {/if} +
    + {#if skill.description} +

    {skill.description}

    + {/if} +

    {formatDate(skill.updatedAt)}

    +
    +
    + + +
    +
    +
  • + {/each} +
+ {/if} +
diff --git a/web/src/lib/components/ui/markdown-editor.svelte b/web/src/lib/components/ui/markdown-editor.svelte new file mode 100644 index 00000000..f5a9c340 --- /dev/null +++ b/web/src/lib/components/ui/markdown-editor.svelte @@ -0,0 +1,94 @@ + + +
+
+ + +
+ +
+
+ +
+
+ {#if value.trim()} +
+ {@html previewHtml} +
+ {:else} +

{placeholder}

+ {/if} +
+
+
diff --git a/web/src/lib/i18n/translations.ts b/web/src/lib/i18n/translations.ts index bef9c71c..c6fb594d 100644 --- a/web/src/lib/i18n/translations.ts +++ b/web/src/lib/i18n/translations.ts @@ -168,6 +168,34 @@ export const translations = { "label-search": "Search", "nav-prev-page": "Previous page", "nav-next-page": "Next page", + "tab-skills": "Skills", + "section-skills": "Shared Skills ({count})", + "btn-add-skill": "Add Skill", + "btn-new-skill": "New Skill", + "modal-skill-create-title": "Create Skill", + "modal-skill-edit-title": "Edit Skill", + "label-skill-id": "ID", + "label-skill-name": "Name", + "label-skill-description": "Description", + "label-skill-content": "Instructions (Markdown)", + "label-skill-autoinvoke": "Auto-invoke", + "label-skill-enabled": "Enabled", + "placeholder-skill-id": "git-release", + "placeholder-skill-name": "Git Release", + "placeholder-skill-description": "When to use this skill…", + "placeholder-skill-content": "## What I do\n\n- …", + "skill-edit": "Edit", + "skill-preview": "Preview", + "skill-empty": "No skills yet. Create a reusable procedure the agent can load.", + "skill-disabled": "Disabled", + "skill-hint": "OpenCode loads these via the native skill tool (V2 plugin bridge).", + "confirm-delete-skill": "Delete skill “{id}”?", + "toast-skill-save-success": "Skill saved", + "toast-skill-save-failed": "Failed to save skill", + "toast-skill-delete-success": "Skill deleted", + "toast-skill-delete-failed": "Failed to delete skill", + "toast-skill-load-failed": "Failed to load skills", + "hint-skill-id": "Lowercase kebab-case (a-z, 0-9, hyphens). Becomes the OpenCode skill id.", }, de: { title: "OpenCode Memory Explorer", @@ -342,6 +370,34 @@ export const translations = { "label-search": "Suchen", "nav-prev-page": "Vorherige Seite", "nav-next-page": "Nächste Seite", + "tab-skills": "Skills", + "section-skills": "Geteilte Skills ({count})", + "btn-add-skill": "Skill hinzufügen", + "btn-new-skill": "Neuer Skill", + "modal-skill-create-title": "Skill erstellen", + "modal-skill-edit-title": "Skill bearbeiten", + "label-skill-id": "ID", + "label-skill-name": "Name", + "label-skill-description": "Beschreibung", + "label-skill-content": "Anweisungen (Markdown)", + "label-skill-autoinvoke": "Auto-Invoke", + "label-skill-enabled": "Aktiv", + "placeholder-skill-id": "git-release", + "placeholder-skill-name": "Git Release", + "placeholder-skill-description": "Wann dieser Skill genutzt werden soll…", + "placeholder-skill-content": "## Was ich tue\n\n- …", + "skill-edit": "Bearbeiten", + "skill-preview": "Vorschau", + "skill-empty": "Noch keine Skills. Lege eine wiederverwendbare Prozedur an, die der Agent laden kann.", + "skill-disabled": "Deaktiviert", + "skill-hint": "OpenCode lädt diese über das native skill-Tool (V2-Plugin-Bridge).", + "confirm-delete-skill": "Skill „{id}“ löschen?", + "toast-skill-save-success": "Skill gespeichert", + "toast-skill-save-failed": "Skill konnte nicht gespeichert werden", + "toast-skill-delete-success": "Skill gelöscht", + "toast-skill-delete-failed": "Skill konnte nicht gelöscht werden", + "toast-skill-load-failed": "Skills konnten nicht geladen werden", + "hint-skill-id": "Kleinbuchstaben kebab-case (a-z, 0-9, Bindestriche). Wird zur OpenCode Skill-ID.", }, zh: { title: "OpenCode Memory Explorer", diff --git a/web/src/lib/routes.ts b/web/src/lib/routes.ts index 7396effe..de71fd5f 100644 --- a/web/src/lib/routes.ts +++ b/web/src/lib/routes.ts @@ -2,9 +2,10 @@ export const ROUTES = { home: "/", project: "/project-memories", profile: "/user-profile", + skills: "/skills", } as const; -export type AppView = "project" | "profile"; +export type AppView = "project" | "profile" | "skills"; export function normalizePath(pathname: string): string { const path = pathname.replace(/\/+$/, "") || "/"; @@ -12,22 +13,32 @@ export function normalizePath(pathname: string): string { } export function viewFromPath(pathname: string): AppView { - return normalizePath(pathname) === ROUTES.profile ? "profile" : "project"; + const path = normalizePath(pathname); + if (path === ROUTES.profile) return "profile"; + if (path === ROUTES.skills) return "skills"; + return "project"; } export function pathForView(view: AppView): string { - return view === "profile" ? ROUTES.profile : ROUTES.project; + if (view === "profile") return ROUTES.profile; + if (view === "skills") return ROUTES.skills; + return ROUTES.project; } export function isAppPath(pathname: string): boolean { const path = normalizePath(pathname); - return path === ROUTES.home || path === ROUTES.project || path === ROUTES.profile; + return ( + path === ROUTES.home || + path === ROUTES.project || + path === ROUTES.profile || + path === ROUTES.skills + ); } /** Canonical app path — `/` redirects to project memories. */ export function resolveAppPath(pathname: string): string { const path = normalizePath(pathname); if (path === ROUTES.home) return ROUTES.project; - if (path === ROUTES.project || path === ROUTES.profile) return path; + if (path === ROUTES.project || path === ROUTES.profile || path === ROUTES.skills) return path; return ROUTES.project; } diff --git a/web/src/lib/stores/skills.svelte.ts b/web/src/lib/stores/skills.svelte.ts new file mode 100644 index 00000000..37996903 --- /dev/null +++ b/web/src/lib/stores/skills.svelte.ts @@ -0,0 +1,166 @@ +import { toast } from "$lib/toast/toast.svelte"; +import { fetchAPI, toastApiResult } from "$lib/api"; +import { t } from "$lib/i18n"; +import { askConfirm } from "$lib/confirm.svelte"; + +export type SkillItem = { + id: string; + name: string; + description: string; + content: string; + autoinvoke: boolean; + enabled: boolean; + containerTag: string | null; + createdAt: string; + updatedAt: string; +}; + +export type SkillDraft = { + id: string; + name: string; + description: string; + content: string; + autoinvoke: boolean; + enabled: boolean; +}; + +function emptyDraft(): SkillDraft { + return { + id: "", + name: "", + description: "", + content: "", + autoinvoke: true, + enabled: true, + }; +} + +export function createSkillsStore() { + let skills = $state([]); + let loading = $state(false); + let dialogOpen = $state(false); + let editingId = $state(null); + let draft = $state(emptyDraft()); + + async function loadSkills() { + loading = true; + const result = await fetchAPI<{ items: SkillItem[] }>("/api/skills"); + loading = false; + if (!result.success || !result.data) { + await toastApiResult(result, { failKey: "toast-skill-load-failed" }); + return; + } + skills = result.data.items; + } + + function openCreate() { + editingId = null; + draft = emptyDraft(); + dialogOpen = true; + } + + function openEdit(skill: SkillItem) { + editingId = skill.id; + draft = { + id: skill.id, + name: skill.name, + description: skill.description, + content: skill.content, + autoinvoke: skill.autoinvoke, + enabled: skill.enabled, + }; + dialogOpen = true; + } + + async function saveSkill() { + const payload = { + id: draft.id.trim(), + name: draft.name.trim(), + description: draft.description.trim(), + content: draft.content, + autoinvoke: draft.autoinvoke, + enabled: draft.enabled, + }; + if (!payload.name || !payload.content.trim() || (!editingId && !payload.id)) { + toast.error(t("toast-skill-save-failed")); + return; + } + + const result = editingId + ? await fetchAPI(`/api/skills/${encodeURIComponent(editingId)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: payload.name, + description: payload.description, + content: payload.content, + autoinvoke: payload.autoinvoke, + enabled: payload.enabled, + }), + }) + : await fetchAPI("/api/skills", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + await toastApiResult(result, { + successKey: "toast-skill-save-success", + failKey: "toast-skill-save-failed", + onSuccess: async () => { + dialogOpen = false; + await loadSkills(); + }, + }); + } + + async function deleteSkill(id: string) { + const ok = await askConfirm({ + title: t("confirm-delete-title"), + detail: t("confirm-delete-skill", { id }), + confirmLabel: t("btn-delete-selected"), + cancelLabel: t("btn-cancel"), + tone: "danger", + }); + if (!ok) return; + const result = await fetchAPI(`/api/skills/${encodeURIComponent(id)}`, { + method: "DELETE", + }); + await toastApiResult(result, { + successKey: "toast-skill-delete-success", + failKey: "toast-skill-delete-failed", + onSuccess: () => loadSkills(), + }); + } + + return { + get skills() { + return skills; + }, + get loading() { + return loading; + }, + get dialogOpen() { + return dialogOpen; + }, + set dialogOpen(v: boolean) { + dialogOpen = v; + }, + get editingId() { + return editingId; + }, + get draft() { + return draft; + }, + set draft(v: SkillDraft) { + draft = v; + }, + loadSkills, + openCreate, + openEdit, + saveSkill, + deleteSkill, + }; +} + +export type SkillsStore = ReturnType; diff --git a/web/vite.config.ts b/web/vite.config.ts index 27b4ad20..96370f0d 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -20,6 +20,16 @@ export default defineConfig({ "@": path.resolve(webRoot, "src"), }, }, + optimizeDeps: { + exclude: [ + "codemirror", + "svelte-codemirror-editor", + "@codemirror/lang-markdown", + "@codemirror/theme-one-dark", + "@codemirror/view", + "@codemirror/state", + ], + }, server: { // When SIM is on, the plugin handles /api before the proxy. proxy: simEnabled @@ -29,6 +39,10 @@ export default defineConfig({ target: "http://127.0.0.1:4747", changeOrigin: true, }, + "/skills": { + target: "http://127.0.0.1:4747", + changeOrigin: true, + }, }, }, build: { From 83885e3693e6d6ca79bdb1e7f414dfff9f007a85 Mon Sep 17 00:00:00 2001 From: EyJunge1 <149941075+EyJunge1@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:48:46 +0200 Subject: [PATCH 02/25] fix(web): reuse FormField/Textarea for skill dialog labels Drop the custom CodeMirror markdown editor so floating labels match existing memory dialogs and stop fighting hint/placeholder layout. --- web/bun.lock | 58 ------------ web/package.json | 6 -- .../components/explorer/SkillDialog.svelte | 35 ++----- .../lib/components/ui/markdown-editor.svelte | 94 ------------------- web/vite.config.ts | 10 -- 5 files changed, 10 insertions(+), 193 deletions(-) delete mode 100644 web/src/lib/components/ui/markdown-editor.svelte diff --git a/web/bun.lock b/web/bun.lock index a0748e56..59c29c2a 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -5,15 +5,9 @@ "": { "name": "web", "dependencies": { - "@codemirror/lang-markdown": "^6.5.2", - "@codemirror/state": "^6.7.6", - "@codemirror/theme-one-dark": "^6.1.3", - "@codemirror/view": "^6.43.14", - "codemirror": "^6.0.2", "dompurify": "^3.4.16", "jsonrepair": "^3.15.0", "marked": "^18.1.0", - "svelte-codemirror-editor": "^2.1.0", "zod": "^4.6.5", }, "devDependencies": { @@ -38,32 +32,6 @@ }, }, "packages": { - "@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g=="], - - "@codemirror/commands": ["@codemirror/commands@6.11.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.7.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg=="], - - "@codemirror/lang-css": ["@codemirror/lang-css@6.3.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.2", "@lezer/css": "^1.1.7" } }, "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg=="], - - "@codemirror/lang-html": ["@codemirror/lang-html@6.4.12", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/css": "^1.1.0", "@lezer/html": "^1.3.12" } }, "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w=="], - - "@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.5", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A=="], - - "@codemirror/lang-markdown": ["@codemirror/lang-markdown@6.5.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.7.1", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.3.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/markdown": "^1.0.0" } }, "sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw=="], - - "@codemirror/language": ["@codemirror/language@6.13.1", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/streamparser": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw=="], - - "@codemirror/lint": ["@codemirror/lint@6.9.7", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.42.0", "crelt": "^1.0.5" } }, "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg=="], - - "@codemirror/search": ["@codemirror/search@6.7.2", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA=="], - - "@codemirror/state": ["@codemirror/state@6.7.6", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w=="], - - "@codemirror/streamparser": ["@codemirror/streamparser@6.0.0", "", {}, "sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w=="], - - "@codemirror/theme-one-dark": ["@codemirror/theme-one-dark@6.1.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/highlight": "^1.0.0" } }, "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA=="], - - "@codemirror/view": ["@codemirror/view@6.43.14", "", { "dependencies": { "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ=="], - "@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="], "@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="], @@ -84,24 +52,8 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@lezer/common": ["@lezer/common@1.5.3", "", {}, "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q=="], - - "@lezer/css": ["@lezer/css@1.3.9", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-ZmzgvTOBdJWGIsY/ItAfN7UW8DV802oZG+jeG6E508SwEzz5YhuAajptpWPICvIyqYAD2+VjMdOS40GgGkyEOQ=="], - - "@lezer/highlight": ["@lezer/highlight@1.2.5", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w=="], - - "@lezer/html": ["@lezer/html@1.3.13", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg=="], - - "@lezer/javascript": ["@lezer/javascript@1.5.6", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.0" } }, "sha512-sEvOqpl4vJQca0NugSQK02vQPbc5KrvoojnYGoqqFgFO0MtcYhwGiHKVEB+LuXYJnfyXo4QPA8ztbrEa4Fw5NA=="], - - "@lezer/lr": ["@lezer/lr@1.4.11", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw=="], - - "@lezer/markdown": ["@lezer/markdown@1.8.0", "", { "dependencies": { "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0" } }, "sha512-X6w3A2NpyhdtGqTWRJB8gR4QDXBaFMbTOs780ZdZ15KKCfBXOQTKcB3Vr+W6tqJHVlealt53tLmHRvOqPTQgYA=="], - "@lucide/svelte": ["@lucide/svelte@1.52.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-bQ8t3IcZxWqAdmdMZPyZEv8Qf+MDgBAf7n43eRQoXyBeLWr8HWToiMIO6vKlHxC/lnMiCDdTzm09hjy92ch46A=="], - "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.4", "", {}, "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ=="], - "@oxc-project/types": ["@oxc-project/types@0.152.0", "", {}, "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw=="], "@resvg/resvg-js": ["@resvg/resvg-js@2.6.2", "", { "optionalDependencies": { "@resvg/resvg-js-android-arm-eabi": "2.6.2", "@resvg/resvg-js-android-arm64": "2.6.2", "@resvg/resvg-js-darwin-arm64": "2.6.2", "@resvg/resvg-js-darwin-x64": "2.6.2", "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2", "@resvg/resvg-js-linux-arm64-gnu": "2.6.2", "@resvg/resvg-js-linux-arm64-musl": "2.6.2", "@resvg/resvg-js-linux-x64-gnu": "2.6.2", "@resvg/resvg-js-linux-x64-musl": "2.6.2", "@resvg/resvg-js-win32-arm64-msvc": "2.6.2", "@resvg/resvg-js-win32-ia32-msvc": "2.6.2", "@resvg/resvg-js-win32-x64-msvc": "2.6.2" } }, "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q=="], @@ -218,10 +170,6 @@ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], - "codemirror": ["codemirror@6.0.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw=="], - - "crelt": ["crelt@1.0.7", "", {}, "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA=="], - "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], @@ -310,16 +258,12 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], - "style-mod": ["style-mod@4.1.4", "", {}, "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw=="], - "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], "svelte": ["svelte@5.57.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "@jridgewell/sourcemap-codec": "^1.6.0", "@sveltejs/acorn-typescript": "^1.0.13", "@types/estree": "^1.0.9", "acorn": "^8.18.0", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.9.2", "esm-env": "^1.2.1", "esrap": "^2.3.6", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-Uqj49lWKB+iSSnneuwiYYJ7MZgkB+eXr0LXBhv4uDuAkXqnWmq65Sxflfvp0Lc6MdKjMUxGaeOKWJqz5SNiVIA=="], "svelte-check": ["svelte-check@4.7.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "@sveltejs/load-config": "^0.2.3", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.0.0 || ^6.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw=="], - "svelte-codemirror-editor": ["svelte-codemirror-editor@2.1.0", "", { "peerDependencies": { "codemirror": "^6.0.0", "svelte": "^5.0.0" } }, "sha512-WGkSsIYNpVcOVxaQPkmdBQhaGyKLmg6pgaS/b+7guRb4eikrbXYtvNFuW2AzKJi8ZbLhSngrf3SRiZOuwuskrQ=="], - "svelte-toolbelt": ["svelte-toolbelt@0.10.6", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.35.1", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ=="], "tabbable": ["tabbable@6.5.0", "", {}, "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA=="], @@ -346,8 +290,6 @@ "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], - "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], - "zimmerframe": ["zimmerframe@1.1.5", "", {}, "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA=="], "zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], diff --git a/web/package.json b/web/package.json index 6fe3ee2b..6e58a522 100644 --- a/web/package.json +++ b/web/package.json @@ -11,15 +11,9 @@ "check": "svelte-check --tsconfig ./tsconfig.app.json --threshold error" }, "dependencies": { - "@codemirror/lang-markdown": "^6.5.2", - "@codemirror/state": "^6.7.6", - "@codemirror/theme-one-dark": "^6.1.3", - "@codemirror/view": "^6.43.14", - "codemirror": "^6.0.2", "dompurify": "^3.4.16", "jsonrepair": "^3.15.0", "marked": "^18.1.0", - "svelte-codemirror-editor": "^2.1.0", "zod": "^4.6.5" }, "devDependencies": { diff --git a/web/src/lib/components/explorer/SkillDialog.svelte b/web/src/lib/components/explorer/SkillDialog.svelte index 3a4c1bf9..c3e76ec2 100644 --- a/web/src/lib/components/explorer/SkillDialog.svelte +++ b/web/src/lib/components/explorer/SkillDialog.svelte @@ -5,9 +5,9 @@ import FormField from "$lib/components/ui/form-field.svelte"; import Input from "$lib/components/ui/input.svelte"; import Checkbox from "$lib/components/ui/checkbox.svelte"; - import MarkdownEditor from "$lib/components/ui/markdown-editor.svelte"; + import Textarea from "$lib/components/ui/textarea.svelte"; import { useI18n } from "$lib/i18n/context.svelte"; - import { STACK_FORM } from "$lib/ui/styles"; + import { GAP_LOOSE, STACK_FORM } from "$lib/ui/styles"; type Props = { open?: boolean; @@ -40,7 +40,7 @@
-
+
-

{i18n.t("hint-skill-id")}

- +
+

{i18n.t("hint-skill-id")}

+ - +
@@ -97,14 +88,8 @@
- - + + +> + +
diff --git a/web/src/lib/components/ui/toast-card.svelte b/web/src/lib/components/ui/toast-card.svelte index 92d34b38..5f053160 100644 --- a/web/src/lib/components/ui/toast-card.svelte +++ b/web/src/lib/components/ui/toast-card.svelte @@ -37,7 +37,7 @@
Date: Mon, 12 Oct 2026 11:08:39 +0200 Subject: [PATCH 19/25] refactor(web): remove duplicate skill content field in SkillDialog for cleaner code --- .../components/explorer/SkillDialog.svelte | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/web/src/lib/components/explorer/SkillDialog.svelte b/web/src/lib/components/explorer/SkillDialog.svelte index 737df873..6d92a222 100644 --- a/web/src/lib/components/explorer/SkillDialog.svelte +++ b/web/src/lib/components/explorer/SkillDialog.svelte @@ -73,6 +73,16 @@ /> + +