mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
Add plugin manager and bump to v0.1.2
This commit is contained in:
73
.opencode/skill/publish/SKILL.md
Normal file
73
.opencode/skill/publish/SKILL.md
Normal 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
|
||||
```
|
||||
35
.opencode/skill/publish/client.ts
Normal file
35
.opencode/skill/publish/client.ts
Normal 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 };
|
||||
}
|
||||
36
.opencode/skill/publish/first-call.ts
Normal file
36
.opencode/skill/publish/first-call.ts
Normal 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);
|
||||
});
|
||||
23
.opencode/skill/publish/load-env.ts
Normal file
23
.opencode/skill/publish/load-env.ts
Normal 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 };
|
||||
}
|
||||
17
README.md
17
README.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
2
src-tauri/Cargo.lock
generated
@@ -2014,7 +2014,7 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "openwork"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "openwork"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
description = "OpenWork"
|
||||
authors = ["Different AI"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
571
src/App.tsx
571
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<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"
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user