Skip to content

Commit a925643

Browse files
authored
feat(skills): add shared editable skills with V2 bridge and GUI (#403)
* 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 * 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. * fix(web): remove skills bridge hint from skills view * fix(web): align skills empty state with memory explorer * fix(web): rename skills section heading to Skills * fix(web): show lucide icon on skills empty state * fix(web): show skills Total chip in page header * fix(web): move skills UI to /skill-library to avoid catalog clash Direct /skills navigations were proxied to the OpenCode HTTP catalog instead of the SPA; keep catalog under /skills/ and the explorer at /skill-library. * docs: note skills UI path vs HTTP catalog prefix * fix(web): keep skills section label without Shared prefix * fix(web): use icon-only add button on skills page * feat(web): add client-side skills search bar * fix(web): use scroll-text icon for skills * fix(web): replace Sparkles icon with ScrollText in skills components * feat(web): implement search functionality and enhance skills display in SkillsView component * refactor(web): streamline SkillDialog and SkillsView components, removing skill ID and enhancing placeholders * refactor(web): replace Input with Textarea for skill description in SkillDialog * refactor(web): update layout and styling in various components for improved responsiveness and consistency * refactor(web): remove duplicate skill content field in SkillDialog for cleaner code * feat(web): add CRUD functionality for skills in memory simulation, including API endpoints for retrieval, creation, updating, and deletion * feat(web): enhance SkillDialog with edit and preview modes for skill content, improving user experience and interactivity * fix(skills): materialize SKILL.md and cover OpenCode skill.transform Write enabled skills to disk so Skill.Info.path is readable, and assert V2 registration/reload against a mock OpenCode skill editor. * fix(skills): ensure Skill.Info.path is correctly written to disk and validate V2 registration with mock OpenCode skill editor * fix(i18n): add skill strings for zh, ar, tr, and nl * fix(test): assert absolute skill paths in a Windows-safe way
1 parent 15fa3ed commit a925643

36 files changed

Lines changed: 2084 additions & 53 deletions

‎README.md‎

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -161,15 +161,23 @@ You do **not** need to ask OpenCode to “remember” things for the plugin to w
161161

162162
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.
163163

164-
### Memory vs AGENTS.md / project docs
164+
### Memory vs AGENTS.md vs Skills
165165

166-
| Store in **memory** | Store in **AGENTS.md** / static docs |
167-
| -------------------------------------------------------------------- | ----------------------------------------------------------- |
168-
| Project-specific decisions, bug patterns, “we tried X and it failed” | Stable rules and workflows that rarely change |
169-
| User preferences discovered over sessions | Always-on coding conventions and process |
170-
| Facts that should follow you across chats | Instructions every agent should see regardless of retrieval |
166+
| Store in **memory** | Store in **AGENTS.md** / static docs | Store as **Skills** (web UI / `skills.db`) |
167+
| -------------------------------------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------- |
168+
| 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 |
169+
| User preferences discovered over sessions | Always-on coding conventions and process | Team-editable skill packs shared via the same storage |
170+
| Facts that should follow you across chats | Instructions every agent should see regardless of retrieval | OpenCode-native `skill` tool (V2 plugin bridge) |
171171

172-
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.
172+
Rule of thumb: lasting always-on project instructions → `AGENTS.md`; growing work context → memory; reusable on-demand procedures → **Skills**.
173+
174+
#### Shared Skills (V2)
175+
176+
- Create/edit skills in the web explorer (**Skills** tab at `/skill-library`).
177+
- Persisted in `skills.db` under the plugin storage path (not as memory vectors).
178+
- On OpenCode **V2**, the plugin registers enabled skills via `ctx.skill.transform` so the agent can load them with the native `skill` tool.
179+
- Optional HTTP catalog for other OpenCode instances: `"skills": ["http://127.0.0.1:4747/skills/"]` (serves `/skills/index.json` and `/skills/<id>/<id>.md` — separate from the UI path).
180+
- REST: `GET/POST /api/skills`, `GET/PUT/DELETE /api/skills/:id`.
173181

174182
### Intelligent prompt-based memory extraction
175183

‎src/services/api-handlers.ts‎

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1669,3 +1669,148 @@ export async function handleRunTagMigrationBatch(
16691669
return { success: false, error: String(error) };
16701670
}
16711671
}
1672+
1673+
function skillToApi(skill: {
1674+
id: string;
1675+
name: string;
1676+
description: string;
1677+
content: string;
1678+
autoinvoke: boolean;
1679+
enabled: boolean;
1680+
containerTag: string | null;
1681+
createdAt: number;
1682+
updatedAt: number;
1683+
}) {
1684+
return {
1685+
id: skill.id,
1686+
name: skill.name,
1687+
description: skill.description,
1688+
content: skill.content,
1689+
autoinvoke: skill.autoinvoke,
1690+
enabled: skill.enabled,
1691+
containerTag: skill.containerTag,
1692+
createdAt: safeToISOString(skill.createdAt),
1693+
updatedAt: safeToISOString(skill.updatedAt),
1694+
};
1695+
}
1696+
1697+
export async function handleListSkills(options?: {
1698+
containerTag?: string;
1699+
enabledOnly?: boolean;
1700+
}): Promise<ApiResponse<{ items: ReturnType<typeof skillToApi>[] }>> {
1701+
try {
1702+
const { skillsManager } = await import("./skills/skills-manager.js");
1703+
const items = await skillsManager.list({
1704+
containerTag: options?.containerTag,
1705+
enabledOnly: options?.enabledOnly,
1706+
});
1707+
return { success: true, data: { items: items.map(skillToApi) } };
1708+
} catch (error) {
1709+
log("handleListSkills: error", { error: String(error) });
1710+
return { success: false, error: String(error) };
1711+
}
1712+
}
1713+
1714+
export async function handleGetSkill(
1715+
id: string
1716+
): Promise<ApiResponse<ReturnType<typeof skillToApi>>> {
1717+
try {
1718+
const { skillsManager } = await import("./skills/skills-manager.js");
1719+
const skill = await skillsManager.get(id);
1720+
if (!skill) return { success: false, error: `Skill not found: ${id}` };
1721+
return { success: true, data: skillToApi(skill) };
1722+
} catch (error) {
1723+
log("handleGetSkill: error", { error: String(error) });
1724+
return { success: false, error: String(error) };
1725+
}
1726+
}
1727+
1728+
export async function handleCreateSkill(body: {
1729+
id?: string;
1730+
name?: string;
1731+
description?: string;
1732+
content?: string;
1733+
autoinvoke?: boolean;
1734+
enabled?: boolean;
1735+
containerTag?: string | null;
1736+
}): Promise<ApiResponse<ReturnType<typeof skillToApi>>> {
1737+
try {
1738+
if (!body?.id || !body?.name || !body?.content) {
1739+
return { success: false, error: "id, name, and content are required" };
1740+
}
1741+
const { skillsManager } = await import("./skills/skills-manager.js");
1742+
const skill = await skillsManager.create({
1743+
id: body.id,
1744+
name: body.name,
1745+
description: body.description,
1746+
content: body.content,
1747+
autoinvoke: body.autoinvoke,
1748+
enabled: body.enabled,
1749+
containerTag: body.containerTag,
1750+
});
1751+
return { success: true, data: skillToApi(skill) };
1752+
} catch (error) {
1753+
log("handleCreateSkill: error", { error: String(error) });
1754+
return { success: false, error: String(error) };
1755+
}
1756+
}
1757+
1758+
export async function handleUpdateSkill(
1759+
id: string,
1760+
body: {
1761+
name?: string;
1762+
description?: string;
1763+
content?: string;
1764+
autoinvoke?: boolean;
1765+
enabled?: boolean;
1766+
containerTag?: string | null;
1767+
}
1768+
): Promise<ApiResponse<ReturnType<typeof skillToApi>>> {
1769+
try {
1770+
const { skillsManager } = await import("./skills/skills-manager.js");
1771+
const skill = await skillsManager.update(id, body);
1772+
return { success: true, data: skillToApi(skill) };
1773+
} catch (error) {
1774+
log("handleUpdateSkill: error", { error: String(error) });
1775+
return { success: false, error: String(error) };
1776+
}
1777+
}
1778+
1779+
export async function handleDeleteSkill(id: string): Promise<ApiResponse<{ deleted: boolean }>> {
1780+
try {
1781+
const { skillsManager } = await import("./skills/skills-manager.js");
1782+
const deleted = await skillsManager.delete(id);
1783+
if (!deleted) return { success: false, error: `Skill not found: ${id}` };
1784+
return { success: true, data: { deleted: true } };
1785+
} catch (error) {
1786+
log("handleDeleteSkill: error", { error: String(error) });
1787+
return { success: false, error: String(error) };
1788+
}
1789+
}
1790+
1791+
export async function handleSkillsCatalogIndex(): Promise<
1792+
ApiResponse<{ skills: Array<{ name: string; version: string; files: string[] }> }>
1793+
> {
1794+
try {
1795+
const { skillsManager } = await import("./skills/skills-manager.js");
1796+
const catalog = await skillsManager.toHttpCatalog();
1797+
return { success: true, data: catalog };
1798+
} catch (error) {
1799+
log("handleSkillsCatalogIndex: error", { error: String(error) });
1800+
return { success: false, error: String(error) };
1801+
}
1802+
}
1803+
1804+
export async function handleSkillsCatalogFile(
1805+
id: string
1806+
): Promise<ApiResponse<{ markdown: string }>> {
1807+
try {
1808+
const { skillsManager } = await import("./skills/skills-manager.js");
1809+
const markdown = await skillsManager.toCatalogMarkdown(id);
1810+
if (!markdown) return { success: false, error: `Skill not found: ${id}` };
1811+
return { success: true, data: { markdown } };
1812+
} catch (error) {
1813+
log("handleSkillsCatalogFile: error", { error: String(error) });
1814+
return { success: false, error: String(error) };
1815+
}
1816+
}

0 commit comments

Comments
 (0)