diff --git a/packages/app/pr/openwork-server.md b/packages/app/pr/openwork-server.md new file mode 100644 index 000000000..fdb34b3ed --- /dev/null +++ b/packages/app/pr/openwork-server.md @@ -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 `) +- 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//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/.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//SKILL.md` + - `.claude/skills//SKILL.md` +- Skill discovery paths (global): + - `~/.config/opencode/skills//SKILL.md` + - `~/.claude/skills//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 +``` +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 `. +- 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//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; + listWorkspaces(): Promise; + getConfig(id: string): Promise<{ opencode: object; openwork: object }>; + patchConfig(id: string, body: { opencode?: object; openwork?: object }): Promise; + listPlugins(id: string): Promise; + addPlugin(id: string, spec: string): Promise; + removePlugin(id: string, name: string): Promise; + listSkills(id: string): Promise; + upsertSkill(id: string, payload: { name: string; content: string }): Promise; + listMcp(id: string): Promise; + addMcp(id: string, payload: { name: string; config: McpServerConfig }): Promise; + removeMcp(id: string, name: string): Promise; + listCommands(id: string): Promise; + upsertCommand(id: string, payload: WorkspaceCommand): Promise; + deleteCommand(id: string, name: string): Promise; +}; +``` + +### 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 don’t 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//SKILL.md` +- Add command -> `POST /workspace/:id/commands` -> `.opencode/commands/.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//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/.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 doesn’t 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 `, `opencode mcp list`, `opencode mcp logout `, `opencode mcp debug `. + +--- +## 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//SKILL.md` + - `.opencode/commands/.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/.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 diff --git a/packages/app/src/app/app.tsx b/packages/app/src/app/app.tsx index 141a4ed53..ff1508250 100644 --- a/packages/app/src/app/app.tsx +++ b/packages/app/src/app/app.tsx @@ -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({}); + const [openworkServerUrl, setOpenworkServerUrl] = createSignal(""); + const [openworkServerStatus, setOpenworkServerStatus] = createSignal("disconnected"); + const [openworkServerCapabilities, setOpenworkServerCapabilities] = createSignal(null); + const [openworkServerCheckedAt, setOpenworkServerCheckedAt] = createSignal(null); + const [openworkServerWorkspaceId, setOpenworkServerWorkspaceId] = createSignal(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(null); const [connectedVersion, setConnectedVersion] = createSignal( 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) => { @@ -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(); diff --git a/packages/app/src/app/command-state.ts b/packages/app/src/app/command-state.ts index 9e7e05337..e777e9f9a 100644 --- a/packages/app/src/app/command-state.ts +++ b/packages/app/src/app/command-state.ts @@ -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; activeWorkspaceRoot: Accessor; + workspaceType: Accessor<"local" | "remote">; + openworkServerClient: Accessor; + openworkServerCapabilities: Accessor; + openworkServerWorkspaceId: Accessor; 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(); let globalNames = new Set(); - 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 }); diff --git a/packages/app/src/app/components/part-view.tsx b/packages/app/src/app/components/part-view.tsx index 84b17ad5b..08d3830d8 100644 --- a/packages/app/src/app/components/part-view.tsx +++ b/packages/app/src/app/components/part-view.tsx @@ -23,12 +23,12 @@ function useThrottledValue(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) { - - - - + + diff --git a/packages/app/src/app/components/provider-auth-modal.tsx b/packages/app/src/app/components/provider-auth-modal.tsx index e29a1847e..b4c01f9b0 100644 --- a/packages/app/src/app/components/provider-auth-modal.tsx +++ b/packages/app/src/app/components/provider-auth-modal.tsx @@ -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; onSelect: (providerId: string) => void; diff --git a/packages/app/src/app/context/extensions.ts b/packages/app/src/app/context/extensions.ts index 9361583d8..d108b53ac 100644 --- a/packages/app/src/app/context/extensions.ts +++ b/packages/app/src/app/context/extensions.ts @@ -23,6 +23,11 @@ import { writeOpencodeConfig, type OpencodeConfigFile, } from "../lib/tauri"; +import type { + OpenworkServerCapabilities, + OpenworkServerClient, + OpenworkServerStatus, +} from "../lib/openwork-server"; export type ExtensionsStore = ReturnType; @@ -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("project"); const [pluginConfig, setPluginConfig] = createSignal(null); + const [pluginConfigPath, setPluginConfigPath] = createSignal(null); const [pluginList, setPluginList] = createSignal([]); const [pluginInput, setPluginInput] = createSignal(""); const [pluginStatus, setPluginStatus] = createSignal(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, diff --git a/packages/app/src/app/context/global-sync.tsx b/packages/app/src/app/context/global-sync.tsx index 269362bd2..82ebb128e 100644 --- a/packages/app/src/app/context/global-sync.tsx +++ b/packages/app/src/app/context/global-sync.tsx @@ -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, SetStoreFunction]; 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(); } diff --git a/packages/app/src/app/context/workspace.ts b/packages/app/src/app/context/workspace.ts index 6ee94a359..c23f338f3 100644 --- a/packages/app/src/app/context/workspace.ts +++ b/packages/app/src/app/context/workspace.ts @@ -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; @@ -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) => 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 { diff --git a/packages/app/src/app/lib/openwork-server.ts b/packages/app/src/app/lib/openwork-server.ts new file mode 100644 index 000000000..b568f37b5 --- /dev/null +++ b/packages/app/src/app/lib/openwork-server.ts @@ -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; + 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) { + const headers: Record = { "Content-Type": "application/json" }; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + if (extra) { + Object.assign(headers, extra); + } + return headers; +} + +async function requestJson( + baseUrl: string, + path: string, + options: { method?: string; token?: string; body?: unknown } = {}, +): Promise { + 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(baseUrl, "/capabilities", { token }), + listWorkspaces: () => requestJson<{ items: OpenworkWorkspaceInfo[] }>(baseUrl, "/workspaces", { token }), + getConfig: (workspaceId: string) => + requestJson<{ opencode: Record; openwork: Record; updatedAt?: number | null }>( + baseUrl, + `/workspace/${workspaceId}/config`, + { token }, + ), + patchConfig: (workspaceId: string, payload: { opencode?: Record; openwork?: Record }) => + 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(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 }) => + 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; diff --git a/packages/app/src/app/pages/dashboard.tsx b/packages/app/src/app/pages/dashboard.tsx index 61fcd7436..856fc1d73 100644 --- a/packages/app/src/app/pages/dashboard.tsx +++ b/packages/app/src/app/pages/dashboard.tsx @@ -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; 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 (
+
+
+
+ + OpenCode Engine + {opencodeStatusMeta().label} +
+
+
+ + OpenWork Server + {openworkStatusMeta().label} +
+
+ +
+ {props.openworkServerUrl} +
+
+
+ + +
+ OpenWork server is offline — remote tasks still run. +
+
+
+
+
+
+
OpenWork Server
+
+ Connect a remote OpenWork server to manage skills and plugins. +
+
+
+ {openworkStatusLabel()} +
+
+ +
+ 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} + /> + + +
+ +
+ Resolved URL: {props.openworkServerUrl || "Not set"} +
+ +
+ + + +
+
+
diff --git a/packages/app/src/app/pages/skills.tsx b/packages/app/src/app/pages/skills.tsx index 6d94f5343..2e1949dd4 100644 --- a/packages/app/src/app/pages/skills.tsx +++ b/packages/app/src/app/pages/skills.tsx @@ -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) {
{translate("skills.add_title")}
{translate("skills.add_description")}
- + +
{props.accessHint}
+
+
{translate("skills.host_mode_only")}
@@ -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} > {skillCreatorInstalled() ? translate("skills.installed_label") : translate("skills.install")} @@ -76,7 +89,11 @@ export default function SkillsView(props: SkillsViewProps) {
{translate("skills.import_local")}
{translate("skills.import_local_hint")}
- @@ -87,7 +104,11 @@ export default function SkillsView(props: SkillsViewProps) {
{translate("skills.reveal_folder")}
{translate("skills.reveal_folder_hint")}
- @@ -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")} diff --git a/packages/app/src/app/system-state.ts b/packages/app/src/app/system-state.ts index af0731b3e..03ec53552 100644 --- a/packages/app/src/app/system-state.ts +++ b/packages/app/src/app/system-state.ts @@ -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; refreshMcpServers?: () => Promise; reloadWorkspaceEngine?: () => Promise; - setProviders: (value: Provider[]) => void; + setProviders: (value: ProviderListItem[]) => void; setProviderDefaults: (value: Record) => 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 { diff --git a/packages/app/src/app/types.ts b/packages/app/src/app/types.ts index 3bc362eec..d8e35cad3 100644 --- a/packages/app/src/app/types.ts +++ b/packages/app/src/app/types.ts @@ -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; +export type ProviderListItem = ProviderListResponse["all"][number]; + export type PlaceholderAssistantMessage = { id: string; sessionID: string; diff --git a/packages/app/src/app/utils/index.ts b/packages/app/src/app/utils/index.ts index f68520212..008959068 100644 --- a/packages/app/src/app/utils/index.ts +++ b/packages/app/src/app/utils/index.ts @@ -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]; diff --git a/packages/app/src/app/utils/providers.ts b/packages/app/src/app/utils/providers.ts new file mode 100644 index 000000000..5809e3ae1 --- /dev/null +++ b/packages/app/src/app/utils/providers.ts @@ -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, + }; + }); diff --git a/packages/server/README.md b/packages/server/README.md new file mode 100644 index 000000000..b2d32dae6 --- /dev/null +++ b/packages/server/README.md @@ -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. diff --git a/packages/server/package.json b/packages/server/package.json new file mode 100644 index 000000000..d97719d1b --- /dev/null +++ b/packages/server/package.json @@ -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" +} diff --git a/packages/server/src/approvals.ts b/packages/server/src/approvals.ts new file mode 100644 index 000000000..6aa29ea86 --- /dev/null +++ b/packages/server/src/approvals.ts @@ -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(); + + constructor(config: ApprovalConfig) { + this.config = config; + } + + list(): ApprovalRequest[] { + return Array.from(this.pending.values()).map((entry) => entry.request); + } + + async requestApproval( + input: Omit, + ): Promise { + 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((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; + } +} diff --git a/packages/server/src/audit.ts b/packages/server/src/audit.ts new file mode 100644 index 000000000..c98eff3a6 --- /dev/null +++ b/packages/server/src/audit.ts @@ -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 { + const path = auditLogPath(workspaceRoot); + await ensureDir(dirname(path)); + await appendFile(path, JSON.stringify(entry) + "\n", "utf8"); +} + +export async function readLastAudit(workspaceRoot: string): Promise { + 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; + } +} diff --git a/packages/server/src/bun.d.ts b/packages/server/src/bun.d.ts new file mode 100644 index 000000000..1916e9c11 --- /dev/null +++ b/packages/server/src/bun.d.ts @@ -0,0 +1,9 @@ +declare const Bun: { + serve: (options: { + hostname: string; + port: number; + fetch: (request: Request) => Response | Promise; + }) => { + port: number; + }; +}; diff --git a/packages/server/src/cli.ts b/packages/server/src/cli.ts new file mode 100644 index 000000000..94328456b --- /dev/null +++ b/packages/server/src/cli.ts @@ -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}`); +} diff --git a/packages/server/src/commands.ts b/packages/server/src/commands.ts new file mode 100644 index 000000000..1f05084e9 --- /dev/null +++ b/packages/server/src/commands.ts @@ -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 { + 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 { + 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 { + 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 { + const sanitized = sanitizeCommandName(name); + validateCommandName(sanitized); + const path = join(projectCommandsDir(workspaceRoot), `${sanitized}.md`); + await rm(path, { force: true }); +} diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts new file mode 100644 index 000000000..6126a1a9a --- /dev/null +++ b/packages/server/src/config.ts @@ -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; + 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 to server.json", + " --host Hostname (default 127.0.0.1)", + " --port Port (default 8787)", + " --token Client bearer token", + " --host-token Host approval token", + " --approval manual | auto", + " --approval-timeout Approval timeout", + " --workspace Workspace root (repeatable)", + " --cors Comma-separated origins or *", + " --read-only Disable writes", + ].join("\n"); + console.log(message); +} + +async function loadFileConfig(configPath: string): Promise { + const parsed = await readJsonFile(configPath); + return parsed ?? {}; +} + +export async function resolveServerConfig(cli: CliArgs): Promise { + 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, + }; +} diff --git a/packages/server/src/errors.ts b/packages/server/src/errors.ts new file mode 100644 index 000000000..0857aa149 --- /dev/null +++ b/packages/server/src/errors.ts @@ -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, + }; +} diff --git a/packages/server/src/frontmatter.ts b/packages/server/src/frontmatter.ts new file mode 100644 index 000000000..bdfee2535 --- /dev/null +++ b/packages/server/src/frontmatter.ts @@ -0,0 +1,17 @@ +import { parse, stringify } from "yaml"; + +export function parseFrontmatter(content: string): { data: Record; 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) ?? {}; + const body = content.slice(match[0].length); + return { data, body }; +} + +export function buildFrontmatter(data: Record): string { + const yaml = stringify(data).trimEnd(); + return `---\n${yaml}\n---\n`; +} diff --git a/packages/server/src/jsonc.ts b/packages/server/src/jsonc.ts new file mode 100644 index 000000000..d0a9698f8 --- /dev/null +++ b/packages/server/src/jsonc.ts @@ -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 { + data: T; + raw: string; +} + +export async function readJsoncFile(path: string, fallback: T): Promise> { + 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): Promise { + 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 { + await ensureDir(dirname(path)); + const content = JSON.stringify(value, null, 2) + "\n"; + await writeFile(path, content, "utf8"); +} diff --git a/packages/server/src/mcp.ts b/packages/server/src/mcp.ts new file mode 100644 index 000000000..62a6e4c0b --- /dev/null +++ b/packages/server/src/mcp.ts @@ -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): Record> { + const mcp = config.mcp; + if (!mcp || typeof mcp !== "object") return {}; + return mcp as Record>; +} + +function getDeniedToolPatterns(config: Record): 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, 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 { + const { data: config } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record); + 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): Promise { + validateMcpName(name); + validateMcpConfig(config); + const { data } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record); + const mcpMap = getMcpConfig(data); + mcpMap[name] = config; + await updateJsoncTopLevel(opencodeConfigPath(workspaceRoot), { mcp: mcpMap }); +} + +export async function removeMcp(workspaceRoot: string, name: string): Promise { + const { data } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record); + const mcpMap = getMcpConfig(data); + delete mcpMap[name]; + await updateJsoncTopLevel(opencodeConfigPath(workspaceRoot), { mcp: mcpMap }); +} diff --git a/packages/server/src/paths.ts b/packages/server/src/paths.ts new file mode 100644 index 000000000..bdd0d65f9 --- /dev/null +++ b/packages/server/src/paths.ts @@ -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 { + 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; +} diff --git a/packages/server/src/plugins.ts b/packages/server/src/plugins.ts new file mode 100644 index 000000000..cb7125fd9 --- /dev/null +++ b/packages/server/src/plugins.ts @@ -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[] { + 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 { + 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); + 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 { + validatePluginSpec(spec); + const { data: config } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record); + 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 { + const { data: config } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record); + const pluginSpecs = pluginListFromConfig(config); + const normalized = normalizePluginSpec(name); + const filtered = pluginSpecs.filter((item) => normalizePluginSpec(item) !== normalized); + await updateJsoncTopLevel(opencodeConfigPath(workspaceRoot), { plugin: filtered }); +} diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts new file mode 100644 index 000000000..c562a6a21 --- /dev/null +++ b/packages/server/src/server.ts @@ -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; +} + +interface RequestContext { + request: Request; + url: URL; + params: Record; + 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 = {}; + 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 | undefined; + const openwork = body.openwork as Record | 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 | 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 { + 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 { + 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> { + try { + const json = await request.json(); + return json as Record; + } catch { + throw new ApiError(400, "invalid_json", "Invalid JSON body"); + } +} + +async function readOpencodeConfig(workspaceRoot: string): Promise> { + const { data } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record); + return data; +} + +async function readOpenworkConfig(workspaceRoot: string): Promise> { + const path = openworkConfigPath(workspaceRoot); + if (!(await exists(path))) return {}; + try { + const raw = await readFile(path, "utf8"); + return JSON.parse(raw) as Record; + } catch { + throw new ApiError(422, "invalid_json", "Failed to parse openwork.json"); + } +} + +async function writeOpenworkConfig(workspaceRoot: string, payload: Record, merge: boolean): Promise { + 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, +): Promise { + 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): Promise { + const modes = (payload.mode as Record | undefined) ?? {}; + const opencode = payload.opencode as Record | undefined; + const openwork = payload.openwork as Record | 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, + }); + } + } + } +} diff --git a/packages/server/src/skills.ts b/packages/server/src/skills.ts new file mode 100644 index 000000000..77651a9da --- /dev/null +++ b/packages/server/src/skills.ts @@ -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 { + 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 { + 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 { + 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(); + 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 { + 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; +} diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts new file mode 100644 index 000000000..688866a00 --- /dev/null +++ b/packages/server/src/types.ts @@ -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; + 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; +} diff --git a/packages/server/src/utils.ts b/packages/server/src/utils.ts new file mode 100644 index 000000000..cadd95fa8 --- /dev/null +++ b/packages/server/src/utils.ts @@ -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 { + try { + await stat(path); + return true; + } catch { + return false; + } +} + +export async function ensureDir(path: string): Promise { + await mkdir(path, { recursive: true }); +} + +export async function readJsonFile(path: string): Promise { + 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); +} diff --git a/packages/server/src/validators.ts b/packages/server/src/validators.ts new file mode 100644 index 000000000..a1c0736c4 --- /dev/null +++ b/packages/server/src/validators.ts @@ -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): 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"); + } + } +} diff --git a/packages/server/src/workspace-files.ts b/packages/server/src/workspace-files.ts new file mode 100644 index 000000000..749a4a1c4 --- /dev/null +++ b/packages/server/src/workspace-files.ts @@ -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"); +} diff --git a/packages/server/src/workspaces.ts b/packages/server/src/workspaces.ts new file mode 100644 index 000000000..43916b19a --- /dev/null +++ b/packages/server/src/workspaces.ts @@ -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, + }; + }); +} diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json new file mode 100644 index 000000000..e3bb03ed1 --- /dev/null +++ b/packages/server/tsconfig.json @@ -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"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9cb7fa00..ec01f8f9b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {}