Add plugin manager and bump to v0.1.2

This commit is contained in:
Benjamin Shafii
2026-01-13 21:23:52 -08:00
parent 98a31f9ee5
commit 02fdac0a4a
14 changed files with 952 additions and 57 deletions

View File

@@ -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_<version>_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 "<summary>"
gh release upload vX.Y.Z "src-tauri/target/release/bundle/dmg/<DMG_NAME>.dmg" --clobber
```
## Helper
Run the quick check:
```bash
bun .opencode/skill/publish/first-call.ts
```

View File

@@ -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<number>((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 };
}

View File

@@ -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 <dmg> --clobber",
],
},
null,
2,
),
);
}
main().catch((e) => {
const message = e instanceof Error ? e.message : String(e);
console.error(message);
process.exit(1);
});

View File

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

View File

@@ -102,6 +102,23 @@ If `opkg` is not installed globally, OpenWork falls back to:
pnpm dlx opkg install <package>
```
## 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**: `<workspace>/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

View File

@@ -18,6 +18,7 @@ OpenWork competes directly with Anthropics 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 @@ OpenWorks 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

View File

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

2
src-tauri/Cargo.lock generated
View File

@@ -2014,7 +2014,7 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "openwork"
version = "0.1.1"
version = "0.1.2"
dependencies = [
"serde",
"serde_json",

View File

@@ -1,6 +1,6 @@
[package]
name = "openwork"
version = "0.1.1"
version = "0.1.2"
description = "OpenWork"
authors = ["Different AI"]
edition = "2021"

View File

@@ -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<String>,
}
fn find_free_port() -> Result<u16, String> {
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<ExecResult, String> {
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<Option<ExecResult>, 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<PathBuf, String> {
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<ExecResult, Stri
return Err("package is required".to_string());
}
let mut command = Command::new("opkg");
command
let mut opkg = Command::new("opkg");
opkg
.arg("install")
.arg(&package)
.current_dir(&project_dir)
@@ -213,33 +249,58 @@ fn opkg_install(project_dir: String, package: String) -> Result<ExecResult, Stri
.stdout(Stdio::piped())
.stderr(Stdio::piped());
match command.output() {
Ok(output) => {
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<OpencodeConfigFile, String> {
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<ExecResult, String> {
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");

View File

@@ -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",

View File

@@ -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<typeof createClient>;
@@ -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<SkillCard[]>([]);
const [skillsStatus, setSkillsStatus] = createSignal<string | null>(null);
const [openPackageSource, setOpenPackageSource] = createSignal("");
const [packageSearch, setPackageSearch] = createSignal("");
const [pluginScope, setPluginScope] = createSignal<PluginScope>("project");
const [pluginConfig, setPluginConfig] = createSignal<OpencodeConfigFile | null>(null);
const [pluginList, setPluginList] = createSignal<string[]>([]);
const [pluginInput, setPluginInput] = createSignal("");
const [pluginStatus, setPluginStatus] = createSignal<string | null>(null);
const [activePluginGuide, setActivePluginGuide] = createSignal<string | null>(null);
const [events, setEvents] = createSignal<OpencodeEvent[]>([]);
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<string, unknown> | 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<string, unknown> | 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)}
/>
<Button
onClick={installFromOpenPackage}
onClick={() => installFromOpenPackage()}
disabled={busy() || mode() !== "host" || !isTauriRuntime()}
class="md:w-auto"
>
@@ -1720,6 +2011,69 @@ export default function App() {
</Show>
</div>
<div class="bg-zinc-900/30 border border-zinc-800/50 rounded-2xl p-5 space-y-4">
<div class="flex items-center justify-between">
<div class="text-sm font-medium text-white">Curated packages</div>
<div class="text-xs text-zinc-500">{filteredPackages().length}</div>
</div>
<input
class="w-full bg-zinc-900/50 border border-zinc-800 rounded-xl px-3 py-2 text-sm text-white placeholder-zinc-600 focus:outline-none focus:ring-1 focus:ring-zinc-600 focus:border-zinc-600 transition-all"
placeholder="Search packages or lists (e.g. claude, registry, community)"
value={packageSearch()}
onInput={(e) => setPackageSearch(e.currentTarget.value)}
/>
<Show
when={filteredPackages().length}
fallback={
<div class="rounded-xl bg-black/20 border border-zinc-800 p-3 text-xs text-zinc-400">
No curated matches. Try a different search.
</div>
}
>
<div class="space-y-3">
<For each={filteredPackages()}>
{(pkg) => (
<div class="rounded-xl border border-zinc-800/70 bg-zinc-950/40 p-4">
<div class="flex items-start justify-between gap-4">
<div class="space-y-2">
<div class="text-sm font-medium text-white">{pkg.name}</div>
<div class="text-xs text-zinc-500 font-mono break-all">{pkg.source}</div>
<div class="text-sm text-zinc-500">{pkg.description}</div>
<div class="flex flex-wrap gap-2">
<For each={pkg.tags}>
{(tag) => (
<span class="text-[10px] uppercase tracking-wide bg-zinc-800/70 text-zinc-400 px-2 py-0.5 rounded-full">
{tag}
</span>
)}
</For>
</div>
</div>
<Button
variant={pkg.installable ? "secondary" : "outline"}
onClick={() => useCuratedPackage(pkg)}
disabled={
busy() ||
(pkg.installable && (mode() !== "host" || !isTauriRuntime()))
}
>
{pkg.installable ? "Install" : "View"}
</Button>
</div>
</div>
)}
</For>
</div>
</Show>
<div class="text-xs text-zinc-500">
Publishing to the OpenPackage registry (`opkg push`) requires authentication today. A registry search + curated list sync is planned.
</div>
</div>
<div>
<div class="flex items-center justify-between mb-3">
<div class="text-sm font-medium text-white">Installed skills</div>
@@ -1755,6 +2109,199 @@ export default function App() {
</section>
</Match>
<Match when={tab() === "plugins"}>
<section class="space-y-6">
<div class="bg-zinc-900/30 border border-zinc-800/50 rounded-2xl p-5 space-y-4">
<div class="flex items-start justify-between gap-4">
<div class="space-y-1">
<div class="text-sm font-medium text-white">OpenCode plugins</div>
<div class="text-xs text-zinc-500">
Manage `opencode.json` for your project or global OpenCode plugins.
</div>
</div>
<div class="flex items-center gap-2">
<button
class={`px-3 py-1 rounded-full text-xs font-medium border transition-colors ${
pluginScope() === "project"
? "bg-white/10 text-white border-white/20"
: "text-zinc-500 border-zinc-800 hover:text-white"
}`}
onClick={() => {
setPluginScope("project");
refreshPlugins("project").catch(() => undefined);
}}
>
Project
</button>
<button
class={`px-3 py-1 rounded-full text-xs font-medium border transition-colors ${
pluginScope() === "global"
? "bg-white/10 text-white border-white/20"
: "text-zinc-500 border-zinc-800 hover:text-white"
}`}
onClick={() => {
setPluginScope("global");
refreshPlugins("global").catch(() => undefined);
}}
>
Global
</button>
<Button variant="ghost" onClick={() => refreshPlugins().catch(() => undefined)}>
Refresh
</Button>
</div>
</div>
<div class="flex flex-col gap-1 text-xs text-zinc-500">
<div>Config</div>
<div class="text-zinc-600 font-mono truncate">
{pluginConfig()?.path ?? "Not loaded yet"}
</div>
</div>
<div class="space-y-3">
<div class="text-xs font-medium text-zinc-400 uppercase tracking-wider">Suggested plugins</div>
<div class="grid gap-3">
<For each={SUGGESTED_PLUGINS}>
{(plugin) => {
const isGuided = () => plugin.installMode === "guided";
const isInstalled = () =>
isPluginInstalled(plugin.packageName, plugin.aliases ?? []);
const isGuideOpen = () => activePluginGuide() === plugin.packageName;
return (
<div class="rounded-2xl border border-zinc-800/60 bg-zinc-950/40 p-4 space-y-3">
<div class="flex items-start justify-between gap-4">
<div>
<div class="text-sm font-medium text-white font-mono">{plugin.name}</div>
<div class="text-xs text-zinc-500 mt-1">{plugin.description}</div>
<Show when={plugin.packageName !== plugin.name}>
<div class="text-xs text-zinc-600 font-mono mt-1">
{plugin.packageName}
</div>
</Show>
</div>
<div class="flex items-center gap-2">
<Show when={isGuided()}>
<Button
variant="ghost"
onClick={() =>
setActivePluginGuide(isGuideOpen() ? null : plugin.packageName)
}
>
{isGuideOpen() ? "Hide setup" : "Setup"}
</Button>
</Show>
<Button
variant={isInstalled() ? "outline" : "secondary"}
onClick={() => addPlugin(plugin.packageName)}
disabled={
busy() ||
isInstalled() ||
!isTauriRuntime() ||
(pluginScope() === "project" && !projectDir().trim())
}
>
{isInstalled() ? "Added" : "Add"}
</Button>
</div>
</div>
<div class="flex flex-wrap gap-2">
<For each={plugin.tags}>
{(tag) => (
<span class="text-[10px] uppercase tracking-wide bg-zinc-800/70 text-zinc-400 px-2 py-0.5 rounded-full">
{tag}
</span>
)}
</For>
</div>
<Show when={isGuided() && isGuideOpen()}>
<div class="rounded-xl border border-zinc-800/70 bg-zinc-950/60 p-4 space-y-3">
<For each={plugin.steps ?? []}>
{(step, idx) => (
<div class="space-y-1">
<div class="text-xs font-medium text-zinc-300">
{idx() + 1}. {step.title}
</div>
<div class="text-xs text-zinc-500">{step.description}</div>
<Show when={step.command}>
<div class="text-xs font-mono text-zinc-200 bg-zinc-900/60 border border-zinc-800/70 rounded-lg px-3 py-2">
{step.command}
</div>
</Show>
<Show when={step.note}>
<div class="text-xs text-zinc-500">{step.note}</div>
</Show>
<Show when={step.url}>
<div class="text-xs text-zinc-500">
Open: <span class="font-mono text-zinc-400">{step.url}</span>
</div>
</Show>
<Show when={step.path}>
<div class="text-xs text-zinc-500">
Path: <span class="font-mono text-zinc-400">{step.path}</span>
</div>
</Show>
</div>
)}
</For>
</div>
</Show>
</div>
);
}}
</For>
</div>
</div>
<Show
when={pluginList().length}
fallback={
<div class="rounded-xl border border-zinc-800/60 bg-zinc-950/40 p-4 text-sm text-zinc-500">
No plugins configured yet.
</div>
}
>
<div class="grid gap-2">
<For each={pluginList()}>
{(pluginName) => (
<div class="flex items-center justify-between rounded-xl border border-zinc-800/60 bg-zinc-950/40 px-4 py-2.5">
<div class="text-sm text-zinc-200 font-mono">{pluginName}</div>
<div class="text-[10px] uppercase tracking-wide text-zinc-500">Enabled</div>
</div>
)}
</For>
</div>
</Show>
<div class="flex flex-col gap-3">
<div class="flex flex-col md:flex-row gap-3">
<div class="flex-1">
<TextInput
label="Add plugin"
placeholder="opencode-wakatime"
value={pluginInput()}
onInput={(e) => setPluginInput(e.currentTarget.value)}
hint="Add npm package names, e.g. opencode-wakatime"
/>
</div>
<Button
variant="secondary"
onClick={() => addPlugin()}
disabled={busy() || !pluginInput().trim()}
class="md:mt-6"
>
Add
</Button>
</div>
<Show when={pluginStatus()}>
<div class="text-xs text-zinc-500">{pluginStatus()}</div>
</Show>
</div>
</div>
</section>
</Match>
<Match when={tab() === "settings"}>
<section class="space-y-6">
<div class="bg-zinc-900/30 border border-zinc-800/50 rounded-2xl p-5 space-y-3">
@@ -1820,6 +2367,7 @@ export default function App() {
{navItem("sessions", "Sessions", <Play size={18} />)}
{navItem("templates", "Templates", <FileText size={18} />)}
{navItem("skills", "Skills", <Package size={18} />)}
{navItem("plugins", "Plugins", <Cpu size={18} />)}
{navItem("settings", "Settings", <Settings size={18} />)}
</nav>
</div>
@@ -1910,7 +2458,7 @@ export default function App() {
</Show>
<nav class="md:hidden fixed bottom-0 left-0 right-0 border-t border-zinc-800 bg-zinc-950/90 backdrop-blur-md">
<div class="mx-auto max-w-5xl px-4 py-3 grid grid-cols-5 gap-2">
<div class="mx-auto max-w-5xl px-4 py-3 grid grid-cols-6 gap-2">
<button
class={`flex flex-col items-center gap-1 text-xs ${
tab() === "home" ? "text-white" : "text-zinc-500"
@@ -1947,6 +2495,15 @@ export default function App() {
<Package size={18} />
Skills
</button>
<button
class={`flex flex-col items-center gap-1 text-xs ${
tab() === "plugins" ? "text-white" : "text-zinc-500"
}`}
onClick={() => setTab("plugins")}
>
<Cpu size={18} />
Plugins
</button>
<button
class={`flex flex-col items-center gap-1 text-xs ${
tab() === "settings" ? "text-white" : "text-zinc-500"

View File

@@ -1,3 +1,4 @@
import { splitProps } from "solid-js";
import type { JSX } from "solid-js";
type ButtonProps = JSX.ButtonHTMLAttributes<HTMLButtonElement> & {
@@ -5,7 +6,8 @@ type ButtonProps = JSX.ButtonHTMLAttributes<HTMLButtonElement> & {
};
export default function Button(props: ButtonProps) {
const variant = () => props.variant ?? "primary";
const [local, rest] = splitProps(props, ["variant", "class", "disabled", "title", "type"]);
const variant = () => local.variant ?? "primary";
const base =
"inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium transition-all duration-200 active:scale-95 focus:outline-none focus:ring-2 focus:ring-white/15 disabled:opacity-50 disabled:cursor-not-allowed";
@@ -18,12 +20,14 @@ export default function Button(props: ButtonProps) {
danger: "bg-red-500/10 text-red-400 hover:bg-red-500/20 border border-red-500/20",
};
const { variant: _variant, class: className, ...rest } = props;
return (
<button
{...rest}
class={`${base} ${variants[variant()]} ${className ?? ""}`.trim()}
type={local.type ?? "button"}
disabled={local.disabled}
aria-disabled={local.disabled}
title={local.title}
class={`${base} ${variants[variant()]} ${local.class ?? ""}`.trim()}
/>
);
}

View File

@@ -57,3 +57,24 @@ export async function importSkill(
overwrite: options?.overwrite ?? false,
});
}
export type OpencodeConfigFile = {
path: string;
exists: boolean;
content: string | null;
};
export async function readOpencodeConfig(
scope: "project" | "global",
projectDir: string,
): Promise<OpencodeConfigFile> {
return invoke<OpencodeConfigFile>("read_opencode_config", { scope, projectDir });
}
export async function writeOpencodeConfig(
scope: "project" | "global",
projectDir: string,
content: string,
): Promise<ExecResult> {
return invoke<ExecResult>("write_opencode_config", { scope, projectDir, content });
}