mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
feat(skills): add basic viewer/editor with server-backed reads
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user