feat: openwork sync v0 (#280)

* docs: add openwork server PRD

* feat: wire OpenWork server remote management

* feat: add OpenWork server settings panel
This commit is contained in:
ben
2026-01-26 23:00:47 -08:00
committed by GitHub
parent 4d8333eeb0
commit 4bcbf8aae0
41 changed files with 3890 additions and 88 deletions

View File

@@ -0,0 +1,792 @@
# OpenWork Server
Bridge missing capabilities between OpenWork and OpenCode
---
## Summarize
Introduce an OpenWork server layer that fills gaps in OpenCode APIs, enabling remote clients to manage workspace config, skills, plugins, and MCPs without direct filesystem access.
---
## Define problem
Remote clients cannot read or write workspace config because critical state lives in the filesystem. OpenWork needs a safe, minimal surface to access and mutate config, skills, plugins, and MCPs when connected to a host.
---
## Set goals
- Bridge OpenWork needs that OpenCode does not expose today
- Enable remote clients to view and update workspace config safely
- Keep the surface minimal, auditable, and aligned to OpenCode primitives
---
## Mark non-goals
- Replacing OpenCode's server or duplicating its APIs
- Arbitrary filesystem access outside approved workspace roots
- Hosting multi-tenant or cloud-managed instances
---
## Describe personas
- Remote client user: needs visibility into installed skills/plugins while connected
- Host operator: wants safe, explicit control over config changes
---
## List requirements
- Functional: expose workspace config read/write APIs for `.opencode` and `opencode.json`
- Functional: list installed skills, plugins, MCPs for a workspace without direct FS access
- Functional: allow saving new skills, plugins, and MCP entries from a remote client
- UX: show remote-config origin, last updated time, and change attribution
---
## Define API (initial)
All endpoints are scoped to an approved workspace root and require host approval for writes.
When OpenCode already exposes a stable API (agents, skills, MCP status), prefer OpenCode directly and avoid duplicating it here. This server only covers filesystem-backed gaps.
### API conventions
- Base URL: provided by the host during pairing (e.g., `http://host:PORT/openwork`)
- Content-Type: `application/json`
- Auth: bearer token issued during pairing (`Authorization: Bearer <token>`)
- Errors: JSON body with `{ code, message, details? }`
- All writes require explicit host approval and return `403` when denied
### Workspaces (discovery)
- `GET /workspaces` -> list known workspaces on the host
- Response fields align with `WorkspaceInfo` (id, name, path, workspaceType, baseUrl?, directory?)
- Used by remote clients to select a workspace without filesystem access
### Health
- `GET /health` -> { ok, version, uptimeMs }
### Capabilities
- `GET /capabilities` -> { skills: { read, write, source }, plugins: { read, write }, mcp: { read, write }, commands: { read, write }, config: { read, write } }
- `source` indicates whether OpenCode or OpenWork server is the authoritative API
### Workspace config
- `GET /workspace/:id/config` -> returns parsed `opencode.json` + `.opencode/openwork.json`
- `PATCH /workspace/:id/config` -> merges and writes config (write approval required)
- Request body: `{ opencode?: object, openwork?: object }`
- Merge strategy: shallow merge at top-level keys; arrays replaced (aligns with OpenCode config behavior)
- Response: `{ opencode, openwork, updatedAt }`
- Only project config is writable by default; global config requires explicit host-only scope
### Skills
- Prefer OpenCode skills API when available; OpenWork server only fills local FS gaps.
- `GET /workspace/:id/skills` -> list skill metadata from `.opencode/skills` (fallback only)
- `POST /workspace/:id/skills` -> add/update skill file(s) (write approval required, fallback only)
- Request body: `{ name, content, description? }`
- Writes to `.opencode/skills/<name>/SKILL.md`
- Response: `{ name, path, description, scope }`
### Plugins
- `GET /workspace/:id/plugins` -> list configured plugins from `opencode.json`
- `POST /workspace/:id/plugins` -> add plugin entry to config (write approval required)
- `DELETE /workspace/:id/plugins/:name` -> remove plugin entry (write approval required)
- Request body (POST): `{ spec }` where `spec` is a plugin string (npm or file URL)
- Response: `{ items: [{ spec, source, scope, path? }], loadOrder: string[] }`
- Server de-dupes by normalized name (strip version where possible)
### MCPs
- Prefer OpenCode MCP APIs for status/runtime; OpenWork server only reads/writes config.
- `GET /workspace/:id/mcp` -> list configured MCP servers
- `POST /workspace/:id/mcp` -> add MCP config entry (write approval required)
- `DELETE /workspace/:id/mcp/:name` -> remove MCP config entry (write approval required)
- Request body (POST): `{ name, config }` where `config` matches `McpServerConfig`
- Response: `{ items: [{ name, config, source }] }`
### Agents
- Prefer OpenCode agents API; no OpenWork server endpoints needed unless OpenCode lacks coverage for remote clients.
### Commands
- `GET /workspace/:id/commands` -> list commands from `.opencode/commands` (fallback only)
- Optional query: `scope=workspace|global` (global requires host-only approval)
- `POST /workspace/:id/commands` -> create/update command (write approval required)
- `DELETE /workspace/:id/commands/:name` -> delete command (write approval required)
- Request body (POST): `{ name, description?, template, agent?, model?, subtask? }`
- Server writes `.opencode/commands/<name>.md` with YAML frontmatter
---
## OpenCode behavior reference (from docs)
This section captures the exact OpenCode semantics the OpenWork server must respect.
### Plugins
- Config list: `opencode.json` -> `plugin` field. Can be string or string[].
- Local plugin folders:
- Project: `.opencode/plugins/`
- Global: `~/.config/opencode/plugins/`
- Load order (all hooks run in sequence):
1) Global config `~/.config/opencode/opencode.json`
2) Project config `opencode.json`
3) Global plugin dir `~/.config/opencode/plugins/`
4) Project plugin dir `.opencode/plugins/`
- NPM plugins are installed automatically using Bun at startup.
- Cached node_modules live in `~/.cache/opencode/node_modules/`.
- Local plugins can use dependencies listed in `.opencode/package.json`.
- Duplicate npm packages with the same name+version load once; local and npm with similar names both load.
### Skills
- Skill discovery paths (project):
- `.opencode/skills/<name>/SKILL.md`
- `.claude/skills/<name>/SKILL.md`
- Skill discovery paths (global):
- `~/.config/opencode/skills/<name>/SKILL.md`
- `~/.claude/skills/<name>/SKILL.md`
- Discovery walks up from current working directory to git worktree root.
- `SKILL.md` must include YAML frontmatter with `name` and `description`.
- Name constraints:
- 1-64 chars
- lowercase alphanumeric with single hyphen separators
- must match directory name
- regex: `^[a-z0-9]+(-[a-z0-9]+)*$`
- Description length: 1-1024 chars.
- Permissions: `opencode.json` `permission.skill` supports `allow`, `deny`, `ask` patterns.
### MCP servers
- Config lives in `opencode.json` under `mcp` object.
- Local MCP fields: `type: "local"`, `command[]`, optional `environment`, `enabled`, `timeout`.
- Remote MCP fields: `type: "remote"`, `url`, optional `headers`, `oauth`, `enabled`, `timeout`.
- `oauth: false` disables automatic OAuth detection.
- Remote defaults can be provided via `.well-known/opencode`; local config overrides.
- MCP tools can be disabled globally in `tools` via glob patterns.
- Per-agent tool enabling overrides global settings.
### OpenCode server APIs
- OpenCode already exposes: `/config`, `/mcp` (runtime), `/agent`, `/command` (list), `/session`.
- OpenWork server should prefer OpenCode APIs for runtime status and only handle FS-backed config gaps.
- When reading config, prefer OpenCode `/config` to capture precedence (remote defaults + global + project).
- When writing, only modify project `opencode.json` to avoid clobbering upstream defaults.
- `/command` is list-only; OpenWork server adds create/delete via filesystem for remote clients.
### Config precedence (reference)
- Remote defaults from `.well-known/opencode`
- Global config `~/.config/opencode/opencode.json`
- Project config `opencode.json`
- `.opencode/` directories and inline env overrides
- OpenWork server should preserve this ordering when presenting config sources
---
## OpenCode server alignment (from docs)
- OpenCode runs an HTTP server via `opencode serve` (default `127.0.0.1:4096`).
- `--cors` can be used to allow browser origins; OpenWork should align with that for web clients.
- Basic auth can be enabled via `OPENCODE_SERVER_PASSWORD` (username defaults to `opencode`).
- The OpenWork server should not bypass OpenCode auth; if OpenCode is password-protected, the host UI must collect credentials and pass them to the client.
- OpenCode publishes its OpenAPI spec at `/doc`; the OpenWork server should track upstream changes and avoid duplicating stable APIs.
---
## Endpoint examples (requests and responses)
### List workspaces
Request:
```
GET /workspaces
Authorization: Bearer <token>
```
Response:
```json
{
"items": [
{ "id": "ws_1", "name": "Finance", "path": "/Users/susan/Finance", "workspaceType": "local" },
{ "id": "ws_2", "name": "Remote Ops", "path": "/Users/bob/Shared", "workspaceType": "remote", "baseUrl": "http://10.0.0.8:4096" }
]
}
```
### Get config
Request:
```
GET /workspace/ws_1/config
```
Response:
```json
{
"opencode": { "plugin": ["opencode-github"], "mcp": { "chrome": { "type": "local", "command": ["npx", "-y", "chrome-devtools-mcp@latest"] } } },
"openwork": { "version": 1, "authorizedRoots": ["/Users/susan/Finance"] }
}
```
### Patch config
Request:
```json
PATCH /workspace/ws_1/config
{ "opencode": { "plugin": ["opencode-github", "opencode-notion"] } }
```
Response:
```json
{ "updatedAt": 1730000000000 }
```
### Add plugin
Request:
```json
POST /workspace/ws_1/plugins
{ "spec": "opencode-notion" }
```
Response:
```json
{
"items": [
{ "spec": "opencode-github", "source": "config", "scope": "project" },
{ "spec": "opencode-notion", "source": "config", "scope": "project" }
],
"loadOrder": ["config.global", "config.project", "dir.global", "dir.project"]
}
```
### Add skill
Request:
```json
POST /workspace/ws_1/skills
{ "name": "expense-audit", "content": "# Expense Audit\n..." }
```
Response:
```json
{ "name": "expense-audit", "path": ".opencode/skills/expense-audit", "description": "Audit expenses...", "scope": "project" }
```
### Add MCP server
Request:
```json
POST /workspace/ws_1/mcp
{ "name": "chrome", "config": { "type": "local", "command": ["npx", "-y", "chrome-devtools-mcp@latest"] } }
```
Response:
```json
{ "items": [{ "name": "chrome", "config": { "type": "local", "command": ["npx", "-y", "chrome-devtools-mcp@latest"] }, "source": "config.project" }] }
```
### Add command
Request:
```json
POST /workspace/ws_1/commands
{ "name": "daily-report", "description": "Daily report", "template": "summarize yesterday", "agent": "default" }
```
Response:
```json
{ "items": [{ "name": "daily-report", "description": "Daily report", "template": "summarize yesterday", "agent": "default", "scope": "workspace" }] }
```
---
## Outline integration
- Host side: OpenWork server exposes a narrow API layer on top of OpenCode
- Implementation: Bun-based server initially, shipped as a sidecar inside the OpenWork desktop app
- Client side: OpenWork UI uses this layer when remote, falls back to FS when local
- Storage: persists changes to `.opencode` and `opencode.json` within workspace root
---
## Server runtime and sidecar lifecycle
- The desktop app launches the OpenWork server as a sidecar process.
- The server binds to localhost on an ephemeral port and reports its URL back to the host UI.
- The host UI includes the OpenWork server URL in the pairing payload for remote clients.
- On crash, the host restarts the server and re-issues capabilities.
- The server must never expose filesystem paths outside approved workspace roots.
---
## Workspace identity and scoping
- Workspaces are referenced by `workspace.id` (stable hash of the workspace path).
- Remote clients fetch the list via `GET /workspaces` and select a target id.
- Each request includes `workspaceId` in the path; server resolves to a validated root.
- If the workspace is missing or not authorized, return `404` or `403`.
---
## Authentication and pairing
- The host generates a short-lived pairing token and includes the OpenWork server base URL in the pairing payload.
- Remote clients store the token in memory (not on disk) and send it as `Authorization: Bearer <token>`.
- Tokens are scoped to the host and expire on disconnect or after a short TTL (e.g., 24h).
- The OpenWork server rejects requests without a valid token (`401`).
- Future: rotate tokens on reconnect and support revocation from host settings.
---
## Capability schema (example)
```json
{
"skills": { "read": true, "write": true, "source": "openwork" },
"plugins": { "read": true, "write": true },
"mcp": { "read": true, "write": true },
"commands": { "read": true, "write": true },
"config": { "read": true, "write": true }
}
```
---
## Audit log format (example)
```json
{
"id": "audit_123",
"workspaceId": "ws_abc",
"actor": { "type": "remote", "clientId": "client_1" },
"action": "plugins.add",
"target": "opencode.json",
"summary": "Added opencode-github",
"timestamp": 1730000000000
}
```
---
## Web app integration
- Remote clients connect to both OpenCode (engine) and OpenWork server (config layer)
- Capability check gates UI actions: if OpenWork server is missing, config actions are read-only
- Writes require host approval and are surfaced in the audit log
- Future: this evolves into a sync layer across clients (out of scope here)
---
## Client caching and consistency
- Cache the last successful config snapshot per workspace in local state.
- On write success, refresh via `GET /workspace/:id/config` before updating UI.
- Use optimistic UI only for read-only lists; for writes, wait for approval + server response.
- If OpenWork server is unreachable, show read-only data and a reconnect CTA.
---
## Sequence flows (examples)
### Add plugin (remote)
1) User clicks “Add plugin” in Plugins page.
2) `context/extensions.ts` calls `POST /workspace/:id/plugins` with `{ spec }`.
3) Server requests host approval; host approves.
4) Server writes `opencode.json`, returns updated list.
5) Client refreshes plugins list and shows success toast.
### Add skill (remote)
1) User uploads a skill in Skills page.
2) Client sends `POST /workspace/:id/skills` with `{ name, content }`.
3) Server writes `.opencode/skills/<name>/SKILL.md` after approval.
4) Client refreshes skills list.
### Add MCP (remote)
1) User fills MCP config form.
2) Client sends `POST /workspace/:id/mcp` with `{ name, config }`.
3) Server merges into `opencode.json` and returns updated list.
4) UI shows “Reload engine” banner if required.
---
## OpenWork UI wiring (specific)
These are the concrete integration points inside `packages/app`.
### Data layer
- `src/app/lib/opencode.ts`: keep as-is for OpenCode engine calls
- `src/app/lib/tauri.ts`: host-only FS actions (local) stay here
- **New** `src/app/lib/openwork-server.ts`: remote config API client (HTTP) + capability check
Example client surface (TypeScript):
```ts
type Capabilities = {
skills: { read: boolean; write: boolean; source: "opencode" | "openwork" };
plugins: { read: boolean; write: boolean };
mcp: { read: boolean; write: boolean };
commands: { read: boolean; write: boolean };
config: { read: boolean; write: boolean };
};
export const openworkServer = {
health(): Promise<{ ok: boolean; version: string; uptimeMs: number }>;
capabilities(): Promise<Capabilities>;
listWorkspaces(): Promise<WorkspaceInfo[]>;
getConfig(id: string): Promise<{ opencode: object; openwork: object }>;
patchConfig(id: string, body: { opencode?: object; openwork?: object }): Promise<void>;
listPlugins(id: string): Promise<string[]>;
addPlugin(id: string, spec: string): Promise<string[]>;
removePlugin(id: string, name: string): Promise<string[]>;
listSkills(id: string): Promise<SkillCard[]>;
upsertSkill(id: string, payload: { name: string; content: string }): Promise<SkillCard>;
listMcp(id: string): Promise<McpServerEntry[]>;
addMcp(id: string, payload: { name: string; config: McpServerConfig }): Promise<McpServerEntry[]>;
removeMcp(id: string, name: string): Promise<McpServerEntry[]>;
listCommands(id: string): Promise<WorkspaceCommand[]>;
upsertCommand(id: string, payload: WorkspaceCommand): Promise<WorkspaceCommand[]>;
deleteCommand(id: string, name: string): Promise<void>;
};
```
### State stores
- `src/app/context/workspace.ts`: route remote config reads/writes to OpenWork server when workspaceType is `remote`
- `src/app/context/extensions.ts`: use OpenWork server to list/add skills/plugins/MCPs in remote mode
- `src/app/context/session.ts`: no changes; stays on OpenCode engine
### UI surfaces
- `src/app/pages/dashboard.tsx`: display workspace config status + enable “Share config” only when supported
- `src/app/pages/skills.tsx`: list and import skills via OpenWork server in remote mode
- `src/app/pages/plugins.tsx`: list/add/remove plugins via OpenWork server in remote mode
- `src/app/pages/mcp.tsx`: list/connect MCPs via OpenWork server in remote mode
- `src/app/pages/commands.tsx`: list/add/remove commands via OpenWork server in remote mode
- `src/app/pages/session.tsx`: surface agent list/selection via OpenWork server when remote if OpenCode lacks agent APIs
- `src/app/components/workspace-chip.tsx` + `workspace-picker.tsx`: show capability badges (read-only if server missing)
### Capability checks
- On connect, call `GET /health` on OpenWork server
- Store a `serverCapabilities` flag in app state and guard remote config actions
---
## Detailed wiring notes
This section explains exactly how requests flow and where the UI switches between local FS and the OpenWork server.
### Connection flow (remote)
1) User connects to a host (OpenCode engine). The client already has a base URL for OpenCode.
2) The client derives or receives the OpenWork server base URL from the host pairing payload.
3) The client calls `GET /health` and `GET /capabilities` on the OpenWork server.
4) UI stores `openworkServerStatus` (ok/error) and `openworkServerCapabilities` in app state.
5) All config-mutating UI surfaces check capabilities before enabling write actions.
### Local vs remote switching
- **Local workspaces**: use Tauri FS helpers (existing `src/app/lib/tauri.ts`).
- **Remote workspaces**: route all config reads/writes through `src/app/lib/openwork-server.ts`.
- The decision happens in the stores, not the UI, so pages dont need to branch on runtime.
### Store-level routing (concrete)
- `context/extensions.ts`
- `refreshSkills()` uses OpenWork server when remote, else lists local skills from FS.
- `refreshPlugins()` pulls config from OpenWork server in remote mode, else reads `opencode.json` locally.
- `refreshMcpServers()` reads MCP config from OpenWork server in remote mode, else from FS.
- `context/workspace.ts`
- Loads `openwork.json` and `opencode.json` from OpenWork server when remote.
- On writes, calls OpenWork server endpoints and refreshes local state on success.
- `context/commands.ts`
- `list`, `create`, and `delete` commands via OpenWork server when remote.
### Action mapping (UI -> endpoint -> file)
- Add plugin (Plugins page) -> `POST /workspace/:id/plugins` -> `opencode.json` `plugin` array
- Remove plugin -> `DELETE /workspace/:id/plugins/:name` -> `opencode.json` `plugin` array
- Add MCP server -> `POST /workspace/:id/mcp` -> `opencode.json` `mcp` map
- Remove MCP -> `DELETE /workspace/:id/mcp/:name` -> `opencode.json` `mcp` map
- Add skill -> `POST /workspace/:id/skills` -> `.opencode/skills/<name>/SKILL.md`
- Add command -> `POST /workspace/:id/commands` -> `.opencode/commands/<name>.md`
### UI wiring expectations
- Pages call store methods without caring about local vs remote.
- “Read-only” badges are derived from `openworkServerCapabilities` (e.g. missing `config.write`).
- “Share config” and any write action is disabled when capabilities are absent.
### Permissions and approvals
- Any write request from a remote client triggers a host approval prompt.
- The host UI should show: action type, target workspace, and files to be written.
- If approval is denied or times out, the server returns a clear error and the UI shows a non-blocking toast.
### Data contracts (expected formats)
These map to existing OpenWork types.
**Plugins**
- Source of truth: `opencode.json``plugin` field (string or string[]).
- Response shape:
```json
{
"items": [
{ "spec": "opencode-github", "source": "config", "scope": "project" },
{ "spec": "file:///path/to/plugin.js", "source": "dir.project", "scope": "project", "path": ".opencode/plugins/custom.js" }
],
"loadOrder": ["config.global", "config.project", "dir.global", "dir.project"]
}
```
**Skills**
- Source of truth: `.opencode/skills/<name>/SKILL.md`.
- Response shape:
```json
{
"items": [
{ "name": "my-skill", "path": ".opencode/skills/my-skill", "description": "...", "scope": "project" },
{ "name": "global-skill", "path": "~/.config/opencode/skills/global-skill", "description": "...", "scope": "global" }
]
}
```
**MCP**
- Source of truth: `opencode.json``mcp` object.
- Response shape:
```json
{
"items": [
{
"name": "chrome",
"config": { "type": "local", "command": ["npx", "-y", "chrome-devtools-mcp@latest"], "enabled": true },
"source": "config.project"
}
]
}
```
**Commands**
- Source of truth: `.opencode/commands/<name>.md` with frontmatter.
- Response shape:
```json
{
"items": [
{ "name": "daily-report", "description": "...", "template": "...", "agent": "default", "model": null, "subtask": false, "scope": "workspace" }
]
}
```
**Agents**
- Prefer OpenCode SDK (`listAgents`) as the primary source.
- Only add OpenWork server agent endpoints if OpenCode doesnt expose them for remote clients.
---
## Write approval flow (detailed)
1) Client sends a write request (POST/PATCH/DELETE).
2) OpenWork server emits a permission request to the host UI with:
- action (write type), workspace id, list of file paths, and summary of changes
3) Host approves or denies within a timeout window.
4) Server executes the write only after approval and records an audit log entry.
5) Client receives success or `403` with a reason and shows a toast.
Approval response schema (host -> server):
```json
{ "requestId": "...", "reply": "allow" | "deny" }
```
---
## Config merge rules (detailed)
- `opencode.json` is parsed as JSONC to preserve comments where possible.
- Writes are shallow merges at top-level keys; arrays replace existing values.
- Unknown keys are preserved.
- On parse errors, the server returns `422` with the error location.
Example: adding a plugin
```json
{ "plugin": ["opencode-github"] }
```
Server behavior:
- Read current `opencode.json`
- Normalize plugin list, append new spec if missing
- Write updated JSONC back to disk
---
## Validation rules (initial)
- Plugin spec: non-empty string; if duplicate, no-op.
- Skill name: kebab-case; 1-64 chars; must match folder name; regex `^[a-z0-9]+(-[a-z0-9]+)*$`.
- Skill description: 1-1024 chars, required in frontmatter.
- MCP name: `^[A-Za-z0-9_-]+$`, cannot start with `-`.
- MCP config: `type` required; for `local` require `command[]`; for `remote` require `url`.
- Commands: name sanitized to `[A-Za-z0-9_-]`; template required.
- Reject any path traversal (`..`) or absolute paths in payloads.
---
## Plugin handling (detailed)
- `plugin` list in `opencode.json` is treated as the source of npm plugins.
- Specs may be unscoped or scoped npm packages (e.g., `opencode-wakatime`, `@my-org/custom-plugin`).
- Specs may also be file URLs or absolute paths when supported by OpenCode.
- Local plugin files are discovered in `.opencode/plugins/` and `~/.config/opencode/plugins/`.
- Only JavaScript/TypeScript files are treated as plugins (`.js`, `.ts`).
- The OpenWork server should return both config plugins and local plugin files, with a `source` field:
- `config` for npm specs
- `dir.project` for `.opencode/plugins/`
- `dir.global` for `~/.config/opencode/plugins/`
- The UI can display these as separate sections while preserving OpenCode load order.
- The server should not run `bun install`; OpenCode handles installs on startup.
- If `.opencode/package.json` is present, note it in responses so the UI can link to dependency setup.
- Plugin runtime behavior (events, custom tools, logging) remains owned by OpenCode; OpenWork server only manages config.
---
## Skill handling (detailed)
- Discovery must match OpenCode:
- Walk up from the workspace root to the git worktree root.
- Include any `.opencode/skills/*/SKILL.md` and `.claude/skills/*/SKILL.md`.
- Include global skills from `~/.config/opencode/skills/*/SKILL.md` and `~/.claude/skills/*/SKILL.md`.
- Validate frontmatter fields:
- `name` and `description` required
- `license`, `compatibility`, `metadata` optional
- Enforce name and description length rules on write.
- The OpenWork server does not parse or interpret skill content beyond frontmatter extraction.
---
## MCP handling (detailed)
- Read `mcp` config from `opencode.json`.
- Preserve `enabled`, `environment`, `headers`, `oauth`, and `timeout` fields.
- If OpenCode provides remote defaults via `.well-known/opencode`, treat those as `source: "config.remote"`.
- Writes always go to project `opencode.json` and should not mutate remote defaults.
- If MCP tools are disabled via `tools` glob patterns, surface that as `disabledByTools: true` in responses.
- OAuth tokens are managed by OpenCode and stored in `~/.local/share/opencode/mcp-auth.json`; OpenWork server should not manage tokens directly.
- Authentication flows should be triggered via OpenCode (`/mcp` endpoints or CLI), not via OpenWork server.
- Reference CLI flows: `opencode mcp auth <name>`, `opencode mcp list`, `opencode mcp logout <name>`, `opencode mcp debug <name>`.
---
## Commands handling (detailed)
- Commands are markdown files with YAML frontmatter.
- The server should sanitize command names to `[A-Za-z0-9_-]` and strip leading `/`.
- The command template is the body after frontmatter and is required.
- Workspace scope lives under `.opencode/commands/` in the project.
- Global scope lives under `~/.config/opencode/commands/` and should be disabled by default for remote clients.
---
## Path safety
- All write targets are resolved under the workspace root.
- The server verifies the resolved path begins with the workspace root.
- Any violation returns `400` with a safe error message.
---
## Error codes
- `400` invalid request payload
- `401` missing/invalid token
- `403` write denied or capability missing
- `404` workspace not found
- `409` conflict (concurrent edit detected)
- `422` config parse/validation error
- `500` unexpected server error
---
## Implementation checklist
### Server runtime (Bun)
- Create `packages/openwork-server` with HTTP routing + JSON schema validation
- Define stable port + discovery mechanism for clients
- Add lifecycle hooks: start/stop/restart + health checks
### Auth + handshake
- Pairing token or session key for remote clients
- `GET /capabilities` endpoint to drive UI gating
- CORS rules and origin allowlist for web clients
### Permissions + audit
- Host approval for any write request
- Audit log for config mutations (who/what/when)
- Clear denial/error propagation to clients
### Filesystem writes
- Workspace-root scoping + path validation
- Config merge rules aligned to OpenCode
- Serialization helpers for:
- `.opencode/skills/<name>/SKILL.md`
- `.opencode/commands/<name>.md` (frontmatter)
- `opencode.json` plugin + mcp updates
### UI wiring
- Add `src/app/lib/openwork-server.ts` client
- Route remote mode reads/writes through OpenWork server:
- `src/app/context/extensions.ts`
- `src/app/context/workspace.ts`
- `src/app/pages/commands.tsx`
- UI gating badges for missing capabilities
### Resilience
- Retry/backoff for transient network errors
- Conflict handling for concurrent writes
- Friendly errors for missing workspace roots
### Packaging
- Bundle Bun server as a desktop sidecar
- Wire sidecar launch + permissions in Tauri config
---
## Testing strategy
### Unit tests (server)
- Config merge rules (arrays replace, unknown keys preserved)
- Validation rules for skill/plugin/mcp/command names
- Path safety checks (reject absolute paths and path traversal)
### Filesystem tests (local)
- Create a temp workspace with `.opencode/` and `opencode.json`
- Verify `GET /workspace/:id/plugins` reflects:
- plugin list from `opencode.json`
- local plugin files in `.opencode/plugins`
- global plugin files from `~/.config/opencode/plugins` (optional, behind a flag)
- Verify plugin list preserves OpenCode load order metadata
- Verify `GET /workspace/:id/skills` returns:
- `.opencode/skills/*/SKILL.md`
- `.claude/skills/*/SKILL.md`
- global skills from `~/.config/opencode/skills` (optional)
- Verify skill discovery respects git worktree boundary (walk up to `.git` only)
- Verify skill frontmatter parsing and name/description constraints
- Verify `.opencode/package.json` is detected and reported when present
### Integration tests (server + FS)
- Start server against a temp workspace; verify read/write endpoints
- Ensure writes only affect `.opencode` and `opencode.json`
- Verify audit log entries for each write action
- Validate local plugin discovery only includes `.js` and `.ts`
- Validate MCP config writes preserve `enabled` and `oauth` fields
### Approval flow tests
- Write request triggers approval prompt
- Deny returns `403` and no file is written
- Approve writes file and returns success
### Client wiring tests (OpenWork web)
- Remote mode uses OpenWork server endpoints instead of Tauri FS
- Missing capability switches UI to read-only
- Reconnect restores write actions after capabilities return
- OpenCode basic auth prompts propagate to client when enabled
### Sidecar lifecycle tests
- Server starts on app launch and reports base URL
- Crash triggers restart and new capabilities handshake
---
## Test cases (initial)
### Config
- `GET /workspace/:id/config` returns both `opencode` and `openwork` blocks
- `PATCH /workspace/:id/config` updates plugin list and preserves unknown keys
- Invalid JSONC returns `422` with parse location
- Remote defaults from `.well-known/opencode` appear as `source: config.remote` in responses
- Writes only update project `opencode.json`, leaving remote defaults unchanged
### Plugins
- Add plugin appends to list and de-dupes existing spec
- Remove plugin deletes only matching spec
- Invalid spec returns `400`
- Local plugin files are returned with `source: dir.project`
- Global plugin files are returned with `source: dir.global` when enabled
- Non-js/ts files in plugin dirs are ignored
### Skills
- Add skill writes `SKILL.md` with kebab-case name validation
- List skills returns `name`, `path`, and `description`
- Invalid skill name returns `400`
- Missing frontmatter fields return `422`
- Skill name mismatch with folder returns `400`
### MCP
- Add MCP writes to `opencode.json` under `mcp` map
- Invalid MCP name returns `400`
- Remove MCP deletes entry and returns updated list
- `enabled: false` is preserved in responses
- `oauth: false` is preserved in responses
- Missing `command` for `type: local` returns `400`
- Missing `url` for `type: remote` returns `400`
- `tools` glob disables MCP entries and marks them as `disabledByTools: true`
### Commands
- Create command writes `.opencode/commands/<name>.md`
- Delete command removes file and returns success
- Invalid template returns `400`
- Name with leading `/` is sanitized
### Permissions
- Denied approval returns `403` and no file changes
- Approval timeout returns `403` with timeout reason
### Security
- Path traversal payload returns `400` and is logged
- Workspace id mismatch returns `404`
### UI
- Remote client with OpenWork server: write actions enabled
- Remote client without OpenWork server: write actions disabled + read-only badge
---
## Set permissions
- Explicit approval for any config write originating from a remote client
- Scope-limited to the active workspace root only
---
## Cover data
- Audit log of config changes (who, what, when)
- Optional telemetry for success/failure counts, opt-in only
---
## Map flow
- Connect: remote client connects to host with capability check
- View: UI shows skills/plugins/MCPs from server API
- Update: user adds skill/plugin/MCP, server validates + writes config, UI refreshes
---
## Note risks
- Over-expanding API surface could drift from OpenCode primitives
- Mis-scoped writes could affect unrelated projects
---
## Ask questions
- Which config surfaces should be writable vs read-only initially?
- Should writes be batched or immediate per action?
---
## Measure success
- Remote users can view skills/plugins/MCPs without filesystem access
- Remote users can add a skill/plugin/MCP with a single approval
---
## Plan rollout
- Phase 0: Bun server prototype running alongside OpenWork host
- Phase 1: read-only APIs for skills/plugins/MCPs + config metadata
- Phase 2: write APIs for skills/plugins/MCPs with audit log
- Phase 3: config export/import support for remote clients
- Phase 4: bundle as a first-class sidecar in desktop builds

View File

@@ -13,7 +13,6 @@ import { useLocation, useNavigate } from "@solidjs/router";
import type {
Agent,
Provider,
Part,
TextPartInput,
FilePartInput,
@@ -77,6 +76,7 @@ import type {
ComposerAttachment,
ComposerDraft,
ComposerPart,
ProviderListItem,
WorkspaceCommand,
UpdateHandle,
} from "./types";
@@ -89,6 +89,7 @@ import {
groupMessageParts,
isTauriRuntime,
modelEquals,
normalizeDirectoryPath,
} from "./utils";
import { isEditableTarget, matchKeybind, normalizeKeybind } from "./utils/keybinds";
import { currentLocale, setLocale, t, type Language } from "../i18n";
@@ -124,6 +125,17 @@ import {
readOpencodeConfig,
writeOpencodeConfig,
} from "./lib/tauri";
import {
createOpenworkServerClient,
deriveOpenworkServerUrl,
readOpenworkServerSettings,
writeOpenworkServerSettings,
clearOpenworkServerSettings,
type OpenworkServerCapabilities,
type OpenworkServerStatus,
type OpenworkServerSettings,
OpenworkServerError,
} from "./lib/openwork-server";
export default function App() {
type ProviderAuthMethod = { type: "oauth" | "api"; label: string };
@@ -209,6 +221,91 @@ export default function App() {
const [baseUrl, setBaseUrl] = createSignal("http://127.0.0.1:4096");
const [clientDirectory, setClientDirectory] = createSignal("");
const [openworkServerSettings, setOpenworkServerSettings] = createSignal<OpenworkServerSettings>({});
const [openworkServerUrl, setOpenworkServerUrl] = createSignal("");
const [openworkServerStatus, setOpenworkServerStatus] = createSignal<OpenworkServerStatus>("disconnected");
const [openworkServerCapabilities, setOpenworkServerCapabilities] = createSignal<OpenworkServerCapabilities | null>(null);
const [openworkServerCheckedAt, setOpenworkServerCheckedAt] = createSignal<number | null>(null);
const [openworkServerWorkspaceId, setOpenworkServerWorkspaceId] = createSignal<string | null>(null);
const openworkServerClient = createMemo(() => {
const url = openworkServerUrl().trim();
if (!url) return null;
const token = openworkServerSettings().token;
return createOpenworkServerClient({ baseUrl: url, token });
});
createEffect(() => {
if (typeof window === "undefined") return;
setOpenworkServerSettings(readOpenworkServerSettings());
});
createEffect(() => {
const derived = deriveOpenworkServerUrl(baseUrl(), openworkServerSettings());
setOpenworkServerUrl(derived ?? "");
});
const checkOpenworkServer = async (url: string, token?: string) => {
const client = createOpenworkServerClient({ baseUrl: url, token });
try {
await client.health();
} catch {
return { status: "disconnected" as OpenworkServerStatus, capabilities: null };
}
if (!token) {
return { status: "limited" as OpenworkServerStatus, capabilities: null };
}
try {
const caps = await client.capabilities();
return { status: "connected" as OpenworkServerStatus, capabilities: caps };
} catch (error) {
if (error instanceof OpenworkServerError && (error.status === 401 || error.status === 403)) {
return { status: "limited" as OpenworkServerStatus, capabilities: null };
}
return { status: "disconnected" as OpenworkServerStatus, capabilities: null };
}
};
createEffect(() => {
if (typeof window === "undefined") return;
const url = openworkServerUrl().trim();
const token = openworkServerSettings().token;
if (!url) {
setOpenworkServerStatus("disconnected");
setOpenworkServerCapabilities(null);
setOpenworkServerCheckedAt(Date.now());
return;
}
let active = true;
let busy = false;
const run = async () => {
if (busy) return;
busy = true;
try {
const result = await checkOpenworkServer(url, token);
if (!active) return;
setOpenworkServerStatus(result.status);
setOpenworkServerCapabilities(result.capabilities);
} finally {
if (!active) return;
setOpenworkServerCheckedAt(Date.now());
busy = false;
}
};
run();
const interval = window.setInterval(run, 10_000);
onCleanup(() => {
active = false;
window.clearInterval(interval);
});
});
const [client, setClient] = createSignal<Client | null>(null);
const [connectedVersion, setConnectedVersion] = createSignal<string | null>(
null
@@ -668,6 +765,11 @@ export default function App() {
mode,
projectDir: () => workspaceProjectDir(),
activeWorkspaceRoot: () => workspaceStore.activeWorkspaceRoot(),
workspaceType: () => workspaceStore.activeWorkspaceDisplay().workspaceType,
openworkServerClient,
openworkServerStatus,
openworkServerCapabilities,
openworkServerWorkspaceId,
setBusy,
setBusyLabel,
setBusyStartedAt,
@@ -692,6 +794,7 @@ export default function App() {
pluginScope,
setPluginScope,
pluginConfig,
pluginConfigPath,
pluginList,
pluginInput,
setPluginInput,
@@ -715,7 +818,7 @@ export default function App() {
const providers = createMemo(() => globalSync.data.provider.all ?? []);
const providerDefaults = createMemo(() => globalSync.data.provider.default ?? {});
const providerConnectedIds = createMemo(() => globalSync.data.provider.connected ?? []);
const setProviders = (value: Provider[]) => {
const setProviders = (value: ProviderListItem[]) => {
globalSync.set("provider", "all", value);
};
const setProviderDefaults = (value: Record<string, string>) => {
@@ -887,6 +990,72 @@ export default function App() {
isWindowsPlatform,
});
createEffect(() => {
const status = openworkServerStatus();
const root = workspaceStore.activeWorkspaceRoot().trim();
const client = openworkServerClient();
if (status !== "connected" || !root || !client) {
setOpenworkServerWorkspaceId(null);
return;
}
let cancelled = false;
const resolveWorkspace = async () => {
try {
const response = await client.listWorkspaces();
if (cancelled) return;
const match = response.items.find(
(entry) => normalizeDirectoryPath(entry.path) === normalizeDirectoryPath(root),
);
setOpenworkServerWorkspaceId(match?.id ?? null);
} catch {
if (!cancelled) setOpenworkServerWorkspaceId(null);
}
};
resolveWorkspace();
onCleanup(() => {
cancelled = true;
});
});
const openworkServerReady = createMemo(() => openworkServerStatus() === "connected");
const openworkServerWorkspaceReady = createMemo(() => Boolean(openworkServerWorkspaceId()));
const openworkServerCanWriteSkills = createMemo(
() => openworkServerReady() && openworkServerWorkspaceReady() && (openworkServerCapabilities()?.skills?.write ?? false),
);
const openworkServerCanWritePlugins = createMemo(
() => openworkServerReady() && openworkServerWorkspaceReady() && (openworkServerCapabilities()?.plugins?.write ?? false),
);
const updateOpenworkServerSettings = (next: OpenworkServerSettings) => {
const stored = writeOpenworkServerSettings(next);
setOpenworkServerSettings(stored);
};
const resetOpenworkServerSettings = () => {
clearOpenworkServerSettings();
setOpenworkServerSettings({});
};
const testOpenworkServerConnection = async (next: OpenworkServerSettings) => {
const derived = deriveOpenworkServerUrl(baseUrl(), next);
if (!derived) {
setOpenworkServerStatus("disconnected");
setOpenworkServerCapabilities(null);
setOpenworkServerCheckedAt(Date.now());
return false;
}
const result = await checkOpenworkServer(derived, next.token);
setOpenworkServerStatus(result.status);
setOpenworkServerCapabilities(result.capabilities);
setOpenworkServerCheckedAt(Date.now());
return result.status === "connected" || result.status === "limited";
};
const commandState = createCommandState({
client,
selectedSession,
@@ -901,6 +1070,10 @@ export default function App() {
setView,
isDemoMode,
activeWorkspaceRoot: () => workspaceStore.activeWorkspaceRoot(),
workspaceType: () => workspaceStore.activeWorkspaceDisplay().workspaceType,
openworkServerClient,
openworkServerCapabilities,
openworkServerWorkspaceId,
setBusy,
setBusyLabel,
setBusyStartedAt,
@@ -1574,7 +1747,7 @@ export default function App() {
footerBits.push(t("settings.model_default", currentLocale()));
}
if (isFree) footerBits.push(t("settings.model_free", currentLocale()));
if (model.capabilities?.reasoning) footerBits.push(t("settings.model_reasoning", currentLocale()));
if (model.reasoning) footerBits.push(t("settings.model_reasoning", currentLocale()));
next.push({
providerID: provider.id,
@@ -1743,6 +1916,51 @@ export default function App() {
async function refreshMcpServers() {
const projectDir = workspaceProjectDir().trim();
const isRemoteWorkspace = workspaceStore.activeWorkspaceDisplay().workspaceType === "remote";
const openworkClient = openworkServerClient();
const openworkWorkspaceId = openworkServerWorkspaceId();
const openworkCapabilities = openworkServerCapabilities();
if (isRemoteWorkspace) {
if (!openworkClient || !openworkWorkspaceId || !openworkCapabilities?.mcp?.read) {
setMcpStatus("OpenWork server unavailable. MCP config is read-only.");
setMcpServers([]);
setMcpStatuses({});
return;
}
try {
setMcpStatus(null);
const response = await openworkClient.listMcp(openworkWorkspaceId);
const next = response.items.map((entry) => ({
name: entry.name,
config: entry.config as McpServerEntry["config"],
}));
setMcpServers(next);
setMcpLastUpdatedAt(Date.now());
const activeClient = client();
if (activeClient && projectDir) {
try {
const status = unwrap(await activeClient.mcp.status({ directory: projectDir }));
setMcpStatuses(status as McpStatusMap);
} catch {
setMcpStatuses({});
}
} else {
setMcpStatuses({});
}
if (!next.length) {
setMcpStatus("No MCP servers configured yet.");
}
} catch (e) {
setMcpServers([]);
setMcpStatuses({});
setMcpStatus(e instanceof Error ? e.message : "Failed to load MCP servers");
}
return;
}
if (!isTauriRuntime()) {
setMcpStatus("MCP configuration is only available in Host mode.");
@@ -2718,7 +2936,38 @@ export default function App() {
setThemeMode,
});
const dashboardProps = () => ({
const dashboardProps = () => {
const workspaceType = activeWorkspaceDisplay().workspaceType;
const isRemoteWorkspace = workspaceType === "remote";
const openworkStatus = openworkServerStatus();
const canUseDesktopTools = isTauriRuntime() && !isRemoteWorkspace;
const canInstallSkillCreator = isRemoteWorkspace
? openworkServerCanWriteSkills()
: isTauriRuntime();
const canEditPlugins = isRemoteWorkspace
? openworkServerCanWritePlugins()
: isTauriRuntime();
const canUseGlobalPluginScope = !isRemoteWorkspace && isTauriRuntime();
const skillsAccessHint = isRemoteWorkspace
? openworkStatus === "disconnected"
? "OpenWork server unavailable. Connect to manage skills."
: openworkStatus === "limited"
? "OpenWork server needs a token to manage skills."
: openworkServerCanWriteSkills()
? null
: "OpenWork server is read-only for skills."
: null;
const pluginsAccessHint = isRemoteWorkspace
? openworkStatus === "disconnected"
? "OpenWork server unavailable. Plugins are read-only."
: openworkStatus === "limited"
? "OpenWork server needs a token to edit plugins."
: openworkServerCanWritePlugins()
? null
: "OpenWork server is read-only for plugins."
: null;
return {
tab: tab(),
setTab,
view: currentView(),
@@ -2732,6 +2981,12 @@ export default function App() {
newTaskDisabled: newTaskDisabled(),
headerStatus: headerStatus(),
error: error(),
openworkServerStatus: openworkStatus,
openworkServerUrl: openworkServerUrl(),
openworkServerSettings: openworkServerSettings(),
updateOpenworkServerSettings,
resetOpenworkServerSettings,
testOpenworkServerConnection,
activeWorkspaceDisplay: activeWorkspaceDisplay(),
workspaceSearch: workspaceStore.workspaceSearch(),
setWorkspaceSearch: workspaceStore.setWorkspaceSearch,
@@ -2782,13 +3037,19 @@ export default function App() {
refreshPlugins(scopeOverride).catch(() => undefined),
skills: skills(),
skillsStatus: skillsStatus(),
skillsAccessHint,
canInstallSkillCreator,
canUseDesktopTools,
importLocalSkill,
installSkillCreator,
revealSkillsFolder,
uninstallSkill,
pluginsAccessHint,
canEditPlugins,
canUseGlobalPluginScope,
pluginScope: pluginScope(),
setPluginScope,
pluginConfigPath: pluginConfig()?.path ?? null,
pluginConfigPath: pluginConfigPath() ?? pluginConfig()?.path ?? null,
pluginList: pluginList(),
pluginInput: pluginInput(),
setPluginInput,
@@ -2862,7 +3123,8 @@ export default function App() {
reloadMcpEngine: () => reloadWorkspaceEngine(),
language: currentLocale(),
setLanguage: setLocale,
});
};
};
const searchWorkspaceFiles = async (query: string) => {
const trimmed = query.trim();

View File

@@ -6,6 +6,7 @@ import { addOpencodeCacheHint, isTauriRuntime, parseModelRef, safeStringify } fr
import { opencodeCommandDelete, opencodeCommandList, opencodeCommandWrite } from "./lib/tauri";
import { unwrap } from "./lib/opencode";
import { t, currentLocale } from "../i18n";
import type { OpenworkServerCapabilities, OpenworkServerClient } from "./lib/openwork-server";
const COMMANDS_PATH = ".opencode/commands";
const COMMAND_FILE_SUFFIX = ".md";
@@ -44,6 +45,10 @@ export function createCommandState(options: {
setView: (view: "onboarding" | "dashboard" | "session") => void;
isDemoMode: Accessor<boolean>;
activeWorkspaceRoot: Accessor<string>;
workspaceType: Accessor<"local" | "remote">;
openworkServerClient: Accessor<OpenworkServerClient | null>;
openworkServerCapabilities: Accessor<OpenworkServerCapabilities | null>;
openworkServerWorkspaceId: Accessor<string | null>;
setBusy: (value: boolean) => void;
setBusyLabel: (value: string | null) => void;
setBusyStartedAt: (value: number | null) => void;
@@ -108,12 +113,28 @@ export function createCommandState(options: {
return;
}
if (!isTauriRuntime()) {
const isRemoteWorkspace = options.workspaceType() === "remote";
const openworkClient = options.openworkServerClient();
const openworkWorkspaceId = options.openworkServerWorkspaceId();
const openworkCapabilities = options.openworkServerCapabilities();
if (isRemoteWorkspace) {
if (draft.scope !== "workspace") {
options.setError("Global commands are only available in Host mode.");
return;
}
if (!openworkClient || !openworkWorkspaceId || !openworkCapabilities?.commands?.write) {
options.setError("OpenWork server unavailable. Connect to save commands.");
return;
}
}
if (!isRemoteWorkspace && !isTauriRuntime()) {
options.setError(t("app.error.workspace_commands_desktop", currentLocale()));
return;
}
if (draft.scope === "workspace" && !options.activeWorkspaceRoot().trim()) {
if (!isRemoteWorkspace && draft.scope === "workspace" && !options.activeWorkspaceRoot().trim()) {
options.setError(t("app.error.pick_workspace_folder", currentLocale()));
return;
}
@@ -144,15 +165,23 @@ export function createCommandState(options: {
try {
const workspaceRoot = options.activeWorkspaceRoot().trim();
await opencodeCommandWrite({
scope: draft.scope,
projectDir: workspaceRoot,
command: {
if (isRemoteWorkspace && openworkClient && openworkWorkspaceId) {
await openworkClient.upsertCommand(openworkWorkspaceId, {
name: safeName,
description: draft.description || undefined,
template: draft.template,
},
});
});
} else {
await opencodeCommandWrite({
scope: draft.scope,
projectDir: workspaceRoot,
command: {
name: safeName,
description: draft.description || undefined,
template: draft.template,
},
});
}
// Directly add/update the command in local state since the SDK's
// command list won't reflect the new file until app restart
@@ -201,12 +230,28 @@ export function createCommandState(options: {
return;
}
if (!isTauriRuntime()) {
const isRemoteWorkspace = options.workspaceType() === "remote";
const openworkClient = options.openworkServerClient();
const openworkWorkspaceId = options.openworkServerWorkspaceId();
const openworkCapabilities = options.openworkServerCapabilities();
if (isRemoteWorkspace) {
if (command.scope !== "workspace") {
options.setError("Global commands are only available in Host mode.");
return;
}
if (!openworkClient || !openworkWorkspaceId || !openworkCapabilities?.commands?.write) {
options.setError("OpenWork server unavailable. Connect to delete commands.");
return;
}
}
if (!isRemoteWorkspace && !isTauriRuntime()) {
options.setError(t("app.error.workspace_commands_desktop", currentLocale()));
return;
}
if (command.scope === "workspace" && !options.activeWorkspaceRoot().trim()) {
if (!isRemoteWorkspace && command.scope === "workspace" && !options.activeWorkspaceRoot().trim()) {
options.setError(t("app.error.pick_workspace_folder", currentLocale()));
return;
}
@@ -218,11 +263,15 @@ export function createCommandState(options: {
try {
const workspaceRoot = options.activeWorkspaceRoot().trim();
await opencodeCommandDelete({
scope: command.scope,
projectDir: workspaceRoot,
name: command.name,
});
if (isRemoteWorkspace && openworkClient && openworkWorkspaceId) {
await openworkClient.deleteCommand(openworkWorkspaceId, command.name);
} else {
await opencodeCommandDelete({
scope: command.scope,
projectDir: workspaceRoot,
name: command.name,
});
}
await loadCommands({ workspaceRoot, quiet: true });
} catch (e) {
const message = e instanceof Error ? e.message : safeStringify(e);
@@ -283,6 +332,10 @@ export function createCommandState(options: {
async function loadCommands(optionsLoad?: { workspaceRoot?: string; quiet?: boolean }) {
const c = options.client();
const root = (optionsLoad?.workspaceRoot ?? options.activeWorkspaceRoot()).trim();
const isRemoteWorkspace = options.workspaceType() === "remote";
const openworkClient = options.openworkServerClient();
const openworkWorkspaceId = options.openworkServerWorkspaceId();
const openworkCapabilities = options.openworkServerCapabilities();
if (!c) return;
try {
@@ -290,7 +343,16 @@ export function createCommandState(options: {
let workspaceNames = new Set<string>();
let globalNames = new Set<string>();
if (isTauriRuntime()) {
if (isRemoteWorkspace) {
if (openworkClient && openworkWorkspaceId && openworkCapabilities?.commands?.read) {
try {
const response = await openworkClient.listCommands(openworkWorkspaceId, "workspace");
workspaceNames = new Set(response.items.map((item) => item.name));
} catch {
workspaceNames = new Set();
}
}
} else if (isTauriRuntime()) {
if (root) {
try {
const names = await opencodeCommandList({ scope: "workspace", projectDir: root });

View File

@@ -23,12 +23,12 @@ function useThrottledValue<T>(value: () => T, delayMs = 80) {
createEffect(() => {
const next = value();
if (!delayMs) {
setState(next);
setState(() => next);
return;
}
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
setState(next);
setState(() => next);
timer = undefined;
}, delayMs);
});
@@ -259,8 +259,10 @@ export default function PartView(props: Props) {
};
const inlineImage = () => {
if (p().type !== "image") return null;
if (p().type !== "file") return null;
const record = p() as any;
const mime = typeof record?.mime === "string" ? record.mime : "";
if (!mime.startsWith("image/")) return null;
const src = record?.url ?? record?.src ?? record?.data ?? record?.source;
if (!src) return null;
if (record?.data && record?.mediaType && !String(record.data).startsWith("data:")) {
@@ -464,14 +466,12 @@ export default function PartView(props: Props) {
</Show>
</Match>
<Match when={p().type === "image"}>
<Show when={inlineImage()}>
<img
src={inlineImage()!}
alt=""
class="max-w-full h-auto rounded-xl border border-gray-6/50"
/>
</Show>
<Match when={inlineImage()}>
<img
src={inlineImage()!}
alt=""
class="max-w-full h-auto rounded-xl border border-gray-6/50"
/>
</Match>
<Match when={p().type === "step-start" || p().type === "step-finish"}>

View File

@@ -1,5 +1,5 @@
import { CheckCircle2, X } from "lucide-solid";
import type { Provider } from "@opencode-ai/sdk/v2/client";
import type { ProviderListItem } from "../types";
import { createMemo, For, Show } from "solid-js";
import Button from "./button";
@@ -17,7 +17,7 @@ export type ProviderAuthModalProps = {
loading: boolean;
submitting: boolean;
error: string | null;
providers: Provider[];
providers: ProviderListItem[];
connectedProviderIds: string[];
authMethods: Record<string, ProviderAuthMethod[]>;
onSelect: (providerId: string) => void;

View File

@@ -23,6 +23,11 @@ import {
writeOpencodeConfig,
type OpencodeConfigFile,
} from "../lib/tauri";
import type {
OpenworkServerCapabilities,
OpenworkServerClient,
OpenworkServerStatus,
} from "../lib/openwork-server";
export type ExtensionsStore = ReturnType<typeof createExtensionsStore>;
@@ -31,6 +36,11 @@ export function createExtensionsStore(options: {
mode: () => Mode | null;
projectDir: () => string;
activeWorkspaceRoot: () => string;
workspaceType: () => "local" | "remote";
openworkServerClient: () => OpenworkServerClient | null;
openworkServerStatus: () => OpenworkServerStatus;
openworkServerCapabilities: () => OpenworkServerCapabilities | null;
openworkServerWorkspaceId: () => string | null;
setBusy: (value: boolean) => void;
setBusyLabel: (value: string | null) => void;
setBusyStartedAt: (value: number | null) => void;
@@ -48,6 +58,7 @@ export function createExtensionsStore(options: {
const [pluginScope, setPluginScope] = createSignal<PluginScope>("project");
const [pluginConfig, setPluginConfig] = createSignal<OpencodeConfigFile | null>(null);
const [pluginConfigPath, setPluginConfigPath] = createSignal<string | null>(null);
const [pluginList, setPluginList] = createSignal<string[]>([]);
const [pluginInput, setPluginInput] = createSignal("");
const [pluginStatus, setPluginStatus] = createSignal<string | null>(null);
@@ -73,6 +84,10 @@ export function createExtensionsStore(options: {
async function refreshSkills(optionsOverride?: { force?: boolean }) {
const root = options.activeWorkspaceRoot().trim();
const isRemoteWorkspace = options.workspaceType() === "remote";
const openworkClient = options.openworkServerClient();
const openworkWorkspaceId = options.openworkServerWorkspaceId();
const openworkCapabilities = options.openworkServerCapabilities();
if (!root) {
setSkills([]);
setSkillsStatus(translate("skills.pick_workspace_first"));
@@ -127,10 +142,58 @@ export function createExtensionsStore(options: {
return;
}
if (isRemoteWorkspace && openworkClient && openworkWorkspaceId && openworkCapabilities?.skills?.read) {
if (root !== skillsRoot) {
skillsLoaded = false;
}
if (!optionsOverride?.force && skillsLoaded) {
return;
}
if (refreshSkillsInFlight) {
return;
}
refreshSkillsInFlight = true;
refreshSkillsAborted = false;
try {
setSkillsStatus(null);
const response = await openworkClient.listSkills(openworkWorkspaceId);
if (refreshSkillsAborted) return;
const next: SkillCard[] = Array.isArray(response.items)
? response.items.map((entry) => ({
name: entry.name,
description: entry.description,
path: entry.path,
}))
: [];
setSkills(next);
if (!next.length) {
setSkillsStatus(translate("skills.no_skills_found"));
}
skillsLoaded = true;
skillsRoot = root;
} catch (e) {
if (refreshSkillsAborted) return;
setSkills([]);
setSkillsStatus(e instanceof Error ? e.message : translate("skills.failed_to_load"));
} finally {
refreshSkillsInFlight = false;
}
return;
}
const c = options.client();
if (!c) {
setSkills([]);
setSkillsStatus(translate("skills.connect_host_to_load"));
setSkillsStatus(
isRemoteWorkspace
? "OpenWork server unavailable. Connect to load skills."
: translate("skills.connect_host_to_load"),
);
return;
}
@@ -198,13 +261,10 @@ export function createExtensionsStore(options: {
}
async function refreshPlugins(scopeOverride?: PluginScope) {
if (!isTauriRuntime()) {
setPluginStatus(translate("skills.plugin_management_host_only"));
setPluginList([]);
setSidebarPluginStatus(translate("skills.plugins_host_only"));
setSidebarPluginList([]);
return;
}
const isRemoteWorkspace = options.workspaceType() === "remote";
const openworkClient = options.openworkServerClient();
const openworkWorkspaceId = options.openworkServerWorkspaceId();
const openworkCapabilities = options.openworkServerCapabilities();
// Skip if already in flight
if (refreshPluginsInFlight) {
@@ -217,6 +277,64 @@ export function createExtensionsStore(options: {
const scope = scopeOverride ?? pluginScope();
const targetDir = options.projectDir().trim();
if (isRemoteWorkspace) {
setPluginConfig(null);
setPluginConfigPath("opencode.json (remote)");
if (scope !== "project") {
setPluginStatus("Global plugins are only available in Host mode.");
setPluginList([]);
setSidebarPluginStatus("Switch to project scope to view remote plugins.");
setSidebarPluginList([]);
refreshPluginsInFlight = false;
return;
}
if (!openworkClient || !openworkWorkspaceId || !openworkCapabilities?.plugins?.read) {
setPluginStatus("OpenWork server unavailable. Plugins are read-only.");
setPluginList([]);
setSidebarPluginStatus("Connect OpenWork server to load plugins.");
setSidebarPluginList([]);
refreshPluginsInFlight = false;
return;
}
try {
setPluginStatus(null);
setSidebarPluginStatus(null);
const result = await openworkClient.listPlugins(openworkWorkspaceId);
if (refreshPluginsAborted) return;
const configItems = result.items.filter((item) => item.source === "config");
const list = configItems.map((item) => item.spec);
setPluginList(list);
setSidebarPluginList(list);
if (!list.length) {
setPluginStatus("No plugins configured yet.");
}
} catch (e) {
if (refreshPluginsAborted) return;
setPluginList([]);
setSidebarPluginStatus("Failed to load plugins.");
setSidebarPluginList([]);
setPluginStatus(e instanceof Error ? e.message : "Failed to load plugins.");
} finally {
refreshPluginsInFlight = false;
}
return;
}
if (!isTauriRuntime()) {
setPluginStatus(translate("skills.plugin_management_host_only"));
setPluginList([]);
setSidebarPluginStatus(translate("skills.plugins_host_only"));
setSidebarPluginList([]);
refreshPluginsInFlight = false;
return;
}
if (scope === "project" && !targetDir) {
setPluginStatus(translate("skills.pick_project_for_plugins"));
setPluginList([]);
@@ -237,6 +355,7 @@ export function createExtensionsStore(options: {
if (refreshPluginsAborted) return;
setPluginConfig(config);
setPluginConfigPath(config.path ?? null);
if (!config.exists) {
setPluginList([]);
@@ -258,6 +377,7 @@ export function createExtensionsStore(options: {
} catch (e) {
if (refreshPluginsAborted) return;
setPluginConfig(null);
setPluginConfigPath(null);
setPluginList([]);
setPluginStatus(e instanceof Error ? e.message : translate("skills.failed_load_opencode"));
setSidebarPluginStatus(translate("skills.failed_load_active"));
@@ -268,14 +388,14 @@ export function createExtensionsStore(options: {
}
async function addPlugin(pluginNameOverride?: string) {
if (!isTauriRuntime()) {
setPluginStatus(translate("skills.plugin_management_host_only"));
return;
}
const pluginName = (pluginNameOverride ?? pluginInput()).trim();
const isManualInput = pluginNameOverride == null;
const isRemoteWorkspace = options.workspaceType() === "remote";
const openworkClient = options.openworkServerClient();
const openworkWorkspaceId = options.openworkServerWorkspaceId();
const openworkCapabilities = options.openworkServerCapabilities();
if (!pluginName) {
if (isManualInput) {
setPluginStatus(translate("skills.enter_plugin_name"));
@@ -283,6 +403,34 @@ export function createExtensionsStore(options: {
return;
}
if (isRemoteWorkspace) {
if (pluginScope() !== "project") {
setPluginStatus("Global plugins are only available in Host mode.");
return;
}
if (!openworkClient || !openworkWorkspaceId || !openworkCapabilities?.plugins?.write) {
setPluginStatus("OpenWork server unavailable. Connect to add plugins.");
return;
}
try {
setPluginStatus(null);
await openworkClient.addPlugin(openworkWorkspaceId, pluginName);
if (isManualInput) {
setPluginInput("");
}
await refreshPlugins("project");
} catch (e) {
setPluginStatus(e instanceof Error ? e.message : "Failed to add plugin.");
}
return;
}
if (!isTauriRuntime()) {
setPluginStatus(translate("skills.plugin_management_host_only"));
return;
}
const scope = pluginScope();
const targetDir = options.projectDir().trim();
@@ -377,6 +525,37 @@ export function createExtensionsStore(options: {
}
async function installSkillCreator() {
const isRemoteWorkspace = options.workspaceType() === "remote";
const openworkClient = options.openworkServerClient();
const openworkWorkspaceId = options.openworkServerWorkspaceId();
const openworkCapabilities = options.openworkServerCapabilities();
if (isRemoteWorkspace) {
if (!openworkClient || !openworkWorkspaceId || !openworkCapabilities?.skills?.write) {
setSkillsStatus("OpenWork server unavailable. Connect to install skills.");
return;
}
options.setBusy(true);
options.setError(null);
setSkillsStatus(translate("skills.installing_skill_creator"));
try {
await openworkClient.upsertSkill(openworkWorkspaceId, {
name: "skill-creator",
content: skillCreatorTemplate,
});
setSkillsStatus(translate("skills.skill_creator_installed"));
await refreshSkills({ force: true });
} catch (e) {
const message = e instanceof Error ? e.message : translate("skills.unknown_error");
options.setError(addOpencodeCacheHint(message));
} finally {
options.setBusy(false);
}
return;
}
if (!isTauriRuntime()) {
setSkillsStatus(translate("skills.desktop_required"));
return;
@@ -510,6 +689,7 @@ export function createExtensionsStore(options: {
pluginScope,
setPluginScope,
pluginConfig,
pluginConfigPath,
pluginList,
pluginInput,
setPluginInput,

View File

@@ -20,6 +20,7 @@ import type {
import type { McpStatusMap, TodoItem } from "../types";
import { unwrap } from "../lib/opencode";
import { safeStringify } from "../utils";
import { mapConfigProvidersToList } from "../utils/providers";
import { useGlobalSDK } from "./global-sdk";
export type WorkspaceState = {
@@ -36,7 +37,6 @@ type WorkspaceStore = [Store<WorkspaceState>, SetStoreFunction<WorkspaceState>];
type ProjectMeta = {
name?: string;
icon?: Project["icon"];
commands?: Project["commands"];
};
type GlobalState = {
@@ -107,7 +107,6 @@ export function GlobalSyncProvider(props: ParentProps) {
next[project.worktree] = {
name: project.name,
icon: project.icon,
commands: project.commands,
};
}
setGlobalStore("projectMeta", next);
@@ -125,7 +124,7 @@ export function GlobalSyncProvider(props: ParentProps) {
} catch {
const fallback = unwrap(await globalSDK.client().config.providers()) as ConfigProvidersResponse;
setGlobalStore("provider", {
all: fallback.providers as ProviderListResponse["all"],
all: mapConfigProvidersToList(fallback.providers),
connected: [],
default: fallback.default,
});
@@ -239,7 +238,9 @@ export function GlobalSyncProvider(props: ParentProps) {
children.set(key, store);
void refreshDirectory(directory);
if (!subscriptions.has(key)) {
const unsubscribe = globalSDK.event.listen(key, (event: Event) => {
const unsubscribe = globalSDK.event.listen((payload) => {
if (payload.name !== key) return;
const event = payload.details as Event;
if (event.type === "lsp.updated") {
void refreshLsp(directory);
}
@@ -268,7 +269,9 @@ export function GlobalSyncProvider(props: ParentProps) {
const globalKey = keyFor("");
if (!subscriptions.has(globalKey)) {
const unsubscribe = globalSDK.event.listen(globalKey, (event: Event) => {
const unsubscribe = globalSDK.event.listen((payload) => {
if (payload.name !== globalKey) return;
const event = payload.details as Event;
if (event.type === "lsp.updated") {
void refreshLsp();
}

View File

@@ -35,8 +35,9 @@ import {
type WorkspaceInfo,
} from "../lib/tauri";
import { waitForHealthy, createClient } from "../lib/opencode";
import type { Provider } from "@opencode-ai/sdk/v2/client";
import type { ProviderListItem } from "../types";
import { t, currentLocale } from "../../i18n";
import { mapConfigProvidersToList } from "../utils/providers";
export type WorkspaceStore = ReturnType<typeof createWorkspaceStore>;
@@ -54,7 +55,7 @@ export function createWorkspaceStore(options: {
setClient: (value: Client | null) => void;
setConnectedVersion: (value: string | null) => void;
setSseConnected: (value: boolean) => void;
setProviders: (value: Provider[]) => void;
setProviders: (value: ProviderListItem[]) => void;
setProviderDefaults: (value: Record<string, string>) => void;
setProviderConnectedIds: (value: string[]) => void;
setError: (value: string | null) => void;
@@ -385,13 +386,13 @@ export function createWorkspaceStore(options: {
try {
const providerList = unwrap(await nextClient.provider.list());
options.setProviders(providerList.all as unknown as Provider[]);
options.setProviders(providerList.all);
options.setProviderDefaults(providerList.default);
options.setProviderConnectedIds(providerList.connected);
} catch {
try {
const cfg = unwrap(await nextClient.config.providers());
options.setProviders(cfg.providers as unknown as Provider[]);
options.setProviders(mapConfigProvidersToList(cfg.providers));
options.setProviderDefaults(cfg.default);
options.setProviderConnectedIds([]);
} catch {

View File

@@ -0,0 +1,285 @@
export type OpenworkServerCapabilities = {
skills: { read: boolean; write: boolean; source: "openwork" | "opencode" };
plugins: { read: boolean; write: boolean };
mcp: { read: boolean; write: boolean };
commands: { read: boolean; write: boolean };
config: { read: boolean; write: boolean };
};
export type OpenworkServerStatus = "connected" | "disconnected" | "limited";
export type OpenworkServerSettings = {
urlOverride?: string;
portOverride?: number;
token?: string;
};
export type OpenworkWorkspaceInfo = {
id: string;
name: string;
path: string;
workspaceType: "local" | "remote";
baseUrl?: string;
directory?: string;
};
export type OpenworkPluginItem = {
spec: string;
source: "config" | "dir.project" | "dir.global";
scope: "project" | "global";
path?: string;
};
export type OpenworkSkillItem = {
name: string;
path: string;
description: string;
scope: "project" | "global";
};
export type OpenworkCommandItem = {
name: string;
description?: string;
template: string;
agent?: string;
model?: string | null;
subtask?: boolean;
scope: "workspace" | "global";
};
export type OpenworkMcpItem = {
name: string;
config: Record<string, unknown>;
source: "config.project" | "config.global" | "config.remote";
disabledByTools?: boolean;
};
export const DEFAULT_OPENWORK_SERVER_PORT = 4097;
const STORAGE_URL_OVERRIDE = "openwork.server.urlOverride";
const STORAGE_PORT_OVERRIDE = "openwork.server.port";
const STORAGE_TOKEN = "openwork.server.token";
export function normalizeOpenworkServerUrl(input: string) {
const trimmed = input.trim();
if (!trimmed) return null;
const withProtocol = /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`;
return withProtocol.replace(/\/+$/, "");
}
export function readOpenworkServerSettings(): OpenworkServerSettings {
if (typeof window === "undefined") return {};
try {
const urlOverride = normalizeOpenworkServerUrl(
window.localStorage.getItem(STORAGE_URL_OVERRIDE) ?? "",
);
const portRaw = window.localStorage.getItem(STORAGE_PORT_OVERRIDE) ?? "";
const portOverride = portRaw ? Number(portRaw) : undefined;
const token = window.localStorage.getItem(STORAGE_TOKEN) ?? undefined;
return {
urlOverride: urlOverride ?? undefined,
portOverride: Number.isNaN(portOverride) ? undefined : portOverride,
token: token?.trim() || undefined,
};
} catch {
return {};
}
}
export function writeOpenworkServerSettings(next: OpenworkServerSettings): OpenworkServerSettings {
if (typeof window === "undefined") return next;
try {
const urlOverride = normalizeOpenworkServerUrl(next.urlOverride ?? "");
const portOverride = typeof next.portOverride === "number" ? next.portOverride : undefined;
const token = next.token?.trim() || undefined;
if (urlOverride) {
window.localStorage.setItem(STORAGE_URL_OVERRIDE, urlOverride);
} else {
window.localStorage.removeItem(STORAGE_URL_OVERRIDE);
}
if (typeof portOverride === "number" && !Number.isNaN(portOverride)) {
window.localStorage.setItem(STORAGE_PORT_OVERRIDE, String(portOverride));
} else {
window.localStorage.removeItem(STORAGE_PORT_OVERRIDE);
}
if (token) {
window.localStorage.setItem(STORAGE_TOKEN, token);
} else {
window.localStorage.removeItem(STORAGE_TOKEN);
}
return readOpenworkServerSettings();
} catch {
return next;
}
}
export function clearOpenworkServerSettings() {
if (typeof window === "undefined") return;
try {
window.localStorage.removeItem(STORAGE_URL_OVERRIDE);
window.localStorage.removeItem(STORAGE_PORT_OVERRIDE);
window.localStorage.removeItem(STORAGE_TOKEN);
} catch {
// ignore
}
}
export function deriveOpenworkServerUrl(
opencodeBaseUrl: string,
settings?: OpenworkServerSettings,
) {
const override = settings?.urlOverride?.trim();
if (override) {
return normalizeOpenworkServerUrl(override);
}
const base = opencodeBaseUrl.trim();
if (!base) return null;
try {
const url = new URL(base);
const port = settings?.portOverride ?? DEFAULT_OPENWORK_SERVER_PORT;
url.port = String(port);
url.pathname = "";
url.search = "";
url.hash = "";
return url.origin;
} catch {
return null;
}
}
export class OpenworkServerError extends Error {
status: number;
code: string;
details?: unknown;
constructor(status: number, code: string, message: string, details?: unknown) {
super(message);
this.status = status;
this.code = code;
this.details = details;
}
}
function buildHeaders(token?: string, extra?: Record<string, string>) {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (token) {
headers.Authorization = `Bearer ${token}`;
}
if (extra) {
Object.assign(headers, extra);
}
return headers;
}
async function requestJson<T>(
baseUrl: string,
path: string,
options: { method?: string; token?: string; body?: unknown } = {},
): Promise<T> {
const url = `${baseUrl}${path}`;
const response = await fetch(url, {
method: options.method ?? "GET",
headers: buildHeaders(options.token),
body: options.body ? JSON.stringify(options.body) : undefined,
});
const text = await response.text();
const json = text ? JSON.parse(text) : null;
if (!response.ok) {
const code = typeof json?.code === "string" ? json.code : "request_failed";
const message = typeof json?.message === "string" ? json.message : response.statusText;
throw new OpenworkServerError(response.status, code, message, json?.details);
}
return json as T;
}
export function createOpenworkServerClient(options: { baseUrl: string; token?: string }) {
const baseUrl = options.baseUrl.replace(/\/+$/, "");
const token = options.token;
return {
baseUrl,
token,
health: () => requestJson<{ ok: boolean; version: string; uptimeMs: number }>(baseUrl, "/health"),
capabilities: () => requestJson<OpenworkServerCapabilities>(baseUrl, "/capabilities", { token }),
listWorkspaces: () => requestJson<{ items: OpenworkWorkspaceInfo[] }>(baseUrl, "/workspaces", { token }),
getConfig: (workspaceId: string) =>
requestJson<{ opencode: Record<string, unknown>; openwork: Record<string, unknown>; updatedAt?: number | null }>(
baseUrl,
`/workspace/${workspaceId}/config`,
{ token },
),
patchConfig: (workspaceId: string, payload: { opencode?: Record<string, unknown>; openwork?: Record<string, unknown> }) =>
requestJson<{ updatedAt?: number | null }>(baseUrl, `/workspace/${workspaceId}/config`, {
token,
method: "PATCH",
body: payload,
}),
listPlugins: (workspaceId: string) =>
requestJson<{ items: OpenworkPluginItem[]; loadOrder: string[] }>(baseUrl, `/workspace/${workspaceId}/plugins`, {
token,
}),
addPlugin: (workspaceId: string, spec: string) =>
requestJson<{ items: OpenworkPluginItem[]; loadOrder: string[] }>(
baseUrl,
`/workspace/${workspaceId}/plugins`,
{ token, method: "POST", body: { spec } },
),
removePlugin: (workspaceId: string, name: string) =>
requestJson<{ items: OpenworkPluginItem[]; loadOrder: string[] }>(
baseUrl,
`/workspace/${workspaceId}/plugins/${encodeURIComponent(name)}`,
{ token, method: "DELETE" },
),
listSkills: (workspaceId: string) =>
requestJson<{ items: OpenworkSkillItem[] }>(baseUrl, `/workspace/${workspaceId}/skills`, { token }),
upsertSkill: (workspaceId: string, payload: { name: string; content: string; description?: string }) =>
requestJson<OpenworkSkillItem>(baseUrl, `/workspace/${workspaceId}/skills`, {
token,
method: "POST",
body: payload,
}),
listMcp: (workspaceId: string) =>
requestJson<{ items: OpenworkMcpItem[] }>(baseUrl, `/workspace/${workspaceId}/mcp`, { token }),
addMcp: (workspaceId: string, payload: { name: string; config: Record<string, unknown> }) =>
requestJson<{ items: OpenworkMcpItem[] }>(baseUrl, `/workspace/${workspaceId}/mcp`, {
token,
method: "POST",
body: payload,
}),
removeMcp: (workspaceId: string, name: string) =>
requestJson<{ items: OpenworkMcpItem[] }>(baseUrl, `/workspace/${workspaceId}/mcp/${encodeURIComponent(name)}`, {
token,
method: "DELETE",
}),
listCommands: (workspaceId: string, scope: "workspace" | "global" = "workspace") =>
requestJson<{ items: OpenworkCommandItem[] }>(
baseUrl,
`/workspace/${workspaceId}/commands?scope=${scope}`,
{ token },
),
upsertCommand: (
workspaceId: string,
payload: { name: string; description?: string; template: string; agent?: string; model?: string | null; subtask?: boolean },
) =>
requestJson<{ items: OpenworkCommandItem[] }>(baseUrl, `/workspace/${workspaceId}/commands`, {
token,
method: "POST",
body: payload,
}),
deleteCommand: (workspaceId: string, name: string) =>
requestJson<{ ok: boolean }>(baseUrl, `/workspace/${workspaceId}/commands/${encodeURIComponent(name)}`, {
token,
method: "DELETE",
}),
};
}
export type OpenworkServerClient = ReturnType<typeof createOpenworkServerClient>;

View File

@@ -10,6 +10,7 @@ import type {
} from "../types";
import type { McpDirectoryInfo } from "../constants";
import { formatRelativeTime, normalizeDirectoryPath } from "../utils";
import type { OpenworkServerSettings, OpenworkServerStatus } from "../lib/openwork-server";
import Button from "../components/button";
import OpenWorkLogo from "../components/openwork-logo";
@@ -45,6 +46,12 @@ export type DashboardViewProps = {
newTaskDisabled: boolean;
headerStatus: string;
error: string | null;
openworkServerStatus: OpenworkServerStatus;
openworkServerUrl: string;
openworkServerSettings: OpenworkServerSettings;
updateOpenworkServerSettings: (next: OpenworkServerSettings) => void;
resetOpenworkServerSettings: () => void;
testOpenworkServerConnection: (next: OpenworkServerSettings) => Promise<boolean>;
keybindItems: KeybindSetting[];
onOverrideKeybind: (id: string, keybind: string | null) => void;
onResetKeybind: (id: string) => void;
@@ -95,10 +102,16 @@ export type DashboardViewProps = {
refreshMcpServers: () => void;
skills: SkillCard[];
skillsStatus: string | null;
skillsAccessHint?: string | null;
canInstallSkillCreator: boolean;
canUseDesktopTools: boolean;
importLocalSkill: () => void;
installSkillCreator: () => void;
revealSkillsFolder: () => void;
uninstallSkill: (name: string) => void;
pluginsAccessHint?: string | null;
canEditPlugins: boolean;
canUseGlobalPluginScope: boolean;
pluginScope: PluginScope;
setPluginScope: (scope: PluginScope) => void;
pluginConfigPath: string | null;
@@ -330,6 +343,23 @@ export default function DashboardView(props: DashboardViewProps) {
);
};
const opencodeStatusMeta = createMemo(() => ({
dot: props.clientConnected ? "bg-green-9" : "bg-gray-6",
text: props.clientConnected ? "text-green-11" : "text-gray-10",
label: props.clientConnected ? "Connected" : "Not connected",
}));
const openworkStatusMeta = createMemo(() => {
switch (props.openworkServerStatus) {
case "connected":
return { dot: "bg-green-9", text: "text-green-11", label: "Ready" };
case "limited":
return { dot: "bg-amber-9", text: "text-amber-11", label: "Limited access" };
default:
return { dot: "bg-gray-6", text: "text-gray-10", label: "Unavailable" };
}
});
return (
<div class="flex h-screen bg-gray-1 text-gray-12 overflow-hidden">
<aside class="w-64 border-r border-gray-6 p-6 hidden md:flex flex-col justify-between bg-gray-1">
@@ -394,6 +424,33 @@ export default function DashboardView(props: DashboardViewProps) {
</Show>
</div>
<div class="px-3 py-2 rounded-xl bg-gray-2/40 border border-gray-6">
<div class="grid grid-cols-[1fr_auto_1fr] items-center gap-3 text-xs">
<div class="flex items-center gap-2">
<span class={`w-2 h-2 rounded-full ${opencodeStatusMeta().dot}`} />
<span class="text-gray-11 font-medium">OpenCode Engine</span>
<span class={opencodeStatusMeta().text}>{opencodeStatusMeta().label}</span>
</div>
<div class="w-px h-5 bg-gray-6/70" />
<div class="flex items-center gap-2 justify-end">
<span class={`w-2 h-2 rounded-full ${openworkStatusMeta().dot}`} />
<span class="text-gray-11 font-medium">OpenWork Server</span>
<span class={openworkStatusMeta().text}>{openworkStatusMeta().label}</span>
</div>
</div>
<Show when={props.developerMode && props.openworkServerUrl}>
<div class="mt-2 text-[11px] text-gray-7 font-mono truncate">
{props.openworkServerUrl}
</div>
</Show>
</div>
<Show when={props.mode === "client" && props.openworkServerStatus === "disconnected"}>
<div class="text-[11px] text-gray-9 px-1">
OpenWork server is offline remote tasks still run.
</div>
</Show>
<Show when={!props.clientConnected && !props.demoMode}>
<Button
variant="secondary"
@@ -819,6 +876,9 @@ export default function DashboardView(props: DashboardViewProps) {
<SkillsView
busy={props.busy}
mode={props.mode}
canInstallSkillCreator={props.canInstallSkillCreator}
canUseDesktopTools={props.canUseDesktopTools}
accessHint={props.skillsAccessHint}
refreshSkills={props.refreshSkills}
skills={props.skills}
skillsStatus={props.skillsStatus}
@@ -833,6 +893,9 @@ export default function DashboardView(props: DashboardViewProps) {
<PluginsView
busy={props.busy}
activeWorkspaceRoot={props.activeWorkspaceRoot}
canEditPlugins={props.canEditPlugins}
canUseGlobalScope={props.canUseGlobalPluginScope}
accessHint={props.pluginsAccessHint}
pluginScope={props.pluginScope}
setPluginScope={props.setPluginScope}
pluginConfigPath={props.pluginConfigPath}
@@ -875,6 +938,12 @@ export default function DashboardView(props: DashboardViewProps) {
baseUrl={props.baseUrl}
headerStatus={props.headerStatus}
busy={props.busy}
openworkServerStatus={props.openworkServerStatus}
openworkServerUrl={props.openworkServerUrl}
openworkServerSettings={props.openworkServerSettings}
updateOpenworkServerSettings={props.updateOpenworkServerSettings}
resetOpenworkServerSettings={props.resetOpenworkServerSettings}
testOpenworkServerConnection={props.testOpenworkServerConnection}
developerMode={props.developerMode}
toggleDeveloperMode={props.toggleDeveloperMode}
stopHost={props.stopHost}

View File

@@ -1,7 +1,6 @@
import { For, Show } from "solid-js";
import type { PluginScope } from "../types";
import { isTauriRuntime } from "../utils";
import Button from "../components/button";
import TextInput from "../components/text-input";
@@ -10,6 +9,9 @@ import { Cpu } from "lucide-solid";
export type PluginsViewProps = {
busy: boolean;
activeWorkspaceRoot: string;
canEditPlugins: boolean;
canUseGlobalScope: boolean;
accessHint?: string | null;
pluginScope: PluginScope;
setPluginScope: (scope: PluginScope) => void;
pluginConfigPath: string | null;
@@ -64,12 +66,14 @@ export default function PluginsView(props: PluginsViewProps) {
Project
</button>
<button
disabled={!props.canUseGlobalScope}
class={`px-3 py-1 rounded-full text-xs font-medium border transition-colors ${
props.pluginScope === "global"
? "bg-gray-12/10 text-gray-12 border-gray-6/20"
: "text-gray-10 border-gray-6 hover:text-gray-12"
}`}
} ${!props.canUseGlobalScope ? "opacity-40 cursor-not-allowed hover:text-gray-10" : ""}`}
onClick={() => {
if (!props.canUseGlobalScope) return;
props.setPluginScope("global");
props.refreshPlugins("global");
}}
@@ -85,6 +89,9 @@ export default function PluginsView(props: PluginsViewProps) {
<div class="flex flex-col gap-1 text-xs text-gray-10">
<div>Config</div>
<div class="text-gray-7 font-mono truncate">{props.pluginConfigPath ?? "Not loaded yet"}</div>
<Show when={props.accessHint}>
<div class="text-gray-9">{props.accessHint}</div>
</Show>
</div>
<div class="space-y-3">
@@ -121,7 +128,7 @@ export default function PluginsView(props: PluginsViewProps) {
disabled={
props.busy ||
isInstalled() ||
!isTauriRuntime() ||
!props.canEditPlugins ||
(props.pluginScope === "project" && !props.activeWorkspaceRoot.trim())
}
>
@@ -211,7 +218,7 @@ export default function PluginsView(props: PluginsViewProps) {
<Button
variant="secondary"
onClick={() => props.addPlugin()}
disabled={props.busy || !props.pluginInput.trim()}
disabled={props.busy || !props.pluginInput.trim() || !props.canEditPlugins}
class="md:mt-6"
>
Add

View File

@@ -1,5 +1,5 @@
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js";
import type { Agent, Part, Provider } from "@opencode-ai/sdk/v2/client";
import type { Agent, Part } from "@opencode-ai/sdk/v2/client";
import type {
ArtifactItem,
DashboardTab,
@@ -11,6 +11,7 @@ import type {
McpServerEntry,
McpStatusMap,
PendingPermission,
ProviderListItem,
SkillCard,
TodoItem,
View,
@@ -101,7 +102,7 @@ export type SessionViewProps = {
providerAuthBusy: boolean;
providerAuthError: string | null;
providerAuthMethods: Record<string, { type: "oauth" | "api"; label: string }[]>;
providers: Provider[];
providers: ProviderListItem[];
providerConnectedIds: string[];
listAgents: () => Promise<Agent[]>;
searchFiles: (query: string) => Promise<string[]>;

View File

@@ -1,16 +1,24 @@
import { Match, Show, Switch } from "solid-js";
import { Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js";
import { formatBytes, formatRelativeTime, isTauriRuntime } from "../utils";
import Button from "../components/button";
import TextInput from "../components/text-input";
import SettingsKeybinds, { type KeybindSetting } from "../components/settings-keybinds";
import { HardDrive, RefreshCcw, Shield, Smartphone } from "lucide-solid";
import type { OpenworkServerSettings, OpenworkServerStatus } from "../lib/openwork-server";
export type SettingsViewProps = {
mode: "host" | "client" | null;
baseUrl: string;
headerStatus: string;
busy: boolean;
openworkServerStatus: OpenworkServerStatus;
openworkServerUrl: string;
openworkServerSettings: OpenworkServerSettings;
updateOpenworkServerSettings: (next: OpenworkServerSettings) => void;
resetOpenworkServerSettings: () => void;
testOpenworkServerConnection: (next: OpenworkServerSettings) => Promise<boolean>;
developerMode: boolean;
toggleDeveloperMode: () => void;
stopHost: () => void;
@@ -104,6 +112,49 @@ export default function SettingsView(props: SettingsViewProps) {
return "bg-gray-4/60 text-gray-11 border-gray-7/50";
};
const [openworkUrl, setOpenworkUrl] = createSignal("");
const [openworkToken, setOpenworkToken] = createSignal("");
const [openworkTokenVisible, setOpenworkTokenVisible] = createSignal(false);
createEffect(() => {
setOpenworkUrl(props.openworkServerSettings.urlOverride ?? "");
setOpenworkToken(props.openworkServerSettings.token ?? "");
});
const openworkStatusLabel = createMemo(() => {
switch (props.openworkServerStatus) {
case "connected":
return "Connected";
case "limited":
return "Limited";
default:
return "Not connected";
}
});
const openworkStatusStyle = createMemo(() => {
switch (props.openworkServerStatus) {
case "connected":
return "bg-green-7/10 text-green-11 border-green-7/20";
case "limited":
return "bg-amber-7/10 text-amber-11 border-amber-7/20";
default:
return "bg-gray-4/60 text-gray-11 border-gray-7/50";
}
});
const buildOpenworkSettings = () => ({
...props.openworkServerSettings,
urlOverride: openworkUrl().trim() || undefined,
token: openworkToken().trim() || undefined,
});
const hasOpenworkChanges = createMemo(() => {
const currentUrl = props.openworkServerSettings.urlOverride ?? "";
const currentToken = props.openworkServerSettings.token ?? "";
return openworkUrl().trim() !== currentUrl || openworkToken().trim() !== currentToken;
});
return (
<section class="space-y-6">
@@ -130,6 +181,86 @@ export default function SettingsView(props: SettingsViewProps) {
</div>
<div class="bg-gray-2/30 border border-gray-6/50 rounded-2xl p-5 space-y-4">
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<div class="text-sm font-medium text-gray-12">OpenWork Server</div>
<div class="text-xs text-gray-10">
Connect a remote OpenWork server to manage skills and plugins.
</div>
</div>
<div class={`text-xs px-2 py-1 rounded-full border ${openworkStatusStyle()}`}>
{openworkStatusLabel()}
</div>
</div>
<div class="grid gap-3">
<TextInput
label="Server URL"
value={openworkUrl()}
onInput={(event) => setOpenworkUrl(event.currentTarget.value)}
placeholder="http://127.0.0.1:8787"
hint="Leave blank to use your OpenCode URL with port 4097."
disabled={props.busy}
/>
<label class="block">
<div class="mb-1 text-xs font-medium text-gray-11">Access token</div>
<div class="flex items-center gap-2">
<input
type={openworkTokenVisible() ? "text" : "password"}
value={openworkToken()}
onInput={(event) => setOpenworkToken(event.currentTarget.value)}
placeholder="Optional bearer token"
disabled={props.busy}
class="w-full rounded-xl bg-gray-2/60 px-3 py-2 text-sm text-gray-12 placeholder:text-gray-10 shadow-[0_0_0_1px_rgba(255,255,255,0.08)] focus:outline-none focus:ring-2 focus:ring-gray-6/20"
/>
<Button
variant="outline"
class="text-xs h-9 px-3 shrink-0"
onClick={() => setOpenworkTokenVisible((prev) => !prev)}
disabled={props.busy}
>
{openworkTokenVisible() ? "Hide" : "Show"}
</Button>
</div>
<div class="mt-1 text-xs text-gray-10">Keep this private. It grants access to your server.</div>
</label>
</div>
<div class="text-[11px] text-gray-7 font-mono truncate">
Resolved URL: {props.openworkServerUrl || "Not set"}
</div>
<div class="flex flex-wrap gap-2">
<Button
variant="secondary"
onClick={async () => {
const next = buildOpenworkSettings();
props.updateOpenworkServerSettings(next);
await props.testOpenworkServerConnection(next);
}}
disabled={props.busy}
>
Test connection
</Button>
<Button
variant="outline"
onClick={() => props.updateOpenworkServerSettings(buildOpenworkSettings())}
disabled={props.busy || !hasOpenworkChanges()}
>
Save
</Button>
<Button
variant="ghost"
onClick={props.resetOpenworkServerSettings}
disabled={props.busy}
>
Clear
</Button>
</div>
</div>
<div class="bg-gray-2/30 border border-gray-6/50 rounded-2xl p-5 space-y-4">
<div>

View File

@@ -9,6 +9,9 @@ import { currentLocale, t } from "../../i18n";
export type SkillsViewProps = {
busy: boolean;
mode: "host" | "client" | null;
canInstallSkillCreator: boolean;
canUseDesktopTools: boolean;
accessHint?: string | null;
refreshSkills: (options?: { force?: boolean }) => void;
skills: SkillCard[];
skillsStatus: string | null;
@@ -47,7 +50,17 @@ export default function SkillsView(props: SkillsViewProps) {
<div class="text-xs font-semibold text-gray-11 uppercase tracking-wider">{translate("skills.add_title")}</div>
<div class="text-sm text-gray-10 mt-2">{translate("skills.add_description")}</div>
</div>
<Show when={props.mode !== "host"}>
<Show when={props.accessHint}>
<div class="text-xs text-gray-10">{props.accessHint}</div>
</Show>
<Show
when={
!props.accessHint &&
props.mode !== "host" &&
!props.canInstallSkillCreator &&
!props.canUseDesktopTools
}
>
<div class="text-xs text-gray-10">{translate("skills.host_mode_only")}</div>
</Show>
</div>
@@ -64,7 +77,7 @@ export default function SkillsView(props: SkillsViewProps) {
if (skillCreatorInstalled()) return;
props.installSkillCreator();
}}
disabled={props.busy || skillCreatorInstalled()}
disabled={props.busy || skillCreatorInstalled() || !props.canInstallSkillCreator}
>
<Package size={16} />
{skillCreatorInstalled() ? translate("skills.installed_label") : translate("skills.install")}
@@ -76,7 +89,11 @@ export default function SkillsView(props: SkillsViewProps) {
<div class="text-sm font-medium text-gray-12">{translate("skills.import_local")}</div>
<div class="text-xs text-gray-10 mt-1">{translate("skills.import_local_hint")}</div>
</div>
<Button variant="secondary" onClick={props.importLocalSkill} disabled={props.busy}>
<Button
variant="secondary"
onClick={props.importLocalSkill}
disabled={props.busy || !props.canUseDesktopTools}
>
<Upload size={16} />
{translate("skills.import")}
</Button>
@@ -87,7 +104,11 @@ export default function SkillsView(props: SkillsViewProps) {
<div class="text-sm font-medium text-gray-12">{translate("skills.reveal_folder")}</div>
<div class="text-xs text-gray-10 mt-1">{translate("skills.reveal_folder_hint")}</div>
</div>
<Button variant="secondary" onClick={props.revealSkillsFolder} disabled={props.busy}>
<Button
variant="secondary"
onClick={props.revealSkillsFolder}
disabled={props.busy || !props.canUseDesktopTools}
>
<FolderOpen size={16} />
{translate("skills.reveal_button")}
</Button>
@@ -137,7 +158,7 @@ export default function SkillsView(props: SkillsViewProps) {
variant="danger"
class="!px-3 !py-2 text-xs"
onClick={() => setUninstallTarget(s)}
disabled={props.busy}
disabled={props.busy || !props.canUseDesktopTools}
title={translate("skills.uninstall")}
>
{translate("skills.uninstall")}

View File

@@ -1,12 +1,14 @@
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js";
import type { Provider, Session } from "@opencode-ai/sdk/v2/client";
import type { Session } from "@opencode-ai/sdk/v2/client";
import type { ProviderListItem } from "./types";
import { check } from "@tauri-apps/plugin-updater";
import { relaunch } from "@tauri-apps/plugin-process";
import type { Client, Mode, PluginScope, ReloadReason, ResetOpenworkMode, UpdateHandle } from "./types";
import { addOpencodeCacheHint, isTauriRuntime, safeStringify } from "./utils";
import { mapConfigProvidersToList } from "./utils/providers";
import { createUpdaterState } from "./context/updater";
import { resetOpenworkState, resetOpencodeCache } from "./lib/tauri";
import { unwrap, waitForHealthy } from "./lib/opencode";
@@ -29,7 +31,7 @@ export function createSystemState(options: {
refreshSkills: (options?: { force?: boolean }) => Promise<void>;
refreshMcpServers?: () => Promise<void>;
reloadWorkspaceEngine?: () => Promise<boolean>;
setProviders: (value: Provider[]) => void;
setProviders: (value: ProviderListItem[]) => void;
setProviderDefaults: (value: Record<string, string>) => void;
setProviderConnectedIds: (value: string[]) => void;
setError: (value: string | null) => void;
@@ -234,13 +236,13 @@ export function createSystemState(options: {
try {
const providerList = unwrap(await nextClient.provider.list());
options.setProviders(providerList.all as unknown as Provider[]);
options.setProviders(providerList.all);
options.setProviderDefaults(providerList.default);
options.setProviderConnectedIds(providerList.connected);
} catch {
try {
const cfg = unwrap(await nextClient.config.providers());
options.setProviders(cfg.providers);
options.setProviders(mapConfigProvidersToList(cfg.providers));
options.setProviderDefaults(cfg.default);
options.setProviderConnectedIds([]);
} catch {

View File

@@ -1,9 +1,17 @@
import type { Message, Part, PermissionRequest as ApiPermissionRequest, Provider, Session } from "@opencode-ai/sdk/v2/client";
import type {
Message,
Part,
PermissionRequest as ApiPermissionRequest,
ProviderListResponse,
Session,
} from "@opencode-ai/sdk/v2/client";
import type { createClient } from "./lib/opencode";
import type { OpencodeConfigFile, WorkspaceInfo } from "./lib/tauri";
export type Client = ReturnType<typeof createClient>;
export type ProviderListItem = ProviderListResponse["all"][number];
export type PlaceholderAssistantMessage = {
id: string;
sessionID: string;

View File

@@ -1,5 +1,14 @@
import type { Part, Provider, Session } from "@opencode-ai/sdk/v2/client";
import type { ArtifactItem, MessageGroup, MessageInfo, MessageWithParts, ModelRef, OpencodeEvent, PlaceholderAssistantMessage } from "../types";
import type { Part, Session } from "@opencode-ai/sdk/v2/client";
import type {
ArtifactItem,
MessageGroup,
MessageInfo,
MessageWithParts,
ModelRef,
OpencodeEvent,
PlaceholderAssistantMessage,
ProviderListItem,
} from "../types";
export function formatModelRef(model: ModelRef) {
return `${model.providerID}/${model.modelID}`;
@@ -47,7 +56,7 @@ const humanizeModelLabel = (value: string) => {
.join(" ");
};
export function formatModelLabel(model: ModelRef, providers: Provider[] = []) {
export function formatModelLabel(model: ModelRef, providers: ProviderListItem[] = []) {
const provider = providers.find((p) => p.id === model.providerID);
const modelInfo = provider?.models?.[model.modelID];

View File

@@ -0,0 +1,76 @@
import type { Provider as ConfigProvider, ProviderListResponse } from "@opencode-ai/sdk/v2/client";
type ProviderListItem = ProviderListResponse["all"][number];
type ProviderListModel = ProviderListItem["models"][string];
const buildModalities = (caps?: ConfigProvider["models"][string]["capabilities"]) => {
if (!caps) return undefined;
const input = Object.entries(caps.input)
.filter(([, enabled]) => enabled)
.map(([key]) => key as "text" | "audio" | "image" | "video" | "pdf");
const output = Object.entries(caps.output)
.filter(([, enabled]) => enabled)
.map(([key]) => key as "text" | "audio" | "image" | "video" | "pdf");
if (!input.length && !output.length) return undefined;
return { input, output };
};
const mapModel = (model: ConfigProvider["models"][string]): ProviderListModel => {
const interleaved = model.capabilities?.interleaved;
const modalities = buildModalities(model.capabilities);
const status = model.status === "alpha" || model.status === "beta" || model.status === "deprecated"
? model.status
: undefined;
return {
id: model.id,
name: model.name ?? model.id,
family: model.family,
release_date: model.release_date ?? "",
attachment: model.capabilities?.attachment ?? false,
reasoning: model.capabilities?.reasoning ?? false,
temperature: model.capabilities?.temperature ?? false,
tool_call: model.capabilities?.toolcall ?? false,
interleaved: interleaved === false ? undefined : interleaved,
cost: model.cost
? {
input: model.cost.input,
output: model.cost.output,
cache_read: model.cost.cache.read,
cache_write: model.cost.cache.write,
context_over_200k: model.cost.experimentalOver200K
? {
input: model.cost.experimentalOver200K.input,
output: model.cost.experimentalOver200K.output,
cache_read: model.cost.experimentalOver200K.cache.read,
cache_write: model.cost.experimentalOver200K.cache.write,
}
: undefined,
}
: undefined,
limit: model.limit,
modalities,
experimental: status === "alpha" ? true : undefined,
status,
options: model.options ?? {},
headers: model.headers ?? undefined,
provider: model.api?.npm ? { npm: model.api.npm } : undefined,
variants: model.variants,
};
};
export const mapConfigProvidersToList = (providers: ConfigProvider[]): ProviderListResponse["all"] =>
providers.map((provider) => {
const models = Object.fromEntries(
Object.entries(provider.models ?? {}).map(([key, model]) => [key, mapModel(model)]),
);
return {
id: provider.id,
name: provider.name ?? provider.id,
env: provider.env ?? [],
models,
};
});

70
packages/server/README.md Normal file
View File

@@ -0,0 +1,70 @@
# OpenWork Server
Filesystem-backed API for OpenWork remote clients. This package provides the OpenWork server layer described in `packages/app/pr/openwork-server.md` and is intentionally independent from the desktop app.
## Quick start
```bash
pnpm --filter @different-ai/openwork-server dev -- \
--workspace /path/to/workspace \
--approval auto
```
The server logs the client token and host token on boot when they are auto-generated.
## Config file
Defaults to `~/.config/openwork/server.json` (override with `OPENWORK_SERVER_CONFIG` or `--config`).
```json
{
"host": "127.0.0.1",
"port": 8787,
"approval": { "mode": "manual", "timeoutMs": 30000 },
"workspaces": [
{ "path": "/Users/susan/Finance", "name": "Finance", "workspaceType": "local" }
],
"corsOrigins": ["http://localhost:5173"]
}
```
## Environment variables
- `OPENWORK_SERVER_CONFIG` path to config JSON
- `OPENWORK_HOST` / `OPENWORK_PORT`
- `OPENWORK_TOKEN` client bearer token
- `OPENWORK_HOST_TOKEN` host approval token
- `OPENWORK_APPROVAL_MODE` (`manual` | `auto`)
- `OPENWORK_APPROVAL_TIMEOUT_MS`
- `OPENWORK_WORKSPACES` (JSON array or comma-separated list of paths)
- `OPENWORK_CORS_ORIGINS` (comma-separated list or `*`)
## Endpoints (initial)
- `GET /health`
- `GET /capabilities`
- `GET /workspaces`
- `GET /workspace/:id/config`
- `PATCH /workspace/:id/config`
- `GET /workspace/:id/plugins`
- `POST /workspace/:id/plugins`
- `DELETE /workspace/:id/plugins/:name`
- `GET /workspace/:id/skills`
- `POST /workspace/:id/skills`
- `GET /workspace/:id/mcp`
- `POST /workspace/:id/mcp`
- `DELETE /workspace/:id/mcp/:name`
- `GET /workspace/:id/commands`
- `POST /workspace/:id/commands`
- `DELETE /workspace/:id/commands/:name`
- `GET /workspace/:id/export`
- `POST /workspace/:id/import`
## Approvals
All writes are gated by host approval. Host APIs require `X-OpenWork-Host-Token`:
- `GET /approvals`
- `POST /approvals/:id` with `{ "reply": "allow" | "deny" }`
Set `OPENWORK_APPROVAL_MODE=auto` to auto-approve during local development.

View File

@@ -0,0 +1,27 @@
{
"name": "@different-ai/openwork-server",
"version": "0.1.0",
"private": true,
"type": "module",
"bin": {
"openwork-server": "dist/cli.js"
},
"scripts": {
"dev": "bun src/cli.ts",
"build": "tsc -p tsconfig.json",
"start": "bun dist/cli.js",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"jsonc-parser": "^3.2.1",
"minimatch": "^10.0.1",
"yaml": "^2.6.1"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@types/minimatch": "^5.1.2",
"bun-types": "^1.1.29",
"typescript": "^5.6.3"
},
"packageManager": "pnpm@10.27.0"
}

View File

@@ -0,0 +1,66 @@
import type { ApprovalConfig, ApprovalRequest } from "./types.js";
import { shortId } from "./utils.js";
interface ApprovalResult {
id: string;
allowed: boolean;
reason?: string;
}
interface PendingApproval {
request: ApprovalRequest;
resolve: (result: ApprovalResult) => void;
timeout?: NodeJS.Timeout;
}
export class ApprovalService {
private config: ApprovalConfig;
private pending = new Map<string, PendingApproval>();
constructor(config: ApprovalConfig) {
this.config = config;
}
list(): ApprovalRequest[] {
return Array.from(this.pending.values()).map((entry) => entry.request);
}
async requestApproval(
input: Omit<ApprovalRequest, "id" | "createdAt">,
): Promise<ApprovalResult> {
if (this.config.mode === "auto") {
return { id: "auto", allowed: true };
}
const id = shortId();
const request: ApprovalRequest = {
...input,
id,
createdAt: Date.now(),
};
const result = await new Promise<ApprovalResult>((resolve) => {
const timeout = setTimeout(() => {
this.pending.delete(id);
resolve({ id, allowed: false, reason: "timeout" });
}, this.config.timeoutMs);
this.pending.set(id, { request, resolve, timeout });
});
return result;
}
respond(id: string, reply: "allow" | "deny"): ApprovalResult | null {
const pending = this.pending.get(id);
if (!pending) return null;
if (pending.timeout) clearTimeout(pending.timeout);
this.pending.delete(id);
const result: ApprovalResult = {
id,
allowed: reply === "allow",
reason: reply === "allow" ? undefined : "denied",
};
pending.resolve(result);
return result;
}
}

View File

@@ -0,0 +1,28 @@
import { dirname, join } from "node:path";
import { appendFile, readFile } from "node:fs/promises";
import type { AuditEntry } from "./types.js";
import { ensureDir, exists } from "./utils.js";
export function auditLogPath(workspaceRoot: string): string {
return join(workspaceRoot, ".opencode", "openwork", "audit.jsonl");
}
export async function recordAudit(workspaceRoot: string, entry: AuditEntry): Promise<void> {
const path = auditLogPath(workspaceRoot);
await ensureDir(dirname(path));
await appendFile(path, JSON.stringify(entry) + "\n", "utf8");
}
export async function readLastAudit(workspaceRoot: string): Promise<AuditEntry | null> {
const path = auditLogPath(workspaceRoot);
if (!(await exists(path))) return null;
const content = await readFile(path, "utf8");
const lines = content.trim().split("\n");
const last = lines[lines.length - 1];
if (!last) return null;
try {
return JSON.parse(last) as AuditEntry;
} catch {
return null;
}
}

9
packages/server/src/bun.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
declare const Bun: {
serve: (options: {
hostname: string;
port: number;
fetch: (request: Request) => Response | Promise<Response>;
}) => {
port: number;
};
};

View File

@@ -0,0 +1,31 @@
#!/usr/bin/env bun
import { parseCliArgs, printHelp, resolveServerConfig } from "./config.js";
import { startServer } from "./server.js";
const args = parseCliArgs(process.argv.slice(2));
if (args.help) {
printHelp();
process.exit(0);
}
const config = await resolveServerConfig(args);
const server = startServer(config);
const url = `http://${config.host}:${server.port}`;
console.log(`OpenWork server listening on ${url}`);
if (config.tokenSource === "generated") {
console.log(`Client token: ${config.token}`);
}
if (config.hostTokenSource === "generated") {
console.log(`Host token: ${config.hostToken}`);
}
if (config.workspaces.length === 0) {
console.log("No workspaces configured. Add --workspace or update server.json.");
} else {
console.log(`Workspaces: ${config.workspaces.length}`);
}

View File

@@ -0,0 +1,77 @@
import { readdir, readFile, writeFile, rm, mkdir } from "node:fs/promises";
import { join } from "node:path";
import { homedir } from "node:os";
import type { CommandItem } from "./types.js";
import { parseFrontmatter, buildFrontmatter } from "./frontmatter.js";
import { exists } from "./utils.js";
import { projectCommandsDir } from "./workspace-files.js";
import { validateCommandName, sanitizeCommandName } from "./validators.js";
import { ApiError } from "./errors.js";
async function listCommandsInDir(dir: string, scope: "workspace" | "global"): Promise<CommandItem[]> {
if (!(await exists(dir))) return [];
const entries = await readdir(dir, { withFileTypes: true });
const items: CommandItem[] = [];
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!entry.name.endsWith(".md")) continue;
const filePath = join(dir, entry.name);
const content = await readFile(filePath, "utf8");
const { data, body } = parseFrontmatter(content);
const name = typeof data.name === "string" ? data.name : entry.name.replace(/\.md$/, "");
try {
validateCommandName(name);
} catch {
continue;
}
items.push({
name,
description: typeof data.description === "string" ? data.description : undefined,
template: body.trim(),
agent: typeof data.agent === "string" ? data.agent : undefined,
model: typeof data.model === "string" ? data.model : null,
subtask: typeof data.subtask === "boolean" ? data.subtask : undefined,
scope,
});
}
return items;
}
export async function listCommands(workspaceRoot: string, scope: "workspace" | "global"): Promise<CommandItem[]> {
if (scope === "global") {
const dir = join(homedir(), ".config", "opencode", "commands");
return listCommandsInDir(dir, "global");
}
return listCommandsInDir(projectCommandsDir(workspaceRoot), "workspace");
}
export async function upsertCommand(
workspaceRoot: string,
payload: { name: string; description?: string; template: string; agent?: string; model?: string | null; subtask?: boolean },
): Promise<string> {
if (!payload.template || payload.template.trim().length === 0) {
throw new ApiError(400, "invalid_command_template", "Command template is required");
}
const sanitized = sanitizeCommandName(payload.name);
validateCommandName(sanitized);
const frontmatter = buildFrontmatter({
name: sanitized,
description: payload.description,
agent: payload.agent,
model: payload.model ?? null,
subtask: payload.subtask ?? false,
});
const content = frontmatter + "\n" + payload.template.trim() + "\n";
const dir = projectCommandsDir(workspaceRoot);
await mkdir(dir, { recursive: true });
const path = join(dir, `${sanitized}.md`);
await writeFile(path, content, "utf8");
return path;
}
export async function deleteCommand(workspaceRoot: string, name: string): Promise<void> {
const sanitized = sanitizeCommandName(name);
validateCommandName(sanitized);
const path = join(projectCommandsDir(workspaceRoot), `${sanitized}.md`);
await rm(path, { force: true });
}

View File

@@ -0,0 +1,214 @@
import { homedir } from "node:os";
import { dirname, resolve } from "node:path";
import type { ApprovalMode, ApprovalConfig, ServerConfig, WorkspaceConfig } from "./types.js";
import { buildWorkspaceInfos } from "./workspaces.js";
import { parseList, readJsonFile, shortId } from "./utils.js";
interface CliArgs {
configPath?: string;
host?: string;
port?: number;
token?: string;
hostToken?: string;
approvalMode?: ApprovalMode;
approvalTimeoutMs?: number;
workspaces: string[];
corsOrigins?: string[];
readOnly?: boolean;
help?: boolean;
}
interface FileConfig {
host?: string;
port?: number;
token?: string;
hostToken?: string;
approval?: Partial<ApprovalConfig>;
workspaces?: WorkspaceConfig[];
corsOrigins?: string[];
authorizedRoots?: string[];
readOnly?: boolean;
}
const DEFAULT_PORT = 8787;
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_TIMEOUT_MS = 30000;
export function parseCliArgs(argv: string[]): CliArgs {
const args: CliArgs = { workspaces: [] };
for (let index = 0; index < argv.length; index += 1) {
const value = argv[index];
if (!value) continue;
if (value === "--help" || value === "-h") {
args.help = true;
continue;
}
if (value === "--config") {
args.configPath = argv[index + 1];
index += 1;
continue;
}
if (value === "--host") {
args.host = argv[index + 1];
index += 1;
continue;
}
if (value === "--port") {
const port = Number(argv[index + 1]);
if (!Number.isNaN(port)) args.port = port;
index += 1;
continue;
}
if (value === "--token") {
args.token = argv[index + 1];
index += 1;
continue;
}
if (value === "--host-token") {
args.hostToken = argv[index + 1];
index += 1;
continue;
}
if (value === "--approval") {
const mode = argv[index + 1] as ApprovalMode | undefined;
if (mode === "manual" || mode === "auto") args.approvalMode = mode;
index += 1;
continue;
}
if (value === "--approval-timeout") {
const timeout = Number(argv[index + 1]);
if (!Number.isNaN(timeout)) args.approvalTimeoutMs = timeout;
index += 1;
continue;
}
if (value === "--workspace") {
const path = argv[index + 1];
if (path) args.workspaces.push(path);
index += 1;
continue;
}
if (value === "--cors") {
args.corsOrigins = parseList(argv[index + 1]);
index += 1;
continue;
}
if (value === "--read-only") {
args.readOnly = true;
continue;
}
}
return args;
}
export function printHelp(): void {
const message = [
"openwork-server",
"",
"Options:",
" --config <path> Path to server.json",
" --host <host> Hostname (default 127.0.0.1)",
" --port <port> Port (default 8787)",
" --token <token> Client bearer token",
" --host-token <token> Host approval token",
" --approval <mode> manual | auto",
" --approval-timeout <ms> Approval timeout",
" --workspace <path> Workspace root (repeatable)",
" --cors <origins> Comma-separated origins or *",
" --read-only Disable writes",
].join("\n");
console.log(message);
}
async function loadFileConfig(configPath: string): Promise<FileConfig> {
const parsed = await readJsonFile<FileConfig>(configPath);
return parsed ?? {};
}
export async function resolveServerConfig(cli: CliArgs): Promise<ServerConfig> {
const envConfigPath = process.env.OPENWORK_SERVER_CONFIG;
const configPath = cli.configPath ?? envConfigPath ?? resolve(homedir(), ".config", "openwork", "server.json");
const fileConfig = await loadFileConfig(configPath);
const configDir = dirname(configPath);
const envWorkspaces = parseList(process.env.OPENWORK_WORKSPACES);
const workspaceConfigs: WorkspaceConfig[] =
cli.workspaces.length > 0
? cli.workspaces.map((path) => ({ path }))
: envWorkspaces.length > 0
? envWorkspaces.map((path) => ({ path }))
: fileConfig.workspaces ?? [];
const workspaces = buildWorkspaceInfos(workspaceConfigs, configDir);
const tokenFromEnv = process.env.OPENWORK_TOKEN;
const hostTokenFromEnv = process.env.OPENWORK_HOST_TOKEN;
const token = cli.token ?? tokenFromEnv ?? fileConfig.token ?? shortId();
const hostToken = cli.hostToken ?? hostTokenFromEnv ?? fileConfig.hostToken ?? shortId();
const tokenSource: ServerConfig["tokenSource"] = cli.token
? "cli"
: tokenFromEnv
? "env"
: fileConfig.token
? "file"
: "generated";
const hostTokenSource: ServerConfig["hostTokenSource"] = cli.hostToken
? "cli"
: hostTokenFromEnv
? "env"
: fileConfig.hostToken
? "file"
: "generated";
const approvalMode =
cli.approvalMode ??
(process.env.OPENWORK_APPROVAL_MODE as ApprovalMode | undefined) ??
fileConfig.approval?.mode ??
"manual";
const approvalTimeoutMs =
cli.approvalTimeoutMs ??
(process.env.OPENWORK_APPROVAL_TIMEOUT_MS ? Number(process.env.OPENWORK_APPROVAL_TIMEOUT_MS) : undefined) ??
fileConfig.approval?.timeoutMs ??
DEFAULT_TIMEOUT_MS;
const approval: ApprovalConfig = {
mode: approvalMode === "auto" ? "auto" : "manual",
timeoutMs: Number.isNaN(approvalTimeoutMs) ? DEFAULT_TIMEOUT_MS : approvalTimeoutMs,
};
const envCorsOrigins = process.env.OPENWORK_CORS_ORIGINS;
const parsedEnvCors = envCorsOrigins ? parseList(envCorsOrigins) : null;
const corsOrigins = cli.corsOrigins ?? parsedEnvCors ?? fileConfig.corsOrigins ?? ["*"];
const envReadOnly = process.env.OPENWORK_READONLY;
const parsedReadOnly = envReadOnly
? ["true", "1", "yes"].includes(envReadOnly.toLowerCase())
: undefined;
const readOnly = cli.readOnly ?? parsedReadOnly ?? fileConfig.readOnly ?? false;
const authorizedRoots =
fileConfig.authorizedRoots?.length
? fileConfig.authorizedRoots.map((root) => resolve(configDir, root))
: workspaces.map((workspace) => workspace.path);
const host = cli.host ?? process.env.OPENWORK_HOST ?? fileConfig.host ?? DEFAULT_HOST;
const port = cli.port ?? (process.env.OPENWORK_PORT ? Number(process.env.OPENWORK_PORT) : undefined) ?? fileConfig.port ?? DEFAULT_PORT;
return {
host,
port: Number.isNaN(port) ? DEFAULT_PORT : port,
token,
hostToken,
approval,
corsOrigins,
workspaces,
authorizedRoots,
readOnly,
startedAt: Date.now(),
tokenSource,
hostTokenSource,
};
}

View File

@@ -0,0 +1,22 @@
import type { ApiErrorBody } from "./types.js";
export class ApiError extends Error {
status: number;
code: string;
details?: unknown;
constructor(status: number, code: string, message: string, details?: unknown) {
super(message);
this.status = status;
this.code = code;
this.details = details;
}
}
export function formatError(err: ApiError): ApiErrorBody {
return {
code: err.code,
message: err.message,
details: err.details,
};
}

View File

@@ -0,0 +1,17 @@
import { parse, stringify } from "yaml";
export function parseFrontmatter(content: string): { data: Record<string, unknown>; body: string } {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
if (!match) {
return { data: {}, body: content };
}
const raw = match[1] ?? "";
const data = (parse(raw) as Record<string, unknown>) ?? {};
const body = content.slice(match[0].length);
return { data, body };
}
export function buildFrontmatter(data: Record<string, unknown>): string {
const yaml = stringify(data).trimEnd();
return `---\n${yaml}\n---\n`;
}

View File

@@ -0,0 +1,52 @@
import { applyEdits, modify, parse, printParseErrorCode } from "jsonc-parser";
import { dirname } from "node:path";
import { readFile, writeFile } from "node:fs/promises";
import { ApiError } from "./errors.js";
import { ensureDir, exists } from "./utils.js";
interface ParseResult<T> {
data: T;
raw: string;
}
export async function readJsoncFile<T>(path: string, fallback: T): Promise<ParseResult<T>> {
if (!(await exists(path))) {
return { data: fallback, raw: "" };
}
const raw = await readFile(path, "utf8");
const errors: { error: number; offset: number; length: number }[] = [];
const data = parse(raw, errors, { allowTrailingComma: true }) as T;
if (errors.length > 0) {
const details = errors.map((error) => ({
code: printParseErrorCode(error.error),
offset: error.offset,
length: error.length,
}));
throw new ApiError(422, "invalid_jsonc", "Failed to parse JSONC", details);
}
return { data, raw };
}
export async function updateJsoncTopLevel(path: string, updates: Record<string, unknown>): Promise<void> {
const hasFile = await exists(path);
if (!hasFile) {
await ensureDir(dirname(path));
const content = JSON.stringify(updates, null, 2) + "\n";
await writeFile(path, content, "utf8");
return;
}
let content = await readFile(path, "utf8");
const formattingOptions = { insertSpaces: true, tabSize: 2, eol: "\n" };
for (const [key, value] of Object.entries(updates)) {
const edits = modify(content, [key], value, { formattingOptions });
content = applyEdits(content, edits);
}
await writeFile(path, content.endsWith("\n") ? content : content + "\n", "utf8");
}
export async function writeJsoncFile(path: string, value: unknown): Promise<void> {
await ensureDir(dirname(path));
const content = JSON.stringify(value, null, 2) + "\n";
await writeFile(path, content, "utf8");
}

View File

@@ -0,0 +1,53 @@
import { minimatch } from "minimatch";
import type { McpItem } from "./types.js";
import { readJsoncFile, updateJsoncTopLevel } from "./jsonc.js";
import { opencodeConfigPath } from "./workspace-files.js";
import { validateMcpConfig, validateMcpName } from "./validators.js";
function getMcpConfig(config: Record<string, unknown>): Record<string, Record<string, unknown>> {
const mcp = config.mcp;
if (!mcp || typeof mcp !== "object") return {};
return mcp as Record<string, Record<string, unknown>>;
}
function getDeniedToolPatterns(config: Record<string, unknown>): string[] {
const tools = config.tools;
if (!tools || typeof tools !== "object") return [];
const deny = (tools as { deny?: unknown }).deny;
if (!Array.isArray(deny)) return [];
return deny.filter((item) => typeof item === "string") as string[];
}
function isMcpDisabledByTools(config: Record<string, unknown>, name: string): boolean {
const patterns = getDeniedToolPatterns(config);
if (patterns.length === 0) return false;
const candidates = [`mcp.${name}`, `mcp.${name}.*`, `mcp:${name}`, `mcp:${name}:*`, "mcp.*", "mcp:*"];
return patterns.some((pattern) => candidates.some((candidate) => minimatch(candidate, pattern)));
}
export async function listMcp(workspaceRoot: string): Promise<McpItem[]> {
const { data: config } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record<string, unknown>);
const mcpMap = getMcpConfig(config);
return Object.entries(mcpMap).map(([name, entry]) => ({
name,
config: entry,
source: "config.project",
disabledByTools: isMcpDisabledByTools(config, name) || undefined,
}));
}
export async function addMcp(workspaceRoot: string, name: string, config: Record<string, unknown>): Promise<void> {
validateMcpName(name);
validateMcpConfig(config);
const { data } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record<string, unknown>);
const mcpMap = getMcpConfig(data);
mcpMap[name] = config;
await updateJsoncTopLevel(opencodeConfigPath(workspaceRoot), { mcp: mcpMap });
}
export async function removeMcp(workspaceRoot: string, name: string): Promise<void> {
const { data } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record<string, unknown>);
const mcpMap = getMcpConfig(data);
delete mcpMap[name];
await updateJsoncTopLevel(opencodeConfigPath(workspaceRoot), { mcp: mcpMap });
}

View File

@@ -0,0 +1,20 @@
import { realpath } from "node:fs/promises";
import { isAbsolute, resolve, sep } from "node:path";
import { ApiError } from "./errors.js";
export function assertAbsolute(path: string): void {
if (!isAbsolute(path)) {
throw new ApiError(400, "invalid_path", "Path must be absolute");
}
}
export async function resolveWithinRoot(root: string, ...segments: string[]): Promise<string> {
const resolvedRoot = await realpath(root);
const candidate = resolve(resolvedRoot, ...segments);
const resolvedCandidate = await realpath(candidate).catch(() => candidate);
if (resolvedCandidate === resolvedRoot) return candidate;
if (!resolvedCandidate.startsWith(resolvedRoot + sep)) {
throw new ApiError(400, "path_escape", "Path escapes workspace root");
}
return candidate;
}

View File

@@ -0,0 +1,92 @@
import { homedir } from "node:os";
import { join, relative } from "node:path";
import { readdir } from "node:fs/promises";
import type { PluginItem } from "./types.js";
import { readJsoncFile, updateJsoncTopLevel } from "./jsonc.js";
import { opencodeConfigPath, projectPluginsDir } from "./workspace-files.js";
import { exists } from "./utils.js";
import { validatePluginSpec } from "./validators.js";
function normalizePluginSpec(spec: string): string {
const trimmed = spec.trim();
if (trimmed.startsWith("file:") || trimmed.startsWith("http:") || trimmed.startsWith("https:") || trimmed.startsWith("git:")) {
return trimmed;
}
if (trimmed.startsWith("/")) {
return trimmed;
}
if (trimmed.startsWith("@")) {
const atIndex = trimmed.indexOf("@", 1);
return atIndex > 0 ? trimmed.slice(0, atIndex) : trimmed;
}
const atIndex = trimmed.indexOf("@");
return atIndex > 0 ? trimmed.slice(0, atIndex) : trimmed;
}
function pluginListFromConfig(config: Record<string, unknown>): string[] {
const plugin = config.plugin;
if (typeof plugin === "string") return [plugin];
if (Array.isArray(plugin)) return plugin.filter((item) => typeof item === "string") as string[];
return [];
}
async function listPluginFiles(dir: string, scope: "project" | "global", workspaceRoot?: string): Promise<PluginItem[]> {
if (!(await exists(dir))) return [];
const entries = await readdir(dir, { withFileTypes: true });
const items: PluginItem[] = [];
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!entry.name.endsWith(".js") && !entry.name.endsWith(".ts")) continue;
const absolutePath = join(dir, entry.name);
const relativePath = workspaceRoot ? relative(workspaceRoot, absolutePath) : absolutePath;
items.push({
spec: `file://${absolutePath}`,
source: scope === "project" ? "dir.project" : "dir.global",
scope,
path: relativePath,
});
}
return items;
}
export async function listPlugins(workspaceRoot: string, includeGlobal: boolean): Promise<{ items: PluginItem[]; loadOrder: string[] }> {
const { data: config } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record<string, unknown>);
const pluginSpecs = pluginListFromConfig(config);
const items: PluginItem[] = pluginSpecs.map((spec) => ({
spec,
source: "config",
scope: "project",
}));
const projectDir = projectPluginsDir(workspaceRoot);
items.push(...(await listPluginFiles(projectDir, "project", workspaceRoot)));
if (includeGlobal) {
const globalDir = join(homedir(), ".config", "opencode", "plugins");
items.push(...(await listPluginFiles(globalDir, "global")));
}
return {
items,
loadOrder: ["config.global", "config.project", "dir.global", "dir.project"],
};
}
export async function addPlugin(workspaceRoot: string, spec: string): Promise<void> {
validatePluginSpec(spec);
const { data: config } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record<string, unknown>);
const pluginSpecs = pluginListFromConfig(config);
const normalized = normalizePluginSpec(spec);
const existing = pluginSpecs.find((item) => normalizePluginSpec(item) === normalized);
if (existing) return;
pluginSpecs.push(spec);
await updateJsoncTopLevel(opencodeConfigPath(workspaceRoot), { plugin: pluginSpecs });
}
export async function removePlugin(workspaceRoot: string, name: string): Promise<void> {
const { data: config } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record<string, unknown>);
const pluginSpecs = pluginListFromConfig(config);
const normalized = normalizePluginSpec(name);
const filtered = pluginSpecs.filter((item) => normalizePluginSpec(item) !== normalized);
await updateJsoncTopLevel(opencodeConfigPath(workspaceRoot), { plugin: filtered });
}

View File

@@ -0,0 +1,647 @@
import { readFile, writeFile, rm } from "node:fs/promises";
import { join, resolve, sep } from "node:path";
import type { ApprovalRequest, Capabilities, ServerConfig, WorkspaceInfo, Actor } from "./types.js";
import { ApprovalService } from "./approvals.js";
import { addPlugin, listPlugins, removePlugin } from "./plugins.js";
import { addMcp, listMcp, removeMcp } from "./mcp.js";
import { listSkills, upsertSkill } from "./skills.js";
import { deleteCommand, listCommands, upsertCommand } from "./commands.js";
import { ApiError, formatError } from "./errors.js";
import { readJsoncFile, updateJsoncTopLevel, writeJsoncFile } from "./jsonc.js";
import { recordAudit, readLastAudit } from "./audit.js";
import { parseFrontmatter } from "./frontmatter.js";
import { opencodeConfigPath, openworkConfigPath, projectCommandsDir, projectSkillsDir } from "./workspace-files.js";
import { ensureDir, exists, hashToken, shortId } from "./utils.js";
import { sanitizeCommandName } from "./validators.js";
type AuthMode = "none" | "client" | "host";
interface Route {
method: string;
regex: RegExp;
keys: string[];
auth: AuthMode;
handler: (ctx: RequestContext) => Promise<Response>;
}
interface RequestContext {
request: Request;
url: URL;
params: Record<string, string>;
config: ServerConfig;
approvals: ApprovalService;
actor?: Actor;
}
export function startServer(config: ServerConfig) {
const approvals = new ApprovalService(config.approval);
const routes = createRoutes(config, approvals);
const server = Bun.serve({
hostname: config.host,
port: config.port,
fetch: async (request: Request) => {
const url = new URL(request.url);
if (request.method === "OPTIONS") {
return withCors(new Response(null, { status: 204 }), request, config);
}
const route = matchRoute(routes, request.method, url.pathname);
if (!route) {
return withCors(jsonResponse({ code: "not_found", message: "Not found" }, 404), request, config);
}
try {
const actor = route.auth === "host" ? requireHost(request, config) : route.auth === "client" ? requireClient(request, config) : undefined;
const response = await route.handler({ request, url, params: route.params, config, approvals, actor });
return withCors(response, request, config);
} catch (error) {
const apiError = error instanceof ApiError
? error
: new ApiError(500, "internal_error", "Unexpected server error");
return withCors(jsonResponse(formatError(apiError), apiError.status), request, config);
}
},
});
return server;
}
function matchRoute(routes: Route[], method: string, path: string) {
for (const route of routes) {
if (route.method !== method) continue;
const match = path.match(route.regex);
if (!match) continue;
const params: Record<string, string> = {};
route.keys.forEach((key, index) => {
params[key] = decodeURIComponent(match[index + 1]);
});
return { ...route, params };
}
return null;
}
function addRoute(routes: Route[], method: string, path: string, auth: AuthMode, handler: Route["handler"]) {
const keys: string[] = [];
const regex = pathToRegex(path, keys);
routes.push({ method, regex, keys, auth, handler });
}
function pathToRegex(path: string, keys: string[]): RegExp {
const pattern = path.replace(/:([A-Za-z0-9_]+)/g, (_, key) => {
keys.push(key);
return "([^/]+)";
});
return new RegExp(`^${pattern}$`);
}
function jsonResponse(data: unknown, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
});
}
function withCors(response: Response, request: Request, config: ServerConfig) {
const origin = request.headers.get("origin");
const allowedOrigins = config.corsOrigins;
let allowOrigin: string | null = null;
if (allowedOrigins.includes("*")) {
allowOrigin = "*";
} else if (origin && allowedOrigins.includes(origin)) {
allowOrigin = origin;
}
if (!allowOrigin) return response;
const headers = new Headers(response.headers);
headers.set("Access-Control-Allow-Origin", allowOrigin);
headers.set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-OpenWork-Host-Token, X-OpenWork-Client-Id");
headers.set("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS");
headers.set("Vary", "Origin");
return new Response(response.body, { status: response.status, headers });
}
function requireClient(request: Request, config: ServerConfig): Actor {
const header = request.headers.get("authorization") ?? "";
const match = header.match(/^Bearer\s+(.+)$/i);
const token = match?.[1];
if (!token || token !== config.token) {
throw new ApiError(401, "unauthorized", "Invalid bearer token");
}
const clientId = request.headers.get("x-openwork-client-id") ?? undefined;
return { type: "remote", clientId, tokenHash: hashToken(token) };
}
function requireHost(request: Request, config: ServerConfig): Actor {
const token = request.headers.get("x-openwork-host-token");
if (!token || token !== config.hostToken) {
throw new ApiError(401, "unauthorized", "Invalid host token");
}
return { type: "host", tokenHash: hashToken(token) };
}
function buildCapabilities(config: ServerConfig): Capabilities {
const writeEnabled = !config.readOnly;
return {
skills: { read: true, write: writeEnabled, source: "openwork" },
plugins: { read: true, write: writeEnabled },
mcp: { read: true, write: writeEnabled },
commands: { read: true, write: writeEnabled },
config: { read: true, write: writeEnabled },
};
}
function createRoutes(config: ServerConfig, approvals: ApprovalService): Route[] {
const routes: Route[] = [];
addRoute(routes, "GET", "/health", "none", async () => {
return jsonResponse({ ok: true, version: "0.1.0", uptimeMs: Date.now() - config.startedAt });
});
addRoute(routes, "GET", "/capabilities", "client", async () => {
return jsonResponse(buildCapabilities(config));
});
addRoute(routes, "GET", "/workspaces", "client", async () => {
return jsonResponse({ items: config.workspaces });
});
addRoute(routes, "GET", "/workspace/:id/config", "client", async (ctx) => {
const workspace = await resolveWorkspace(config, ctx.params.id);
const opencode = await readOpencodeConfig(workspace.path);
const openwork = await readOpenworkConfig(workspace.path);
const lastAudit = await readLastAudit(workspace.path);
return jsonResponse({ opencode, openwork, updatedAt: lastAudit?.timestamp ?? null });
});
addRoute(routes, "PATCH", "/workspace/:id/config", "client", async (ctx) => {
ensureWritable(config);
const workspace = await resolveWorkspace(config, ctx.params.id);
const body = await readJsonBody(ctx.request);
const opencode = body.opencode as Record<string, unknown> | undefined;
const openwork = body.openwork as Record<string, unknown> | undefined;
if (!opencode && !openwork) {
throw new ApiError(400, "invalid_payload", "opencode or openwork updates required");
}
await requireApproval(ctx, {
workspaceId: workspace.id,
action: "config.patch",
summary: "Patch workspace config",
paths: [opencode ? opencodeConfigPath(workspace.path) : null, openwork ? openworkConfigPath(workspace.path) : null].filter(Boolean) as string[],
});
if (opencode) {
await updateJsoncTopLevel(opencodeConfigPath(workspace.path), opencode);
}
if (openwork) {
await writeOpenworkConfig(workspace.path, openwork, true);
}
await recordAudit(workspace.path, {
id: shortId(),
workspaceId: workspace.id,
actor: ctx.actor ?? { type: "remote" },
action: "config.patch",
target: "opencode.json",
summary: "Patched workspace config",
timestamp: Date.now(),
});
return jsonResponse({ updatedAt: Date.now() });
});
addRoute(routes, "GET", "/workspace/:id/plugins", "client", async (ctx) => {
const workspace = await resolveWorkspace(config, ctx.params.id);
const includeGlobal = ctx.url.searchParams.get("includeGlobal") === "true";
const result = await listPlugins(workspace.path, includeGlobal);
return jsonResponse(result);
});
addRoute(routes, "POST", "/workspace/:id/plugins", "client", async (ctx) => {
ensureWritable(config);
const workspace = await resolveWorkspace(config, ctx.params.id);
const body = await readJsonBody(ctx.request);
const spec = String(body.spec ?? "");
await requireApproval(ctx, {
workspaceId: workspace.id,
action: "plugins.add",
summary: `Add plugin ${spec}`,
paths: [opencodeConfigPath(workspace.path)],
});
await addPlugin(workspace.path, spec);
await recordAudit(workspace.path, {
id: shortId(),
workspaceId: workspace.id,
actor: ctx.actor ?? { type: "remote" },
action: "plugins.add",
target: "opencode.json",
summary: `Added ${spec}`,
timestamp: Date.now(),
});
const result = await listPlugins(workspace.path, false);
return jsonResponse(result);
});
addRoute(routes, "DELETE", "/workspace/:id/plugins/:name", "client", async (ctx) => {
ensureWritable(config);
const workspace = await resolveWorkspace(config, ctx.params.id);
const name = ctx.params.name ?? "";
await requireApproval(ctx, {
workspaceId: workspace.id,
action: "plugins.remove",
summary: `Remove plugin ${name}`,
paths: [opencodeConfigPath(workspace.path)],
});
await removePlugin(workspace.path, name);
await recordAudit(workspace.path, {
id: shortId(),
workspaceId: workspace.id,
actor: ctx.actor ?? { type: "remote" },
action: "plugins.remove",
target: "opencode.json",
summary: `Removed ${name}`,
timestamp: Date.now(),
});
const result = await listPlugins(workspace.path, false);
return jsonResponse(result);
});
addRoute(routes, "GET", "/workspace/:id/skills", "client", async (ctx) => {
const workspace = await resolveWorkspace(config, ctx.params.id);
const includeGlobal = ctx.url.searchParams.get("includeGlobal") === "true";
const items = await listSkills(workspace.path, includeGlobal);
return jsonResponse({ items });
});
addRoute(routes, "POST", "/workspace/:id/skills", "client", async (ctx) => {
ensureWritable(config);
const workspace = await resolveWorkspace(config, ctx.params.id);
const body = await readJsonBody(ctx.request);
const name = String(body.name ?? "");
const content = String(body.content ?? "");
const description = body.description ? String(body.description) : undefined;
await requireApproval(ctx, {
workspaceId: workspace.id,
action: "skills.upsert",
summary: `Upsert skill ${name}`,
paths: [join(workspace.path, ".opencode", "skills", name, "SKILL.md")],
});
const path = await upsertSkill(workspace.path, { name, content, description });
await recordAudit(workspace.path, {
id: shortId(),
workspaceId: workspace.id,
actor: ctx.actor ?? { type: "remote" },
action: "skills.upsert",
target: path,
summary: `Upserted skill ${name}`,
timestamp: Date.now(),
});
return jsonResponse({ name, path, description: description ?? "", scope: "project" });
});
addRoute(routes, "GET", "/workspace/:id/mcp", "client", async (ctx) => {
const workspace = await resolveWorkspace(config, ctx.params.id);
const items = await listMcp(workspace.path);
return jsonResponse({ items });
});
addRoute(routes, "POST", "/workspace/:id/mcp", "client", async (ctx) => {
ensureWritable(config);
const workspace = await resolveWorkspace(config, ctx.params.id);
const body = await readJsonBody(ctx.request);
const name = String(body.name ?? "");
const configPayload = body.config as Record<string, unknown> | undefined;
if (!configPayload) {
throw new ApiError(400, "invalid_payload", "MCP config is required");
}
await requireApproval(ctx, {
workspaceId: workspace.id,
action: "mcp.add",
summary: `Add MCP ${name}`,
paths: [opencodeConfigPath(workspace.path)],
});
await addMcp(workspace.path, name, configPayload);
await recordAudit(workspace.path, {
id: shortId(),
workspaceId: workspace.id,
actor: ctx.actor ?? { type: "remote" },
action: "mcp.add",
target: "opencode.json",
summary: `Added MCP ${name}`,
timestamp: Date.now(),
});
const items = await listMcp(workspace.path);
return jsonResponse({ items });
});
addRoute(routes, "DELETE", "/workspace/:id/mcp/:name", "client", async (ctx) => {
ensureWritable(config);
const workspace = await resolveWorkspace(config, ctx.params.id);
const name = ctx.params.name ?? "";
await requireApproval(ctx, {
workspaceId: workspace.id,
action: "mcp.remove",
summary: `Remove MCP ${name}`,
paths: [opencodeConfigPath(workspace.path)],
});
await removeMcp(workspace.path, name);
await recordAudit(workspace.path, {
id: shortId(),
workspaceId: workspace.id,
actor: ctx.actor ?? { type: "remote" },
action: "mcp.remove",
target: "opencode.json",
summary: `Removed MCP ${name}`,
timestamp: Date.now(),
});
const items = await listMcp(workspace.path);
return jsonResponse({ items });
});
addRoute(routes, "GET", "/workspace/:id/commands", "client", async (ctx) => {
const scope = ctx.url.searchParams.get("scope") === "global" ? "global" : "workspace";
if (scope === "global") {
requireHost(ctx.request, config);
}
const workspace = await resolveWorkspace(config, ctx.params.id);
const items = await listCommands(workspace.path, scope);
return jsonResponse({ items });
});
addRoute(routes, "POST", "/workspace/:id/commands", "client", async (ctx) => {
ensureWritable(config);
const workspace = await resolveWorkspace(config, ctx.params.id);
const body = await readJsonBody(ctx.request);
const name = String(body.name ?? "");
const template = String(body.template ?? "");
await requireApproval(ctx, {
workspaceId: workspace.id,
action: "commands.upsert",
summary: `Upsert command ${name}`,
paths: [join(workspace.path, ".opencode", "commands", `${sanitizeCommandName(name)}.md`)],
});
const path = await upsertCommand(workspace.path, {
name,
description: body.description ? String(body.description) : undefined,
template,
agent: body.agent ? String(body.agent) : undefined,
model: body.model ? String(body.model) : undefined,
subtask: typeof body.subtask === "boolean" ? body.subtask : undefined,
});
await recordAudit(workspace.path, {
id: shortId(),
workspaceId: workspace.id,
actor: ctx.actor ?? { type: "remote" },
action: "commands.upsert",
target: path,
summary: `Upserted command ${name}`,
timestamp: Date.now(),
});
const items = await listCommands(workspace.path, "workspace");
return jsonResponse({ items });
});
addRoute(routes, "DELETE", "/workspace/:id/commands/:name", "client", async (ctx) => {
ensureWritable(config);
const workspace = await resolveWorkspace(config, ctx.params.id);
const name = ctx.params.name ?? "";
await requireApproval(ctx, {
workspaceId: workspace.id,
action: "commands.delete",
summary: `Delete command ${name}`,
paths: [join(workspace.path, ".opencode", "commands", `${sanitizeCommandName(name)}.md`)],
});
await deleteCommand(workspace.path, name);
await recordAudit(workspace.path, {
id: shortId(),
workspaceId: workspace.id,
actor: ctx.actor ?? { type: "remote" },
action: "commands.delete",
target: join(workspace.path, ".opencode", "commands"),
summary: `Deleted command ${name}`,
timestamp: Date.now(),
});
return jsonResponse({ ok: true });
});
addRoute(routes, "GET", "/workspace/:id/export", "client", async (ctx) => {
const workspace = await resolveWorkspace(config, ctx.params.id);
const exportPayload = await exportWorkspace(workspace);
return jsonResponse(exportPayload);
});
addRoute(routes, "POST", "/workspace/:id/import", "client", async (ctx) => {
ensureWritable(config);
const workspace = await resolveWorkspace(config, ctx.params.id);
const body = await readJsonBody(ctx.request);
await requireApproval(ctx, {
workspaceId: workspace.id,
action: "config.import",
summary: "Import workspace config",
paths: [opencodeConfigPath(workspace.path), openworkConfigPath(workspace.path)],
});
await importWorkspace(workspace, body);
await recordAudit(workspace.path, {
id: shortId(),
workspaceId: workspace.id,
actor: ctx.actor ?? { type: "remote" },
action: "config.import",
target: "workspace",
summary: "Imported workspace config",
timestamp: Date.now(),
});
return jsonResponse({ ok: true });
});
addRoute(routes, "GET", "/approvals", "host", async (ctx) => {
return jsonResponse({ items: ctx.approvals.list() });
});
addRoute(routes, "POST", "/approvals/:id", "host", async (ctx) => {
const body = await readJsonBody(ctx.request);
const reply = body.reply === "allow" ? "allow" : "deny";
const result = ctx.approvals.respond(ctx.params.id, reply);
if (!result) {
throw new ApiError(404, "approval_not_found", "Approval request not found");
}
return jsonResponse({ ok: true, allowed: result.allowed });
});
return routes;
}
async function resolveWorkspace(config: ServerConfig, id: string): Promise<WorkspaceInfo> {
const workspace = config.workspaces.find((entry) => entry.id === id);
if (!workspace) {
throw new ApiError(404, "workspace_not_found", "Workspace not found");
}
const resolvedWorkspace = resolve(workspace.path);
const authorized = await isAuthorizedRoot(resolvedWorkspace, config.authorizedRoots);
if (!authorized) {
throw new ApiError(403, "workspace_unauthorized", "Workspace is not authorized");
}
return { ...workspace, path: resolvedWorkspace };
}
async function isAuthorizedRoot(workspacePath: string, roots: string[]): Promise<boolean> {
const resolvedWorkspace = resolve(workspacePath);
for (const root of roots) {
const resolvedRoot = resolve(root);
if (resolvedWorkspace === resolvedRoot) return true;
if (resolvedWorkspace.startsWith(resolvedRoot + sep)) return true;
}
return false;
}
function ensureWritable(config: ServerConfig): void {
if (config.readOnly) {
throw new ApiError(403, "read_only", "Server is read-only");
}
}
async function readJsonBody(request: Request): Promise<Record<string, unknown>> {
try {
const json = await request.json();
return json as Record<string, unknown>;
} catch {
throw new ApiError(400, "invalid_json", "Invalid JSON body");
}
}
async function readOpencodeConfig(workspaceRoot: string): Promise<Record<string, unknown>> {
const { data } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record<string, unknown>);
return data;
}
async function readOpenworkConfig(workspaceRoot: string): Promise<Record<string, unknown>> {
const path = openworkConfigPath(workspaceRoot);
if (!(await exists(path))) return {};
try {
const raw = await readFile(path, "utf8");
return JSON.parse(raw) as Record<string, unknown>;
} catch {
throw new ApiError(422, "invalid_json", "Failed to parse openwork.json");
}
}
async function writeOpenworkConfig(workspaceRoot: string, payload: Record<string, unknown>, merge: boolean): Promise<void> {
const path = openworkConfigPath(workspaceRoot);
const next = merge ? { ...(await readOpenworkConfig(workspaceRoot)), ...payload } : payload;
await ensureDir(join(workspaceRoot, ".opencode"));
await writeFile(path, JSON.stringify(next, null, 2) + "\n", "utf8");
}
async function requireApproval(
ctx: RequestContext,
input: Omit<ApprovalRequest, "id" | "createdAt" | "actor">,
): Promise<void> {
const actor = ctx.actor ?? { type: "remote" };
const result = await ctx.approvals.requestApproval({ ...input, actor });
if (!result.allowed) {
throw new ApiError(403, "write_denied", "Write request denied", {
requestId: result.id,
reason: result.reason,
});
}
}
async function exportWorkspace(workspace: WorkspaceInfo) {
const opencode = await readOpencodeConfig(workspace.path);
const openwork = await readOpenworkConfig(workspace.path);
const skills = await listSkills(workspace.path, false);
const commands = await listCommands(workspace.path, "workspace");
const skillContents = await Promise.all(
skills.map(async (skill) => ({
name: skill.name,
description: skill.description,
content: await readFile(skill.path, "utf8"),
})),
);
const commandContents = await Promise.all(
commands.map(async (command) => ({
name: command.name,
description: command.description,
template: command.template,
})),
);
return {
workspaceId: workspace.id,
exportedAt: Date.now(),
opencode,
openwork,
skills: skillContents,
commands: commandContents,
};
}
async function importWorkspace(workspace: WorkspaceInfo, payload: Record<string, unknown>): Promise<void> {
const modes = (payload.mode as Record<string, string> | undefined) ?? {};
const opencode = payload.opencode as Record<string, unknown> | undefined;
const openwork = payload.openwork as Record<string, unknown> | undefined;
const skills = (payload.skills as { name: string; content: string; description?: string }[] | undefined) ?? [];
const commands = (payload.commands as { name: string; content?: string; description?: string; template?: string; agent?: string; model?: string | null; subtask?: boolean }[] | undefined) ?? [];
if (opencode) {
if (modes.opencode === "replace") {
await writeJsoncFile(opencodeConfigPath(workspace.path), opencode);
} else {
await updateJsoncTopLevel(opencodeConfigPath(workspace.path), opencode);
}
}
if (openwork) {
if (modes.openwork === "replace") {
await writeOpenworkConfig(workspace.path, openwork, false);
} else {
await writeOpenworkConfig(workspace.path, openwork, true);
}
}
if (skills.length > 0) {
if (modes.skills === "replace") {
await rm(projectSkillsDir(workspace.path), { recursive: true, force: true });
}
for (const skill of skills) {
await upsertSkill(workspace.path, skill);
}
}
if (commands.length > 0) {
if (modes.commands === "replace") {
await rm(projectCommandsDir(workspace.path), { recursive: true, force: true });
}
for (const command of commands) {
if (command.content) {
const parsed = parseFrontmatter(command.content);
const name = command.name || (typeof parsed.data.name === "string" ? parsed.data.name : "");
const description = command.description || (typeof parsed.data.description === "string" ? parsed.data.description : undefined);
if (!name) {
throw new ApiError(400, "invalid_command", "Command name is required");
}
const template = parsed.body.trim();
await upsertCommand(workspace.path, {
name,
description,
template,
agent: typeof parsed.data.agent === "string" ? parsed.data.agent : undefined,
model: typeof parsed.data.model === "string" ? parsed.data.model : undefined,
subtask: typeof parsed.data.subtask === "boolean" ? parsed.data.subtask : undefined,
});
} else {
const name = command.name ?? "";
const template = command.template ?? "";
await upsertCommand(workspace.path, {
name,
description: command.description,
template,
agent: command.agent,
model: command.model,
subtask: command.subtask,
});
}
}
}
}

View File

@@ -0,0 +1,117 @@
import { readdir, readFile, writeFile, mkdir } from "node:fs/promises";
import { join, resolve } from "node:path";
import { homedir } from "node:os";
import type { SkillItem } from "./types.js";
import { parseFrontmatter, buildFrontmatter } from "./frontmatter.js";
import { exists } from "./utils.js";
import { validateDescription, validateSkillName } from "./validators.js";
import { ApiError } from "./errors.js";
import { projectSkillsDir } from "./workspace-files.js";
async function findWorkspaceRoots(workspaceRoot: string): Promise<string[]> {
const roots: string[] = [];
let current = resolve(workspaceRoot);
while (true) {
roots.push(current);
const gitPath = join(current, ".git");
if (await exists(gitPath)) break;
const parent = resolve(current, "..");
if (parent === current) break;
current = parent;
}
return roots;
}
async function listSkillsInDir(dir: string, scope: "project" | "global"): Promise<SkillItem[]> {
if (!(await exists(dir))) return [];
const entries = await readdir(dir, { withFileTypes: true });
const items: SkillItem[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const skillPath = join(dir, entry.name, "SKILL.md");
if (!(await exists(skillPath))) continue;
const content = await readFile(skillPath, "utf8");
const { data } = parseFrontmatter(content);
const name = typeof data.name === "string" ? data.name : entry.name;
const description = typeof data.description === "string" ? data.description : "";
try {
validateSkillName(name);
validateDescription(description);
} catch {
continue;
}
if (name !== entry.name) continue;
items.push({
name,
description,
path: skillPath,
scope,
});
}
return items;
}
export async function listSkills(workspaceRoot: string, includeGlobal: boolean): Promise<SkillItem[]> {
const roots = await findWorkspaceRoots(workspaceRoot);
const items: SkillItem[] = [];
for (const root of roots) {
const opencodeDir = join(root, ".opencode", "skills");
const claudeDir = join(root, ".claude", "skills");
items.push(...(await listSkillsInDir(opencodeDir, "project")));
items.push(...(await listSkillsInDir(claudeDir, "project")));
}
if (includeGlobal) {
const globalOpenWork = join(homedir(), ".config", "opencode", "skills");
const globalClaude = join(homedir(), ".claude", "skills");
items.push(...(await listSkillsInDir(globalOpenWork, "global")));
items.push(...(await listSkillsInDir(globalClaude, "global")));
}
const seen = new Set<string>();
return items.filter((item) => {
if (seen.has(item.name)) return false;
seen.add(item.name);
return true;
});
}
export async function upsertSkill(
workspaceRoot: string,
payload: { name: string; content: string; description?: string },
): Promise<string> {
const name = payload.name.trim();
validateSkillName(name);
if (!payload.content) {
throw new ApiError(400, "invalid_skill_content", "Skill content is required");
}
let content = payload.content;
const { data, body } = parseFrontmatter(payload.content);
if (Object.keys(data).length > 0) {
const frontmatterName = typeof data.name === "string" ? data.name : "";
const frontmatterDescription = typeof data.description === "string" ? data.description : "";
if (frontmatterName && frontmatterName !== name) {
throw new ApiError(400, "invalid_skill_name", "Skill frontmatter name must match payload name");
}
validateDescription(frontmatterDescription || payload.description);
const nextDescription = frontmatterDescription || payload.description || "";
const frontmatter = buildFrontmatter({
...data,
name,
description: nextDescription,
});
content = frontmatter + body.replace(/^\n/, "");
} else {
validateDescription(payload.description);
const frontmatter = buildFrontmatter({ name, description: payload.description });
content = frontmatter + payload.content.replace(/^\n/, "");
}
const baseDir = projectSkillsDir(workspaceRoot);
const skillDir = join(baseDir, name);
await mkdir(skillDir, { recursive: true });
const skillPath = join(skillDir, "SKILL.md");
await writeFile(skillPath, content.endsWith("\n") ? content : content + "\n", "utf8");
return skillPath;
}

View File

@@ -0,0 +1,111 @@
export type WorkspaceType = "local" | "remote";
export type ApprovalMode = "manual" | "auto";
export interface WorkspaceConfig {
path: string;
name?: string;
workspaceType?: WorkspaceType;
baseUrl?: string;
directory?: string;
}
export interface WorkspaceInfo {
id: string;
name: string;
path: string;
workspaceType: WorkspaceType;
baseUrl?: string;
directory?: string;
}
export interface ApprovalConfig {
mode: ApprovalMode;
timeoutMs: number;
}
export interface ServerConfig {
host: string;
port: number;
token: string;
hostToken: string;
approval: ApprovalConfig;
corsOrigins: string[];
workspaces: WorkspaceInfo[];
authorizedRoots: string[];
readOnly: boolean;
startedAt: number;
tokenSource: "cli" | "env" | "file" | "generated";
hostTokenSource: "cli" | "env" | "file" | "generated";
}
export interface Capabilities {
skills: { read: boolean; write: boolean; source: "openwork" | "opencode" };
plugins: { read: boolean; write: boolean };
mcp: { read: boolean; write: boolean };
commands: { read: boolean; write: boolean };
config: { read: boolean; write: boolean };
}
export interface ApiErrorBody {
code: string;
message: string;
details?: unknown;
}
export interface PluginItem {
spec: string;
source: "config" | "dir.project" | "dir.global";
scope: "project" | "global";
path?: string;
}
export interface McpItem {
name: string;
config: Record<string, unknown>;
source: "config.project" | "config.global" | "config.remote";
disabledByTools?: boolean;
}
export interface SkillItem {
name: string;
path: string;
description: string;
scope: "project" | "global";
}
export interface CommandItem {
name: string;
description?: string;
template: string;
agent?: string;
model?: string | null;
subtask?: boolean;
scope: "workspace" | "global";
}
export interface Actor {
type: "remote" | "host";
clientId?: string;
tokenHash?: string;
}
export interface ApprovalRequest {
id: string;
workspaceId: string;
action: string;
summary: string;
paths: string[];
createdAt: number;
actor: Actor;
}
export interface AuditEntry {
id: string;
workspaceId: string;
actor: Actor;
action: string;
target: string;
summary: string;
timestamp: number;
}

View File

@@ -0,0 +1,52 @@
import { createHash, randomUUID } from "node:crypto";
import { mkdir, readFile, stat } from "node:fs/promises";
export async function exists(path: string): Promise<boolean> {
try {
await stat(path);
return true;
} catch {
return false;
}
}
export async function ensureDir(path: string): Promise<void> {
await mkdir(path, { recursive: true });
}
export async function readJsonFile<T>(path: string): Promise<T | null> {
try {
const raw = await readFile(path, "utf8");
return JSON.parse(raw) as T;
} catch {
return null;
}
}
export function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
export function shortId(): string {
return randomUUID();
}
export function parseList(input: string | undefined): string[] {
if (!input) return [];
const trimmed = input.trim();
if (!trimmed) return [];
if (trimmed.startsWith("[")) {
try {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
return parsed.map((item) => String(item)).filter(Boolean);
}
} catch {
return [];
}
}
return trimmed
.split(/[,;]/)
.map((value) => value.trim())
.filter(Boolean);
}

View File

@@ -0,0 +1,59 @@
import { ApiError } from "./errors.js";
const SKILL_NAME_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/;
const COMMAND_NAME_REGEX = /^[A-Za-z0-9_-]+$/;
const MCP_NAME_REGEX = /^[A-Za-z0-9_-]+$/;
export function validateSkillName(name: string): void {
if (!name || name.length < 1 || name.length > 64 || !SKILL_NAME_REGEX.test(name)) {
throw new ApiError(400, "invalid_skill_name", "Skill name must be kebab-case (1-64 chars)");
}
}
export function validateDescription(description: string | undefined): void {
if (!description || description.length < 1 || description.length > 1024) {
throw new ApiError(422, "invalid_description", "Description must be 1-1024 characters");
}
}
export function validatePluginSpec(spec: string): void {
if (!spec || spec.trim().length === 0) {
throw new ApiError(400, "invalid_plugin_spec", "Plugin spec is required");
}
}
export function sanitizeCommandName(name: string): string {
const trimmed = name.trim().replace(/^\/+/, "");
return trimmed;
}
export function validateCommandName(name: string): void {
if (!name || !COMMAND_NAME_REGEX.test(name)) {
throw new ApiError(400, "invalid_command_name", "Command name must be alphanumeric with _ or -");
}
}
export function validateMcpName(name: string): void {
if (!name || name.startsWith("-") || !MCP_NAME_REGEX.test(name)) {
throw new ApiError(400, "invalid_mcp_name", "MCP name must be alphanumeric and not start with -");
}
}
export function validateMcpConfig(config: Record<string, unknown>): void {
const type = config.type;
if (type !== "local" && type !== "remote") {
throw new ApiError(400, "invalid_mcp_config", "MCP config type must be local or remote");
}
if (type === "local") {
const command = config.command;
if (!Array.isArray(command) || command.length === 0) {
throw new ApiError(400, "invalid_mcp_config", "Local MCP requires command array");
}
}
if (type === "remote") {
const url = config.url;
if (!url || typeof url !== "string") {
throw new ApiError(400, "invalid_mcp_config", "Remote MCP requires url");
}
}
}

View File

@@ -0,0 +1,21 @@
import { join } from "node:path";
export function opencodeConfigPath(workspaceRoot: string): string {
return join(workspaceRoot, "opencode.json");
}
export function openworkConfigPath(workspaceRoot: string): string {
return join(workspaceRoot, ".opencode", "openwork.json");
}
export function projectSkillsDir(workspaceRoot: string): string {
return join(workspaceRoot, ".opencode", "skills");
}
export function projectCommandsDir(workspaceRoot: string): string {
return join(workspaceRoot, ".opencode", "commands");
}
export function projectPluginsDir(workspaceRoot: string): string {
return join(workspaceRoot, ".opencode", "plugins");
}

View File

@@ -0,0 +1,25 @@
import { createHash } from "node:crypto";
import { basename, resolve } from "node:path";
import type { WorkspaceConfig, WorkspaceInfo } from "./types.js";
export function workspaceIdForPath(path: string): string {
const hash = createHash("sha256").update(path).digest("hex");
return `ws_${hash.slice(0, 12)}`;
}
export function buildWorkspaceInfos(
workspaces: WorkspaceConfig[],
cwd: string,
): WorkspaceInfo[] {
return workspaces.map((workspace) => {
const resolvedPath = resolve(cwd, workspace.path);
return {
id: workspaceIdForPath(resolvedPath),
name: workspace.name ?? basename(resolvedPath),
path: resolvedPath,
workspaceType: workspace.workspaceType ?? "local",
baseUrl: workspace.baseUrl,
directory: workspace.directory,
};
});
}

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"types": ["bun", "node"]
},
"include": ["src"]
}

89
pnpm-lock.yaml generated
View File

@@ -60,7 +60,7 @@ importers:
devDependencies:
'@tailwindcss/vite':
specifier: ^4.1.18
version: 4.1.18(vite@6.4.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))
version: 4.1.18(vite@6.4.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))
tailwindcss:
specifier: ^4.1.18
version: 4.1.18
@@ -69,10 +69,10 @@ importers:
version: 5.9.3
vite:
specifier: ^6.0.1
version: 6.4.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
version: 6.4.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
vite-plugin-solid:
specifier: ^2.11.0
version: 2.11.10(solid-js@1.9.10)(vite@6.4.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))
version: 2.11.10(solid-js@1.9.10)(vite@6.4.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))
packages/desktop:
devDependencies:
@@ -126,6 +126,31 @@ importers:
specifier: ^5.6.3
version: 5.9.3
packages/server:
dependencies:
jsonc-parser:
specifier: ^3.2.1
version: 3.3.1
minimatch:
specifier: ^10.0.1
version: 10.1.1
yaml:
specifier: ^2.6.1
version: 2.8.2
devDependencies:
'@types/minimatch':
specifier: ^5.1.2
version: 5.1.2
'@types/node':
specifier: ^22.10.2
version: 22.19.7
bun-types:
specifier: ^1.1.29
version: 1.3.6
typescript:
specifier: ^5.6.3
version: 5.9.3
packages:
'@babel/code-frame@7.28.6':
@@ -689,6 +714,14 @@ packages:
cpu: [x64]
os: [win32]
'@isaacs/balanced-match@4.0.1':
resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==}
engines: {node: 20 || >=22}
'@isaacs/brace-expansion@5.0.0':
resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==}
engines: {node: 20 || >=22}
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -1109,6 +1142,9 @@ packages:
'@types/long@4.0.2':
resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==}
'@types/minimatch@5.1.2':
resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==}
'@types/node@10.17.60':
resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==}
@@ -1187,6 +1223,9 @@ packages:
buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
bun-types@1.3.6:
resolution: {integrity: sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ==}
cacheable@2.3.2:
resolution: {integrity: sha512-w+ZuRNmex9c1TR9RcsxbfTKCjSL0rh1WA5SABbrWprIHeNBdmyQLSYonlDy9gpD+63XT8DgZ/wNh1Smvc9WnJA==}
@@ -1472,6 +1511,10 @@ packages:
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
engines: {node: '>=10'}
minimatch@10.1.1:
resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==}
engines: {node: 20 || >=22}
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
@@ -1812,6 +1855,11 @@ packages:
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
yaml@2.8.2:
resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==}
engines: {node: '>= 14.6'}
hasBin: true
snapshots:
'@babel/code-frame@7.28.6':
@@ -2221,6 +2269,12 @@ snapshots:
'@img/sharp-win32-x64@0.34.5':
optional: true
'@isaacs/balanced-match@4.0.1': {}
'@isaacs/brace-expansion@5.0.0':
dependencies:
'@isaacs/balanced-match': 4.0.1
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -2431,12 +2485,12 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-msvc': 4.1.18
'@tailwindcss/oxide-win32-x64-msvc': 4.1.18
'@tailwindcss/vite@4.1.18(vite@6.4.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))':
'@tailwindcss/vite@4.1.18(vite@6.4.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@tailwindcss/node': 4.1.18
'@tailwindcss/oxide': 4.1.18
tailwindcss: 4.1.18
vite: 6.4.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
vite: 6.4.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
'@tauri-apps/api@2.9.1': {}
@@ -2541,6 +2595,8 @@ snapshots:
'@types/long@4.0.2': {}
'@types/minimatch@5.1.2': {}
'@types/node@10.17.60': {}
'@types/node@22.19.7':
@@ -2630,6 +2686,10 @@ snapshots:
base64-js: 1.5.1
ieee754: 1.2.1
bun-types@1.3.6:
dependencies:
'@types/node': 22.19.7
cacheable@2.3.2:
dependencies:
'@cacheable/memory': 2.0.7
@@ -2892,6 +2952,10 @@ snapshots:
mimic-response@3.1.0: {}
minimatch@10.1.1:
dependencies:
'@isaacs/brace-expansion': 5.0.0
minimist@1.2.8: {}
mkdirp-classic@0.5.3: {}
@@ -3234,7 +3298,7 @@ snapshots:
util-deprecate@1.0.2: {}
vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@6.4.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)):
vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@6.4.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)):
dependencies:
'@babel/core': 7.28.6
'@types/babel__core': 7.20.5
@@ -3242,12 +3306,12 @@ snapshots:
merge-anything: 5.1.7
solid-js: 1.9.10
solid-refresh: 0.6.3(solid-js@1.9.10)
vite: 6.4.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
vitefu: 1.1.1(vite@6.4.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))
vite: 6.4.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
vitefu: 1.1.1(vite@6.4.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))
transitivePeerDependencies:
- supports-color
vite@6.4.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0):
vite@6.4.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.3)
@@ -3261,10 +3325,11 @@ snapshots:
jiti: 2.6.1
lightningcss: 1.30.2
tsx: 4.21.0
yaml: 2.8.2
vitefu@1.1.1(vite@6.4.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)):
vitefu@1.1.1(vite@6.4.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)):
optionalDependencies:
vite: 6.4.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
vite: 6.4.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
webidl-conversions@3.0.1: {}
@@ -3280,3 +3345,5 @@ snapshots:
ws@8.19.0: {}
yallist@3.1.1: {}
yaml@2.8.2: {}