feat(skills): add basic viewer/editor with server-backed reads

This commit is contained in:
Benjamin Shafii
2026-02-05 23:55:14 -08:00
parent 3d7ecffa98
commit e1df942970
10 changed files with 469 additions and 20 deletions

View File

@@ -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.

View File

@@ -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:

View File

@@ -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,
};
}

View File

@@ -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<OpenworkSkillContent>(
baseUrl,
`/workspace/${workspaceId}/skills/${encodeURIComponent(name)}${query}`,
{ token, hostToken },
);
},
upsertSkill: (workspaceId: string, payload: { name: string; content: string; description?: string }) =>
requestJson<OpenworkSkillItem>(baseUrl, `/workspace/${workspaceId}/skills`, {
token,

View File

@@ -489,10 +489,23 @@ export type LocalSkillCard = {
trigger?: string;
};
export type LocalSkillContent = {
path: string;
content: string;
};
export async function listLocalSkills(projectDir: string): Promise<LocalSkillCard[]> {
return invoke<LocalSkillCard[]>("list_local_skills", { projectDir });
}
export async function readLocalSkill(projectDir: string, name: string): Promise<LocalSkillContent> {
return invoke<LocalSkillContent>("read_local_skill", { projectDir, name });
}
export async function writeLocalSkill(projectDir: string, name: string, content: string): Promise<ExecResult> {
return invoke<ExecResult>("write_local_skill", { projectDir, name, content });
}
export async function uninstallSkill(projectDir: string, name: string): Promise<ExecResult> {
return invoke<ExecResult>("uninstall_skill", { projectDir, name });
}

View File

@@ -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}
/>
</Match>

View File

@@ -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<SkillCard | null>(null);
const [selectedContent, setSelectedContent] = createSignal("");
const [selectedLoading, setSelectedLoading] = createSignal(false);
const [selectedDirty, setSelectedDirty] = createSignal(false);
const [selectedError, setSelectedError] = createSignal<string | null>(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) {
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<For each={filteredSkills()}>
{(skill) => (
<div class="bg-dls-surface border border-dls-border rounded-xl p-4 flex items-start justify-between group hover:border-dls-border transition-all">
<div
role="button"
tabindex="0"
class="bg-dls-surface border border-dls-border rounded-xl p-4 flex items-start justify-between group hover:border-dls-border hover:bg-dls-hover transition-all text-left cursor-pointer"
onClick={() => void openSkill(skill)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
void openSkill(skill);
}
}}
>
<div class="flex gap-4">
<div class="w-10 h-10 rounded-lg flex items-center justify-center shadow-sm border border-dls-border bg-dls-surface">
<Package size={20} class="text-dls-secondary" />
@@ -185,15 +253,39 @@ export default function SkillsView(props: SkillsViewProps) {
</Show>
</div>
</div>
<button
type="button"
class="p-1.5 text-dls-secondary hover:text-dls-text hover:bg-dls-hover rounded-md transition-colors"
onClick={() => setUninstallTarget(skill)}
disabled={props.busy || !props.canUseDesktopTools}
title={translate("skills.uninstall")}
>
<Edit2 size={14} />
</button>
<div class="flex items-center gap-1">
<button
type="button"
class="p-1.5 text-dls-secondary hover:text-dls-text hover:bg-dls-active rounded-md transition-colors"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
void openSkill(skill);
}}
disabled={props.busy}
title="Edit"
>
<Edit2 size={14} />
</button>
<button
type="button"
class={`p-1.5 rounded-md transition-colors ${
props.busy || !props.canUseDesktopTools
? "text-dls-secondary opacity-40"
: "text-dls-secondary hover:text-red-11 hover:bg-red-3/10"
}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (props.busy || !props.canUseDesktopTools) return;
setUninstallTarget(skill);
}}
disabled={props.busy || !props.canUseDesktopTools}
title={translate("skills.uninstall")}
>
<Trash2 size={14} />
</button>
</div>
</div>
)}
</For>
@@ -201,6 +293,62 @@ export default function SkillsView(props: SkillsViewProps) {
</Show>
</div>
<Show when={selectedSkill()}>
<div class="fixed inset-0 z-50 bg-black/40 backdrop-blur-sm flex items-center justify-center p-4">
<div class="w-full max-w-4xl rounded-2xl border border-dls-border bg-dls-surface shadow-2xl overflow-hidden">
<div class="px-5 py-4 border-b border-dls-border flex items-center justify-between gap-3">
<div class="min-w-0">
<div class="text-sm font-semibold text-dls-text truncate">{selectedSkill()!.name}</div>
<div class="text-xs text-dls-secondary truncate">{selectedSkill()!.path}</div>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
selectedDirty() && !props.busy
? "bg-dls-text text-dls-surface hover:opacity-90"
: "bg-dls-active text-dls-secondary"
}`}
disabled={!selectedDirty() || props.busy}
onClick={() => void saveSelectedSkill()}
>
Save
</button>
<button
type="button"
class="px-3 py-1.5 text-xs font-medium rounded-lg bg-dls-hover text-dls-text hover:bg-dls-active transition-colors"
onClick={closeSkill}
>
Close
</button>
</div>
</div>
<div class="p-5">
<Show when={selectedError()}>
<div class="mb-3 rounded-xl border border-red-7/20 bg-red-1/40 px-4 py-3 text-xs text-red-12">
{selectedError()}
</div>
</Show>
<Show
when={!selectedLoading()}
fallback={<div class="text-xs text-dls-secondary">Loading</div>}
>
<textarea
value={selectedContent()}
onInput={(e) => {
setSelectedContent(e.currentTarget.value);
setSelectedDirty(true);
}}
class="w-full min-h-[420px] rounded-xl border border-dls-border bg-dls-hover px-4 py-3 text-xs font-mono text-dls-text focus:outline-none focus:ring-2 focus:ring-[rgba(var(--dls-accent-rgb)/0.25)]"
spellcheck={false}
/>
</Show>
</div>
</div>
</div>
</Show>
<div class="space-y-4">
<h3 class="text-[11px] font-bold text-dls-secondary uppercase tracking-widest">Recommended</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">

View File

@@ -167,6 +167,13 @@ pub struct LocalSkillCard {
pub trigger: Option<String>,
}
#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct LocalSkillContent {
pub path: String,
pub content: String,
}
fn extract_frontmatter_value(raw: &str, keys: &[&str]) -> Option<String> {
let mut lines = raw.lines();
let first = lines.next()?.trim();
@@ -185,7 +192,10 @@ fn extract_frontmatter_value(raw: &str, keys: &[&str]) -> Option<String> {
let Some((key, value)) = trimmed.split_once(':') else {
continue;
};
if !keys.iter().any(|candidate| candidate.eq_ignore_ascii_case(key.trim())) {
if !keys
.iter()
.any(|candidate| candidate.eq_ignore_ascii_case(key.trim()))
{
continue;
}
let mut cleaned = value.trim().to_string();
@@ -320,6 +330,79 @@ pub fn list_local_skills(project_dir: String) -> Result<Vec<LocalSkillCard>, Str
Ok(out)
}
#[tauri::command]
pub fn read_local_skill(project_dir: String, name: String) -> Result<LocalSkillContent, String> {
let project_dir = project_dir.trim();
if project_dir.is_empty() {
return Err("projectDir is required".to_string());
}
let name = validate_skill_name(&name)?;
let roots = collect_skill_roots(project_dir)?;
for root in roots {
let path = root.join(&name).join("SKILL.md");
if !path.is_file() {
continue;
}
let raw = fs::read_to_string(&path)
.map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
return Ok(LocalSkillContent {
path: path.to_string_lossy().to_string(),
content: raw,
});
}
Err("Skill not found".to_string())
}
#[tauri::command]
pub fn write_local_skill(
project_dir: String,
name: String,
content: String,
) -> Result<ExecResult, String> {
let project_dir = project_dir.trim();
if project_dir.is_empty() {
return Err("projectDir is required".to_string());
}
let name = validate_skill_name(&name)?;
let roots = collect_skill_roots(project_dir)?;
let mut target: Option<PathBuf> = None;
for root in roots {
let path = root.join(&name).join("SKILL.md");
if path.is_file() {
target = Some(path);
break;
}
}
let Some(path) = target else {
return Ok(ExecResult {
ok: false,
status: 1,
stdout: String::new(),
stderr: "Skill not found".to_string(),
});
};
let next = if content.ends_with('\n') {
content
} else {
format!("{}\n", content)
};
fs::write(&path, next).map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
Ok(ExecResult {
ok: true,
status: 0,
stdout: format!("Saved skill {}", name),
stderr: String::new(),
})
}
#[tauri::command]
pub fn install_skill_template(
project_dir: String,

View File

@@ -29,7 +29,9 @@ use commands::owpenbot::{
owpenbot_config_set, owpenbot_info, owpenbot_pairing_approve, owpenbot_pairing_deny,
owpenbot_pairing_list, owpenbot_qr, owpenbot_start, owpenbot_status, owpenbot_stop,
};
use commands::skills::{install_skill_template, list_local_skills, uninstall_skill};
use commands::skills::{
install_skill_template, list_local_skills, read_local_skill, uninstall_skill, write_local_skill,
};
use commands::updater::updater_environment;
use commands::window::set_window_decorations;
use commands::workspace::{
@@ -99,7 +101,9 @@ pub fn run() {
import_skill,
install_skill_template,
list_local_skills,
read_local_skill,
uninstall_skill,
write_local_skill,
read_opencode_config,
write_opencode_config,
updater_environment,

View File

@@ -709,6 +709,22 @@ function createRoutes(config: ServerConfig, approvals: ApprovalService): Route[]
return jsonResponse({ items });
});
addRoute(routes, "GET", "/workspace/:id/skills/:name", "client", async (ctx) => {
const workspace = await resolveWorkspace(config, ctx.params.id);
const includeGlobal = ctx.url.searchParams.get("includeGlobal") === "true";
const name = String(ctx.params.name ?? "").trim();
if (!name) {
throw new ApiError(400, "invalid_skill_name", "Skill name is required");
}
const items = await listSkills(workspace.path, includeGlobal);
const item = items.find((skill) => skill.name === name);
if (!item) {
throw new ApiError(404, "skill_not_found", `Skill not found: ${name}`);
}
const content = await readFile(item.path, "utf8");
return jsonResponse({ item, content });
});
addRoute(routes, "POST", "/workspace/:id/skills", "client", async (ctx) => {
ensureWritable(config);
const workspace = await resolveWorkspace(config, ctx.params.id);