diff --git a/.opencode/skill/publish/SKILL.md b/.opencode/skill/publish/SKILL.md new file mode 100644 index 00000000..2fddddae --- /dev/null +++ b/.opencode/skill/publish/SKILL.md @@ -0,0 +1,73 @@ +name: publish +description: Publish OpenWork DMG releases +--- + +## Purpose + +Publish a new OpenWork release (version bump + DMG + GitHub release) without forgetting any steps. + +## Prereqs + +- `pnpm` +- Rust toolchain (`cargo`, `rustc`) +- `gh` authenticated (`gh auth status`) +- macOS tools: `hdiutil`, `codesign`, `spctl` + +## Workflow + +### 1) Clean working tree + +```bash +git status +``` + +### 2) Bump version everywhere + +- `package.json` (`version`) +- `src-tauri/tauri.conf.json` (`version`) +- `src-tauri/Cargo.toml` (`version`) + +### 3) Validate builds + +```bash +pnpm typecheck +pnpm build:web +cargo check --manifest-path src-tauri/Cargo.toml +``` + +### 4) Build DMG + +```bash +pnpm tauri build --bundles dmg +``` + +Expected output (Apple Silicon example): + +- `src-tauri/target/release/bundle/dmg/OpenWork__aarch64.dmg` + +### 5) Commit + tag + +```bash +git commit -am "Release vX.Y.Z" +git tag -a vX.Y.Z -m "OpenWork vX.Y.Z" +git push +git push origin vX.Y.Z +``` + +### 6) GitHub release + +```bash +gh release create vX.Y.Z \ + --title "OpenWork vX.Y.Z" \ + --notes "" + +gh release upload vX.Y.Z "src-tauri/target/release/bundle/dmg/.dmg" --clobber +``` + +## Helper + +Run the quick check: + +```bash +bun .opencode/skill/publish/first-call.ts +``` diff --git a/.opencode/skill/publish/client.ts b/.opencode/skill/publish/client.ts new file mode 100644 index 00000000..8685c1a1 --- /dev/null +++ b/.opencode/skill/publish/client.ts @@ -0,0 +1,35 @@ +import { spawn } from "child_process"; + +export async function run( + command: string, + args: string[], + options?: { cwd?: string; allowFailure?: boolean }, +): Promise<{ ok: boolean; code: number; stdout: string; stderr: string }> { + const child = spawn(command, args, { + cwd: options?.cwd, + stdio: ["ignore", "pipe", "pipe"], + env: process.env, + }); + + let stdout = ""; + let stderr = ""; + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + + child.stdout.on("data", (d) => (stdout += d)); + child.stderr.on("data", (d) => (stderr += d)); + + const code = await new Promise((resolve) => { + child.on("close", (c) => resolve(c ?? -1)); + }); + + const ok = code === 0; + if (!ok && !options?.allowFailure) { + throw new Error( + `Command failed (${code}): ${command} ${args.join(" ")}\n${stderr || stdout}`, + ); + } + + return { ok, code, stdout, stderr }; +} diff --git a/.opencode/skill/publish/first-call.ts b/.opencode/skill/publish/first-call.ts new file mode 100644 index 00000000..1a1e15b5 --- /dev/null +++ b/.opencode/skill/publish/first-call.ts @@ -0,0 +1,36 @@ +import { readFile } from "fs/promises"; +import { loadEnv } from "./load-env"; +import { run } from "./client"; + +async function main() { + await loadEnv(); + await run("gh", ["auth", "status"], { allowFailure: false }); + + const pkgRaw = await readFile("package.json", "utf8"); + const pkg = JSON.parse(pkgRaw) as { name?: string; version?: string }; + + console.log( + JSON.stringify( + { + ok: true, + package: pkg.name ?? null, + version: pkg.version ?? null, + next: [ + "pnpm typecheck", + "pnpm build:web", + "cargo check --manifest-path src-tauri/Cargo.toml", + "pnpm tauri build --bundles dmg", + "gh release upload vX.Y.Z --clobber", + ], + }, + null, + 2, + ), + ); +} + +main().catch((e) => { + const message = e instanceof Error ? e.message : String(e); + console.error(message); + process.exit(1); +}); diff --git a/.opencode/skill/publish/load-env.ts b/.opencode/skill/publish/load-env.ts new file mode 100644 index 00000000..0db77721 --- /dev/null +++ b/.opencode/skill/publish/load-env.ts @@ -0,0 +1,23 @@ +import { run } from "./client"; + +const REQUIRED = ["pnpm", "cargo", "gh", "hdiutil", "codesign", "spctl", "git"]; + +export async function loadEnv() { + const missing: string[] = []; + + for (const bin of REQUIRED) { + try { + await run("/usr/bin/env", ["bash", "-lc", `command -v ${bin}`], { + allowFailure: false, + }); + } catch { + missing.push(bin); + } + } + + if (missing.length) { + throw new Error(`Missing required tools: ${missing.join(", ")}`); + } + + return { ok: true as const }; +} diff --git a/README.md b/README.md index c33fdf4e..54025f72 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,23 @@ If `opkg` is not installed globally, OpenWork falls back to: pnpm dlx opkg install ``` +## OpenCode Plugins + +Plugins are the **native** way to extend OpenCode. OpenWork now manages them from the Skills tab by +reading and writing `opencode.json`. + +- **Project scope**: `/opencode.json` +- **Global scope**: `~/.config/opencode/opencode.json` (or `$XDG_CONFIG_HOME/opencode/opencode.json`) + +You can still edit `opencode.json` manually; OpenWork uses the same format as the OpenCode CLI: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["opencode-wakatime"] +} +``` + ## Useful Commands ```bash diff --git a/design-prd.md b/design-prd.md index c614fc90..a1e430c0 100644 --- a/design-prd.md +++ b/design-prd.md @@ -18,6 +18,7 @@ OpenWork competes directly with Anthropic’s Cowork conceptually, but stays ope - Provide long-running tasks with resumability. - Provide explicit, understandable permissions and auditing. - Work with **only the folders the user authorizes**. +- Treat **plugins + skills** as the primary extensibility system. ## Non-Goals @@ -173,6 +174,28 @@ OpenWork’s settings pages use: - `client.config.providers()` - `client.auth.set()` (optional flow to store keys) +### Extensibility — Skills + Plugins + +OpenWork exposes two extension surfaces: + +1. **Skills (OpenPackage)** + - Installed into `.opencode/skill/*`. + - OpenWork can run `opkg install` to pull packages from the registry or GitHub. + +2. **Plugins (OpenCode)** + - Plugins are configured via `opencode.json` in the workspace. + - The format is the same as OpenCode CLI uses today. + - OpenWork should show plugin status and instructions; a native plugin manager is planned. + +### OpenPackage Registry (Current + Future) + +- Today, OpenWork only supports **curated lists + manual sources**. +- Publishing to the official registry currently requires authentication (`opkg push` + `opkg configure`). +- Future goals: + - in-app registry search + - curated list sync (e.g. Awesome Claude Skills) + - frictionless publishing without signup (pending registry changes) + ## When it comes to design diff --git a/package.json b/package.json index 0a877204..6be545e3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@different-ai/openwork", "private": true, - "version": "0.1.1", + "version": "0.1.2", "type": "module", "scripts": { "dev": "tauri dev", @@ -22,6 +22,7 @@ "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-dialog": "^2.5.0", "lucide-solid": "^0.562.0", + "jsonc-parser": "^3.2.1", "solid-js": "^1.9.0" }, "devDependencies": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 50259f9d..b195e099 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2014,7 +2014,7 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "openwork" -version = "0.1.1" +version = "0.1.2" dependencies = [ "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cf798bc3..0484ec73 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openwork" -version = "0.1.1" +version = "0.1.2" description = "OpenWork" authors = ["Different AI"] edition = "2021" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6fee40de..9f979d01 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ use std::{ + env, fs, net::TcpListener, path::{Path, PathBuf}, @@ -43,25 +44,37 @@ pub struct ExecResult { pub stderr: String, } +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct OpencodeConfigFile { + pub path: String, + pub exists: bool, + pub content: Option, +} + fn find_free_port() -> Result { let listener = TcpListener::bind(("127.0.0.1", 0)).map_err(|e| e.to_string())?; let port = listener.local_addr().map_err(|e| e.to_string())?.port(); Ok(port) } -fn run_capture(command: &mut Command) -> Result { - let output = command - .output() - .map_err(|e| format!("Failed to run command: {e}"))?; - - let status = output.status.code().unwrap_or(-1); - - Ok(ExecResult { - ok: output.status.success(), - status, - stdout: String::from_utf8_lossy(&output.stdout).to_string(), - stderr: String::from_utf8_lossy(&output.stderr).to_string(), - }) +fn run_capture_optional(command: &mut Command) -> Result, String> { + match command.output() { + Ok(output) => { + let status = output.status.code().unwrap_or(-1); + Ok(Some(ExecResult { + ok: output.status.success(), + status, + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + })) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(format!( + "Failed to run {}: {e}", + command.get_program().to_string_lossy() + )), + } } fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<(), String> { @@ -95,6 +108,29 @@ fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<(), String> { Ok(()) } +fn resolve_opencode_config_path(scope: &str, project_dir: &str) -> Result { + match scope { + "project" => { + if project_dir.trim().is_empty() { + return Err("projectDir is required".to_string()); + } + Ok(PathBuf::from(project_dir).join("opencode.json")) + } + "global" => { + let base = if let Ok(dir) = env::var("XDG_CONFIG_HOME") { + PathBuf::from(dir) + } else if let Ok(home) = env::var("HOME") { + PathBuf::from(home).join(".config") + } else { + return Err("Unable to resolve config directory".to_string()); + }; + + Ok(base.join("opencode").join("opencode.json")) + } + _ => Err("scope must be 'project' or 'global'".to_string()), + } +} + impl EngineManager { fn snapshot_locked(state: &mut EngineState) -> EngineInfo { let (running, pid) = match state.child.as_mut() { @@ -204,8 +240,8 @@ fn opkg_install(project_dir: String, package: String) -> Result Result { - let status = output.status.code().unwrap_or(-1); - Ok(ExecResult { - ok: output.status.success(), - status, - stdout: String::from_utf8_lossy(&output.stdout).to_string(), - stderr: String::from_utf8_lossy(&output.stderr).to_string(), - }) - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - // Fallback: use pnpm to download+run opkg on demand. - let mut pnpm = Command::new("pnpm"); - pnpm - .arg("dlx") - .arg("opkg") - .arg("install") - .arg(&package) - .current_dir(&project_dir) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - run_capture(&mut pnpm) - } - Err(e) => Err(format!("Failed to run opkg: {e}")), + if let Some(result) = run_capture_optional(&mut opkg)? { + return Ok(result); } + + let mut openpackage = Command::new("openpackage"); + openpackage + .arg("install") + .arg(&package) + .current_dir(&project_dir) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + if let Some(result) = run_capture_optional(&mut openpackage)? { + return Ok(result); + } + + let mut pnpm = Command::new("pnpm"); + pnpm + .arg("dlx") + .arg("opkg") + .arg("install") + .arg(&package) + .current_dir(&project_dir) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + if let Some(result) = run_capture_optional(&mut pnpm)? { + return Ok(result); + } + + let mut npx = Command::new("npx"); + npx + .arg("opkg") + .arg("install") + .arg(&package) + .current_dir(&project_dir) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + if let Some(result) = run_capture_optional(&mut npx)? { + return Ok(result); + } + + Ok(ExecResult { + ok: false, + status: -1, + stdout: String::new(), + stderr: "OpenPackage CLI not found. Install with `npm install -g opkg` (or `openpackage`), or ensure pnpm/npx is available.".to_string(), + }) } #[tauri::command] @@ -284,6 +345,48 @@ fn import_skill(project_dir: String, source_dir: String, overwrite: bool) -> Res }) } +#[tauri::command] +fn read_opencode_config(scope: String, project_dir: String) -> Result { + let path = resolve_opencode_config_path(scope.trim(), &project_dir)?; + let exists = path.exists(); + + let content = if exists { + Some(fs::read_to_string(&path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?) + } else { + None + }; + + Ok(OpencodeConfigFile { + path: path.to_string_lossy().to_string(), + exists, + content, + }) +} + +#[tauri::command] +fn write_opencode_config( + scope: String, + project_dir: String, + content: String, +) -> Result { + let path = resolve_opencode_config_path(scope.trim(), &project_dir)?; + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create config dir {}: {e}", parent.display()))?; + } + + fs::write(&path, content) + .map_err(|e| format!("Failed to write {}: {e}", path.display()))?; + + Ok(ExecResult { + ok: true, + status: 0, + stdout: format!("Wrote {}", path.display()), + stderr: String::new(), + }) +} + pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) @@ -293,7 +396,9 @@ pub fn run() { engine_stop, engine_info, opkg_install, - import_skill + import_skill, + read_opencode_config, + write_opencode_config ]) .run(tauri::generate_context!()) .expect("error while running OpenWork"); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index fd388907..3ca15da1 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenWork", - "version": "0.1.1", + "version": "0.1.2", "identifier": "com.differentai.openwork", "build": { "beforeDevCommand": "pnpm dev:web", diff --git a/src/App.tsx b/src/App.tsx index 3b134455..eac5190b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,8 @@ import { onMount, } from "solid-js"; +import { applyEdits, modify, parse } from "jsonc-parser"; + import type { Message, Part, @@ -52,7 +54,10 @@ import { importSkill, opkgInstall, pickDirectory, + readOpencodeConfig, + writeOpencodeConfig, type EngineInfo, + type OpencodeConfigFile, } from "./lib/tauri"; type Client = ReturnType; @@ -73,7 +78,7 @@ type Mode = "host" | "client"; type OnboardingStep = "mode" | "host" | "client" | "connecting"; -type DashboardTab = "home" | "sessions" | "templates" | "skills" | "settings"; +type DashboardTab = "home" | "sessions" | "templates" | "skills" | "plugins" | "settings"; type Template = { id: string; @@ -89,10 +94,118 @@ type SkillCard = { description?: string; }; +type CuratedPackage = { + name: string; + source: string; + description: string; + tags: string[]; + installable: boolean; +}; + +type PluginInstallStep = { + title: string; + description: string; + command?: string; + url?: string; + path?: string; + note?: string; +}; + +type SuggestedPlugin = { + name: string; + packageName: string; + description: string; + tags: string[]; + aliases?: string[]; + installMode?: "simple" | "guided"; + steps?: PluginInstallStep[]; +}; + +type PluginScope = "project" | "global"; + type PendingPermission = ApiPermissionRequest & { receivedAt: number; }; +const CURATED_PACKAGES: CuratedPackage[] = [ + { + name: "OpenPackage Essentials", + source: "essentials", + description: "Starter rules, commands, and skills from the OpenPackage registry.", + tags: ["registry", "starter"], + installable: true, + }, + { + name: "Claude Code Plugins", + source: "github:anthropics/claude-code", + description: "Official Claude Code plugin pack from GitHub.", + tags: ["github", "claude"], + installable: true, + }, + { + name: "Claude Code Commit Commands", + source: "github:anthropics/claude-code#subdirectory=plugins/commit-commands", + description: "Commit message helper commands (Claude Code plugin).", + tags: ["github", "workflow"], + installable: true, + }, + { + name: "Awesome OpenPackage", + source: "git:https://github.com/enulus/awesome-openpackage.git", + description: "Community collection of OpenPackage examples and templates.", + tags: ["community"], + installable: true, + }, + { + name: "Awesome Claude Skills", + source: "https://github.com/ComposioHQ/awesome-claude-skills", + description: "Curated list of Claude skills and prompts (not an OpenPackage yet).", + tags: ["community", "list"], + installable: false, + }, +]; + +const SUGGESTED_PLUGINS: SuggestedPlugin[] = [ + { + name: "opencode-scheduler", + packageName: "opencode-scheduler", + description: "Run scheduled jobs with the OpenCode scheduler plugin.", + tags: ["automation", "jobs"], + installMode: "simple", + }, + { + name: "opencode-browser", + packageName: "@different-ai/opencode-browser", + description: "Browser automation with a local extension + native host.", + tags: ["browser", "extension"], + aliases: ["opencode-browser"], + installMode: "guided", + steps: [ + { + title: "Run the installer", + description: "Installs the extension + native host and prepares the local broker.", + command: "bunx @different-ai/opencode-browser@latest install", + note: "Use npx @different-ai/opencode-browser@latest install if you do not have bunx.", + }, + { + title: "Load the extension", + description: + "Open chrome://extensions, enable Developer mode, click Load unpacked, and select the extension folder.", + url: "chrome://extensions", + path: "~/.opencode-browser/extension", + }, + { + title: "Pin the extension", + description: "Pin OpenCode Browser Automation in your browser toolbar.", + }, + { + title: "Add plugin to config", + description: "Click Add to write @different-ai/opencode-browser into opencode.json.", + }, + ], + }, +]; + function isTauriRuntime() { return typeof window !== "undefined" && (window as any).__TAURI_INTERNALS__ != null; } @@ -285,6 +398,14 @@ export default function App() { const [skills, setSkills] = createSignal([]); const [skillsStatus, setSkillsStatus] = createSignal(null); const [openPackageSource, setOpenPackageSource] = createSignal(""); + const [packageSearch, setPackageSearch] = createSignal(""); + + const [pluginScope, setPluginScope] = createSignal("project"); + const [pluginConfig, setPluginConfig] = createSignal(null); + const [pluginList, setPluginList] = createSignal([]); + const [pluginInput, setPluginInput] = createSignal(""); + const [pluginStatus, setPluginStatus] = createSignal(null); + const [activePluginGuide, setActivePluginGuide] = createSignal(null); const [events, setEvents] = createSignal([]); const [developerMode, setDeveloperMode] = createSignal(false); @@ -313,6 +434,57 @@ export default function App() { return busy(); }); + const filteredPackages = createMemo(() => { + const query = packageSearch().trim().toLowerCase(); + if (!query) return CURATED_PACKAGES; + + return CURATED_PACKAGES.filter((pkg) => { + const haystack = [pkg.name, pkg.source, pkg.description, pkg.tags.join(" ")] + .join(" ") + .toLowerCase(); + return haystack.includes(query); + }); + }); + + const normalizePluginList = (value: unknown) => { + if (!value) return [] as string[]; + if (Array.isArray(value)) { + return value + .map((entry) => (typeof entry === "string" ? entry.trim() : "")) + .filter((entry) => entry.length > 0); + } + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed ? [trimmed] : []; + } + return [] as string[]; + }; + + const pluginNamesLower = createMemo(() => + new Set(pluginList().map((entry) => entry.toLowerCase())), + ); + + const isPluginInstalled = (pluginName: string, aliases: string[] = []) => { + const list = pluginNamesLower(); + return [pluginName, ...aliases].some((entry) => list.has(entry.toLowerCase())); + }; + + const loadPluginsFromConfig = (config: OpencodeConfigFile | null) => { + if (!config?.content) { + setPluginList([]); + return; + } + + try { + const parsed = parse(config.content) as Record | undefined; + const next = normalizePluginList(parsed?.plugin); + setPluginList(next); + } catch (e) { + setPluginList([]); + setPluginStatus(e instanceof Error ? e.message : "Failed to parse opencode.json"); + } + }; + const selectedSession = createMemo(() => { const id = selectedSessionId(); if (!id) return null; @@ -675,14 +847,115 @@ export default function App() { } } - async function installFromOpenPackage() { + async function refreshPlugins(scopeOverride?: PluginScope) { + if (!isTauriRuntime()) { + setPluginStatus("Plugin management is only available in Host mode."); + setPluginList([]); + return; + } + + const scope = scopeOverride ?? pluginScope(); + const targetDir = projectDir().trim(); + + if (scope === "project" && !targetDir) { + setPluginStatus("Pick a project folder to manage project plugins."); + setPluginList([]); + return; + } + + try { + setPluginStatus(null); + const config = await readOpencodeConfig(scope, targetDir); + setPluginConfig(config); + + if (!config.exists) { + setPluginList([]); + setPluginStatus("No opencode.json found yet. Add a plugin to create one."); + return; + } + + loadPluginsFromConfig(config); + } catch (e) { + setPluginConfig(null); + setPluginList([]); + setPluginStatus(e instanceof Error ? e.message : "Failed to load opencode.json"); + } + } + + async function addPlugin(pluginNameOverride?: string) { + if (!isTauriRuntime()) { + setPluginStatus("Plugin management is only available in Host mode."); + return; + } + + const pluginName = (pluginNameOverride ?? pluginInput()).trim(); + const isManualInput = pluginNameOverride == null; + + if (!pluginName) { + if (isManualInput) { + setPluginStatus("Enter a plugin package name."); + } + return; + } + + const scope = pluginScope(); + const targetDir = projectDir().trim(); + + if (scope === "project" && !targetDir) { + setPluginStatus("Pick a project folder to manage project plugins."); + return; + } + + try { + setPluginStatus(null); + const config = await readOpencodeConfig(scope, targetDir); + const raw = config.content ?? ""; + + if (!raw.trim()) { + const payload = { + $schema: "https://opencode.ai/config.json", + plugin: [pluginName], + }; + await writeOpencodeConfig(scope, targetDir, `${JSON.stringify(payload, null, 2)}\n`); + if (isManualInput) { + setPluginInput(""); + } + await refreshPlugins(scope); + return; + } + + const parsed = parse(raw) as Record | undefined; + const plugins = normalizePluginList(parsed?.plugin); + + if (plugins.some((entry) => entry.toLowerCase() === pluginName.toLowerCase())) { + setPluginStatus("Plugin already listed in opencode.json."); + return; + } + + const next = [...plugins, pluginName]; + const edits = modify(raw, ["plugin"], next, { + formattingOptions: { insertSpaces: true, tabSize: 2 }, + }); + const updated = applyEdits(raw, edits); + + await writeOpencodeConfig(scope, targetDir, updated); + if (isManualInput) { + setPluginInput(""); + } + await refreshPlugins(scope); + } catch (e) { + setPluginStatus(e instanceof Error ? e.message : "Failed to update opencode.json"); + } + } + + async function installFromOpenPackage(sourceOverride?: string) { if (mode() !== "host" || !isTauriRuntime()) { setError("OpenPackage installs are only available in Host mode."); return; } const targetDir = projectDir().trim(); - const pkg = openPackageSource().trim(); + const pkg = (sourceOverride ?? openPackageSource()).trim(); if (!targetDir) { setError("Pick a project folder first."); @@ -694,9 +967,10 @@ export default function App() { return; } + setOpenPackageSource(pkg); setBusy(true); setError(null); - setSkillsStatus(null); + setSkillsStatus("Installing OpenPackage..."); try { const result = await opkgInstall(targetDir, pkg); @@ -708,12 +982,24 @@ export default function App() { await refreshSkills(); } catch (e) { - setError(e instanceof Error ? e.message : "Unknown error"); + setError(e instanceof Error ? e.message : safeStringify(e)); } finally { setBusy(false); } } + async function useCuratedPackage(pkg: CuratedPackage) { + if (pkg.installable) { + await installFromOpenPackage(pkg.source); + return; + } + + setOpenPackageSource(pkg.source); + setSkillsStatus( + "This is a curated list, not an OpenPackage yet. Copy the link or watch the PRD for planned registry search integration.", + ); + } + async function importLocalSkill() { if (mode() !== "host" || !isTauriRuntime()) { setError("Skill import is only available in Host mode."); @@ -1425,6 +1711,8 @@ export default function App() { return "Templates"; case "skills": return "Skills"; + case "plugins": + return "Plugins"; case "settings": return "Settings"; default: @@ -1438,6 +1726,9 @@ export default function App() { if (tab() === "skills") { refreshSkills().catch(() => undefined); } + if (tab() === "plugins") { + refreshPlugins().catch(() => undefined); + } }); const navItem = (t: DashboardTab, label: string, icon: any) => { @@ -1689,7 +1980,7 @@ export default function App() { onInput={(e) => setOpenPackageSource(e.currentTarget.value)} /> + + + )} + + + + +
+ Publishing to the OpenPackage registry (`opkg push`) requires authentication today. A registry search + curated list sync is planned. +
+ + +
Installed skills
@@ -1755,6 +2109,199 @@ export default function App() { + +
+
+
+
+
OpenCode plugins
+
+ Manage `opencode.json` for your project or global OpenCode plugins. +
+
+
+ + + +
+
+ +
+
Config
+
+ {pluginConfig()?.path ?? "Not loaded yet"} +
+
+ +
+
Suggested plugins
+
+ + {(plugin) => { + const isGuided = () => plugin.installMode === "guided"; + const isInstalled = () => + isPluginInstalled(plugin.packageName, plugin.aliases ?? []); + const isGuideOpen = () => activePluginGuide() === plugin.packageName; + + return ( +
+
+
+
{plugin.name}
+
{plugin.description}
+ +
+ {plugin.packageName} +
+
+
+
+ + + + +
+
+
+ + {(tag) => ( + + {tag} + + )} + +
+ +
+ + {(step, idx) => ( +
+
+ {idx() + 1}. {step.title} +
+
{step.description}
+ +
+ {step.command} +
+
+ +
{step.note}
+
+ +
+ Open: {step.url} +
+
+ +
+ Path: {step.path} +
+
+
+ )} +
+
+
+
+ ); + }} +
+
+
+ + + No plugins configured yet. +
+ } + > +
+ + {(pluginName) => ( +
+
{pluginName}
+
Enabled
+
+ )} +
+
+ + +
+
+
+ setPluginInput(e.currentTarget.value)} + hint="Add npm package names, e.g. opencode-wakatime" + /> +
+ +
+ +
{pluginStatus()}
+
+
+
+ + +
@@ -1820,6 +2367,7 @@ export default function App() { {navItem("sessions", "Sessions", )} {navItem("templates", "Templates", )} {navItem("skills", "Skills", )} + {navItem("plugins", "Plugins", )} {navItem("settings", "Settings", )}
@@ -1910,7 +2458,7 @@ export default function App() {