diff --git a/AGENTS.md b/AGENTS.md index a2635b3d..89e40292 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,7 @@ Read INFRASTRUCTURE.md * **Purpose-first UI**: prioritize clarity, safety, and approachability for non-technical users. * **Parity with OpenCode**: anything the UI can do must map cleanly to OpenCode tools. * **Prefer OpenCode primitives**: represent concepts using OpenCode's native surfaces first (folders/projects, `.opencode`, `opencode.json`, skills, plugins) before introducing new abstractions. +* **Web parity**: anything that mutates `.opencode/` should be expressible via the OpenWork server API; Tauri-only filesystem calls are a fallback for host mode, not a separate capability set. * **Self-referential**: maintain a gitignored mirror of OpenCode at `vendor/opencode` for inspection. * **Self-building**: prefer prompts, skills, and composable primitives over bespoke logic. * **Open source**: keep the repo portable; no secrets committed. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 06f458a2..f8c55a0b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -60,6 +60,23 @@ OpenWork is a Tauri application with two runtime modes: This split makes mobile "first-class" without requiring the full engine to run on-device. +## Web Parity + Filesystem Actions + +The browser runtime cannot read or write arbitrary local files. Any feature that: + +- reads skills/commands/plugins from `.opencode/` +- edits `SKILL.md` / command templates / `opencode.json` +- opens folders / reveals paths + +must be routed through a host-side service. + +In OpenWork, the long-term direction is: + +- Use the OpenWork server (`packages/server`) as the single API surface for filesystem-backed operations. +- Treat Tauri-only file operations as an implementation detail / convenience fallback, not a separate feature set. + +This ensures the same UI flows work on desktop, mobile, and web clients, with approvals and auditing handled centrally. + ## OpenCode Integration (Exact SDK + APIs) OpenWork uses the official JavaScript/TypeScript SDK: diff --git a/packages/app/src/app/context/extensions.ts b/packages/app/src/app/context/extensions.ts index caca403b..100bef32 100644 --- a/packages/app/src/app/context/extensions.ts +++ b/packages/app/src/app/context/extensions.ts @@ -17,7 +17,9 @@ import { importSkill, installSkillTemplate, listLocalSkills, + readLocalSkill, uninstallSkill as uninstallSkillCommand, + writeLocalSkill, pickDirectory, readOpencodeConfig, writeOpencodeConfig, @@ -722,6 +724,150 @@ export function createExtensionsStore(options: { } } + async function readSkill(name: string): Promise<{ name: string; path: string; content: string } | null> { + const trimmed = name.trim(); + if (!trimmed) return null; + + const root = options.activeWorkspaceRoot().trim(); + if (!root) { + setSkillsStatus(translate("skills.pick_workspace_first")); + return null; + } + + const isRemoteWorkspace = options.workspaceType() === "remote"; + const isLocalWorkspace = options.workspaceType() === "local"; + const openworkClient = options.openworkServerClient(); + const openworkWorkspaceId = options.openworkServerWorkspaceId(); + const openworkCapabilities = options.openworkServerCapabilities(); + const canUseOpenworkServer = + options.openworkServerStatus() === "connected" && + openworkClient && + openworkWorkspaceId && + openworkCapabilities?.skills?.read && + typeof (openworkClient as any).getSkill === "function"; + + if (canUseOpenworkServer) { + try { + setSkillsStatus(null); + const result = await (openworkClient as OpenworkServerClient & { getSkill: any }).getSkill( + openworkWorkspaceId, + trimmed, + { includeGlobal: isLocalWorkspace }, + ); + return { + name: result.item.name, + path: result.item.path, + content: result.content, + }; + } catch (e) { + setSkillsStatus(e instanceof Error ? e.message : translate("skills.failed_to_load")); + return null; + } + } + + if (isRemoteWorkspace) { + setSkillsStatus("OpenWork server unavailable. Connect to view skills."); + return null; + } + + if (!isTauriRuntime()) { + setSkillsStatus(translate("skills.desktop_required")); + return null; + } + + if (!isLocalWorkspace) { + setSkillsStatus("Local workspaces are required to view skills."); + return null; + } + + try { + setSkillsStatus(null); + const result = await readLocalSkill(root, trimmed); + return { name: trimmed, path: result.path, content: result.content }; + } catch (e) { + setSkillsStatus(e instanceof Error ? e.message : translate("skills.failed_to_load")); + return null; + } + } + + async function saveSkill(input: { name: string; content: string; description?: string }) { + const trimmed = input.name.trim(); + if (!trimmed) return; + + const root = options.activeWorkspaceRoot().trim(); + if (!root) { + setSkillsStatus(translate("skills.pick_workspace_first")); + return; + } + + const isRemoteWorkspace = options.workspaceType() === "remote"; + const isLocalWorkspace = options.workspaceType() === "local"; + const openworkClient = options.openworkServerClient(); + const openworkWorkspaceId = options.openworkServerWorkspaceId(); + const openworkCapabilities = options.openworkServerCapabilities(); + const canUseOpenworkServer = + options.openworkServerStatus() === "connected" && + openworkClient && + openworkWorkspaceId && + openworkCapabilities?.skills?.write; + + if (canUseOpenworkServer) { + options.setBusy(true); + options.setError(null); + setSkillsStatus(null); + try { + await openworkClient.upsertSkill(openworkWorkspaceId, { + name: trimmed, + content: input.content, + description: input.description, + }); + options.markReloadRequired("skills", { type: "skill", name: trimmed, action: "updated" }); + await refreshSkills({ force: true }); + setSkillsStatus("Saved."); + } catch (e) { + const message = e instanceof Error ? e.message : translate("skills.unknown_error"); + options.setError(addOpencodeCacheHint(message)); + } finally { + options.setBusy(false); + } + return; + } + + if (isRemoteWorkspace) { + setSkillsStatus("OpenWork server unavailable. Connect to edit skills."); + return; + } + + if (!isTauriRuntime()) { + setSkillsStatus(translate("skills.desktop_required")); + return; + } + + if (!isLocalWorkspace) { + setSkillsStatus("Local workspaces are required to edit skills."); + return; + } + + options.setBusy(true); + options.setError(null); + setSkillsStatus(null); + try { + const result = await writeLocalSkill(root, trimmed, input.content); + if (!result.ok) { + setSkillsStatus(result.stderr || result.stdout || translate("skills.unknown_error")); + } else { + setSkillsStatus(result.stdout || "Saved."); + options.markReloadRequired("skills", { type: "skill", name: trimmed, action: "updated" }); + } + await refreshSkills({ force: true }); + } catch (e) { + const message = e instanceof Error ? e.message : translate("skills.unknown_error"); + options.setError(addOpencodeCacheHint(message)); + } finally { + options.setBusy(false); + } + } + function abortRefreshes() { refreshSkillsAborted = true; refreshPluginsAborted = true; @@ -750,6 +896,8 @@ export function createExtensionsStore(options: { installSkillCreator, revealSkillsFolder, uninstallSkill, + readSkill, + saveSkill, abortRefreshes, }; } diff --git a/packages/app/src/app/lib/openwork-server.ts b/packages/app/src/app/lib/openwork-server.ts index 8e055436..5bc253c2 100644 --- a/packages/app/src/app/lib/openwork-server.ts +++ b/packages/app/src/app/lib/openwork-server.ts @@ -68,6 +68,11 @@ export type OpenworkSkillItem = { trigger?: string; }; +export type OpenworkSkillContent = { + item: OpenworkSkillItem; + content: string; +}; + export type OpenworkCommandItem = { name: string; description?: string; @@ -441,6 +446,14 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s { token, hostToken }, ); }, + getSkill: (workspaceId: string, name: string, options?: { includeGlobal?: boolean }) => { + const query = options?.includeGlobal ? "?includeGlobal=true" : ""; + return requestJson( + baseUrl, + `/workspace/${workspaceId}/skills/${encodeURIComponent(name)}${query}`, + { token, hostToken }, + ); + }, upsertSkill: (workspaceId: string, payload: { name: string; content: string; description?: string }) => requestJson(baseUrl, `/workspace/${workspaceId}/skills`, { token, diff --git a/packages/app/src/app/lib/tauri.ts b/packages/app/src/app/lib/tauri.ts index c662e002..c87d3f31 100644 --- a/packages/app/src/app/lib/tauri.ts +++ b/packages/app/src/app/lib/tauri.ts @@ -489,10 +489,23 @@ export type LocalSkillCard = { trigger?: string; }; +export type LocalSkillContent = { + path: string; + content: string; +}; + export async function listLocalSkills(projectDir: string): Promise { return invoke("list_local_skills", { projectDir }); } +export async function readLocalSkill(projectDir: string, name: string): Promise { + return invoke("read_local_skill", { projectDir, name }); +} + +export async function writeLocalSkill(projectDir: string, name: string, content: string): Promise { + return invoke("write_local_skill", { projectDir, name, content }); +} + export async function uninstallSkill(projectDir: string, name: string): Promise { return invoke("uninstall_skill", { projectDir, name }); } diff --git a/packages/app/src/app/pages/dashboard.tsx b/packages/app/src/app/pages/dashboard.tsx index 18f860e1..df5bbfe8 100644 --- a/packages/app/src/app/pages/dashboard.tsx +++ b/packages/app/src/app/pages/dashboard.tsx @@ -132,6 +132,8 @@ export type DashboardViewProps = { installSkillCreator: () => void; revealSkillsFolder: () => void; uninstallSkill: (name: string) => void; + readSkill: (name: string) => Promise<{ name: string; path: string; content: string } | null>; + saveSkill: (input: { name: string; content: string; description?: string }) => void; pluginsAccessHint?: string | null; canEditPlugins: boolean; canUseGlobalPluginScope: boolean; @@ -798,6 +800,10 @@ export default function DashboardView(props: DashboardViewProps) { installSkillCreator={props.installSkillCreator} revealSkillsFolder={props.revealSkillsFolder} uninstallSkill={props.uninstallSkill} + readSkill={props.readSkill} + saveSkill={props.saveSkill} + createSessionAndOpen={props.createSessionAndOpen} + setPrompt={props.setPrompt} /> diff --git a/packages/app/src/app/pages/skills.tsx b/packages/app/src/app/pages/skills.tsx index 66414dd8..7694fb29 100644 --- a/packages/app/src/app/pages/skills.tsx +++ b/packages/app/src/app/pages/skills.tsx @@ -3,7 +3,7 @@ import { For, Show, createMemo, createSignal } from "solid-js"; import type { SkillCard } from "../types"; import Button from "../components/button"; -import { Edit2, FolderOpen, Package, Plus, RefreshCw, Search, Sparkles, Upload } from "lucide-solid"; +import { Edit2, FolderOpen, Package, Plus, RefreshCw, Search, Sparkles, Trash2, Upload } from "lucide-solid"; import { currentLocale, t } from "../../i18n"; export type SkillsViewProps = { @@ -18,6 +18,10 @@ export type SkillsViewProps = { installSkillCreator: () => void; revealSkillsFolder: () => void; uninstallSkill: (name: string) => void; + readSkill: (name: string) => Promise<{ name: string; path: string; content: string } | null>; + saveSkill: (input: { name: string; content: string; description?: string }) => void; + createSessionAndOpen: () => void; + setPrompt: (value: string) => void; }; export default function SkillsView(props: SkillsViewProps) { @@ -32,6 +36,12 @@ export default function SkillsView(props: SkillsViewProps) { const uninstallOpen = createMemo(() => uninstallTarget() != null); const [searchQuery, setSearchQuery] = createSignal(""); + const [selectedSkill, setSelectedSkill] = createSignal(null); + const [selectedContent, setSelectedContent] = createSignal(""); + const [selectedLoading, setSelectedLoading] = createSignal(false); + const [selectedDirty, setSelectedDirty] = createSignal(false); + const [selectedError, setSelectedError] = createSignal(null); + const filteredSkills = createMemo(() => { const query = searchQuery().trim().toLowerCase(); if (!query) return props.skills; @@ -71,22 +81,69 @@ export default function SkillsView(props: SkillsViewProps) { }, ]); - const handleNewSkill = () => { + const handleNewSkill = async () => { if (props.busy) return; + // Ensure skill-creator exists when we can. if (props.canInstallSkillCreator && !skillCreatorInstalled()) { - props.installSkillCreator(); - return; + await Promise.resolve(props.installSkillCreator()); } - if (props.canUseDesktopTools) { - props.revealSkillsFolder(); + // Open a new session and preselect /skill-creator. + await Promise.resolve(props.createSessionAndOpen()); + props.setPrompt("/skill-creator"); + }; + + const openSkill = async (skill: SkillCard) => { + if (props.busy) return; + setSelectedSkill(skill); + setSelectedContent(""); + setSelectedDirty(false); + setSelectedError(null); + setSelectedLoading(true); + try { + const result = await props.readSkill(skill.name); + if (!result) { + setSelectedError("Failed to load skill."); + return; + } + setSelectedContent(result.content); + } catch (e) { + setSelectedError(e instanceof Error ? e.message : "Failed to load skill."); + } finally { + setSelectedLoading(false); + } + }; + + const closeSkill = () => { + setSelectedSkill(null); + setSelectedContent(""); + setSelectedDirty(false); + setSelectedError(null); + setSelectedLoading(false); + }; + + const saveSelectedSkill = async () => { + const skill = selectedSkill(); + if (!skill) return; + if (!selectedDirty()) return; + setSelectedError(null); + try { + await Promise.resolve( + props.saveSkill({ + name: skill.name, + content: selectedContent(), + description: skill.description, + }), + ); + setSelectedDirty(false); + } catch (e) { + setSelectedError(e instanceof Error ? e.message : "Failed to save skill."); } }; const newSkillDisabled = createMemo( () => props.busy || - (!props.canUseDesktopTools && - (!props.canInstallSkillCreator || skillCreatorInstalled())) + (!props.canInstallSkillCreator && !props.canUseDesktopTools) ); return ( @@ -169,7 +226,18 @@ export default function SkillsView(props: SkillsViewProps) {
{(skill) => ( -
+
void openSkill(skill)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + void openSkill(skill); + } + }} + >
@@ -185,15 +253,39 @@ export default function SkillsView(props: SkillsViewProps) {
- +
+ + +
)} @@ -201,6 +293,62 @@ export default function SkillsView(props: SkillsViewProps) {
+ +
+
+
+
+
{selectedSkill()!.name}
+
{selectedSkill()!.path}
+
+
+ + +
+
+ +
+ +
+ {selectedError()} +
+
+ Loading…
} + > +