mirror of
https://github.com/different-ai/openwork
synced 2026-05-10 01:02:03 +02:00
feat: openwork sync v0 (#280)
* docs: add openwork server PRD * feat: wire OpenWork server remote management * feat: add OpenWork server settings panel
This commit is contained in:
792
packages/app/pr/openwork-server.md
Normal file
792
packages/app/pr/openwork-server.md
Normal file
@@ -0,0 +1,792 @@
|
||||
# OpenWork Server
|
||||
Bridge missing capabilities between OpenWork and OpenCode
|
||||
|
||||
---
|
||||
## Summarize
|
||||
Introduce an OpenWork server layer that fills gaps in OpenCode APIs, enabling remote clients to manage workspace config, skills, plugins, and MCPs without direct filesystem access.
|
||||
|
||||
---
|
||||
## Define problem
|
||||
Remote clients cannot read or write workspace config because critical state lives in the filesystem. OpenWork needs a safe, minimal surface to access and mutate config, skills, plugins, and MCPs when connected to a host.
|
||||
|
||||
---
|
||||
## Set goals
|
||||
- Bridge OpenWork needs that OpenCode does not expose today
|
||||
- Enable remote clients to view and update workspace config safely
|
||||
- Keep the surface minimal, auditable, and aligned to OpenCode primitives
|
||||
|
||||
---
|
||||
## Mark non-goals
|
||||
- Replacing OpenCode's server or duplicating its APIs
|
||||
- Arbitrary filesystem access outside approved workspace roots
|
||||
- Hosting multi-tenant or cloud-managed instances
|
||||
|
||||
---
|
||||
## Describe personas
|
||||
- Remote client user: needs visibility into installed skills/plugins while connected
|
||||
- Host operator: wants safe, explicit control over config changes
|
||||
|
||||
---
|
||||
## List requirements
|
||||
- Functional: expose workspace config read/write APIs for `.opencode` and `opencode.json`
|
||||
- Functional: list installed skills, plugins, MCPs for a workspace without direct FS access
|
||||
- Functional: allow saving new skills, plugins, and MCP entries from a remote client
|
||||
- UX: show remote-config origin, last updated time, and change attribution
|
||||
|
||||
---
|
||||
## Define API (initial)
|
||||
All endpoints are scoped to an approved workspace root and require host approval for writes.
|
||||
|
||||
When OpenCode already exposes a stable API (agents, skills, MCP status), prefer OpenCode directly and avoid duplicating it here. This server only covers filesystem-backed gaps.
|
||||
|
||||
### API conventions
|
||||
- Base URL: provided by the host during pairing (e.g., `http://host:PORT/openwork`)
|
||||
- Content-Type: `application/json`
|
||||
- Auth: bearer token issued during pairing (`Authorization: Bearer <token>`)
|
||||
- Errors: JSON body with `{ code, message, details? }`
|
||||
- All writes require explicit host approval and return `403` when denied
|
||||
|
||||
### Workspaces (discovery)
|
||||
- `GET /workspaces` -> list known workspaces on the host
|
||||
- Response fields align with `WorkspaceInfo` (id, name, path, workspaceType, baseUrl?, directory?)
|
||||
- Used by remote clients to select a workspace without filesystem access
|
||||
|
||||
### Health
|
||||
- `GET /health` -> { ok, version, uptimeMs }
|
||||
|
||||
### Capabilities
|
||||
- `GET /capabilities` -> { skills: { read, write, source }, plugins: { read, write }, mcp: { read, write }, commands: { read, write }, config: { read, write } }
|
||||
- `source` indicates whether OpenCode or OpenWork server is the authoritative API
|
||||
|
||||
### Workspace config
|
||||
- `GET /workspace/:id/config` -> returns parsed `opencode.json` + `.opencode/openwork.json`
|
||||
- `PATCH /workspace/:id/config` -> merges and writes config (write approval required)
|
||||
- Request body: `{ opencode?: object, openwork?: object }`
|
||||
- Merge strategy: shallow merge at top-level keys; arrays replaced (aligns with OpenCode config behavior)
|
||||
- Response: `{ opencode, openwork, updatedAt }`
|
||||
- Only project config is writable by default; global config requires explicit host-only scope
|
||||
|
||||
### Skills
|
||||
- Prefer OpenCode skills API when available; OpenWork server only fills local FS gaps.
|
||||
- `GET /workspace/:id/skills` -> list skill metadata from `.opencode/skills` (fallback only)
|
||||
- `POST /workspace/:id/skills` -> add/update skill file(s) (write approval required, fallback only)
|
||||
- Request body: `{ name, content, description? }`
|
||||
- Writes to `.opencode/skills/<name>/SKILL.md`
|
||||
- Response: `{ name, path, description, scope }`
|
||||
|
||||
### Plugins
|
||||
- `GET /workspace/:id/plugins` -> list configured plugins from `opencode.json`
|
||||
- `POST /workspace/:id/plugins` -> add plugin entry to config (write approval required)
|
||||
- `DELETE /workspace/:id/plugins/:name` -> remove plugin entry (write approval required)
|
||||
- Request body (POST): `{ spec }` where `spec` is a plugin string (npm or file URL)
|
||||
- Response: `{ items: [{ spec, source, scope, path? }], loadOrder: string[] }`
|
||||
- Server de-dupes by normalized name (strip version where possible)
|
||||
|
||||
### MCPs
|
||||
- Prefer OpenCode MCP APIs for status/runtime; OpenWork server only reads/writes config.
|
||||
- `GET /workspace/:id/mcp` -> list configured MCP servers
|
||||
- `POST /workspace/:id/mcp` -> add MCP config entry (write approval required)
|
||||
- `DELETE /workspace/:id/mcp/:name` -> remove MCP config entry (write approval required)
|
||||
- Request body (POST): `{ name, config }` where `config` matches `McpServerConfig`
|
||||
- Response: `{ items: [{ name, config, source }] }`
|
||||
|
||||
### Agents
|
||||
- Prefer OpenCode agents API; no OpenWork server endpoints needed unless OpenCode lacks coverage for remote clients.
|
||||
|
||||
### Commands
|
||||
- `GET /workspace/:id/commands` -> list commands from `.opencode/commands` (fallback only)
|
||||
- Optional query: `scope=workspace|global` (global requires host-only approval)
|
||||
- `POST /workspace/:id/commands` -> create/update command (write approval required)
|
||||
- `DELETE /workspace/:id/commands/:name` -> delete command (write approval required)
|
||||
- Request body (POST): `{ name, description?, template, agent?, model?, subtask? }`
|
||||
- Server writes `.opencode/commands/<name>.md` with YAML frontmatter
|
||||
|
||||
---
|
||||
## OpenCode behavior reference (from docs)
|
||||
This section captures the exact OpenCode semantics the OpenWork server must respect.
|
||||
|
||||
### Plugins
|
||||
- Config list: `opencode.json` -> `plugin` field. Can be string or string[].
|
||||
- Local plugin folders:
|
||||
- Project: `.opencode/plugins/`
|
||||
- Global: `~/.config/opencode/plugins/`
|
||||
- Load order (all hooks run in sequence):
|
||||
1) Global config `~/.config/opencode/opencode.json`
|
||||
2) Project config `opencode.json`
|
||||
3) Global plugin dir `~/.config/opencode/plugins/`
|
||||
4) Project plugin dir `.opencode/plugins/`
|
||||
- NPM plugins are installed automatically using Bun at startup.
|
||||
- Cached node_modules live in `~/.cache/opencode/node_modules/`.
|
||||
- Local plugins can use dependencies listed in `.opencode/package.json`.
|
||||
- Duplicate npm packages with the same name+version load once; local and npm with similar names both load.
|
||||
|
||||
### Skills
|
||||
- Skill discovery paths (project):
|
||||
- `.opencode/skills/<name>/SKILL.md`
|
||||
- `.claude/skills/<name>/SKILL.md`
|
||||
- Skill discovery paths (global):
|
||||
- `~/.config/opencode/skills/<name>/SKILL.md`
|
||||
- `~/.claude/skills/<name>/SKILL.md`
|
||||
- Discovery walks up from current working directory to git worktree root.
|
||||
- `SKILL.md` must include YAML frontmatter with `name` and `description`.
|
||||
- Name constraints:
|
||||
- 1-64 chars
|
||||
- lowercase alphanumeric with single hyphen separators
|
||||
- must match directory name
|
||||
- regex: `^[a-z0-9]+(-[a-z0-9]+)*$`
|
||||
- Description length: 1-1024 chars.
|
||||
- Permissions: `opencode.json` `permission.skill` supports `allow`, `deny`, `ask` patterns.
|
||||
|
||||
### MCP servers
|
||||
- Config lives in `opencode.json` under `mcp` object.
|
||||
- Local MCP fields: `type: "local"`, `command[]`, optional `environment`, `enabled`, `timeout`.
|
||||
- Remote MCP fields: `type: "remote"`, `url`, optional `headers`, `oauth`, `enabled`, `timeout`.
|
||||
- `oauth: false` disables automatic OAuth detection.
|
||||
- Remote defaults can be provided via `.well-known/opencode`; local config overrides.
|
||||
- MCP tools can be disabled globally in `tools` via glob patterns.
|
||||
- Per-agent tool enabling overrides global settings.
|
||||
|
||||
### OpenCode server APIs
|
||||
- OpenCode already exposes: `/config`, `/mcp` (runtime), `/agent`, `/command` (list), `/session`.
|
||||
- OpenWork server should prefer OpenCode APIs for runtime status and only handle FS-backed config gaps.
|
||||
- When reading config, prefer OpenCode `/config` to capture precedence (remote defaults + global + project).
|
||||
- When writing, only modify project `opencode.json` to avoid clobbering upstream defaults.
|
||||
- `/command` is list-only; OpenWork server adds create/delete via filesystem for remote clients.
|
||||
|
||||
### Config precedence (reference)
|
||||
- Remote defaults from `.well-known/opencode`
|
||||
- Global config `~/.config/opencode/opencode.json`
|
||||
- Project config `opencode.json`
|
||||
- `.opencode/` directories and inline env overrides
|
||||
- OpenWork server should preserve this ordering when presenting config sources
|
||||
|
||||
---
|
||||
## OpenCode server alignment (from docs)
|
||||
- OpenCode runs an HTTP server via `opencode serve` (default `127.0.0.1:4096`).
|
||||
- `--cors` can be used to allow browser origins; OpenWork should align with that for web clients.
|
||||
- Basic auth can be enabled via `OPENCODE_SERVER_PASSWORD` (username defaults to `opencode`).
|
||||
- The OpenWork server should not bypass OpenCode auth; if OpenCode is password-protected, the host UI must collect credentials and pass them to the client.
|
||||
- OpenCode publishes its OpenAPI spec at `/doc`; the OpenWork server should track upstream changes and avoid duplicating stable APIs.
|
||||
|
||||
---
|
||||
## Endpoint examples (requests and responses)
|
||||
### List workspaces
|
||||
Request:
|
||||
```
|
||||
GET /workspaces
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{ "id": "ws_1", "name": "Finance", "path": "/Users/susan/Finance", "workspaceType": "local" },
|
||||
{ "id": "ws_2", "name": "Remote Ops", "path": "/Users/bob/Shared", "workspaceType": "remote", "baseUrl": "http://10.0.0.8:4096" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Get config
|
||||
Request:
|
||||
```
|
||||
GET /workspace/ws_1/config
|
||||
```
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"opencode": { "plugin": ["opencode-github"], "mcp": { "chrome": { "type": "local", "command": ["npx", "-y", "chrome-devtools-mcp@latest"] } } },
|
||||
"openwork": { "version": 1, "authorizedRoots": ["/Users/susan/Finance"] }
|
||||
}
|
||||
```
|
||||
|
||||
### Patch config
|
||||
Request:
|
||||
```json
|
||||
PATCH /workspace/ws_1/config
|
||||
{ "opencode": { "plugin": ["opencode-github", "opencode-notion"] } }
|
||||
```
|
||||
Response:
|
||||
```json
|
||||
{ "updatedAt": 1730000000000 }
|
||||
```
|
||||
|
||||
### Add plugin
|
||||
Request:
|
||||
```json
|
||||
POST /workspace/ws_1/plugins
|
||||
{ "spec": "opencode-notion" }
|
||||
```
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{ "spec": "opencode-github", "source": "config", "scope": "project" },
|
||||
{ "spec": "opencode-notion", "source": "config", "scope": "project" }
|
||||
],
|
||||
"loadOrder": ["config.global", "config.project", "dir.global", "dir.project"]
|
||||
}
|
||||
```
|
||||
|
||||
### Add skill
|
||||
Request:
|
||||
```json
|
||||
POST /workspace/ws_1/skills
|
||||
{ "name": "expense-audit", "content": "# Expense Audit\n..." }
|
||||
```
|
||||
Response:
|
||||
```json
|
||||
{ "name": "expense-audit", "path": ".opencode/skills/expense-audit", "description": "Audit expenses...", "scope": "project" }
|
||||
```
|
||||
|
||||
### Add MCP server
|
||||
Request:
|
||||
```json
|
||||
POST /workspace/ws_1/mcp
|
||||
{ "name": "chrome", "config": { "type": "local", "command": ["npx", "-y", "chrome-devtools-mcp@latest"] } }
|
||||
```
|
||||
Response:
|
||||
```json
|
||||
{ "items": [{ "name": "chrome", "config": { "type": "local", "command": ["npx", "-y", "chrome-devtools-mcp@latest"] }, "source": "config.project" }] }
|
||||
```
|
||||
|
||||
### Add command
|
||||
Request:
|
||||
```json
|
||||
POST /workspace/ws_1/commands
|
||||
{ "name": "daily-report", "description": "Daily report", "template": "summarize yesterday", "agent": "default" }
|
||||
```
|
||||
Response:
|
||||
```json
|
||||
{ "items": [{ "name": "daily-report", "description": "Daily report", "template": "summarize yesterday", "agent": "default", "scope": "workspace" }] }
|
||||
```
|
||||
|
||||
---
|
||||
## Outline integration
|
||||
- Host side: OpenWork server exposes a narrow API layer on top of OpenCode
|
||||
- Implementation: Bun-based server initially, shipped as a sidecar inside the OpenWork desktop app
|
||||
- Client side: OpenWork UI uses this layer when remote, falls back to FS when local
|
||||
- Storage: persists changes to `.opencode` and `opencode.json` within workspace root
|
||||
|
||||
---
|
||||
## Server runtime and sidecar lifecycle
|
||||
- The desktop app launches the OpenWork server as a sidecar process.
|
||||
- The server binds to localhost on an ephemeral port and reports its URL back to the host UI.
|
||||
- The host UI includes the OpenWork server URL in the pairing payload for remote clients.
|
||||
- On crash, the host restarts the server and re-issues capabilities.
|
||||
- The server must never expose filesystem paths outside approved workspace roots.
|
||||
|
||||
---
|
||||
## Workspace identity and scoping
|
||||
- Workspaces are referenced by `workspace.id` (stable hash of the workspace path).
|
||||
- Remote clients fetch the list via `GET /workspaces` and select a target id.
|
||||
- Each request includes `workspaceId` in the path; server resolves to a validated root.
|
||||
- If the workspace is missing or not authorized, return `404` or `403`.
|
||||
|
||||
---
|
||||
## Authentication and pairing
|
||||
- The host generates a short-lived pairing token and includes the OpenWork server base URL in the pairing payload.
|
||||
- Remote clients store the token in memory (not on disk) and send it as `Authorization: Bearer <token>`.
|
||||
- Tokens are scoped to the host and expire on disconnect or after a short TTL (e.g., 24h).
|
||||
- The OpenWork server rejects requests without a valid token (`401`).
|
||||
- Future: rotate tokens on reconnect and support revocation from host settings.
|
||||
|
||||
---
|
||||
## Capability schema (example)
|
||||
```json
|
||||
{
|
||||
"skills": { "read": true, "write": true, "source": "openwork" },
|
||||
"plugins": { "read": true, "write": true },
|
||||
"mcp": { "read": true, "write": true },
|
||||
"commands": { "read": true, "write": true },
|
||||
"config": { "read": true, "write": true }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
## Audit log format (example)
|
||||
```json
|
||||
{
|
||||
"id": "audit_123",
|
||||
"workspaceId": "ws_abc",
|
||||
"actor": { "type": "remote", "clientId": "client_1" },
|
||||
"action": "plugins.add",
|
||||
"target": "opencode.json",
|
||||
"summary": "Added opencode-github",
|
||||
"timestamp": 1730000000000
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
## Web app integration
|
||||
- Remote clients connect to both OpenCode (engine) and OpenWork server (config layer)
|
||||
- Capability check gates UI actions: if OpenWork server is missing, config actions are read-only
|
||||
- Writes require host approval and are surfaced in the audit log
|
||||
- Future: this evolves into a sync layer across clients (out of scope here)
|
||||
|
||||
---
|
||||
## Client caching and consistency
|
||||
- Cache the last successful config snapshot per workspace in local state.
|
||||
- On write success, refresh via `GET /workspace/:id/config` before updating UI.
|
||||
- Use optimistic UI only for read-only lists; for writes, wait for approval + server response.
|
||||
- If OpenWork server is unreachable, show read-only data and a reconnect CTA.
|
||||
|
||||
---
|
||||
## Sequence flows (examples)
|
||||
### Add plugin (remote)
|
||||
1) User clicks “Add plugin” in Plugins page.
|
||||
2) `context/extensions.ts` calls `POST /workspace/:id/plugins` with `{ spec }`.
|
||||
3) Server requests host approval; host approves.
|
||||
4) Server writes `opencode.json`, returns updated list.
|
||||
5) Client refreshes plugins list and shows success toast.
|
||||
|
||||
### Add skill (remote)
|
||||
1) User uploads a skill in Skills page.
|
||||
2) Client sends `POST /workspace/:id/skills` with `{ name, content }`.
|
||||
3) Server writes `.opencode/skills/<name>/SKILL.md` after approval.
|
||||
4) Client refreshes skills list.
|
||||
|
||||
### Add MCP (remote)
|
||||
1) User fills MCP config form.
|
||||
2) Client sends `POST /workspace/:id/mcp` with `{ name, config }`.
|
||||
3) Server merges into `opencode.json` and returns updated list.
|
||||
4) UI shows “Reload engine” banner if required.
|
||||
|
||||
---
|
||||
## OpenWork UI wiring (specific)
|
||||
These are the concrete integration points inside `packages/app`.
|
||||
|
||||
### Data layer
|
||||
- `src/app/lib/opencode.ts`: keep as-is for OpenCode engine calls
|
||||
- `src/app/lib/tauri.ts`: host-only FS actions (local) stay here
|
||||
- **New** `src/app/lib/openwork-server.ts`: remote config API client (HTTP) + capability check
|
||||
|
||||
Example client surface (TypeScript):
|
||||
```ts
|
||||
type Capabilities = {
|
||||
skills: { read: boolean; write: boolean; source: "opencode" | "openwork" };
|
||||
plugins: { read: boolean; write: boolean };
|
||||
mcp: { read: boolean; write: boolean };
|
||||
commands: { read: boolean; write: boolean };
|
||||
config: { read: boolean; write: boolean };
|
||||
};
|
||||
|
||||
export const openworkServer = {
|
||||
health(): Promise<{ ok: boolean; version: string; uptimeMs: number }>;
|
||||
capabilities(): Promise<Capabilities>;
|
||||
listWorkspaces(): Promise<WorkspaceInfo[]>;
|
||||
getConfig(id: string): Promise<{ opencode: object; openwork: object }>;
|
||||
patchConfig(id: string, body: { opencode?: object; openwork?: object }): Promise<void>;
|
||||
listPlugins(id: string): Promise<string[]>;
|
||||
addPlugin(id: string, spec: string): Promise<string[]>;
|
||||
removePlugin(id: string, name: string): Promise<string[]>;
|
||||
listSkills(id: string): Promise<SkillCard[]>;
|
||||
upsertSkill(id: string, payload: { name: string; content: string }): Promise<SkillCard>;
|
||||
listMcp(id: string): Promise<McpServerEntry[]>;
|
||||
addMcp(id: string, payload: { name: string; config: McpServerConfig }): Promise<McpServerEntry[]>;
|
||||
removeMcp(id: string, name: string): Promise<McpServerEntry[]>;
|
||||
listCommands(id: string): Promise<WorkspaceCommand[]>;
|
||||
upsertCommand(id: string, payload: WorkspaceCommand): Promise<WorkspaceCommand[]>;
|
||||
deleteCommand(id: string, name: string): Promise<void>;
|
||||
};
|
||||
```
|
||||
|
||||
### State stores
|
||||
- `src/app/context/workspace.ts`: route remote config reads/writes to OpenWork server when workspaceType is `remote`
|
||||
- `src/app/context/extensions.ts`: use OpenWork server to list/add skills/plugins/MCPs in remote mode
|
||||
- `src/app/context/session.ts`: no changes; stays on OpenCode engine
|
||||
|
||||
### UI surfaces
|
||||
- `src/app/pages/dashboard.tsx`: display workspace config status + enable “Share config” only when supported
|
||||
- `src/app/pages/skills.tsx`: list and import skills via OpenWork server in remote mode
|
||||
- `src/app/pages/plugins.tsx`: list/add/remove plugins via OpenWork server in remote mode
|
||||
- `src/app/pages/mcp.tsx`: list/connect MCPs via OpenWork server in remote mode
|
||||
- `src/app/pages/commands.tsx`: list/add/remove commands via OpenWork server in remote mode
|
||||
- `src/app/pages/session.tsx`: surface agent list/selection via OpenWork server when remote if OpenCode lacks agent APIs
|
||||
- `src/app/components/workspace-chip.tsx` + `workspace-picker.tsx`: show capability badges (read-only if server missing)
|
||||
|
||||
### Capability checks
|
||||
- On connect, call `GET /health` on OpenWork server
|
||||
- Store a `serverCapabilities` flag in app state and guard remote config actions
|
||||
|
||||
---
|
||||
## Detailed wiring notes
|
||||
This section explains exactly how requests flow and where the UI switches between local FS and the OpenWork server.
|
||||
|
||||
### Connection flow (remote)
|
||||
1) User connects to a host (OpenCode engine). The client already has a base URL for OpenCode.
|
||||
2) The client derives or receives the OpenWork server base URL from the host pairing payload.
|
||||
3) The client calls `GET /health` and `GET /capabilities` on the OpenWork server.
|
||||
4) UI stores `openworkServerStatus` (ok/error) and `openworkServerCapabilities` in app state.
|
||||
5) All config-mutating UI surfaces check capabilities before enabling write actions.
|
||||
|
||||
### Local vs remote switching
|
||||
- **Local workspaces**: use Tauri FS helpers (existing `src/app/lib/tauri.ts`).
|
||||
- **Remote workspaces**: route all config reads/writes through `src/app/lib/openwork-server.ts`.
|
||||
- The decision happens in the stores, not the UI, so pages 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/<name>/SKILL.md`
|
||||
- Add command -> `POST /workspace/:id/commands` -> `.opencode/commands/<name>.md`
|
||||
|
||||
### UI wiring expectations
|
||||
- Pages call store methods without caring about local vs remote.
|
||||
- “Read-only” badges are derived from `openworkServerCapabilities` (e.g. missing `config.write`).
|
||||
- “Share config” and any write action is disabled when capabilities are absent.
|
||||
|
||||
### Permissions and approvals
|
||||
- Any write request from a remote client triggers a host approval prompt.
|
||||
- The host UI should show: action type, target workspace, and files to be written.
|
||||
- If approval is denied or times out, the server returns a clear error and the UI shows a non-blocking toast.
|
||||
|
||||
### Data contracts (expected formats)
|
||||
These map to existing OpenWork types.
|
||||
|
||||
**Plugins**
|
||||
- Source of truth: `opencode.json` → `plugin` field (string or string[]).
|
||||
- Response shape:
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{ "spec": "opencode-github", "source": "config", "scope": "project" },
|
||||
{ "spec": "file:///path/to/plugin.js", "source": "dir.project", "scope": "project", "path": ".opencode/plugins/custom.js" }
|
||||
],
|
||||
"loadOrder": ["config.global", "config.project", "dir.global", "dir.project"]
|
||||
}
|
||||
```
|
||||
|
||||
**Skills**
|
||||
- Source of truth: `.opencode/skills/<name>/SKILL.md`.
|
||||
- Response shape:
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{ "name": "my-skill", "path": ".opencode/skills/my-skill", "description": "...", "scope": "project" },
|
||||
{ "name": "global-skill", "path": "~/.config/opencode/skills/global-skill", "description": "...", "scope": "global" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**MCP**
|
||||
- Source of truth: `opencode.json` → `mcp` object.
|
||||
- Response shape:
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"name": "chrome",
|
||||
"config": { "type": "local", "command": ["npx", "-y", "chrome-devtools-mcp@latest"], "enabled": true },
|
||||
"source": "config.project"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Commands**
|
||||
- Source of truth: `.opencode/commands/<name>.md` with frontmatter.
|
||||
- Response shape:
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{ "name": "daily-report", "description": "...", "template": "...", "agent": "default", "model": null, "subtask": false, "scope": "workspace" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Agents**
|
||||
- Prefer OpenCode SDK (`listAgents`) as the primary source.
|
||||
- Only add OpenWork server agent endpoints if OpenCode 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 <name>`, `opencode mcp list`, `opencode mcp logout <name>`, `opencode mcp debug <name>`.
|
||||
|
||||
---
|
||||
## Commands handling (detailed)
|
||||
- Commands are markdown files with YAML frontmatter.
|
||||
- The server should sanitize command names to `[A-Za-z0-9_-]` and strip leading `/`.
|
||||
- The command template is the body after frontmatter and is required.
|
||||
- Workspace scope lives under `.opencode/commands/` in the project.
|
||||
- Global scope lives under `~/.config/opencode/commands/` and should be disabled by default for remote clients.
|
||||
|
||||
---
|
||||
## Path safety
|
||||
- All write targets are resolved under the workspace root.
|
||||
- The server verifies the resolved path begins with the workspace root.
|
||||
- Any violation returns `400` with a safe error message.
|
||||
|
||||
---
|
||||
## Error codes
|
||||
- `400` invalid request payload
|
||||
- `401` missing/invalid token
|
||||
- `403` write denied or capability missing
|
||||
- `404` workspace not found
|
||||
- `409` conflict (concurrent edit detected)
|
||||
- `422` config parse/validation error
|
||||
- `500` unexpected server error
|
||||
|
||||
---
|
||||
## Implementation checklist
|
||||
### Server runtime (Bun)
|
||||
- Create `packages/openwork-server` with HTTP routing + JSON schema validation
|
||||
- Define stable port + discovery mechanism for clients
|
||||
- Add lifecycle hooks: start/stop/restart + health checks
|
||||
|
||||
### Auth + handshake
|
||||
- Pairing token or session key for remote clients
|
||||
- `GET /capabilities` endpoint to drive UI gating
|
||||
- CORS rules and origin allowlist for web clients
|
||||
|
||||
### Permissions + audit
|
||||
- Host approval for any write request
|
||||
- Audit log for config mutations (who/what/when)
|
||||
- Clear denial/error propagation to clients
|
||||
|
||||
### Filesystem writes
|
||||
- Workspace-root scoping + path validation
|
||||
- Config merge rules aligned to OpenCode
|
||||
- Serialization helpers for:
|
||||
- `.opencode/skills/<name>/SKILL.md`
|
||||
- `.opencode/commands/<name>.md` (frontmatter)
|
||||
- `opencode.json` plugin + mcp updates
|
||||
|
||||
### UI wiring
|
||||
- Add `src/app/lib/openwork-server.ts` client
|
||||
- Route remote mode reads/writes through OpenWork server:
|
||||
- `src/app/context/extensions.ts`
|
||||
- `src/app/context/workspace.ts`
|
||||
- `src/app/pages/commands.tsx`
|
||||
- UI gating badges for missing capabilities
|
||||
|
||||
### Resilience
|
||||
- Retry/backoff for transient network errors
|
||||
- Conflict handling for concurrent writes
|
||||
- Friendly errors for missing workspace roots
|
||||
|
||||
### Packaging
|
||||
- Bundle Bun server as a desktop sidecar
|
||||
- Wire sidecar launch + permissions in Tauri config
|
||||
|
||||
---
|
||||
## Testing strategy
|
||||
### Unit tests (server)
|
||||
- Config merge rules (arrays replace, unknown keys preserved)
|
||||
- Validation rules for skill/plugin/mcp/command names
|
||||
- Path safety checks (reject absolute paths and path traversal)
|
||||
|
||||
### Filesystem tests (local)
|
||||
- Create a temp workspace with `.opencode/` and `opencode.json`
|
||||
- Verify `GET /workspace/:id/plugins` reflects:
|
||||
- plugin list from `opencode.json`
|
||||
- local plugin files in `.opencode/plugins`
|
||||
- global plugin files from `~/.config/opencode/plugins` (optional, behind a flag)
|
||||
- Verify plugin list preserves OpenCode load order metadata
|
||||
- Verify `GET /workspace/:id/skills` returns:
|
||||
- `.opencode/skills/*/SKILL.md`
|
||||
- `.claude/skills/*/SKILL.md`
|
||||
- global skills from `~/.config/opencode/skills` (optional)
|
||||
- Verify skill discovery respects git worktree boundary (walk up to `.git` only)
|
||||
- Verify skill frontmatter parsing and name/description constraints
|
||||
- Verify `.opencode/package.json` is detected and reported when present
|
||||
|
||||
### Integration tests (server + FS)
|
||||
- Start server against a temp workspace; verify read/write endpoints
|
||||
- Ensure writes only affect `.opencode` and `opencode.json`
|
||||
- Verify audit log entries for each write action
|
||||
- Validate local plugin discovery only includes `.js` and `.ts`
|
||||
- Validate MCP config writes preserve `enabled` and `oauth` fields
|
||||
|
||||
### Approval flow tests
|
||||
- Write request triggers approval prompt
|
||||
- Deny returns `403` and no file is written
|
||||
- Approve writes file and returns success
|
||||
|
||||
### Client wiring tests (OpenWork web)
|
||||
- Remote mode uses OpenWork server endpoints instead of Tauri FS
|
||||
- Missing capability switches UI to read-only
|
||||
- Reconnect restores write actions after capabilities return
|
||||
- OpenCode basic auth prompts propagate to client when enabled
|
||||
|
||||
### Sidecar lifecycle tests
|
||||
- Server starts on app launch and reports base URL
|
||||
- Crash triggers restart and new capabilities handshake
|
||||
|
||||
---
|
||||
## Test cases (initial)
|
||||
### Config
|
||||
- `GET /workspace/:id/config` returns both `opencode` and `openwork` blocks
|
||||
- `PATCH /workspace/:id/config` updates plugin list and preserves unknown keys
|
||||
- Invalid JSONC returns `422` with parse location
|
||||
- Remote defaults from `.well-known/opencode` appear as `source: config.remote` in responses
|
||||
- Writes only update project `opencode.json`, leaving remote defaults unchanged
|
||||
|
||||
### Plugins
|
||||
- Add plugin appends to list and de-dupes existing spec
|
||||
- Remove plugin deletes only matching spec
|
||||
- Invalid spec returns `400`
|
||||
- Local plugin files are returned with `source: dir.project`
|
||||
- Global plugin files are returned with `source: dir.global` when enabled
|
||||
- Non-js/ts files in plugin dirs are ignored
|
||||
|
||||
### Skills
|
||||
- Add skill writes `SKILL.md` with kebab-case name validation
|
||||
- List skills returns `name`, `path`, and `description`
|
||||
- Invalid skill name returns `400`
|
||||
- Missing frontmatter fields return `422`
|
||||
- Skill name mismatch with folder returns `400`
|
||||
|
||||
### MCP
|
||||
- Add MCP writes to `opencode.json` under `mcp` map
|
||||
- Invalid MCP name returns `400`
|
||||
- Remove MCP deletes entry and returns updated list
|
||||
- `enabled: false` is preserved in responses
|
||||
- `oauth: false` is preserved in responses
|
||||
- Missing `command` for `type: local` returns `400`
|
||||
- Missing `url` for `type: remote` returns `400`
|
||||
- `tools` glob disables MCP entries and marks them as `disabledByTools: true`
|
||||
|
||||
### Commands
|
||||
- Create command writes `.opencode/commands/<name>.md`
|
||||
- Delete command removes file and returns success
|
||||
- Invalid template returns `400`
|
||||
- Name with leading `/` is sanitized
|
||||
|
||||
### Permissions
|
||||
- Denied approval returns `403` and no file changes
|
||||
- Approval timeout returns `403` with timeout reason
|
||||
|
||||
### Security
|
||||
- Path traversal payload returns `400` and is logged
|
||||
- Workspace id mismatch returns `404`
|
||||
|
||||
### UI
|
||||
- Remote client with OpenWork server: write actions enabled
|
||||
- Remote client without OpenWork server: write actions disabled + read-only badge
|
||||
|
||||
---
|
||||
## Set permissions
|
||||
- Explicit approval for any config write originating from a remote client
|
||||
- Scope-limited to the active workspace root only
|
||||
|
||||
---
|
||||
## Cover data
|
||||
- Audit log of config changes (who, what, when)
|
||||
- Optional telemetry for success/failure counts, opt-in only
|
||||
|
||||
---
|
||||
## Map flow
|
||||
- Connect: remote client connects to host with capability check
|
||||
- View: UI shows skills/plugins/MCPs from server API
|
||||
- Update: user adds skill/plugin/MCP, server validates + writes config, UI refreshes
|
||||
|
||||
---
|
||||
## Note risks
|
||||
- Over-expanding API surface could drift from OpenCode primitives
|
||||
- Mis-scoped writes could affect unrelated projects
|
||||
|
||||
---
|
||||
## Ask questions
|
||||
- Which config surfaces should be writable vs read-only initially?
|
||||
- Should writes be batched or immediate per action?
|
||||
|
||||
---
|
||||
## Measure success
|
||||
- Remote users can view skills/plugins/MCPs without filesystem access
|
||||
- Remote users can add a skill/plugin/MCP with a single approval
|
||||
|
||||
---
|
||||
## Plan rollout
|
||||
- Phase 0: Bun server prototype running alongside OpenWork host
|
||||
- Phase 1: read-only APIs for skills/plugins/MCPs + config metadata
|
||||
- Phase 2: write APIs for skills/plugins/MCPs with audit log
|
||||
- Phase 3: config export/import support for remote clients
|
||||
- Phase 4: bundle as a first-class sidecar in desktop builds
|
||||
@@ -13,7 +13,6 @@ import { useLocation, useNavigate } from "@solidjs/router";
|
||||
|
||||
import type {
|
||||
Agent,
|
||||
Provider,
|
||||
Part,
|
||||
TextPartInput,
|
||||
FilePartInput,
|
||||
@@ -77,6 +76,7 @@ import type {
|
||||
ComposerAttachment,
|
||||
ComposerDraft,
|
||||
ComposerPart,
|
||||
ProviderListItem,
|
||||
WorkspaceCommand,
|
||||
UpdateHandle,
|
||||
} from "./types";
|
||||
@@ -89,6 +89,7 @@ import {
|
||||
groupMessageParts,
|
||||
isTauriRuntime,
|
||||
modelEquals,
|
||||
normalizeDirectoryPath,
|
||||
} from "./utils";
|
||||
import { isEditableTarget, matchKeybind, normalizeKeybind } from "./utils/keybinds";
|
||||
import { currentLocale, setLocale, t, type Language } from "../i18n";
|
||||
@@ -124,6 +125,17 @@ import {
|
||||
readOpencodeConfig,
|
||||
writeOpencodeConfig,
|
||||
} from "./lib/tauri";
|
||||
import {
|
||||
createOpenworkServerClient,
|
||||
deriveOpenworkServerUrl,
|
||||
readOpenworkServerSettings,
|
||||
writeOpenworkServerSettings,
|
||||
clearOpenworkServerSettings,
|
||||
type OpenworkServerCapabilities,
|
||||
type OpenworkServerStatus,
|
||||
type OpenworkServerSettings,
|
||||
OpenworkServerError,
|
||||
} from "./lib/openwork-server";
|
||||
|
||||
export default function App() {
|
||||
type ProviderAuthMethod = { type: "oauth" | "api"; label: string };
|
||||
@@ -209,6 +221,91 @@ export default function App() {
|
||||
const [baseUrl, setBaseUrl] = createSignal("http://127.0.0.1:4096");
|
||||
const [clientDirectory, setClientDirectory] = createSignal("");
|
||||
|
||||
const [openworkServerSettings, setOpenworkServerSettings] = createSignal<OpenworkServerSettings>({});
|
||||
const [openworkServerUrl, setOpenworkServerUrl] = createSignal("");
|
||||
const [openworkServerStatus, setOpenworkServerStatus] = createSignal<OpenworkServerStatus>("disconnected");
|
||||
const [openworkServerCapabilities, setOpenworkServerCapabilities] = createSignal<OpenworkServerCapabilities | null>(null);
|
||||
const [openworkServerCheckedAt, setOpenworkServerCheckedAt] = createSignal<number | null>(null);
|
||||
const [openworkServerWorkspaceId, setOpenworkServerWorkspaceId] = createSignal<string | null>(null);
|
||||
|
||||
const openworkServerClient = createMemo(() => {
|
||||
const url = openworkServerUrl().trim();
|
||||
if (!url) return null;
|
||||
const token = openworkServerSettings().token;
|
||||
return createOpenworkServerClient({ baseUrl: url, token });
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
setOpenworkServerSettings(readOpenworkServerSettings());
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const derived = deriveOpenworkServerUrl(baseUrl(), openworkServerSettings());
|
||||
setOpenworkServerUrl(derived ?? "");
|
||||
});
|
||||
|
||||
const checkOpenworkServer = async (url: string, token?: string) => {
|
||||
const client = createOpenworkServerClient({ baseUrl: url, token });
|
||||
try {
|
||||
await client.health();
|
||||
} catch {
|
||||
return { status: "disconnected" as OpenworkServerStatus, capabilities: null };
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return { status: "limited" as OpenworkServerStatus, capabilities: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const caps = await client.capabilities();
|
||||
return { status: "connected" as OpenworkServerStatus, capabilities: caps };
|
||||
} catch (error) {
|
||||
if (error instanceof OpenworkServerError && (error.status === 401 || error.status === 403)) {
|
||||
return { status: "limited" as OpenworkServerStatus, capabilities: null };
|
||||
}
|
||||
return { status: "disconnected" as OpenworkServerStatus, capabilities: null };
|
||||
}
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const url = openworkServerUrl().trim();
|
||||
const token = openworkServerSettings().token;
|
||||
|
||||
if (!url) {
|
||||
setOpenworkServerStatus("disconnected");
|
||||
setOpenworkServerCapabilities(null);
|
||||
setOpenworkServerCheckedAt(Date.now());
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
let busy = false;
|
||||
|
||||
const run = async () => {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
const result = await checkOpenworkServer(url, token);
|
||||
if (!active) return;
|
||||
setOpenworkServerStatus(result.status);
|
||||
setOpenworkServerCapabilities(result.capabilities);
|
||||
} finally {
|
||||
if (!active) return;
|
||||
setOpenworkServerCheckedAt(Date.now());
|
||||
busy = false;
|
||||
}
|
||||
};
|
||||
|
||||
run();
|
||||
const interval = window.setInterval(run, 10_000);
|
||||
onCleanup(() => {
|
||||
active = false;
|
||||
window.clearInterval(interval);
|
||||
});
|
||||
});
|
||||
|
||||
const [client, setClient] = createSignal<Client | null>(null);
|
||||
const [connectedVersion, setConnectedVersion] = createSignal<string | null>(
|
||||
null
|
||||
@@ -668,6 +765,11 @@ export default function App() {
|
||||
mode,
|
||||
projectDir: () => workspaceProjectDir(),
|
||||
activeWorkspaceRoot: () => workspaceStore.activeWorkspaceRoot(),
|
||||
workspaceType: () => workspaceStore.activeWorkspaceDisplay().workspaceType,
|
||||
openworkServerClient,
|
||||
openworkServerStatus,
|
||||
openworkServerCapabilities,
|
||||
openworkServerWorkspaceId,
|
||||
setBusy,
|
||||
setBusyLabel,
|
||||
setBusyStartedAt,
|
||||
@@ -692,6 +794,7 @@ export default function App() {
|
||||
pluginScope,
|
||||
setPluginScope,
|
||||
pluginConfig,
|
||||
pluginConfigPath,
|
||||
pluginList,
|
||||
pluginInput,
|
||||
setPluginInput,
|
||||
@@ -715,7 +818,7 @@ export default function App() {
|
||||
const providers = createMemo(() => globalSync.data.provider.all ?? []);
|
||||
const providerDefaults = createMemo(() => globalSync.data.provider.default ?? {});
|
||||
const providerConnectedIds = createMemo(() => globalSync.data.provider.connected ?? []);
|
||||
const setProviders = (value: Provider[]) => {
|
||||
const setProviders = (value: ProviderListItem[]) => {
|
||||
globalSync.set("provider", "all", value);
|
||||
};
|
||||
const setProviderDefaults = (value: Record<string, string>) => {
|
||||
@@ -887,6 +990,72 @@ export default function App() {
|
||||
isWindowsPlatform,
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const status = openworkServerStatus();
|
||||
const root = workspaceStore.activeWorkspaceRoot().trim();
|
||||
const client = openworkServerClient();
|
||||
|
||||
if (status !== "connected" || !root || !client) {
|
||||
setOpenworkServerWorkspaceId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const resolveWorkspace = async () => {
|
||||
try {
|
||||
const response = await client.listWorkspaces();
|
||||
if (cancelled) return;
|
||||
const match = response.items.find(
|
||||
(entry) => normalizeDirectoryPath(entry.path) === normalizeDirectoryPath(root),
|
||||
);
|
||||
setOpenworkServerWorkspaceId(match?.id ?? null);
|
||||
} catch {
|
||||
if (!cancelled) setOpenworkServerWorkspaceId(null);
|
||||
}
|
||||
};
|
||||
|
||||
resolveWorkspace();
|
||||
|
||||
onCleanup(() => {
|
||||
cancelled = true;
|
||||
});
|
||||
});
|
||||
|
||||
const openworkServerReady = createMemo(() => openworkServerStatus() === "connected");
|
||||
const openworkServerWorkspaceReady = createMemo(() => Boolean(openworkServerWorkspaceId()));
|
||||
const openworkServerCanWriteSkills = createMemo(
|
||||
() => openworkServerReady() && openworkServerWorkspaceReady() && (openworkServerCapabilities()?.skills?.write ?? false),
|
||||
);
|
||||
const openworkServerCanWritePlugins = createMemo(
|
||||
() => openworkServerReady() && openworkServerWorkspaceReady() && (openworkServerCapabilities()?.plugins?.write ?? false),
|
||||
);
|
||||
|
||||
const updateOpenworkServerSettings = (next: OpenworkServerSettings) => {
|
||||
const stored = writeOpenworkServerSettings(next);
|
||||
setOpenworkServerSettings(stored);
|
||||
};
|
||||
|
||||
const resetOpenworkServerSettings = () => {
|
||||
clearOpenworkServerSettings();
|
||||
setOpenworkServerSettings({});
|
||||
};
|
||||
|
||||
const testOpenworkServerConnection = async (next: OpenworkServerSettings) => {
|
||||
const derived = deriveOpenworkServerUrl(baseUrl(), next);
|
||||
if (!derived) {
|
||||
setOpenworkServerStatus("disconnected");
|
||||
setOpenworkServerCapabilities(null);
|
||||
setOpenworkServerCheckedAt(Date.now());
|
||||
return false;
|
||||
}
|
||||
const result = await checkOpenworkServer(derived, next.token);
|
||||
setOpenworkServerStatus(result.status);
|
||||
setOpenworkServerCapabilities(result.capabilities);
|
||||
setOpenworkServerCheckedAt(Date.now());
|
||||
return result.status === "connected" || result.status === "limited";
|
||||
};
|
||||
|
||||
const commandState = createCommandState({
|
||||
client,
|
||||
selectedSession,
|
||||
@@ -901,6 +1070,10 @@ export default function App() {
|
||||
setView,
|
||||
isDemoMode,
|
||||
activeWorkspaceRoot: () => workspaceStore.activeWorkspaceRoot(),
|
||||
workspaceType: () => workspaceStore.activeWorkspaceDisplay().workspaceType,
|
||||
openworkServerClient,
|
||||
openworkServerCapabilities,
|
||||
openworkServerWorkspaceId,
|
||||
setBusy,
|
||||
setBusyLabel,
|
||||
setBusyStartedAt,
|
||||
@@ -1574,7 +1747,7 @@ export default function App() {
|
||||
footerBits.push(t("settings.model_default", currentLocale()));
|
||||
}
|
||||
if (isFree) footerBits.push(t("settings.model_free", currentLocale()));
|
||||
if (model.capabilities?.reasoning) footerBits.push(t("settings.model_reasoning", currentLocale()));
|
||||
if (model.reasoning) footerBits.push(t("settings.model_reasoning", currentLocale()));
|
||||
|
||||
next.push({
|
||||
providerID: provider.id,
|
||||
@@ -1743,6 +1916,51 @@ export default function App() {
|
||||
|
||||
async function refreshMcpServers() {
|
||||
const projectDir = workspaceProjectDir().trim();
|
||||
const isRemoteWorkspace = workspaceStore.activeWorkspaceDisplay().workspaceType === "remote";
|
||||
const openworkClient = openworkServerClient();
|
||||
const openworkWorkspaceId = openworkServerWorkspaceId();
|
||||
const openworkCapabilities = openworkServerCapabilities();
|
||||
|
||||
if (isRemoteWorkspace) {
|
||||
if (!openworkClient || !openworkWorkspaceId || !openworkCapabilities?.mcp?.read) {
|
||||
setMcpStatus("OpenWork server unavailable. MCP config is read-only.");
|
||||
setMcpServers([]);
|
||||
setMcpStatuses({});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setMcpStatus(null);
|
||||
const response = await openworkClient.listMcp(openworkWorkspaceId);
|
||||
const next = response.items.map((entry) => ({
|
||||
name: entry.name,
|
||||
config: entry.config as McpServerEntry["config"],
|
||||
}));
|
||||
setMcpServers(next);
|
||||
setMcpLastUpdatedAt(Date.now());
|
||||
|
||||
const activeClient = client();
|
||||
if (activeClient && projectDir) {
|
||||
try {
|
||||
const status = unwrap(await activeClient.mcp.status({ directory: projectDir }));
|
||||
setMcpStatuses(status as McpStatusMap);
|
||||
} catch {
|
||||
setMcpStatuses({});
|
||||
}
|
||||
} else {
|
||||
setMcpStatuses({});
|
||||
}
|
||||
|
||||
if (!next.length) {
|
||||
setMcpStatus("No MCP servers configured yet.");
|
||||
}
|
||||
} catch (e) {
|
||||
setMcpServers([]);
|
||||
setMcpStatuses({});
|
||||
setMcpStatus(e instanceof Error ? e.message : "Failed to load MCP servers");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isTauriRuntime()) {
|
||||
setMcpStatus("MCP configuration is only available in Host mode.");
|
||||
@@ -2718,7 +2936,38 @@ export default function App() {
|
||||
setThemeMode,
|
||||
});
|
||||
|
||||
const dashboardProps = () => ({
|
||||
const dashboardProps = () => {
|
||||
const workspaceType = activeWorkspaceDisplay().workspaceType;
|
||||
const isRemoteWorkspace = workspaceType === "remote";
|
||||
const openworkStatus = openworkServerStatus();
|
||||
const canUseDesktopTools = isTauriRuntime() && !isRemoteWorkspace;
|
||||
const canInstallSkillCreator = isRemoteWorkspace
|
||||
? openworkServerCanWriteSkills()
|
||||
: isTauriRuntime();
|
||||
const canEditPlugins = isRemoteWorkspace
|
||||
? openworkServerCanWritePlugins()
|
||||
: isTauriRuntime();
|
||||
const canUseGlobalPluginScope = !isRemoteWorkspace && isTauriRuntime();
|
||||
const skillsAccessHint = isRemoteWorkspace
|
||||
? openworkStatus === "disconnected"
|
||||
? "OpenWork server unavailable. Connect to manage skills."
|
||||
: openworkStatus === "limited"
|
||||
? "OpenWork server needs a token to manage skills."
|
||||
: openworkServerCanWriteSkills()
|
||||
? null
|
||||
: "OpenWork server is read-only for skills."
|
||||
: null;
|
||||
const pluginsAccessHint = isRemoteWorkspace
|
||||
? openworkStatus === "disconnected"
|
||||
? "OpenWork server unavailable. Plugins are read-only."
|
||||
: openworkStatus === "limited"
|
||||
? "OpenWork server needs a token to edit plugins."
|
||||
: openworkServerCanWritePlugins()
|
||||
? null
|
||||
: "OpenWork server is read-only for plugins."
|
||||
: null;
|
||||
|
||||
return {
|
||||
tab: tab(),
|
||||
setTab,
|
||||
view: currentView(),
|
||||
@@ -2732,6 +2981,12 @@ export default function App() {
|
||||
newTaskDisabled: newTaskDisabled(),
|
||||
headerStatus: headerStatus(),
|
||||
error: error(),
|
||||
openworkServerStatus: openworkStatus,
|
||||
openworkServerUrl: openworkServerUrl(),
|
||||
openworkServerSettings: openworkServerSettings(),
|
||||
updateOpenworkServerSettings,
|
||||
resetOpenworkServerSettings,
|
||||
testOpenworkServerConnection,
|
||||
activeWorkspaceDisplay: activeWorkspaceDisplay(),
|
||||
workspaceSearch: workspaceStore.workspaceSearch(),
|
||||
setWorkspaceSearch: workspaceStore.setWorkspaceSearch,
|
||||
@@ -2782,13 +3037,19 @@ export default function App() {
|
||||
refreshPlugins(scopeOverride).catch(() => undefined),
|
||||
skills: skills(),
|
||||
skillsStatus: skillsStatus(),
|
||||
skillsAccessHint,
|
||||
canInstallSkillCreator,
|
||||
canUseDesktopTools,
|
||||
importLocalSkill,
|
||||
installSkillCreator,
|
||||
revealSkillsFolder,
|
||||
uninstallSkill,
|
||||
pluginsAccessHint,
|
||||
canEditPlugins,
|
||||
canUseGlobalPluginScope,
|
||||
pluginScope: pluginScope(),
|
||||
setPluginScope,
|
||||
pluginConfigPath: pluginConfig()?.path ?? null,
|
||||
pluginConfigPath: pluginConfigPath() ?? pluginConfig()?.path ?? null,
|
||||
pluginList: pluginList(),
|
||||
pluginInput: pluginInput(),
|
||||
setPluginInput,
|
||||
@@ -2862,7 +3123,8 @@ export default function App() {
|
||||
reloadMcpEngine: () => reloadWorkspaceEngine(),
|
||||
language: currentLocale(),
|
||||
setLanguage: setLocale,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const searchWorkspaceFiles = async (query: string) => {
|
||||
const trimmed = query.trim();
|
||||
|
||||
@@ -6,6 +6,7 @@ import { addOpencodeCacheHint, isTauriRuntime, parseModelRef, safeStringify } fr
|
||||
import { opencodeCommandDelete, opencodeCommandList, opencodeCommandWrite } from "./lib/tauri";
|
||||
import { unwrap } from "./lib/opencode";
|
||||
import { t, currentLocale } from "../i18n";
|
||||
import type { OpenworkServerCapabilities, OpenworkServerClient } from "./lib/openwork-server";
|
||||
|
||||
const COMMANDS_PATH = ".opencode/commands";
|
||||
const COMMAND_FILE_SUFFIX = ".md";
|
||||
@@ -44,6 +45,10 @@ export function createCommandState(options: {
|
||||
setView: (view: "onboarding" | "dashboard" | "session") => void;
|
||||
isDemoMode: Accessor<boolean>;
|
||||
activeWorkspaceRoot: Accessor<string>;
|
||||
workspaceType: Accessor<"local" | "remote">;
|
||||
openworkServerClient: Accessor<OpenworkServerClient | null>;
|
||||
openworkServerCapabilities: Accessor<OpenworkServerCapabilities | null>;
|
||||
openworkServerWorkspaceId: Accessor<string | null>;
|
||||
setBusy: (value: boolean) => void;
|
||||
setBusyLabel: (value: string | null) => void;
|
||||
setBusyStartedAt: (value: number | null) => void;
|
||||
@@ -108,12 +113,28 @@ export function createCommandState(options: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isTauriRuntime()) {
|
||||
const isRemoteWorkspace = options.workspaceType() === "remote";
|
||||
const openworkClient = options.openworkServerClient();
|
||||
const openworkWorkspaceId = options.openworkServerWorkspaceId();
|
||||
const openworkCapabilities = options.openworkServerCapabilities();
|
||||
|
||||
if (isRemoteWorkspace) {
|
||||
if (draft.scope !== "workspace") {
|
||||
options.setError("Global commands are only available in Host mode.");
|
||||
return;
|
||||
}
|
||||
if (!openworkClient || !openworkWorkspaceId || !openworkCapabilities?.commands?.write) {
|
||||
options.setError("OpenWork server unavailable. Connect to save commands.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isRemoteWorkspace && !isTauriRuntime()) {
|
||||
options.setError(t("app.error.workspace_commands_desktop", currentLocale()));
|
||||
return;
|
||||
}
|
||||
|
||||
if (draft.scope === "workspace" && !options.activeWorkspaceRoot().trim()) {
|
||||
if (!isRemoteWorkspace && draft.scope === "workspace" && !options.activeWorkspaceRoot().trim()) {
|
||||
options.setError(t("app.error.pick_workspace_folder", currentLocale()));
|
||||
return;
|
||||
}
|
||||
@@ -144,15 +165,23 @@ export function createCommandState(options: {
|
||||
|
||||
try {
|
||||
const workspaceRoot = options.activeWorkspaceRoot().trim();
|
||||
await opencodeCommandWrite({
|
||||
scope: draft.scope,
|
||||
projectDir: workspaceRoot,
|
||||
command: {
|
||||
if (isRemoteWorkspace && openworkClient && openworkWorkspaceId) {
|
||||
await openworkClient.upsertCommand(openworkWorkspaceId, {
|
||||
name: safeName,
|
||||
description: draft.description || undefined,
|
||||
template: draft.template,
|
||||
},
|
||||
});
|
||||
});
|
||||
} else {
|
||||
await opencodeCommandWrite({
|
||||
scope: draft.scope,
|
||||
projectDir: workspaceRoot,
|
||||
command: {
|
||||
name: safeName,
|
||||
description: draft.description || undefined,
|
||||
template: draft.template,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Directly add/update the command in local state since the SDK's
|
||||
// command list won't reflect the new file until app restart
|
||||
@@ -201,12 +230,28 @@ export function createCommandState(options: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isTauriRuntime()) {
|
||||
const isRemoteWorkspace = options.workspaceType() === "remote";
|
||||
const openworkClient = options.openworkServerClient();
|
||||
const openworkWorkspaceId = options.openworkServerWorkspaceId();
|
||||
const openworkCapabilities = options.openworkServerCapabilities();
|
||||
|
||||
if (isRemoteWorkspace) {
|
||||
if (command.scope !== "workspace") {
|
||||
options.setError("Global commands are only available in Host mode.");
|
||||
return;
|
||||
}
|
||||
if (!openworkClient || !openworkWorkspaceId || !openworkCapabilities?.commands?.write) {
|
||||
options.setError("OpenWork server unavailable. Connect to delete commands.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isRemoteWorkspace && !isTauriRuntime()) {
|
||||
options.setError(t("app.error.workspace_commands_desktop", currentLocale()));
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.scope === "workspace" && !options.activeWorkspaceRoot().trim()) {
|
||||
if (!isRemoteWorkspace && command.scope === "workspace" && !options.activeWorkspaceRoot().trim()) {
|
||||
options.setError(t("app.error.pick_workspace_folder", currentLocale()));
|
||||
return;
|
||||
}
|
||||
@@ -218,11 +263,15 @@ export function createCommandState(options: {
|
||||
|
||||
try {
|
||||
const workspaceRoot = options.activeWorkspaceRoot().trim();
|
||||
await opencodeCommandDelete({
|
||||
scope: command.scope,
|
||||
projectDir: workspaceRoot,
|
||||
name: command.name,
|
||||
});
|
||||
if (isRemoteWorkspace && openworkClient && openworkWorkspaceId) {
|
||||
await openworkClient.deleteCommand(openworkWorkspaceId, command.name);
|
||||
} else {
|
||||
await opencodeCommandDelete({
|
||||
scope: command.scope,
|
||||
projectDir: workspaceRoot,
|
||||
name: command.name,
|
||||
});
|
||||
}
|
||||
await loadCommands({ workspaceRoot, quiet: true });
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : safeStringify(e);
|
||||
@@ -283,6 +332,10 @@ export function createCommandState(options: {
|
||||
async function loadCommands(optionsLoad?: { workspaceRoot?: string; quiet?: boolean }) {
|
||||
const c = options.client();
|
||||
const root = (optionsLoad?.workspaceRoot ?? options.activeWorkspaceRoot()).trim();
|
||||
const isRemoteWorkspace = options.workspaceType() === "remote";
|
||||
const openworkClient = options.openworkServerClient();
|
||||
const openworkWorkspaceId = options.openworkServerWorkspaceId();
|
||||
const openworkCapabilities = options.openworkServerCapabilities();
|
||||
if (!c) return;
|
||||
|
||||
try {
|
||||
@@ -290,7 +343,16 @@ export function createCommandState(options: {
|
||||
let workspaceNames = new Set<string>();
|
||||
let globalNames = new Set<string>();
|
||||
|
||||
if (isTauriRuntime()) {
|
||||
if (isRemoteWorkspace) {
|
||||
if (openworkClient && openworkWorkspaceId && openworkCapabilities?.commands?.read) {
|
||||
try {
|
||||
const response = await openworkClient.listCommands(openworkWorkspaceId, "workspace");
|
||||
workspaceNames = new Set(response.items.map((item) => item.name));
|
||||
} catch {
|
||||
workspaceNames = new Set();
|
||||
}
|
||||
}
|
||||
} else if (isTauriRuntime()) {
|
||||
if (root) {
|
||||
try {
|
||||
const names = await opencodeCommandList({ scope: "workspace", projectDir: root });
|
||||
|
||||
@@ -23,12 +23,12 @@ function useThrottledValue<T>(value: () => T, delayMs = 80) {
|
||||
createEffect(() => {
|
||||
const next = value();
|
||||
if (!delayMs) {
|
||||
setState(next);
|
||||
setState(() => next);
|
||||
return;
|
||||
}
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
setState(next);
|
||||
setState(() => next);
|
||||
timer = undefined;
|
||||
}, delayMs);
|
||||
});
|
||||
@@ -259,8 +259,10 @@ export default function PartView(props: Props) {
|
||||
};
|
||||
|
||||
const inlineImage = () => {
|
||||
if (p().type !== "image") return null;
|
||||
if (p().type !== "file") return null;
|
||||
const record = p() as any;
|
||||
const mime = typeof record?.mime === "string" ? record.mime : "";
|
||||
if (!mime.startsWith("image/")) return null;
|
||||
const src = record?.url ?? record?.src ?? record?.data ?? record?.source;
|
||||
if (!src) return null;
|
||||
if (record?.data && record?.mediaType && !String(record.data).startsWith("data:")) {
|
||||
@@ -464,14 +466,12 @@ export default function PartView(props: Props) {
|
||||
</Show>
|
||||
</Match>
|
||||
|
||||
<Match when={p().type === "image"}>
|
||||
<Show when={inlineImage()}>
|
||||
<img
|
||||
src={inlineImage()!}
|
||||
alt=""
|
||||
class="max-w-full h-auto rounded-xl border border-gray-6/50"
|
||||
/>
|
||||
</Show>
|
||||
<Match when={inlineImage()}>
|
||||
<img
|
||||
src={inlineImage()!}
|
||||
alt=""
|
||||
class="max-w-full h-auto rounded-xl border border-gray-6/50"
|
||||
/>
|
||||
</Match>
|
||||
|
||||
<Match when={p().type === "step-start" || p().type === "step-finish"}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CheckCircle2, X } from "lucide-solid";
|
||||
import type { Provider } from "@opencode-ai/sdk/v2/client";
|
||||
import type { ProviderListItem } from "../types";
|
||||
import { createMemo, For, Show } from "solid-js";
|
||||
|
||||
import Button from "./button";
|
||||
@@ -17,7 +17,7 @@ export type ProviderAuthModalProps = {
|
||||
loading: boolean;
|
||||
submitting: boolean;
|
||||
error: string | null;
|
||||
providers: Provider[];
|
||||
providers: ProviderListItem[];
|
||||
connectedProviderIds: string[];
|
||||
authMethods: Record<string, ProviderAuthMethod[]>;
|
||||
onSelect: (providerId: string) => void;
|
||||
|
||||
@@ -23,6 +23,11 @@ import {
|
||||
writeOpencodeConfig,
|
||||
type OpencodeConfigFile,
|
||||
} from "../lib/tauri";
|
||||
import type {
|
||||
OpenworkServerCapabilities,
|
||||
OpenworkServerClient,
|
||||
OpenworkServerStatus,
|
||||
} from "../lib/openwork-server";
|
||||
|
||||
export type ExtensionsStore = ReturnType<typeof createExtensionsStore>;
|
||||
|
||||
@@ -31,6 +36,11 @@ export function createExtensionsStore(options: {
|
||||
mode: () => Mode | null;
|
||||
projectDir: () => string;
|
||||
activeWorkspaceRoot: () => string;
|
||||
workspaceType: () => "local" | "remote";
|
||||
openworkServerClient: () => OpenworkServerClient | null;
|
||||
openworkServerStatus: () => OpenworkServerStatus;
|
||||
openworkServerCapabilities: () => OpenworkServerCapabilities | null;
|
||||
openworkServerWorkspaceId: () => string | null;
|
||||
setBusy: (value: boolean) => void;
|
||||
setBusyLabel: (value: string | null) => void;
|
||||
setBusyStartedAt: (value: number | null) => void;
|
||||
@@ -48,6 +58,7 @@ export function createExtensionsStore(options: {
|
||||
|
||||
const [pluginScope, setPluginScope] = createSignal<PluginScope>("project");
|
||||
const [pluginConfig, setPluginConfig] = createSignal<OpencodeConfigFile | null>(null);
|
||||
const [pluginConfigPath, setPluginConfigPath] = createSignal<string | null>(null);
|
||||
const [pluginList, setPluginList] = createSignal<string[]>([]);
|
||||
const [pluginInput, setPluginInput] = createSignal("");
|
||||
const [pluginStatus, setPluginStatus] = createSignal<string | null>(null);
|
||||
@@ -73,6 +84,10 @@ export function createExtensionsStore(options: {
|
||||
|
||||
async function refreshSkills(optionsOverride?: { force?: boolean }) {
|
||||
const root = options.activeWorkspaceRoot().trim();
|
||||
const isRemoteWorkspace = options.workspaceType() === "remote";
|
||||
const openworkClient = options.openworkServerClient();
|
||||
const openworkWorkspaceId = options.openworkServerWorkspaceId();
|
||||
const openworkCapabilities = options.openworkServerCapabilities();
|
||||
if (!root) {
|
||||
setSkills([]);
|
||||
setSkillsStatus(translate("skills.pick_workspace_first"));
|
||||
@@ -127,10 +142,58 @@ export function createExtensionsStore(options: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRemoteWorkspace && openworkClient && openworkWorkspaceId && openworkCapabilities?.skills?.read) {
|
||||
if (root !== skillsRoot) {
|
||||
skillsLoaded = false;
|
||||
}
|
||||
|
||||
if (!optionsOverride?.force && skillsLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (refreshSkillsInFlight) {
|
||||
return;
|
||||
}
|
||||
|
||||
refreshSkillsInFlight = true;
|
||||
refreshSkillsAborted = false;
|
||||
|
||||
try {
|
||||
setSkillsStatus(null);
|
||||
const response = await openworkClient.listSkills(openworkWorkspaceId);
|
||||
if (refreshSkillsAborted) return;
|
||||
const next: SkillCard[] = Array.isArray(response.items)
|
||||
? response.items.map((entry) => ({
|
||||
name: entry.name,
|
||||
description: entry.description,
|
||||
path: entry.path,
|
||||
}))
|
||||
: [];
|
||||
setSkills(next);
|
||||
if (!next.length) {
|
||||
setSkillsStatus(translate("skills.no_skills_found"));
|
||||
}
|
||||
skillsLoaded = true;
|
||||
skillsRoot = root;
|
||||
} catch (e) {
|
||||
if (refreshSkillsAborted) return;
|
||||
setSkills([]);
|
||||
setSkillsStatus(e instanceof Error ? e.message : translate("skills.failed_to_load"));
|
||||
} finally {
|
||||
refreshSkillsInFlight = false;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const c = options.client();
|
||||
if (!c) {
|
||||
setSkills([]);
|
||||
setSkillsStatus(translate("skills.connect_host_to_load"));
|
||||
setSkillsStatus(
|
||||
isRemoteWorkspace
|
||||
? "OpenWork server unavailable. Connect to load skills."
|
||||
: translate("skills.connect_host_to_load"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -198,13 +261,10 @@ export function createExtensionsStore(options: {
|
||||
}
|
||||
|
||||
async function refreshPlugins(scopeOverride?: PluginScope) {
|
||||
if (!isTauriRuntime()) {
|
||||
setPluginStatus(translate("skills.plugin_management_host_only"));
|
||||
setPluginList([]);
|
||||
setSidebarPluginStatus(translate("skills.plugins_host_only"));
|
||||
setSidebarPluginList([]);
|
||||
return;
|
||||
}
|
||||
const isRemoteWorkspace = options.workspaceType() === "remote";
|
||||
const openworkClient = options.openworkServerClient();
|
||||
const openworkWorkspaceId = options.openworkServerWorkspaceId();
|
||||
const openworkCapabilities = options.openworkServerCapabilities();
|
||||
|
||||
// Skip if already in flight
|
||||
if (refreshPluginsInFlight) {
|
||||
@@ -217,6 +277,64 @@ export function createExtensionsStore(options: {
|
||||
const scope = scopeOverride ?? pluginScope();
|
||||
const targetDir = options.projectDir().trim();
|
||||
|
||||
if (isRemoteWorkspace) {
|
||||
setPluginConfig(null);
|
||||
setPluginConfigPath("opencode.json (remote)");
|
||||
if (scope !== "project") {
|
||||
setPluginStatus("Global plugins are only available in Host mode.");
|
||||
setPluginList([]);
|
||||
setSidebarPluginStatus("Switch to project scope to view remote plugins.");
|
||||
setSidebarPluginList([]);
|
||||
refreshPluginsInFlight = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!openworkClient || !openworkWorkspaceId || !openworkCapabilities?.plugins?.read) {
|
||||
setPluginStatus("OpenWork server unavailable. Plugins are read-only.");
|
||||
setPluginList([]);
|
||||
setSidebarPluginStatus("Connect OpenWork server to load plugins.");
|
||||
setSidebarPluginList([]);
|
||||
refreshPluginsInFlight = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setPluginStatus(null);
|
||||
setSidebarPluginStatus(null);
|
||||
|
||||
const result = await openworkClient.listPlugins(openworkWorkspaceId);
|
||||
if (refreshPluginsAborted) return;
|
||||
|
||||
const configItems = result.items.filter((item) => item.source === "config");
|
||||
const list = configItems.map((item) => item.spec);
|
||||
setPluginList(list);
|
||||
setSidebarPluginList(list);
|
||||
|
||||
if (!list.length) {
|
||||
setPluginStatus("No plugins configured yet.");
|
||||
}
|
||||
} catch (e) {
|
||||
if (refreshPluginsAborted) return;
|
||||
setPluginList([]);
|
||||
setSidebarPluginStatus("Failed to load plugins.");
|
||||
setSidebarPluginList([]);
|
||||
setPluginStatus(e instanceof Error ? e.message : "Failed to load plugins.");
|
||||
} finally {
|
||||
refreshPluginsInFlight = false;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isTauriRuntime()) {
|
||||
setPluginStatus(translate("skills.plugin_management_host_only"));
|
||||
setPluginList([]);
|
||||
setSidebarPluginStatus(translate("skills.plugins_host_only"));
|
||||
setSidebarPluginList([]);
|
||||
refreshPluginsInFlight = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (scope === "project" && !targetDir) {
|
||||
setPluginStatus(translate("skills.pick_project_for_plugins"));
|
||||
setPluginList([]);
|
||||
@@ -237,6 +355,7 @@ export function createExtensionsStore(options: {
|
||||
if (refreshPluginsAborted) return;
|
||||
|
||||
setPluginConfig(config);
|
||||
setPluginConfigPath(config.path ?? null);
|
||||
|
||||
if (!config.exists) {
|
||||
setPluginList([]);
|
||||
@@ -258,6 +377,7 @@ export function createExtensionsStore(options: {
|
||||
} catch (e) {
|
||||
if (refreshPluginsAborted) return;
|
||||
setPluginConfig(null);
|
||||
setPluginConfigPath(null);
|
||||
setPluginList([]);
|
||||
setPluginStatus(e instanceof Error ? e.message : translate("skills.failed_load_opencode"));
|
||||
setSidebarPluginStatus(translate("skills.failed_load_active"));
|
||||
@@ -268,14 +388,14 @@ export function createExtensionsStore(options: {
|
||||
}
|
||||
|
||||
async function addPlugin(pluginNameOverride?: string) {
|
||||
if (!isTauriRuntime()) {
|
||||
setPluginStatus(translate("skills.plugin_management_host_only"));
|
||||
return;
|
||||
}
|
||||
|
||||
const pluginName = (pluginNameOverride ?? pluginInput()).trim();
|
||||
const isManualInput = pluginNameOverride == null;
|
||||
|
||||
const isRemoteWorkspace = options.workspaceType() === "remote";
|
||||
const openworkClient = options.openworkServerClient();
|
||||
const openworkWorkspaceId = options.openworkServerWorkspaceId();
|
||||
const openworkCapabilities = options.openworkServerCapabilities();
|
||||
|
||||
if (!pluginName) {
|
||||
if (isManualInput) {
|
||||
setPluginStatus(translate("skills.enter_plugin_name"));
|
||||
@@ -283,6 +403,34 @@ export function createExtensionsStore(options: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRemoteWorkspace) {
|
||||
if (pluginScope() !== "project") {
|
||||
setPluginStatus("Global plugins are only available in Host mode.");
|
||||
return;
|
||||
}
|
||||
if (!openworkClient || !openworkWorkspaceId || !openworkCapabilities?.plugins?.write) {
|
||||
setPluginStatus("OpenWork server unavailable. Connect to add plugins.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setPluginStatus(null);
|
||||
await openworkClient.addPlugin(openworkWorkspaceId, pluginName);
|
||||
if (isManualInput) {
|
||||
setPluginInput("");
|
||||
}
|
||||
await refreshPlugins("project");
|
||||
} catch (e) {
|
||||
setPluginStatus(e instanceof Error ? e.message : "Failed to add plugin.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isTauriRuntime()) {
|
||||
setPluginStatus(translate("skills.plugin_management_host_only"));
|
||||
return;
|
||||
}
|
||||
|
||||
const scope = pluginScope();
|
||||
const targetDir = options.projectDir().trim();
|
||||
|
||||
@@ -377,6 +525,37 @@ export function createExtensionsStore(options: {
|
||||
}
|
||||
|
||||
async function installSkillCreator() {
|
||||
const isRemoteWorkspace = options.workspaceType() === "remote";
|
||||
const openworkClient = options.openworkServerClient();
|
||||
const openworkWorkspaceId = options.openworkServerWorkspaceId();
|
||||
const openworkCapabilities = options.openworkServerCapabilities();
|
||||
|
||||
if (isRemoteWorkspace) {
|
||||
if (!openworkClient || !openworkWorkspaceId || !openworkCapabilities?.skills?.write) {
|
||||
setSkillsStatus("OpenWork server unavailable. Connect to install skills.");
|
||||
return;
|
||||
}
|
||||
|
||||
options.setBusy(true);
|
||||
options.setError(null);
|
||||
setSkillsStatus(translate("skills.installing_skill_creator"));
|
||||
|
||||
try {
|
||||
await openworkClient.upsertSkill(openworkWorkspaceId, {
|
||||
name: "skill-creator",
|
||||
content: skillCreatorTemplate,
|
||||
});
|
||||
setSkillsStatus(translate("skills.skill_creator_installed"));
|
||||
await refreshSkills({ force: true });
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : translate("skills.unknown_error");
|
||||
options.setError(addOpencodeCacheHint(message));
|
||||
} finally {
|
||||
options.setBusy(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isTauriRuntime()) {
|
||||
setSkillsStatus(translate("skills.desktop_required"));
|
||||
return;
|
||||
@@ -510,6 +689,7 @@ export function createExtensionsStore(options: {
|
||||
pluginScope,
|
||||
setPluginScope,
|
||||
pluginConfig,
|
||||
pluginConfigPath,
|
||||
pluginList,
|
||||
pluginInput,
|
||||
setPluginInput,
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
import type { McpStatusMap, TodoItem } from "../types";
|
||||
import { unwrap } from "../lib/opencode";
|
||||
import { safeStringify } from "../utils";
|
||||
import { mapConfigProvidersToList } from "../utils/providers";
|
||||
import { useGlobalSDK } from "./global-sdk";
|
||||
|
||||
export type WorkspaceState = {
|
||||
@@ -36,7 +37,6 @@ type WorkspaceStore = [Store<WorkspaceState>, SetStoreFunction<WorkspaceState>];
|
||||
type ProjectMeta = {
|
||||
name?: string;
|
||||
icon?: Project["icon"];
|
||||
commands?: Project["commands"];
|
||||
};
|
||||
|
||||
type GlobalState = {
|
||||
@@ -107,7 +107,6 @@ export function GlobalSyncProvider(props: ParentProps) {
|
||||
next[project.worktree] = {
|
||||
name: project.name,
|
||||
icon: project.icon,
|
||||
commands: project.commands,
|
||||
};
|
||||
}
|
||||
setGlobalStore("projectMeta", next);
|
||||
@@ -125,7 +124,7 @@ export function GlobalSyncProvider(props: ParentProps) {
|
||||
} catch {
|
||||
const fallback = unwrap(await globalSDK.client().config.providers()) as ConfigProvidersResponse;
|
||||
setGlobalStore("provider", {
|
||||
all: fallback.providers as ProviderListResponse["all"],
|
||||
all: mapConfigProvidersToList(fallback.providers),
|
||||
connected: [],
|
||||
default: fallback.default,
|
||||
});
|
||||
@@ -239,7 +238,9 @@ export function GlobalSyncProvider(props: ParentProps) {
|
||||
children.set(key, store);
|
||||
void refreshDirectory(directory);
|
||||
if (!subscriptions.has(key)) {
|
||||
const unsubscribe = globalSDK.event.listen(key, (event: Event) => {
|
||||
const unsubscribe = globalSDK.event.listen((payload) => {
|
||||
if (payload.name !== key) return;
|
||||
const event = payload.details as Event;
|
||||
if (event.type === "lsp.updated") {
|
||||
void refreshLsp(directory);
|
||||
}
|
||||
@@ -268,7 +269,9 @@ export function GlobalSyncProvider(props: ParentProps) {
|
||||
|
||||
const globalKey = keyFor("");
|
||||
if (!subscriptions.has(globalKey)) {
|
||||
const unsubscribe = globalSDK.event.listen(globalKey, (event: Event) => {
|
||||
const unsubscribe = globalSDK.event.listen((payload) => {
|
||||
if (payload.name !== globalKey) return;
|
||||
const event = payload.details as Event;
|
||||
if (event.type === "lsp.updated") {
|
||||
void refreshLsp();
|
||||
}
|
||||
|
||||
@@ -35,8 +35,9 @@ import {
|
||||
type WorkspaceInfo,
|
||||
} from "../lib/tauri";
|
||||
import { waitForHealthy, createClient } from "../lib/opencode";
|
||||
import type { Provider } from "@opencode-ai/sdk/v2/client";
|
||||
import type { ProviderListItem } from "../types";
|
||||
import { t, currentLocale } from "../../i18n";
|
||||
import { mapConfigProvidersToList } from "../utils/providers";
|
||||
|
||||
export type WorkspaceStore = ReturnType<typeof createWorkspaceStore>;
|
||||
|
||||
@@ -54,7 +55,7 @@ export function createWorkspaceStore(options: {
|
||||
setClient: (value: Client | null) => void;
|
||||
setConnectedVersion: (value: string | null) => void;
|
||||
setSseConnected: (value: boolean) => void;
|
||||
setProviders: (value: Provider[]) => void;
|
||||
setProviders: (value: ProviderListItem[]) => void;
|
||||
setProviderDefaults: (value: Record<string, string>) => void;
|
||||
setProviderConnectedIds: (value: string[]) => void;
|
||||
setError: (value: string | null) => void;
|
||||
@@ -385,13 +386,13 @@ export function createWorkspaceStore(options: {
|
||||
|
||||
try {
|
||||
const providerList = unwrap(await nextClient.provider.list());
|
||||
options.setProviders(providerList.all as unknown as Provider[]);
|
||||
options.setProviders(providerList.all);
|
||||
options.setProviderDefaults(providerList.default);
|
||||
options.setProviderConnectedIds(providerList.connected);
|
||||
} catch {
|
||||
try {
|
||||
const cfg = unwrap(await nextClient.config.providers());
|
||||
options.setProviders(cfg.providers as unknown as Provider[]);
|
||||
options.setProviders(mapConfigProvidersToList(cfg.providers));
|
||||
options.setProviderDefaults(cfg.default);
|
||||
options.setProviderConnectedIds([]);
|
||||
} catch {
|
||||
|
||||
285
packages/app/src/app/lib/openwork-server.ts
Normal file
285
packages/app/src/app/lib/openwork-server.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
export type OpenworkServerCapabilities = {
|
||||
skills: { read: boolean; write: boolean; source: "openwork" | "opencode" };
|
||||
plugins: { read: boolean; write: boolean };
|
||||
mcp: { read: boolean; write: boolean };
|
||||
commands: { read: boolean; write: boolean };
|
||||
config: { read: boolean; write: boolean };
|
||||
};
|
||||
|
||||
export type OpenworkServerStatus = "connected" | "disconnected" | "limited";
|
||||
|
||||
export type OpenworkServerSettings = {
|
||||
urlOverride?: string;
|
||||
portOverride?: number;
|
||||
token?: string;
|
||||
};
|
||||
|
||||
export type OpenworkWorkspaceInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
workspaceType: "local" | "remote";
|
||||
baseUrl?: string;
|
||||
directory?: string;
|
||||
};
|
||||
|
||||
export type OpenworkPluginItem = {
|
||||
spec: string;
|
||||
source: "config" | "dir.project" | "dir.global";
|
||||
scope: "project" | "global";
|
||||
path?: string;
|
||||
};
|
||||
|
||||
export type OpenworkSkillItem = {
|
||||
name: string;
|
||||
path: string;
|
||||
description: string;
|
||||
scope: "project" | "global";
|
||||
};
|
||||
|
||||
export type OpenworkCommandItem = {
|
||||
name: string;
|
||||
description?: string;
|
||||
template: string;
|
||||
agent?: string;
|
||||
model?: string | null;
|
||||
subtask?: boolean;
|
||||
scope: "workspace" | "global";
|
||||
};
|
||||
|
||||
export type OpenworkMcpItem = {
|
||||
name: string;
|
||||
config: Record<string, unknown>;
|
||||
source: "config.project" | "config.global" | "config.remote";
|
||||
disabledByTools?: boolean;
|
||||
};
|
||||
|
||||
export const DEFAULT_OPENWORK_SERVER_PORT = 4097;
|
||||
|
||||
const STORAGE_URL_OVERRIDE = "openwork.server.urlOverride";
|
||||
const STORAGE_PORT_OVERRIDE = "openwork.server.port";
|
||||
const STORAGE_TOKEN = "openwork.server.token";
|
||||
|
||||
export function normalizeOpenworkServerUrl(input: string) {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
const withProtocol = /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`;
|
||||
return withProtocol.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function readOpenworkServerSettings(): OpenworkServerSettings {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const urlOverride = normalizeOpenworkServerUrl(
|
||||
window.localStorage.getItem(STORAGE_URL_OVERRIDE) ?? "",
|
||||
);
|
||||
const portRaw = window.localStorage.getItem(STORAGE_PORT_OVERRIDE) ?? "";
|
||||
const portOverride = portRaw ? Number(portRaw) : undefined;
|
||||
const token = window.localStorage.getItem(STORAGE_TOKEN) ?? undefined;
|
||||
return {
|
||||
urlOverride: urlOverride ?? undefined,
|
||||
portOverride: Number.isNaN(portOverride) ? undefined : portOverride,
|
||||
token: token?.trim() || undefined,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function writeOpenworkServerSettings(next: OpenworkServerSettings): OpenworkServerSettings {
|
||||
if (typeof window === "undefined") return next;
|
||||
try {
|
||||
const urlOverride = normalizeOpenworkServerUrl(next.urlOverride ?? "");
|
||||
const portOverride = typeof next.portOverride === "number" ? next.portOverride : undefined;
|
||||
const token = next.token?.trim() || undefined;
|
||||
|
||||
if (urlOverride) {
|
||||
window.localStorage.setItem(STORAGE_URL_OVERRIDE, urlOverride);
|
||||
} else {
|
||||
window.localStorage.removeItem(STORAGE_URL_OVERRIDE);
|
||||
}
|
||||
|
||||
if (typeof portOverride === "number" && !Number.isNaN(portOverride)) {
|
||||
window.localStorage.setItem(STORAGE_PORT_OVERRIDE, String(portOverride));
|
||||
} else {
|
||||
window.localStorage.removeItem(STORAGE_PORT_OVERRIDE);
|
||||
}
|
||||
|
||||
if (token) {
|
||||
window.localStorage.setItem(STORAGE_TOKEN, token);
|
||||
} else {
|
||||
window.localStorage.removeItem(STORAGE_TOKEN);
|
||||
}
|
||||
|
||||
return readOpenworkServerSettings();
|
||||
} catch {
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearOpenworkServerSettings() {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.removeItem(STORAGE_URL_OVERRIDE);
|
||||
window.localStorage.removeItem(STORAGE_PORT_OVERRIDE);
|
||||
window.localStorage.removeItem(STORAGE_TOKEN);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function deriveOpenworkServerUrl(
|
||||
opencodeBaseUrl: string,
|
||||
settings?: OpenworkServerSettings,
|
||||
) {
|
||||
const override = settings?.urlOverride?.trim();
|
||||
if (override) {
|
||||
return normalizeOpenworkServerUrl(override);
|
||||
}
|
||||
|
||||
const base = opencodeBaseUrl.trim();
|
||||
if (!base) return null;
|
||||
try {
|
||||
const url = new URL(base);
|
||||
const port = settings?.portOverride ?? DEFAULT_OPENWORK_SERVER_PORT;
|
||||
url.port = String(port);
|
||||
url.pathname = "";
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return url.origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class OpenworkServerError extends Error {
|
||||
status: number;
|
||||
code: string;
|
||||
details?: unknown;
|
||||
|
||||
constructor(status: number, code: string, message: string, details?: unknown) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
function buildHeaders(token?: string, extra?: Record<string, string>) {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
if (extra) {
|
||||
Object.assign(headers, extra);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function requestJson<T>(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
options: { method?: string; token?: string; body?: unknown } = {},
|
||||
): Promise<T> {
|
||||
const url = `${baseUrl}${path}`;
|
||||
const response = await fetch(url, {
|
||||
method: options.method ?? "GET",
|
||||
headers: buildHeaders(options.token),
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
const json = text ? JSON.parse(text) : null;
|
||||
|
||||
if (!response.ok) {
|
||||
const code = typeof json?.code === "string" ? json.code : "request_failed";
|
||||
const message = typeof json?.message === "string" ? json.message : response.statusText;
|
||||
throw new OpenworkServerError(response.status, code, message, json?.details);
|
||||
}
|
||||
|
||||
return json as T;
|
||||
}
|
||||
|
||||
export function createOpenworkServerClient(options: { baseUrl: string; token?: string }) {
|
||||
const baseUrl = options.baseUrl.replace(/\/+$/, "");
|
||||
const token = options.token;
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
token,
|
||||
health: () => requestJson<{ ok: boolean; version: string; uptimeMs: number }>(baseUrl, "/health"),
|
||||
capabilities: () => requestJson<OpenworkServerCapabilities>(baseUrl, "/capabilities", { token }),
|
||||
listWorkspaces: () => requestJson<{ items: OpenworkWorkspaceInfo[] }>(baseUrl, "/workspaces", { token }),
|
||||
getConfig: (workspaceId: string) =>
|
||||
requestJson<{ opencode: Record<string, unknown>; openwork: Record<string, unknown>; updatedAt?: number | null }>(
|
||||
baseUrl,
|
||||
`/workspace/${workspaceId}/config`,
|
||||
{ token },
|
||||
),
|
||||
patchConfig: (workspaceId: string, payload: { opencode?: Record<string, unknown>; openwork?: Record<string, unknown> }) =>
|
||||
requestJson<{ updatedAt?: number | null }>(baseUrl, `/workspace/${workspaceId}/config`, {
|
||||
token,
|
||||
method: "PATCH",
|
||||
body: payload,
|
||||
}),
|
||||
listPlugins: (workspaceId: string) =>
|
||||
requestJson<{ items: OpenworkPluginItem[]; loadOrder: string[] }>(baseUrl, `/workspace/${workspaceId}/plugins`, {
|
||||
token,
|
||||
}),
|
||||
addPlugin: (workspaceId: string, spec: string) =>
|
||||
requestJson<{ items: OpenworkPluginItem[]; loadOrder: string[] }>(
|
||||
baseUrl,
|
||||
`/workspace/${workspaceId}/plugins`,
|
||||
{ token, method: "POST", body: { spec } },
|
||||
),
|
||||
removePlugin: (workspaceId: string, name: string) =>
|
||||
requestJson<{ items: OpenworkPluginItem[]; loadOrder: string[] }>(
|
||||
baseUrl,
|
||||
`/workspace/${workspaceId}/plugins/${encodeURIComponent(name)}`,
|
||||
{ token, method: "DELETE" },
|
||||
),
|
||||
listSkills: (workspaceId: string) =>
|
||||
requestJson<{ items: OpenworkSkillItem[] }>(baseUrl, `/workspace/${workspaceId}/skills`, { token }),
|
||||
upsertSkill: (workspaceId: string, payload: { name: string; content: string; description?: string }) =>
|
||||
requestJson<OpenworkSkillItem>(baseUrl, `/workspace/${workspaceId}/skills`, {
|
||||
token,
|
||||
method: "POST",
|
||||
body: payload,
|
||||
}),
|
||||
listMcp: (workspaceId: string) =>
|
||||
requestJson<{ items: OpenworkMcpItem[] }>(baseUrl, `/workspace/${workspaceId}/mcp`, { token }),
|
||||
addMcp: (workspaceId: string, payload: { name: string; config: Record<string, unknown> }) =>
|
||||
requestJson<{ items: OpenworkMcpItem[] }>(baseUrl, `/workspace/${workspaceId}/mcp`, {
|
||||
token,
|
||||
method: "POST",
|
||||
body: payload,
|
||||
}),
|
||||
removeMcp: (workspaceId: string, name: string) =>
|
||||
requestJson<{ items: OpenworkMcpItem[] }>(baseUrl, `/workspace/${workspaceId}/mcp/${encodeURIComponent(name)}`, {
|
||||
token,
|
||||
method: "DELETE",
|
||||
}),
|
||||
listCommands: (workspaceId: string, scope: "workspace" | "global" = "workspace") =>
|
||||
requestJson<{ items: OpenworkCommandItem[] }>(
|
||||
baseUrl,
|
||||
`/workspace/${workspaceId}/commands?scope=${scope}`,
|
||||
{ token },
|
||||
),
|
||||
upsertCommand: (
|
||||
workspaceId: string,
|
||||
payload: { name: string; description?: string; template: string; agent?: string; model?: string | null; subtask?: boolean },
|
||||
) =>
|
||||
requestJson<{ items: OpenworkCommandItem[] }>(baseUrl, `/workspace/${workspaceId}/commands`, {
|
||||
token,
|
||||
method: "POST",
|
||||
body: payload,
|
||||
}),
|
||||
deleteCommand: (workspaceId: string, name: string) =>
|
||||
requestJson<{ ok: boolean }>(baseUrl, `/workspace/${workspaceId}/commands/${encodeURIComponent(name)}`, {
|
||||
token,
|
||||
method: "DELETE",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export type OpenworkServerClient = ReturnType<typeof createOpenworkServerClient>;
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
} from "../types";
|
||||
import type { McpDirectoryInfo } from "../constants";
|
||||
import { formatRelativeTime, normalizeDirectoryPath } from "../utils";
|
||||
import type { OpenworkServerSettings, OpenworkServerStatus } from "../lib/openwork-server";
|
||||
|
||||
import Button from "../components/button";
|
||||
import OpenWorkLogo from "../components/openwork-logo";
|
||||
@@ -45,6 +46,12 @@ export type DashboardViewProps = {
|
||||
newTaskDisabled: boolean;
|
||||
headerStatus: string;
|
||||
error: string | null;
|
||||
openworkServerStatus: OpenworkServerStatus;
|
||||
openworkServerUrl: string;
|
||||
openworkServerSettings: OpenworkServerSettings;
|
||||
updateOpenworkServerSettings: (next: OpenworkServerSettings) => void;
|
||||
resetOpenworkServerSettings: () => void;
|
||||
testOpenworkServerConnection: (next: OpenworkServerSettings) => Promise<boolean>;
|
||||
keybindItems: KeybindSetting[];
|
||||
onOverrideKeybind: (id: string, keybind: string | null) => void;
|
||||
onResetKeybind: (id: string) => void;
|
||||
@@ -95,10 +102,16 @@ export type DashboardViewProps = {
|
||||
refreshMcpServers: () => void;
|
||||
skills: SkillCard[];
|
||||
skillsStatus: string | null;
|
||||
skillsAccessHint?: string | null;
|
||||
canInstallSkillCreator: boolean;
|
||||
canUseDesktopTools: boolean;
|
||||
importLocalSkill: () => void;
|
||||
installSkillCreator: () => void;
|
||||
revealSkillsFolder: () => void;
|
||||
uninstallSkill: (name: string) => void;
|
||||
pluginsAccessHint?: string | null;
|
||||
canEditPlugins: boolean;
|
||||
canUseGlobalPluginScope: boolean;
|
||||
pluginScope: PluginScope;
|
||||
setPluginScope: (scope: PluginScope) => void;
|
||||
pluginConfigPath: string | null;
|
||||
@@ -330,6 +343,23 @@ export default function DashboardView(props: DashboardViewProps) {
|
||||
);
|
||||
};
|
||||
|
||||
const opencodeStatusMeta = createMemo(() => ({
|
||||
dot: props.clientConnected ? "bg-green-9" : "bg-gray-6",
|
||||
text: props.clientConnected ? "text-green-11" : "text-gray-10",
|
||||
label: props.clientConnected ? "Connected" : "Not connected",
|
||||
}));
|
||||
|
||||
const openworkStatusMeta = createMemo(() => {
|
||||
switch (props.openworkServerStatus) {
|
||||
case "connected":
|
||||
return { dot: "bg-green-9", text: "text-green-11", label: "Ready" };
|
||||
case "limited":
|
||||
return { dot: "bg-amber-9", text: "text-amber-11", label: "Limited access" };
|
||||
default:
|
||||
return { dot: "bg-gray-6", text: "text-gray-10", label: "Unavailable" };
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="flex h-screen bg-gray-1 text-gray-12 overflow-hidden">
|
||||
<aside class="w-64 border-r border-gray-6 p-6 hidden md:flex flex-col justify-between bg-gray-1">
|
||||
@@ -394,6 +424,33 @@ export default function DashboardView(props: DashboardViewProps) {
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="px-3 py-2 rounded-xl bg-gray-2/40 border border-gray-6">
|
||||
<div class="grid grid-cols-[1fr_auto_1fr] items-center gap-3 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class={`w-2 h-2 rounded-full ${opencodeStatusMeta().dot}`} />
|
||||
<span class="text-gray-11 font-medium">OpenCode Engine</span>
|
||||
<span class={opencodeStatusMeta().text}>{opencodeStatusMeta().label}</span>
|
||||
</div>
|
||||
<div class="w-px h-5 bg-gray-6/70" />
|
||||
<div class="flex items-center gap-2 justify-end">
|
||||
<span class={`w-2 h-2 rounded-full ${openworkStatusMeta().dot}`} />
|
||||
<span class="text-gray-11 font-medium">OpenWork Server</span>
|
||||
<span class={openworkStatusMeta().text}>{openworkStatusMeta().label}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={props.developerMode && props.openworkServerUrl}>
|
||||
<div class="mt-2 text-[11px] text-gray-7 font-mono truncate">
|
||||
{props.openworkServerUrl}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={props.mode === "client" && props.openworkServerStatus === "disconnected"}>
|
||||
<div class="text-[11px] text-gray-9 px-1">
|
||||
OpenWork server is offline — remote tasks still run.
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!props.clientConnected && !props.demoMode}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -819,6 +876,9 @@ export default function DashboardView(props: DashboardViewProps) {
|
||||
<SkillsView
|
||||
busy={props.busy}
|
||||
mode={props.mode}
|
||||
canInstallSkillCreator={props.canInstallSkillCreator}
|
||||
canUseDesktopTools={props.canUseDesktopTools}
|
||||
accessHint={props.skillsAccessHint}
|
||||
refreshSkills={props.refreshSkills}
|
||||
skills={props.skills}
|
||||
skillsStatus={props.skillsStatus}
|
||||
@@ -833,6 +893,9 @@ export default function DashboardView(props: DashboardViewProps) {
|
||||
<PluginsView
|
||||
busy={props.busy}
|
||||
activeWorkspaceRoot={props.activeWorkspaceRoot}
|
||||
canEditPlugins={props.canEditPlugins}
|
||||
canUseGlobalScope={props.canUseGlobalPluginScope}
|
||||
accessHint={props.pluginsAccessHint}
|
||||
pluginScope={props.pluginScope}
|
||||
setPluginScope={props.setPluginScope}
|
||||
pluginConfigPath={props.pluginConfigPath}
|
||||
@@ -875,6 +938,12 @@ export default function DashboardView(props: DashboardViewProps) {
|
||||
baseUrl={props.baseUrl}
|
||||
headerStatus={props.headerStatus}
|
||||
busy={props.busy}
|
||||
openworkServerStatus={props.openworkServerStatus}
|
||||
openworkServerUrl={props.openworkServerUrl}
|
||||
openworkServerSettings={props.openworkServerSettings}
|
||||
updateOpenworkServerSettings={props.updateOpenworkServerSettings}
|
||||
resetOpenworkServerSettings={props.resetOpenworkServerSettings}
|
||||
testOpenworkServerConnection={props.testOpenworkServerConnection}
|
||||
developerMode={props.developerMode}
|
||||
toggleDeveloperMode={props.toggleDeveloperMode}
|
||||
stopHost={props.stopHost}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { For, Show } from "solid-js";
|
||||
|
||||
import type { PluginScope } from "../types";
|
||||
import { isTauriRuntime } from "../utils";
|
||||
|
||||
import Button from "../components/button";
|
||||
import TextInput from "../components/text-input";
|
||||
@@ -10,6 +9,9 @@ import { Cpu } from "lucide-solid";
|
||||
export type PluginsViewProps = {
|
||||
busy: boolean;
|
||||
activeWorkspaceRoot: string;
|
||||
canEditPlugins: boolean;
|
||||
canUseGlobalScope: boolean;
|
||||
accessHint?: string | null;
|
||||
pluginScope: PluginScope;
|
||||
setPluginScope: (scope: PluginScope) => void;
|
||||
pluginConfigPath: string | null;
|
||||
@@ -64,12 +66,14 @@ export default function PluginsView(props: PluginsViewProps) {
|
||||
Project
|
||||
</button>
|
||||
<button
|
||||
disabled={!props.canUseGlobalScope}
|
||||
class={`px-3 py-1 rounded-full text-xs font-medium border transition-colors ${
|
||||
props.pluginScope === "global"
|
||||
? "bg-gray-12/10 text-gray-12 border-gray-6/20"
|
||||
: "text-gray-10 border-gray-6 hover:text-gray-12"
|
||||
}`}
|
||||
} ${!props.canUseGlobalScope ? "opacity-40 cursor-not-allowed hover:text-gray-10" : ""}`}
|
||||
onClick={() => {
|
||||
if (!props.canUseGlobalScope) return;
|
||||
props.setPluginScope("global");
|
||||
props.refreshPlugins("global");
|
||||
}}
|
||||
@@ -85,6 +89,9 @@ export default function PluginsView(props: PluginsViewProps) {
|
||||
<div class="flex flex-col gap-1 text-xs text-gray-10">
|
||||
<div>Config</div>
|
||||
<div class="text-gray-7 font-mono truncate">{props.pluginConfigPath ?? "Not loaded yet"}</div>
|
||||
<Show when={props.accessHint}>
|
||||
<div class="text-gray-9">{props.accessHint}</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
@@ -121,7 +128,7 @@ export default function PluginsView(props: PluginsViewProps) {
|
||||
disabled={
|
||||
props.busy ||
|
||||
isInstalled() ||
|
||||
!isTauriRuntime() ||
|
||||
!props.canEditPlugins ||
|
||||
(props.pluginScope === "project" && !props.activeWorkspaceRoot.trim())
|
||||
}
|
||||
>
|
||||
@@ -211,7 +218,7 @@ export default function PluginsView(props: PluginsViewProps) {
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => props.addPlugin()}
|
||||
disabled={props.busy || !props.pluginInput.trim()}
|
||||
disabled={props.busy || !props.pluginInput.trim() || !props.canEditPlugins}
|
||||
class="md:mt-6"
|
||||
>
|
||||
Add
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js";
|
||||
import type { Agent, Part, Provider } from "@opencode-ai/sdk/v2/client";
|
||||
import type { Agent, Part } from "@opencode-ai/sdk/v2/client";
|
||||
import type {
|
||||
ArtifactItem,
|
||||
DashboardTab,
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
McpServerEntry,
|
||||
McpStatusMap,
|
||||
PendingPermission,
|
||||
ProviderListItem,
|
||||
SkillCard,
|
||||
TodoItem,
|
||||
View,
|
||||
@@ -101,7 +102,7 @@ export type SessionViewProps = {
|
||||
providerAuthBusy: boolean;
|
||||
providerAuthError: string | null;
|
||||
providerAuthMethods: Record<string, { type: "oauth" | "api"; label: string }[]>;
|
||||
providers: Provider[];
|
||||
providers: ProviderListItem[];
|
||||
providerConnectedIds: string[];
|
||||
listAgents: () => Promise<Agent[]>;
|
||||
searchFiles: (query: string) => Promise<string[]>;
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import { Match, Show, Switch } from "solid-js";
|
||||
import { Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js";
|
||||
|
||||
import { formatBytes, formatRelativeTime, isTauriRuntime } from "../utils";
|
||||
|
||||
import Button from "../components/button";
|
||||
import TextInput from "../components/text-input";
|
||||
import SettingsKeybinds, { type KeybindSetting } from "../components/settings-keybinds";
|
||||
import { HardDrive, RefreshCcw, Shield, Smartphone } from "lucide-solid";
|
||||
import type { OpenworkServerSettings, OpenworkServerStatus } from "../lib/openwork-server";
|
||||
|
||||
export type SettingsViewProps = {
|
||||
mode: "host" | "client" | null;
|
||||
baseUrl: string;
|
||||
headerStatus: string;
|
||||
busy: boolean;
|
||||
openworkServerStatus: OpenworkServerStatus;
|
||||
openworkServerUrl: string;
|
||||
openworkServerSettings: OpenworkServerSettings;
|
||||
updateOpenworkServerSettings: (next: OpenworkServerSettings) => void;
|
||||
resetOpenworkServerSettings: () => void;
|
||||
testOpenworkServerConnection: (next: OpenworkServerSettings) => Promise<boolean>;
|
||||
developerMode: boolean;
|
||||
toggleDeveloperMode: () => void;
|
||||
stopHost: () => void;
|
||||
@@ -104,6 +112,49 @@ export default function SettingsView(props: SettingsViewProps) {
|
||||
return "bg-gray-4/60 text-gray-11 border-gray-7/50";
|
||||
};
|
||||
|
||||
const [openworkUrl, setOpenworkUrl] = createSignal("");
|
||||
const [openworkToken, setOpenworkToken] = createSignal("");
|
||||
const [openworkTokenVisible, setOpenworkTokenVisible] = createSignal(false);
|
||||
|
||||
createEffect(() => {
|
||||
setOpenworkUrl(props.openworkServerSettings.urlOverride ?? "");
|
||||
setOpenworkToken(props.openworkServerSettings.token ?? "");
|
||||
});
|
||||
|
||||
const openworkStatusLabel = createMemo(() => {
|
||||
switch (props.openworkServerStatus) {
|
||||
case "connected":
|
||||
return "Connected";
|
||||
case "limited":
|
||||
return "Limited";
|
||||
default:
|
||||
return "Not connected";
|
||||
}
|
||||
});
|
||||
|
||||
const openworkStatusStyle = createMemo(() => {
|
||||
switch (props.openworkServerStatus) {
|
||||
case "connected":
|
||||
return "bg-green-7/10 text-green-11 border-green-7/20";
|
||||
case "limited":
|
||||
return "bg-amber-7/10 text-amber-11 border-amber-7/20";
|
||||
default:
|
||||
return "bg-gray-4/60 text-gray-11 border-gray-7/50";
|
||||
}
|
||||
});
|
||||
|
||||
const buildOpenworkSettings = () => ({
|
||||
...props.openworkServerSettings,
|
||||
urlOverride: openworkUrl().trim() || undefined,
|
||||
token: openworkToken().trim() || undefined,
|
||||
});
|
||||
|
||||
const hasOpenworkChanges = createMemo(() => {
|
||||
const currentUrl = props.openworkServerSettings.urlOverride ?? "";
|
||||
const currentToken = props.openworkServerSettings.token ?? "";
|
||||
return openworkUrl().trim() !== currentUrl || openworkToken().trim() !== currentToken;
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<section class="space-y-6">
|
||||
@@ -130,6 +181,86 @@ export default function SettingsView(props: SettingsViewProps) {
|
||||
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-2/30 border border-gray-6/50 rounded-2xl p-5 space-y-4">
|
||||
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-12">OpenWork Server</div>
|
||||
<div class="text-xs text-gray-10">
|
||||
Connect a remote OpenWork server to manage skills and plugins.
|
||||
</div>
|
||||
</div>
|
||||
<div class={`text-xs px-2 py-1 rounded-full border ${openworkStatusStyle()}`}>
|
||||
{openworkStatusLabel()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<TextInput
|
||||
label="Server URL"
|
||||
value={openworkUrl()}
|
||||
onInput={(event) => setOpenworkUrl(event.currentTarget.value)}
|
||||
placeholder="http://127.0.0.1:8787"
|
||||
hint="Leave blank to use your OpenCode URL with port 4097."
|
||||
disabled={props.busy}
|
||||
/>
|
||||
|
||||
<label class="block">
|
||||
<div class="mb-1 text-xs font-medium text-gray-11">Access token</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type={openworkTokenVisible() ? "text" : "password"}
|
||||
value={openworkToken()}
|
||||
onInput={(event) => setOpenworkToken(event.currentTarget.value)}
|
||||
placeholder="Optional bearer token"
|
||||
disabled={props.busy}
|
||||
class="w-full rounded-xl bg-gray-2/60 px-3 py-2 text-sm text-gray-12 placeholder:text-gray-10 shadow-[0_0_0_1px_rgba(255,255,255,0.08)] focus:outline-none focus:ring-2 focus:ring-gray-6/20"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="text-xs h-9 px-3 shrink-0"
|
||||
onClick={() => setOpenworkTokenVisible((prev) => !prev)}
|
||||
disabled={props.busy}
|
||||
>
|
||||
{openworkTokenVisible() ? "Hide" : "Show"}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-10">Keep this private. It grants access to your server.</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="text-[11px] text-gray-7 font-mono truncate">
|
||||
Resolved URL: {props.openworkServerUrl || "Not set"}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={async () => {
|
||||
const next = buildOpenworkSettings();
|
||||
props.updateOpenworkServerSettings(next);
|
||||
await props.testOpenworkServerConnection(next);
|
||||
}}
|
||||
disabled={props.busy}
|
||||
>
|
||||
Test connection
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => props.updateOpenworkServerSettings(buildOpenworkSettings())}
|
||||
disabled={props.busy || !hasOpenworkChanges()}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={props.resetOpenworkServerSettings}
|
||||
disabled={props.busy}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="bg-gray-2/30 border border-gray-6/50 rounded-2xl p-5 space-y-4">
|
||||
<div>
|
||||
|
||||
@@ -9,6 +9,9 @@ import { currentLocale, t } from "../../i18n";
|
||||
export type SkillsViewProps = {
|
||||
busy: boolean;
|
||||
mode: "host" | "client" | null;
|
||||
canInstallSkillCreator: boolean;
|
||||
canUseDesktopTools: boolean;
|
||||
accessHint?: string | null;
|
||||
refreshSkills: (options?: { force?: boolean }) => void;
|
||||
skills: SkillCard[];
|
||||
skillsStatus: string | null;
|
||||
@@ -47,7 +50,17 @@ export default function SkillsView(props: SkillsViewProps) {
|
||||
<div class="text-xs font-semibold text-gray-11 uppercase tracking-wider">{translate("skills.add_title")}</div>
|
||||
<div class="text-sm text-gray-10 mt-2">{translate("skills.add_description")}</div>
|
||||
</div>
|
||||
<Show when={props.mode !== "host"}>
|
||||
<Show when={props.accessHint}>
|
||||
<div class="text-xs text-gray-10">{props.accessHint}</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
!props.accessHint &&
|
||||
props.mode !== "host" &&
|
||||
!props.canInstallSkillCreator &&
|
||||
!props.canUseDesktopTools
|
||||
}
|
||||
>
|
||||
<div class="text-xs text-gray-10">{translate("skills.host_mode_only")}</div>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -64,7 +77,7 @@ export default function SkillsView(props: SkillsViewProps) {
|
||||
if (skillCreatorInstalled()) return;
|
||||
props.installSkillCreator();
|
||||
}}
|
||||
disabled={props.busy || skillCreatorInstalled()}
|
||||
disabled={props.busy || skillCreatorInstalled() || !props.canInstallSkillCreator}
|
||||
>
|
||||
<Package size={16} />
|
||||
{skillCreatorInstalled() ? translate("skills.installed_label") : translate("skills.install")}
|
||||
@@ -76,7 +89,11 @@ export default function SkillsView(props: SkillsViewProps) {
|
||||
<div class="text-sm font-medium text-gray-12">{translate("skills.import_local")}</div>
|
||||
<div class="text-xs text-gray-10 mt-1">{translate("skills.import_local_hint")}</div>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={props.importLocalSkill} disabled={props.busy}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={props.importLocalSkill}
|
||||
disabled={props.busy || !props.canUseDesktopTools}
|
||||
>
|
||||
<Upload size={16} />
|
||||
{translate("skills.import")}
|
||||
</Button>
|
||||
@@ -87,7 +104,11 @@ export default function SkillsView(props: SkillsViewProps) {
|
||||
<div class="text-sm font-medium text-gray-12">{translate("skills.reveal_folder")}</div>
|
||||
<div class="text-xs text-gray-10 mt-1">{translate("skills.reveal_folder_hint")}</div>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={props.revealSkillsFolder} disabled={props.busy}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={props.revealSkillsFolder}
|
||||
disabled={props.busy || !props.canUseDesktopTools}
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
{translate("skills.reveal_button")}
|
||||
</Button>
|
||||
@@ -137,7 +158,7 @@ export default function SkillsView(props: SkillsViewProps) {
|
||||
variant="danger"
|
||||
class="!px-3 !py-2 text-xs"
|
||||
onClick={() => setUninstallTarget(s)}
|
||||
disabled={props.busy}
|
||||
disabled={props.busy || !props.canUseDesktopTools}
|
||||
title={translate("skills.uninstall")}
|
||||
>
|
||||
{translate("skills.uninstall")}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js";
|
||||
|
||||
import type { Provider, Session } from "@opencode-ai/sdk/v2/client";
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client";
|
||||
import type { ProviderListItem } from "./types";
|
||||
|
||||
import { check } from "@tauri-apps/plugin-updater";
|
||||
import { relaunch } from "@tauri-apps/plugin-process";
|
||||
|
||||
import type { Client, Mode, PluginScope, ReloadReason, ResetOpenworkMode, UpdateHandle } from "./types";
|
||||
import { addOpencodeCacheHint, isTauriRuntime, safeStringify } from "./utils";
|
||||
import { mapConfigProvidersToList } from "./utils/providers";
|
||||
import { createUpdaterState } from "./context/updater";
|
||||
import { resetOpenworkState, resetOpencodeCache } from "./lib/tauri";
|
||||
import { unwrap, waitForHealthy } from "./lib/opencode";
|
||||
@@ -29,7 +31,7 @@ export function createSystemState(options: {
|
||||
refreshSkills: (options?: { force?: boolean }) => Promise<void>;
|
||||
refreshMcpServers?: () => Promise<void>;
|
||||
reloadWorkspaceEngine?: () => Promise<boolean>;
|
||||
setProviders: (value: Provider[]) => void;
|
||||
setProviders: (value: ProviderListItem[]) => void;
|
||||
setProviderDefaults: (value: Record<string, string>) => void;
|
||||
setProviderConnectedIds: (value: string[]) => void;
|
||||
setError: (value: string | null) => void;
|
||||
@@ -234,13 +236,13 @@ export function createSystemState(options: {
|
||||
|
||||
try {
|
||||
const providerList = unwrap(await nextClient.provider.list());
|
||||
options.setProviders(providerList.all as unknown as Provider[]);
|
||||
options.setProviders(providerList.all);
|
||||
options.setProviderDefaults(providerList.default);
|
||||
options.setProviderConnectedIds(providerList.connected);
|
||||
} catch {
|
||||
try {
|
||||
const cfg = unwrap(await nextClient.config.providers());
|
||||
options.setProviders(cfg.providers);
|
||||
options.setProviders(mapConfigProvidersToList(cfg.providers));
|
||||
options.setProviderDefaults(cfg.default);
|
||||
options.setProviderConnectedIds([]);
|
||||
} catch {
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import type { Message, Part, PermissionRequest as ApiPermissionRequest, Provider, Session } from "@opencode-ai/sdk/v2/client";
|
||||
import type {
|
||||
Message,
|
||||
Part,
|
||||
PermissionRequest as ApiPermissionRequest,
|
||||
ProviderListResponse,
|
||||
Session,
|
||||
} from "@opencode-ai/sdk/v2/client";
|
||||
import type { createClient } from "./lib/opencode";
|
||||
import type { OpencodeConfigFile, WorkspaceInfo } from "./lib/tauri";
|
||||
|
||||
export type Client = ReturnType<typeof createClient>;
|
||||
|
||||
export type ProviderListItem = ProviderListResponse["all"][number];
|
||||
|
||||
export type PlaceholderAssistantMessage = {
|
||||
id: string;
|
||||
sessionID: string;
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
76
packages/app/src/app/utils/providers.ts
Normal file
76
packages/app/src/app/utils/providers.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { Provider as ConfigProvider, ProviderListResponse } from "@opencode-ai/sdk/v2/client";
|
||||
|
||||
type ProviderListItem = ProviderListResponse["all"][number];
|
||||
type ProviderListModel = ProviderListItem["models"][string];
|
||||
|
||||
const buildModalities = (caps?: ConfigProvider["models"][string]["capabilities"]) => {
|
||||
if (!caps) return undefined;
|
||||
|
||||
const input = Object.entries(caps.input)
|
||||
.filter(([, enabled]) => enabled)
|
||||
.map(([key]) => key as "text" | "audio" | "image" | "video" | "pdf");
|
||||
const output = Object.entries(caps.output)
|
||||
.filter(([, enabled]) => enabled)
|
||||
.map(([key]) => key as "text" | "audio" | "image" | "video" | "pdf");
|
||||
|
||||
if (!input.length && !output.length) return undefined;
|
||||
return { input, output };
|
||||
};
|
||||
|
||||
const mapModel = (model: ConfigProvider["models"][string]): ProviderListModel => {
|
||||
const interleaved = model.capabilities?.interleaved;
|
||||
const modalities = buildModalities(model.capabilities);
|
||||
const status = model.status === "alpha" || model.status === "beta" || model.status === "deprecated"
|
||||
? model.status
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: model.id,
|
||||
name: model.name ?? model.id,
|
||||
family: model.family,
|
||||
release_date: model.release_date ?? "",
|
||||
attachment: model.capabilities?.attachment ?? false,
|
||||
reasoning: model.capabilities?.reasoning ?? false,
|
||||
temperature: model.capabilities?.temperature ?? false,
|
||||
tool_call: model.capabilities?.toolcall ?? false,
|
||||
interleaved: interleaved === false ? undefined : interleaved,
|
||||
cost: model.cost
|
||||
? {
|
||||
input: model.cost.input,
|
||||
output: model.cost.output,
|
||||
cache_read: model.cost.cache.read,
|
||||
cache_write: model.cost.cache.write,
|
||||
context_over_200k: model.cost.experimentalOver200K
|
||||
? {
|
||||
input: model.cost.experimentalOver200K.input,
|
||||
output: model.cost.experimentalOver200K.output,
|
||||
cache_read: model.cost.experimentalOver200K.cache.read,
|
||||
cache_write: model.cost.experimentalOver200K.cache.write,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
limit: model.limit,
|
||||
modalities,
|
||||
experimental: status === "alpha" ? true : undefined,
|
||||
status,
|
||||
options: model.options ?? {},
|
||||
headers: model.headers ?? undefined,
|
||||
provider: model.api?.npm ? { npm: model.api.npm } : undefined,
|
||||
variants: model.variants,
|
||||
};
|
||||
};
|
||||
|
||||
export const mapConfigProvidersToList = (providers: ConfigProvider[]): ProviderListResponse["all"] =>
|
||||
providers.map((provider) => {
|
||||
const models = Object.fromEntries(
|
||||
Object.entries(provider.models ?? {}).map(([key, model]) => [key, mapModel(model)]),
|
||||
);
|
||||
|
||||
return {
|
||||
id: provider.id,
|
||||
name: provider.name ?? provider.id,
|
||||
env: provider.env ?? [],
|
||||
models,
|
||||
};
|
||||
});
|
||||
70
packages/server/README.md
Normal file
70
packages/server/README.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# OpenWork Server
|
||||
|
||||
Filesystem-backed API for OpenWork remote clients. This package provides the OpenWork server layer described in `packages/app/pr/openwork-server.md` and is intentionally independent from the desktop app.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
pnpm --filter @different-ai/openwork-server dev -- \
|
||||
--workspace /path/to/workspace \
|
||||
--approval auto
|
||||
```
|
||||
|
||||
The server logs the client token and host token on boot when they are auto-generated.
|
||||
|
||||
## Config file
|
||||
|
||||
Defaults to `~/.config/openwork/server.json` (override with `OPENWORK_SERVER_CONFIG` or `--config`).
|
||||
|
||||
```json
|
||||
{
|
||||
"host": "127.0.0.1",
|
||||
"port": 8787,
|
||||
"approval": { "mode": "manual", "timeoutMs": 30000 },
|
||||
"workspaces": [
|
||||
{ "path": "/Users/susan/Finance", "name": "Finance", "workspaceType": "local" }
|
||||
],
|
||||
"corsOrigins": ["http://localhost:5173"]
|
||||
}
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
- `OPENWORK_SERVER_CONFIG` path to config JSON
|
||||
- `OPENWORK_HOST` / `OPENWORK_PORT`
|
||||
- `OPENWORK_TOKEN` client bearer token
|
||||
- `OPENWORK_HOST_TOKEN` host approval token
|
||||
- `OPENWORK_APPROVAL_MODE` (`manual` | `auto`)
|
||||
- `OPENWORK_APPROVAL_TIMEOUT_MS`
|
||||
- `OPENWORK_WORKSPACES` (JSON array or comma-separated list of paths)
|
||||
- `OPENWORK_CORS_ORIGINS` (comma-separated list or `*`)
|
||||
|
||||
## Endpoints (initial)
|
||||
|
||||
- `GET /health`
|
||||
- `GET /capabilities`
|
||||
- `GET /workspaces`
|
||||
- `GET /workspace/:id/config`
|
||||
- `PATCH /workspace/:id/config`
|
||||
- `GET /workspace/:id/plugins`
|
||||
- `POST /workspace/:id/plugins`
|
||||
- `DELETE /workspace/:id/plugins/:name`
|
||||
- `GET /workspace/:id/skills`
|
||||
- `POST /workspace/:id/skills`
|
||||
- `GET /workspace/:id/mcp`
|
||||
- `POST /workspace/:id/mcp`
|
||||
- `DELETE /workspace/:id/mcp/:name`
|
||||
- `GET /workspace/:id/commands`
|
||||
- `POST /workspace/:id/commands`
|
||||
- `DELETE /workspace/:id/commands/:name`
|
||||
- `GET /workspace/:id/export`
|
||||
- `POST /workspace/:id/import`
|
||||
|
||||
## Approvals
|
||||
|
||||
All writes are gated by host approval. Host APIs require `X-OpenWork-Host-Token`:
|
||||
|
||||
- `GET /approvals`
|
||||
- `POST /approvals/:id` with `{ "reply": "allow" | "deny" }`
|
||||
|
||||
Set `OPENWORK_APPROVAL_MODE=auto` to auto-approve during local development.
|
||||
27
packages/server/package.json
Normal file
27
packages/server/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@different-ai/openwork-server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"openwork-server": "dist/cli.js"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "bun src/cli.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "bun dist/cli.js",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"jsonc-parser": "^3.2.1",
|
||||
"minimatch": "^10.0.1",
|
||||
"yaml": "^2.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/minimatch": "^5.1.2",
|
||||
"bun-types": "^1.1.29",
|
||||
"typescript": "^5.6.3"
|
||||
},
|
||||
"packageManager": "pnpm@10.27.0"
|
||||
}
|
||||
66
packages/server/src/approvals.ts
Normal file
66
packages/server/src/approvals.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { ApprovalConfig, ApprovalRequest } from "./types.js";
|
||||
import { shortId } from "./utils.js";
|
||||
|
||||
interface ApprovalResult {
|
||||
id: string;
|
||||
allowed: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
interface PendingApproval {
|
||||
request: ApprovalRequest;
|
||||
resolve: (result: ApprovalResult) => void;
|
||||
timeout?: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
export class ApprovalService {
|
||||
private config: ApprovalConfig;
|
||||
private pending = new Map<string, PendingApproval>();
|
||||
|
||||
constructor(config: ApprovalConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
list(): ApprovalRequest[] {
|
||||
return Array.from(this.pending.values()).map((entry) => entry.request);
|
||||
}
|
||||
|
||||
async requestApproval(
|
||||
input: Omit<ApprovalRequest, "id" | "createdAt">,
|
||||
): Promise<ApprovalResult> {
|
||||
if (this.config.mode === "auto") {
|
||||
return { id: "auto", allowed: true };
|
||||
}
|
||||
const id = shortId();
|
||||
const request: ApprovalRequest = {
|
||||
...input,
|
||||
id,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
const result = await new Promise<ApprovalResult>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
resolve({ id, allowed: false, reason: "timeout" });
|
||||
}, this.config.timeoutMs);
|
||||
|
||||
this.pending.set(id, { request, resolve, timeout });
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
respond(id: string, reply: "allow" | "deny"): ApprovalResult | null {
|
||||
const pending = this.pending.get(id);
|
||||
if (!pending) return null;
|
||||
if (pending.timeout) clearTimeout(pending.timeout);
|
||||
this.pending.delete(id);
|
||||
const result: ApprovalResult = {
|
||||
id,
|
||||
allowed: reply === "allow",
|
||||
reason: reply === "allow" ? undefined : "denied",
|
||||
};
|
||||
pending.resolve(result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
28
packages/server/src/audit.ts
Normal file
28
packages/server/src/audit.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { dirname, join } from "node:path";
|
||||
import { appendFile, readFile } from "node:fs/promises";
|
||||
import type { AuditEntry } from "./types.js";
|
||||
import { ensureDir, exists } from "./utils.js";
|
||||
|
||||
export function auditLogPath(workspaceRoot: string): string {
|
||||
return join(workspaceRoot, ".opencode", "openwork", "audit.jsonl");
|
||||
}
|
||||
|
||||
export async function recordAudit(workspaceRoot: string, entry: AuditEntry): Promise<void> {
|
||||
const path = auditLogPath(workspaceRoot);
|
||||
await ensureDir(dirname(path));
|
||||
await appendFile(path, JSON.stringify(entry) + "\n", "utf8");
|
||||
}
|
||||
|
||||
export async function readLastAudit(workspaceRoot: string): Promise<AuditEntry | null> {
|
||||
const path = auditLogPath(workspaceRoot);
|
||||
if (!(await exists(path))) return null;
|
||||
const content = await readFile(path, "utf8");
|
||||
const lines = content.trim().split("\n");
|
||||
const last = lines[lines.length - 1];
|
||||
if (!last) return null;
|
||||
try {
|
||||
return JSON.parse(last) as AuditEntry;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
9
packages/server/src/bun.d.ts
vendored
Normal file
9
packages/server/src/bun.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
declare const Bun: {
|
||||
serve: (options: {
|
||||
hostname: string;
|
||||
port: number;
|
||||
fetch: (request: Request) => Response | Promise<Response>;
|
||||
}) => {
|
||||
port: number;
|
||||
};
|
||||
};
|
||||
31
packages/server/src/cli.ts
Normal file
31
packages/server/src/cli.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { parseCliArgs, printHelp, resolveServerConfig } from "./config.js";
|
||||
import { startServer } from "./server.js";
|
||||
|
||||
const args = parseCliArgs(process.argv.slice(2));
|
||||
|
||||
if (args.help) {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const config = await resolveServerConfig(args);
|
||||
const server = startServer(config);
|
||||
|
||||
const url = `http://${config.host}:${server.port}`;
|
||||
console.log(`OpenWork server listening on ${url}`);
|
||||
|
||||
if (config.tokenSource === "generated") {
|
||||
console.log(`Client token: ${config.token}`);
|
||||
}
|
||||
|
||||
if (config.hostTokenSource === "generated") {
|
||||
console.log(`Host token: ${config.hostToken}`);
|
||||
}
|
||||
|
||||
if (config.workspaces.length === 0) {
|
||||
console.log("No workspaces configured. Add --workspace or update server.json.");
|
||||
} else {
|
||||
console.log(`Workspaces: ${config.workspaces.length}`);
|
||||
}
|
||||
77
packages/server/src/commands.ts
Normal file
77
packages/server/src/commands.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { readdir, readFile, writeFile, rm, mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import type { CommandItem } from "./types.js";
|
||||
import { parseFrontmatter, buildFrontmatter } from "./frontmatter.js";
|
||||
import { exists } from "./utils.js";
|
||||
import { projectCommandsDir } from "./workspace-files.js";
|
||||
import { validateCommandName, sanitizeCommandName } from "./validators.js";
|
||||
import { ApiError } from "./errors.js";
|
||||
|
||||
async function listCommandsInDir(dir: string, scope: "workspace" | "global"): Promise<CommandItem[]> {
|
||||
if (!(await exists(dir))) return [];
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const items: CommandItem[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.endsWith(".md")) continue;
|
||||
const filePath = join(dir, entry.name);
|
||||
const content = await readFile(filePath, "utf8");
|
||||
const { data, body } = parseFrontmatter(content);
|
||||
const name = typeof data.name === "string" ? data.name : entry.name.replace(/\.md$/, "");
|
||||
try {
|
||||
validateCommandName(name);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
items.push({
|
||||
name,
|
||||
description: typeof data.description === "string" ? data.description : undefined,
|
||||
template: body.trim(),
|
||||
agent: typeof data.agent === "string" ? data.agent : undefined,
|
||||
model: typeof data.model === "string" ? data.model : null,
|
||||
subtask: typeof data.subtask === "boolean" ? data.subtask : undefined,
|
||||
scope,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export async function listCommands(workspaceRoot: string, scope: "workspace" | "global"): Promise<CommandItem[]> {
|
||||
if (scope === "global") {
|
||||
const dir = join(homedir(), ".config", "opencode", "commands");
|
||||
return listCommandsInDir(dir, "global");
|
||||
}
|
||||
return listCommandsInDir(projectCommandsDir(workspaceRoot), "workspace");
|
||||
}
|
||||
|
||||
export async function upsertCommand(
|
||||
workspaceRoot: string,
|
||||
payload: { name: string; description?: string; template: string; agent?: string; model?: string | null; subtask?: boolean },
|
||||
): Promise<string> {
|
||||
if (!payload.template || payload.template.trim().length === 0) {
|
||||
throw new ApiError(400, "invalid_command_template", "Command template is required");
|
||||
}
|
||||
const sanitized = sanitizeCommandName(payload.name);
|
||||
validateCommandName(sanitized);
|
||||
const frontmatter = buildFrontmatter({
|
||||
name: sanitized,
|
||||
description: payload.description,
|
||||
agent: payload.agent,
|
||||
model: payload.model ?? null,
|
||||
subtask: payload.subtask ?? false,
|
||||
});
|
||||
const content = frontmatter + "\n" + payload.template.trim() + "\n";
|
||||
const dir = projectCommandsDir(workspaceRoot);
|
||||
await mkdir(dir, { recursive: true });
|
||||
const path = join(dir, `${sanitized}.md`);
|
||||
await writeFile(path, content, "utf8");
|
||||
return path;
|
||||
}
|
||||
|
||||
export async function deleteCommand(workspaceRoot: string, name: string): Promise<void> {
|
||||
const sanitized = sanitizeCommandName(name);
|
||||
validateCommandName(sanitized);
|
||||
const path = join(projectCommandsDir(workspaceRoot), `${sanitized}.md`);
|
||||
await rm(path, { force: true });
|
||||
}
|
||||
214
packages/server/src/config.ts
Normal file
214
packages/server/src/config.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import type { ApprovalMode, ApprovalConfig, ServerConfig, WorkspaceConfig } from "./types.js";
|
||||
import { buildWorkspaceInfos } from "./workspaces.js";
|
||||
import { parseList, readJsonFile, shortId } from "./utils.js";
|
||||
|
||||
interface CliArgs {
|
||||
configPath?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
token?: string;
|
||||
hostToken?: string;
|
||||
approvalMode?: ApprovalMode;
|
||||
approvalTimeoutMs?: number;
|
||||
workspaces: string[];
|
||||
corsOrigins?: string[];
|
||||
readOnly?: boolean;
|
||||
help?: boolean;
|
||||
}
|
||||
|
||||
interface FileConfig {
|
||||
host?: string;
|
||||
port?: number;
|
||||
token?: string;
|
||||
hostToken?: string;
|
||||
approval?: Partial<ApprovalConfig>;
|
||||
workspaces?: WorkspaceConfig[];
|
||||
corsOrigins?: string[];
|
||||
authorizedRoots?: string[];
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_PORT = 8787;
|
||||
const DEFAULT_HOST = "127.0.0.1";
|
||||
const DEFAULT_TIMEOUT_MS = 30000;
|
||||
|
||||
export function parseCliArgs(argv: string[]): CliArgs {
|
||||
const args: CliArgs = { workspaces: [] };
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const value = argv[index];
|
||||
if (!value) continue;
|
||||
if (value === "--help" || value === "-h") {
|
||||
args.help = true;
|
||||
continue;
|
||||
}
|
||||
if (value === "--config") {
|
||||
args.configPath = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (value === "--host") {
|
||||
args.host = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (value === "--port") {
|
||||
const port = Number(argv[index + 1]);
|
||||
if (!Number.isNaN(port)) args.port = port;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (value === "--token") {
|
||||
args.token = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (value === "--host-token") {
|
||||
args.hostToken = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (value === "--approval") {
|
||||
const mode = argv[index + 1] as ApprovalMode | undefined;
|
||||
if (mode === "manual" || mode === "auto") args.approvalMode = mode;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (value === "--approval-timeout") {
|
||||
const timeout = Number(argv[index + 1]);
|
||||
if (!Number.isNaN(timeout)) args.approvalTimeoutMs = timeout;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (value === "--workspace") {
|
||||
const path = argv[index + 1];
|
||||
if (path) args.workspaces.push(path);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (value === "--cors") {
|
||||
args.corsOrigins = parseList(argv[index + 1]);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (value === "--read-only") {
|
||||
args.readOnly = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
export function printHelp(): void {
|
||||
const message = [
|
||||
"openwork-server",
|
||||
"",
|
||||
"Options:",
|
||||
" --config <path> Path to server.json",
|
||||
" --host <host> Hostname (default 127.0.0.1)",
|
||||
" --port <port> Port (default 8787)",
|
||||
" --token <token> Client bearer token",
|
||||
" --host-token <token> Host approval token",
|
||||
" --approval <mode> manual | auto",
|
||||
" --approval-timeout <ms> Approval timeout",
|
||||
" --workspace <path> Workspace root (repeatable)",
|
||||
" --cors <origins> Comma-separated origins or *",
|
||||
" --read-only Disable writes",
|
||||
].join("\n");
|
||||
console.log(message);
|
||||
}
|
||||
|
||||
async function loadFileConfig(configPath: string): Promise<FileConfig> {
|
||||
const parsed = await readJsonFile<FileConfig>(configPath);
|
||||
return parsed ?? {};
|
||||
}
|
||||
|
||||
export async function resolveServerConfig(cli: CliArgs): Promise<ServerConfig> {
|
||||
const envConfigPath = process.env.OPENWORK_SERVER_CONFIG;
|
||||
const configPath = cli.configPath ?? envConfigPath ?? resolve(homedir(), ".config", "openwork", "server.json");
|
||||
const fileConfig = await loadFileConfig(configPath);
|
||||
const configDir = dirname(configPath);
|
||||
|
||||
const envWorkspaces = parseList(process.env.OPENWORK_WORKSPACES);
|
||||
const workspaceConfigs: WorkspaceConfig[] =
|
||||
cli.workspaces.length > 0
|
||||
? cli.workspaces.map((path) => ({ path }))
|
||||
: envWorkspaces.length > 0
|
||||
? envWorkspaces.map((path) => ({ path }))
|
||||
: fileConfig.workspaces ?? [];
|
||||
|
||||
const workspaces = buildWorkspaceInfos(workspaceConfigs, configDir);
|
||||
|
||||
const tokenFromEnv = process.env.OPENWORK_TOKEN;
|
||||
const hostTokenFromEnv = process.env.OPENWORK_HOST_TOKEN;
|
||||
|
||||
const token = cli.token ?? tokenFromEnv ?? fileConfig.token ?? shortId();
|
||||
const hostToken = cli.hostToken ?? hostTokenFromEnv ?? fileConfig.hostToken ?? shortId();
|
||||
|
||||
const tokenSource: ServerConfig["tokenSource"] = cli.token
|
||||
? "cli"
|
||||
: tokenFromEnv
|
||||
? "env"
|
||||
: fileConfig.token
|
||||
? "file"
|
||||
: "generated";
|
||||
|
||||
const hostTokenSource: ServerConfig["hostTokenSource"] = cli.hostToken
|
||||
? "cli"
|
||||
: hostTokenFromEnv
|
||||
? "env"
|
||||
: fileConfig.hostToken
|
||||
? "file"
|
||||
: "generated";
|
||||
|
||||
const approvalMode =
|
||||
cli.approvalMode ??
|
||||
(process.env.OPENWORK_APPROVAL_MODE as ApprovalMode | undefined) ??
|
||||
fileConfig.approval?.mode ??
|
||||
"manual";
|
||||
|
||||
const approvalTimeoutMs =
|
||||
cli.approvalTimeoutMs ??
|
||||
(process.env.OPENWORK_APPROVAL_TIMEOUT_MS ? Number(process.env.OPENWORK_APPROVAL_TIMEOUT_MS) : undefined) ??
|
||||
fileConfig.approval?.timeoutMs ??
|
||||
DEFAULT_TIMEOUT_MS;
|
||||
|
||||
const approval: ApprovalConfig = {
|
||||
mode: approvalMode === "auto" ? "auto" : "manual",
|
||||
timeoutMs: Number.isNaN(approvalTimeoutMs) ? DEFAULT_TIMEOUT_MS : approvalTimeoutMs,
|
||||
};
|
||||
|
||||
const envCorsOrigins = process.env.OPENWORK_CORS_ORIGINS;
|
||||
const parsedEnvCors = envCorsOrigins ? parseList(envCorsOrigins) : null;
|
||||
const corsOrigins = cli.corsOrigins ?? parsedEnvCors ?? fileConfig.corsOrigins ?? ["*"];
|
||||
|
||||
const envReadOnly = process.env.OPENWORK_READONLY;
|
||||
const parsedReadOnly = envReadOnly
|
||||
? ["true", "1", "yes"].includes(envReadOnly.toLowerCase())
|
||||
: undefined;
|
||||
const readOnly = cli.readOnly ?? parsedReadOnly ?? fileConfig.readOnly ?? false;
|
||||
|
||||
const authorizedRoots =
|
||||
fileConfig.authorizedRoots?.length
|
||||
? fileConfig.authorizedRoots.map((root) => resolve(configDir, root))
|
||||
: workspaces.map((workspace) => workspace.path);
|
||||
|
||||
const host = cli.host ?? process.env.OPENWORK_HOST ?? fileConfig.host ?? DEFAULT_HOST;
|
||||
const port = cli.port ?? (process.env.OPENWORK_PORT ? Number(process.env.OPENWORK_PORT) : undefined) ?? fileConfig.port ?? DEFAULT_PORT;
|
||||
|
||||
return {
|
||||
host,
|
||||
port: Number.isNaN(port) ? DEFAULT_PORT : port,
|
||||
token,
|
||||
hostToken,
|
||||
approval,
|
||||
corsOrigins,
|
||||
workspaces,
|
||||
authorizedRoots,
|
||||
readOnly,
|
||||
startedAt: Date.now(),
|
||||
tokenSource,
|
||||
hostTokenSource,
|
||||
};
|
||||
}
|
||||
22
packages/server/src/errors.ts
Normal file
22
packages/server/src/errors.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { ApiErrorBody } from "./types.js";
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
code: string;
|
||||
details?: unknown;
|
||||
|
||||
constructor(status: number, code: string, message: string, details?: unknown) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatError(err: ApiError): ApiErrorBody {
|
||||
return {
|
||||
code: err.code,
|
||||
message: err.message,
|
||||
details: err.details,
|
||||
};
|
||||
}
|
||||
17
packages/server/src/frontmatter.ts
Normal file
17
packages/server/src/frontmatter.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { parse, stringify } from "yaml";
|
||||
|
||||
export function parseFrontmatter(content: string): { data: Record<string, unknown>; body: string } {
|
||||
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
||||
if (!match) {
|
||||
return { data: {}, body: content };
|
||||
}
|
||||
const raw = match[1] ?? "";
|
||||
const data = (parse(raw) as Record<string, unknown>) ?? {};
|
||||
const body = content.slice(match[0].length);
|
||||
return { data, body };
|
||||
}
|
||||
|
||||
export function buildFrontmatter(data: Record<string, unknown>): string {
|
||||
const yaml = stringify(data).trimEnd();
|
||||
return `---\n${yaml}\n---\n`;
|
||||
}
|
||||
52
packages/server/src/jsonc.ts
Normal file
52
packages/server/src/jsonc.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { applyEdits, modify, parse, printParseErrorCode } from "jsonc-parser";
|
||||
import { dirname } from "node:path";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { ApiError } from "./errors.js";
|
||||
import { ensureDir, exists } from "./utils.js";
|
||||
|
||||
interface ParseResult<T> {
|
||||
data: T;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
export async function readJsoncFile<T>(path: string, fallback: T): Promise<ParseResult<T>> {
|
||||
if (!(await exists(path))) {
|
||||
return { data: fallback, raw: "" };
|
||||
}
|
||||
const raw = await readFile(path, "utf8");
|
||||
const errors: { error: number; offset: number; length: number }[] = [];
|
||||
const data = parse(raw, errors, { allowTrailingComma: true }) as T;
|
||||
if (errors.length > 0) {
|
||||
const details = errors.map((error) => ({
|
||||
code: printParseErrorCode(error.error),
|
||||
offset: error.offset,
|
||||
length: error.length,
|
||||
}));
|
||||
throw new ApiError(422, "invalid_jsonc", "Failed to parse JSONC", details);
|
||||
}
|
||||
return { data, raw };
|
||||
}
|
||||
|
||||
export async function updateJsoncTopLevel(path: string, updates: Record<string, unknown>): Promise<void> {
|
||||
const hasFile = await exists(path);
|
||||
if (!hasFile) {
|
||||
await ensureDir(dirname(path));
|
||||
const content = JSON.stringify(updates, null, 2) + "\n";
|
||||
await writeFile(path, content, "utf8");
|
||||
return;
|
||||
}
|
||||
|
||||
let content = await readFile(path, "utf8");
|
||||
const formattingOptions = { insertSpaces: true, tabSize: 2, eol: "\n" };
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
const edits = modify(content, [key], value, { formattingOptions });
|
||||
content = applyEdits(content, edits);
|
||||
}
|
||||
await writeFile(path, content.endsWith("\n") ? content : content + "\n", "utf8");
|
||||
}
|
||||
|
||||
export async function writeJsoncFile(path: string, value: unknown): Promise<void> {
|
||||
await ensureDir(dirname(path));
|
||||
const content = JSON.stringify(value, null, 2) + "\n";
|
||||
await writeFile(path, content, "utf8");
|
||||
}
|
||||
53
packages/server/src/mcp.ts
Normal file
53
packages/server/src/mcp.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { minimatch } from "minimatch";
|
||||
import type { McpItem } from "./types.js";
|
||||
import { readJsoncFile, updateJsoncTopLevel } from "./jsonc.js";
|
||||
import { opencodeConfigPath } from "./workspace-files.js";
|
||||
import { validateMcpConfig, validateMcpName } from "./validators.js";
|
||||
|
||||
function getMcpConfig(config: Record<string, unknown>): Record<string, Record<string, unknown>> {
|
||||
const mcp = config.mcp;
|
||||
if (!mcp || typeof mcp !== "object") return {};
|
||||
return mcp as Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
function getDeniedToolPatterns(config: Record<string, unknown>): string[] {
|
||||
const tools = config.tools;
|
||||
if (!tools || typeof tools !== "object") return [];
|
||||
const deny = (tools as { deny?: unknown }).deny;
|
||||
if (!Array.isArray(deny)) return [];
|
||||
return deny.filter((item) => typeof item === "string") as string[];
|
||||
}
|
||||
|
||||
function isMcpDisabledByTools(config: Record<string, unknown>, name: string): boolean {
|
||||
const patterns = getDeniedToolPatterns(config);
|
||||
if (patterns.length === 0) return false;
|
||||
const candidates = [`mcp.${name}`, `mcp.${name}.*`, `mcp:${name}`, `mcp:${name}:*`, "mcp.*", "mcp:*"];
|
||||
return patterns.some((pattern) => candidates.some((candidate) => minimatch(candidate, pattern)));
|
||||
}
|
||||
|
||||
export async function listMcp(workspaceRoot: string): Promise<McpItem[]> {
|
||||
const { data: config } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record<string, unknown>);
|
||||
const mcpMap = getMcpConfig(config);
|
||||
return Object.entries(mcpMap).map(([name, entry]) => ({
|
||||
name,
|
||||
config: entry,
|
||||
source: "config.project",
|
||||
disabledByTools: isMcpDisabledByTools(config, name) || undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function addMcp(workspaceRoot: string, name: string, config: Record<string, unknown>): Promise<void> {
|
||||
validateMcpName(name);
|
||||
validateMcpConfig(config);
|
||||
const { data } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record<string, unknown>);
|
||||
const mcpMap = getMcpConfig(data);
|
||||
mcpMap[name] = config;
|
||||
await updateJsoncTopLevel(opencodeConfigPath(workspaceRoot), { mcp: mcpMap });
|
||||
}
|
||||
|
||||
export async function removeMcp(workspaceRoot: string, name: string): Promise<void> {
|
||||
const { data } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record<string, unknown>);
|
||||
const mcpMap = getMcpConfig(data);
|
||||
delete mcpMap[name];
|
||||
await updateJsoncTopLevel(opencodeConfigPath(workspaceRoot), { mcp: mcpMap });
|
||||
}
|
||||
20
packages/server/src/paths.ts
Normal file
20
packages/server/src/paths.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { realpath } from "node:fs/promises";
|
||||
import { isAbsolute, resolve, sep } from "node:path";
|
||||
import { ApiError } from "./errors.js";
|
||||
|
||||
export function assertAbsolute(path: string): void {
|
||||
if (!isAbsolute(path)) {
|
||||
throw new ApiError(400, "invalid_path", "Path must be absolute");
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveWithinRoot(root: string, ...segments: string[]): Promise<string> {
|
||||
const resolvedRoot = await realpath(root);
|
||||
const candidate = resolve(resolvedRoot, ...segments);
|
||||
const resolvedCandidate = await realpath(candidate).catch(() => candidate);
|
||||
if (resolvedCandidate === resolvedRoot) return candidate;
|
||||
if (!resolvedCandidate.startsWith(resolvedRoot + sep)) {
|
||||
throw new ApiError(400, "path_escape", "Path escapes workspace root");
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
92
packages/server/src/plugins.ts
Normal file
92
packages/server/src/plugins.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { homedir } from "node:os";
|
||||
import { join, relative } from "node:path";
|
||||
import { readdir } from "node:fs/promises";
|
||||
import type { PluginItem } from "./types.js";
|
||||
import { readJsoncFile, updateJsoncTopLevel } from "./jsonc.js";
|
||||
import { opencodeConfigPath, projectPluginsDir } from "./workspace-files.js";
|
||||
import { exists } from "./utils.js";
|
||||
import { validatePluginSpec } from "./validators.js";
|
||||
|
||||
function normalizePluginSpec(spec: string): string {
|
||||
const trimmed = spec.trim();
|
||||
if (trimmed.startsWith("file:") || trimmed.startsWith("http:") || trimmed.startsWith("https:") || trimmed.startsWith("git:")) {
|
||||
return trimmed;
|
||||
}
|
||||
if (trimmed.startsWith("/")) {
|
||||
return trimmed;
|
||||
}
|
||||
if (trimmed.startsWith("@")) {
|
||||
const atIndex = trimmed.indexOf("@", 1);
|
||||
return atIndex > 0 ? trimmed.slice(0, atIndex) : trimmed;
|
||||
}
|
||||
const atIndex = trimmed.indexOf("@");
|
||||
return atIndex > 0 ? trimmed.slice(0, atIndex) : trimmed;
|
||||
}
|
||||
|
||||
function pluginListFromConfig(config: Record<string, unknown>): string[] {
|
||||
const plugin = config.plugin;
|
||||
if (typeof plugin === "string") return [plugin];
|
||||
if (Array.isArray(plugin)) return plugin.filter((item) => typeof item === "string") as string[];
|
||||
return [];
|
||||
}
|
||||
|
||||
async function listPluginFiles(dir: string, scope: "project" | "global", workspaceRoot?: string): Promise<PluginItem[]> {
|
||||
if (!(await exists(dir))) return [];
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const items: PluginItem[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.endsWith(".js") && !entry.name.endsWith(".ts")) continue;
|
||||
const absolutePath = join(dir, entry.name);
|
||||
const relativePath = workspaceRoot ? relative(workspaceRoot, absolutePath) : absolutePath;
|
||||
items.push({
|
||||
spec: `file://${absolutePath}`,
|
||||
source: scope === "project" ? "dir.project" : "dir.global",
|
||||
scope,
|
||||
path: relativePath,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export async function listPlugins(workspaceRoot: string, includeGlobal: boolean): Promise<{ items: PluginItem[]; loadOrder: string[] }> {
|
||||
const { data: config } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record<string, unknown>);
|
||||
const pluginSpecs = pluginListFromConfig(config);
|
||||
const items: PluginItem[] = pluginSpecs.map((spec) => ({
|
||||
spec,
|
||||
source: "config",
|
||||
scope: "project",
|
||||
}));
|
||||
|
||||
const projectDir = projectPluginsDir(workspaceRoot);
|
||||
items.push(...(await listPluginFiles(projectDir, "project", workspaceRoot)));
|
||||
|
||||
if (includeGlobal) {
|
||||
const globalDir = join(homedir(), ".config", "opencode", "plugins");
|
||||
items.push(...(await listPluginFiles(globalDir, "global")));
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
loadOrder: ["config.global", "config.project", "dir.global", "dir.project"],
|
||||
};
|
||||
}
|
||||
|
||||
export async function addPlugin(workspaceRoot: string, spec: string): Promise<void> {
|
||||
validatePluginSpec(spec);
|
||||
const { data: config } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record<string, unknown>);
|
||||
const pluginSpecs = pluginListFromConfig(config);
|
||||
const normalized = normalizePluginSpec(spec);
|
||||
const existing = pluginSpecs.find((item) => normalizePluginSpec(item) === normalized);
|
||||
if (existing) return;
|
||||
pluginSpecs.push(spec);
|
||||
await updateJsoncTopLevel(opencodeConfigPath(workspaceRoot), { plugin: pluginSpecs });
|
||||
}
|
||||
|
||||
export async function removePlugin(workspaceRoot: string, name: string): Promise<void> {
|
||||
const { data: config } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record<string, unknown>);
|
||||
const pluginSpecs = pluginListFromConfig(config);
|
||||
const normalized = normalizePluginSpec(name);
|
||||
const filtered = pluginSpecs.filter((item) => normalizePluginSpec(item) !== normalized);
|
||||
await updateJsoncTopLevel(opencodeConfigPath(workspaceRoot), { plugin: filtered });
|
||||
}
|
||||
647
packages/server/src/server.ts
Normal file
647
packages/server/src/server.ts
Normal file
@@ -0,0 +1,647 @@
|
||||
import { readFile, writeFile, rm } from "node:fs/promises";
|
||||
import { join, resolve, sep } from "node:path";
|
||||
import type { ApprovalRequest, Capabilities, ServerConfig, WorkspaceInfo, Actor } from "./types.js";
|
||||
import { ApprovalService } from "./approvals.js";
|
||||
import { addPlugin, listPlugins, removePlugin } from "./plugins.js";
|
||||
import { addMcp, listMcp, removeMcp } from "./mcp.js";
|
||||
import { listSkills, upsertSkill } from "./skills.js";
|
||||
import { deleteCommand, listCommands, upsertCommand } from "./commands.js";
|
||||
import { ApiError, formatError } from "./errors.js";
|
||||
import { readJsoncFile, updateJsoncTopLevel, writeJsoncFile } from "./jsonc.js";
|
||||
import { recordAudit, readLastAudit } from "./audit.js";
|
||||
import { parseFrontmatter } from "./frontmatter.js";
|
||||
import { opencodeConfigPath, openworkConfigPath, projectCommandsDir, projectSkillsDir } from "./workspace-files.js";
|
||||
import { ensureDir, exists, hashToken, shortId } from "./utils.js";
|
||||
import { sanitizeCommandName } from "./validators.js";
|
||||
|
||||
type AuthMode = "none" | "client" | "host";
|
||||
|
||||
interface Route {
|
||||
method: string;
|
||||
regex: RegExp;
|
||||
keys: string[];
|
||||
auth: AuthMode;
|
||||
handler: (ctx: RequestContext) => Promise<Response>;
|
||||
}
|
||||
|
||||
interface RequestContext {
|
||||
request: Request;
|
||||
url: URL;
|
||||
params: Record<string, string>;
|
||||
config: ServerConfig;
|
||||
approvals: ApprovalService;
|
||||
actor?: Actor;
|
||||
}
|
||||
|
||||
export function startServer(config: ServerConfig) {
|
||||
const approvals = new ApprovalService(config.approval);
|
||||
const routes = createRoutes(config, approvals);
|
||||
|
||||
const server = Bun.serve({
|
||||
hostname: config.host,
|
||||
port: config.port,
|
||||
fetch: async (request: Request) => {
|
||||
const url = new URL(request.url);
|
||||
if (request.method === "OPTIONS") {
|
||||
return withCors(new Response(null, { status: 204 }), request, config);
|
||||
}
|
||||
|
||||
const route = matchRoute(routes, request.method, url.pathname);
|
||||
if (!route) {
|
||||
return withCors(jsonResponse({ code: "not_found", message: "Not found" }, 404), request, config);
|
||||
}
|
||||
|
||||
try {
|
||||
const actor = route.auth === "host" ? requireHost(request, config) : route.auth === "client" ? requireClient(request, config) : undefined;
|
||||
const response = await route.handler({ request, url, params: route.params, config, approvals, actor });
|
||||
return withCors(response, request, config);
|
||||
} catch (error) {
|
||||
const apiError = error instanceof ApiError
|
||||
? error
|
||||
: new ApiError(500, "internal_error", "Unexpected server error");
|
||||
return withCors(jsonResponse(formatError(apiError), apiError.status), request, config);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
function matchRoute(routes: Route[], method: string, path: string) {
|
||||
for (const route of routes) {
|
||||
if (route.method !== method) continue;
|
||||
const match = path.match(route.regex);
|
||||
if (!match) continue;
|
||||
const params: Record<string, string> = {};
|
||||
route.keys.forEach((key, index) => {
|
||||
params[key] = decodeURIComponent(match[index + 1]);
|
||||
});
|
||||
return { ...route, params };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function addRoute(routes: Route[], method: string, path: string, auth: AuthMode, handler: Route["handler"]) {
|
||||
const keys: string[] = [];
|
||||
const regex = pathToRegex(path, keys);
|
||||
routes.push({ method, regex, keys, auth, handler });
|
||||
}
|
||||
|
||||
function pathToRegex(path: string, keys: string[]): RegExp {
|
||||
const pattern = path.replace(/:([A-Za-z0-9_]+)/g, (_, key) => {
|
||||
keys.push(key);
|
||||
return "([^/]+)";
|
||||
});
|
||||
return new RegExp(`^${pattern}$`);
|
||||
}
|
||||
|
||||
function jsonResponse(data: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function withCors(response: Response, request: Request, config: ServerConfig) {
|
||||
const origin = request.headers.get("origin");
|
||||
const allowedOrigins = config.corsOrigins;
|
||||
let allowOrigin: string | null = null;
|
||||
if (allowedOrigins.includes("*")) {
|
||||
allowOrigin = "*";
|
||||
} else if (origin && allowedOrigins.includes(origin)) {
|
||||
allowOrigin = origin;
|
||||
}
|
||||
|
||||
if (!allowOrigin) return response;
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set("Access-Control-Allow-Origin", allowOrigin);
|
||||
headers.set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-OpenWork-Host-Token, X-OpenWork-Client-Id");
|
||||
headers.set("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS");
|
||||
headers.set("Vary", "Origin");
|
||||
return new Response(response.body, { status: response.status, headers });
|
||||
}
|
||||
|
||||
function requireClient(request: Request, config: ServerConfig): Actor {
|
||||
const header = request.headers.get("authorization") ?? "";
|
||||
const match = header.match(/^Bearer\s+(.+)$/i);
|
||||
const token = match?.[1];
|
||||
if (!token || token !== config.token) {
|
||||
throw new ApiError(401, "unauthorized", "Invalid bearer token");
|
||||
}
|
||||
const clientId = request.headers.get("x-openwork-client-id") ?? undefined;
|
||||
return { type: "remote", clientId, tokenHash: hashToken(token) };
|
||||
}
|
||||
|
||||
function requireHost(request: Request, config: ServerConfig): Actor {
|
||||
const token = request.headers.get("x-openwork-host-token");
|
||||
if (!token || token !== config.hostToken) {
|
||||
throw new ApiError(401, "unauthorized", "Invalid host token");
|
||||
}
|
||||
return { type: "host", tokenHash: hashToken(token) };
|
||||
}
|
||||
|
||||
function buildCapabilities(config: ServerConfig): Capabilities {
|
||||
const writeEnabled = !config.readOnly;
|
||||
return {
|
||||
skills: { read: true, write: writeEnabled, source: "openwork" },
|
||||
plugins: { read: true, write: writeEnabled },
|
||||
mcp: { read: true, write: writeEnabled },
|
||||
commands: { read: true, write: writeEnabled },
|
||||
config: { read: true, write: writeEnabled },
|
||||
};
|
||||
}
|
||||
|
||||
function createRoutes(config: ServerConfig, approvals: ApprovalService): Route[] {
|
||||
const routes: Route[] = [];
|
||||
|
||||
addRoute(routes, "GET", "/health", "none", async () => {
|
||||
return jsonResponse({ ok: true, version: "0.1.0", uptimeMs: Date.now() - config.startedAt });
|
||||
});
|
||||
|
||||
addRoute(routes, "GET", "/capabilities", "client", async () => {
|
||||
return jsonResponse(buildCapabilities(config));
|
||||
});
|
||||
|
||||
addRoute(routes, "GET", "/workspaces", "client", async () => {
|
||||
return jsonResponse({ items: config.workspaces });
|
||||
});
|
||||
|
||||
addRoute(routes, "GET", "/workspace/:id/config", "client", async (ctx) => {
|
||||
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||
const opencode = await readOpencodeConfig(workspace.path);
|
||||
const openwork = await readOpenworkConfig(workspace.path);
|
||||
const lastAudit = await readLastAudit(workspace.path);
|
||||
return jsonResponse({ opencode, openwork, updatedAt: lastAudit?.timestamp ?? null });
|
||||
});
|
||||
|
||||
addRoute(routes, "PATCH", "/workspace/:id/config", "client", async (ctx) => {
|
||||
ensureWritable(config);
|
||||
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||
const body = await readJsonBody(ctx.request);
|
||||
const opencode = body.opencode as Record<string, unknown> | undefined;
|
||||
const openwork = body.openwork as Record<string, unknown> | undefined;
|
||||
|
||||
if (!opencode && !openwork) {
|
||||
throw new ApiError(400, "invalid_payload", "opencode or openwork updates required");
|
||||
}
|
||||
|
||||
await requireApproval(ctx, {
|
||||
workspaceId: workspace.id,
|
||||
action: "config.patch",
|
||||
summary: "Patch workspace config",
|
||||
paths: [opencode ? opencodeConfigPath(workspace.path) : null, openwork ? openworkConfigPath(workspace.path) : null].filter(Boolean) as string[],
|
||||
});
|
||||
|
||||
if (opencode) {
|
||||
await updateJsoncTopLevel(opencodeConfigPath(workspace.path), opencode);
|
||||
}
|
||||
if (openwork) {
|
||||
await writeOpenworkConfig(workspace.path, openwork, true);
|
||||
}
|
||||
|
||||
await recordAudit(workspace.path, {
|
||||
id: shortId(),
|
||||
workspaceId: workspace.id,
|
||||
actor: ctx.actor ?? { type: "remote" },
|
||||
action: "config.patch",
|
||||
target: "opencode.json",
|
||||
summary: "Patched workspace config",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
return jsonResponse({ updatedAt: Date.now() });
|
||||
});
|
||||
|
||||
addRoute(routes, "GET", "/workspace/:id/plugins", "client", async (ctx) => {
|
||||
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||
const includeGlobal = ctx.url.searchParams.get("includeGlobal") === "true";
|
||||
const result = await listPlugins(workspace.path, includeGlobal);
|
||||
return jsonResponse(result);
|
||||
});
|
||||
|
||||
addRoute(routes, "POST", "/workspace/:id/plugins", "client", async (ctx) => {
|
||||
ensureWritable(config);
|
||||
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||
const body = await readJsonBody(ctx.request);
|
||||
const spec = String(body.spec ?? "");
|
||||
await requireApproval(ctx, {
|
||||
workspaceId: workspace.id,
|
||||
action: "plugins.add",
|
||||
summary: `Add plugin ${spec}`,
|
||||
paths: [opencodeConfigPath(workspace.path)],
|
||||
});
|
||||
await addPlugin(workspace.path, spec);
|
||||
await recordAudit(workspace.path, {
|
||||
id: shortId(),
|
||||
workspaceId: workspace.id,
|
||||
actor: ctx.actor ?? { type: "remote" },
|
||||
action: "plugins.add",
|
||||
target: "opencode.json",
|
||||
summary: `Added ${spec}`,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
const result = await listPlugins(workspace.path, false);
|
||||
return jsonResponse(result);
|
||||
});
|
||||
|
||||
addRoute(routes, "DELETE", "/workspace/:id/plugins/:name", "client", async (ctx) => {
|
||||
ensureWritable(config);
|
||||
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||
const name = ctx.params.name ?? "";
|
||||
await requireApproval(ctx, {
|
||||
workspaceId: workspace.id,
|
||||
action: "plugins.remove",
|
||||
summary: `Remove plugin ${name}`,
|
||||
paths: [opencodeConfigPath(workspace.path)],
|
||||
});
|
||||
await removePlugin(workspace.path, name);
|
||||
await recordAudit(workspace.path, {
|
||||
id: shortId(),
|
||||
workspaceId: workspace.id,
|
||||
actor: ctx.actor ?? { type: "remote" },
|
||||
action: "plugins.remove",
|
||||
target: "opencode.json",
|
||||
summary: `Removed ${name}`,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
const result = await listPlugins(workspace.path, false);
|
||||
return jsonResponse(result);
|
||||
});
|
||||
|
||||
addRoute(routes, "GET", "/workspace/:id/skills", "client", async (ctx) => {
|
||||
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||
const includeGlobal = ctx.url.searchParams.get("includeGlobal") === "true";
|
||||
const items = await listSkills(workspace.path, includeGlobal);
|
||||
return jsonResponse({ items });
|
||||
});
|
||||
|
||||
addRoute(routes, "POST", "/workspace/:id/skills", "client", async (ctx) => {
|
||||
ensureWritable(config);
|
||||
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||
const body = await readJsonBody(ctx.request);
|
||||
const name = String(body.name ?? "");
|
||||
const content = String(body.content ?? "");
|
||||
const description = body.description ? String(body.description) : undefined;
|
||||
await requireApproval(ctx, {
|
||||
workspaceId: workspace.id,
|
||||
action: "skills.upsert",
|
||||
summary: `Upsert skill ${name}`,
|
||||
paths: [join(workspace.path, ".opencode", "skills", name, "SKILL.md")],
|
||||
});
|
||||
const path = await upsertSkill(workspace.path, { name, content, description });
|
||||
await recordAudit(workspace.path, {
|
||||
id: shortId(),
|
||||
workspaceId: workspace.id,
|
||||
actor: ctx.actor ?? { type: "remote" },
|
||||
action: "skills.upsert",
|
||||
target: path,
|
||||
summary: `Upserted skill ${name}`,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return jsonResponse({ name, path, description: description ?? "", scope: "project" });
|
||||
});
|
||||
|
||||
addRoute(routes, "GET", "/workspace/:id/mcp", "client", async (ctx) => {
|
||||
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||
const items = await listMcp(workspace.path);
|
||||
return jsonResponse({ items });
|
||||
});
|
||||
|
||||
addRoute(routes, "POST", "/workspace/:id/mcp", "client", async (ctx) => {
|
||||
ensureWritable(config);
|
||||
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||
const body = await readJsonBody(ctx.request);
|
||||
const name = String(body.name ?? "");
|
||||
const configPayload = body.config as Record<string, unknown> | undefined;
|
||||
if (!configPayload) {
|
||||
throw new ApiError(400, "invalid_payload", "MCP config is required");
|
||||
}
|
||||
await requireApproval(ctx, {
|
||||
workspaceId: workspace.id,
|
||||
action: "mcp.add",
|
||||
summary: `Add MCP ${name}`,
|
||||
paths: [opencodeConfigPath(workspace.path)],
|
||||
});
|
||||
await addMcp(workspace.path, name, configPayload);
|
||||
await recordAudit(workspace.path, {
|
||||
id: shortId(),
|
||||
workspaceId: workspace.id,
|
||||
actor: ctx.actor ?? { type: "remote" },
|
||||
action: "mcp.add",
|
||||
target: "opencode.json",
|
||||
summary: `Added MCP ${name}`,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
const items = await listMcp(workspace.path);
|
||||
return jsonResponse({ items });
|
||||
});
|
||||
|
||||
addRoute(routes, "DELETE", "/workspace/:id/mcp/:name", "client", async (ctx) => {
|
||||
ensureWritable(config);
|
||||
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||
const name = ctx.params.name ?? "";
|
||||
await requireApproval(ctx, {
|
||||
workspaceId: workspace.id,
|
||||
action: "mcp.remove",
|
||||
summary: `Remove MCP ${name}`,
|
||||
paths: [opencodeConfigPath(workspace.path)],
|
||||
});
|
||||
await removeMcp(workspace.path, name);
|
||||
await recordAudit(workspace.path, {
|
||||
id: shortId(),
|
||||
workspaceId: workspace.id,
|
||||
actor: ctx.actor ?? { type: "remote" },
|
||||
action: "mcp.remove",
|
||||
target: "opencode.json",
|
||||
summary: `Removed MCP ${name}`,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
const items = await listMcp(workspace.path);
|
||||
return jsonResponse({ items });
|
||||
});
|
||||
|
||||
addRoute(routes, "GET", "/workspace/:id/commands", "client", async (ctx) => {
|
||||
const scope = ctx.url.searchParams.get("scope") === "global" ? "global" : "workspace";
|
||||
if (scope === "global") {
|
||||
requireHost(ctx.request, config);
|
||||
}
|
||||
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||
const items = await listCommands(workspace.path, scope);
|
||||
return jsonResponse({ items });
|
||||
});
|
||||
|
||||
addRoute(routes, "POST", "/workspace/:id/commands", "client", async (ctx) => {
|
||||
ensureWritable(config);
|
||||
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||
const body = await readJsonBody(ctx.request);
|
||||
const name = String(body.name ?? "");
|
||||
const template = String(body.template ?? "");
|
||||
await requireApproval(ctx, {
|
||||
workspaceId: workspace.id,
|
||||
action: "commands.upsert",
|
||||
summary: `Upsert command ${name}`,
|
||||
paths: [join(workspace.path, ".opencode", "commands", `${sanitizeCommandName(name)}.md`)],
|
||||
});
|
||||
const path = await upsertCommand(workspace.path, {
|
||||
name,
|
||||
description: body.description ? String(body.description) : undefined,
|
||||
template,
|
||||
agent: body.agent ? String(body.agent) : undefined,
|
||||
model: body.model ? String(body.model) : undefined,
|
||||
subtask: typeof body.subtask === "boolean" ? body.subtask : undefined,
|
||||
});
|
||||
await recordAudit(workspace.path, {
|
||||
id: shortId(),
|
||||
workspaceId: workspace.id,
|
||||
actor: ctx.actor ?? { type: "remote" },
|
||||
action: "commands.upsert",
|
||||
target: path,
|
||||
summary: `Upserted command ${name}`,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
const items = await listCommands(workspace.path, "workspace");
|
||||
return jsonResponse({ items });
|
||||
});
|
||||
|
||||
addRoute(routes, "DELETE", "/workspace/:id/commands/:name", "client", async (ctx) => {
|
||||
ensureWritable(config);
|
||||
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||
const name = ctx.params.name ?? "";
|
||||
await requireApproval(ctx, {
|
||||
workspaceId: workspace.id,
|
||||
action: "commands.delete",
|
||||
summary: `Delete command ${name}`,
|
||||
paths: [join(workspace.path, ".opencode", "commands", `${sanitizeCommandName(name)}.md`)],
|
||||
});
|
||||
await deleteCommand(workspace.path, name);
|
||||
await recordAudit(workspace.path, {
|
||||
id: shortId(),
|
||||
workspaceId: workspace.id,
|
||||
actor: ctx.actor ?? { type: "remote" },
|
||||
action: "commands.delete",
|
||||
target: join(workspace.path, ".opencode", "commands"),
|
||||
summary: `Deleted command ${name}`,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return jsonResponse({ ok: true });
|
||||
});
|
||||
|
||||
addRoute(routes, "GET", "/workspace/:id/export", "client", async (ctx) => {
|
||||
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||
const exportPayload = await exportWorkspace(workspace);
|
||||
return jsonResponse(exportPayload);
|
||||
});
|
||||
|
||||
addRoute(routes, "POST", "/workspace/:id/import", "client", async (ctx) => {
|
||||
ensureWritable(config);
|
||||
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||
const body = await readJsonBody(ctx.request);
|
||||
await requireApproval(ctx, {
|
||||
workspaceId: workspace.id,
|
||||
action: "config.import",
|
||||
summary: "Import workspace config",
|
||||
paths: [opencodeConfigPath(workspace.path), openworkConfigPath(workspace.path)],
|
||||
});
|
||||
await importWorkspace(workspace, body);
|
||||
await recordAudit(workspace.path, {
|
||||
id: shortId(),
|
||||
workspaceId: workspace.id,
|
||||
actor: ctx.actor ?? { type: "remote" },
|
||||
action: "config.import",
|
||||
target: "workspace",
|
||||
summary: "Imported workspace config",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return jsonResponse({ ok: true });
|
||||
});
|
||||
|
||||
addRoute(routes, "GET", "/approvals", "host", async (ctx) => {
|
||||
return jsonResponse({ items: ctx.approvals.list() });
|
||||
});
|
||||
|
||||
addRoute(routes, "POST", "/approvals/:id", "host", async (ctx) => {
|
||||
const body = await readJsonBody(ctx.request);
|
||||
const reply = body.reply === "allow" ? "allow" : "deny";
|
||||
const result = ctx.approvals.respond(ctx.params.id, reply);
|
||||
if (!result) {
|
||||
throw new ApiError(404, "approval_not_found", "Approval request not found");
|
||||
}
|
||||
return jsonResponse({ ok: true, allowed: result.allowed });
|
||||
});
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
async function resolveWorkspace(config: ServerConfig, id: string): Promise<WorkspaceInfo> {
|
||||
const workspace = config.workspaces.find((entry) => entry.id === id);
|
||||
if (!workspace) {
|
||||
throw new ApiError(404, "workspace_not_found", "Workspace not found");
|
||||
}
|
||||
const resolvedWorkspace = resolve(workspace.path);
|
||||
const authorized = await isAuthorizedRoot(resolvedWorkspace, config.authorizedRoots);
|
||||
if (!authorized) {
|
||||
throw new ApiError(403, "workspace_unauthorized", "Workspace is not authorized");
|
||||
}
|
||||
return { ...workspace, path: resolvedWorkspace };
|
||||
}
|
||||
|
||||
async function isAuthorizedRoot(workspacePath: string, roots: string[]): Promise<boolean> {
|
||||
const resolvedWorkspace = resolve(workspacePath);
|
||||
for (const root of roots) {
|
||||
const resolvedRoot = resolve(root);
|
||||
if (resolvedWorkspace === resolvedRoot) return true;
|
||||
if (resolvedWorkspace.startsWith(resolvedRoot + sep)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function ensureWritable(config: ServerConfig): void {
|
||||
if (config.readOnly) {
|
||||
throw new ApiError(403, "read_only", "Server is read-only");
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonBody(request: Request): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const json = await request.json();
|
||||
return json as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new ApiError(400, "invalid_json", "Invalid JSON body");
|
||||
}
|
||||
}
|
||||
|
||||
async function readOpencodeConfig(workspaceRoot: string): Promise<Record<string, unknown>> {
|
||||
const { data } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record<string, unknown>);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function readOpenworkConfig(workspaceRoot: string): Promise<Record<string, unknown>> {
|
||||
const path = openworkConfigPath(workspaceRoot);
|
||||
if (!(await exists(path))) return {};
|
||||
try {
|
||||
const raw = await readFile(path, "utf8");
|
||||
return JSON.parse(raw) as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new ApiError(422, "invalid_json", "Failed to parse openwork.json");
|
||||
}
|
||||
}
|
||||
|
||||
async function writeOpenworkConfig(workspaceRoot: string, payload: Record<string, unknown>, merge: boolean): Promise<void> {
|
||||
const path = openworkConfigPath(workspaceRoot);
|
||||
const next = merge ? { ...(await readOpenworkConfig(workspaceRoot)), ...payload } : payload;
|
||||
await ensureDir(join(workspaceRoot, ".opencode"));
|
||||
await writeFile(path, JSON.stringify(next, null, 2) + "\n", "utf8");
|
||||
}
|
||||
|
||||
async function requireApproval(
|
||||
ctx: RequestContext,
|
||||
input: Omit<ApprovalRequest, "id" | "createdAt" | "actor">,
|
||||
): Promise<void> {
|
||||
const actor = ctx.actor ?? { type: "remote" };
|
||||
const result = await ctx.approvals.requestApproval({ ...input, actor });
|
||||
if (!result.allowed) {
|
||||
throw new ApiError(403, "write_denied", "Write request denied", {
|
||||
requestId: result.id,
|
||||
reason: result.reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function exportWorkspace(workspace: WorkspaceInfo) {
|
||||
const opencode = await readOpencodeConfig(workspace.path);
|
||||
const openwork = await readOpenworkConfig(workspace.path);
|
||||
const skills = await listSkills(workspace.path, false);
|
||||
const commands = await listCommands(workspace.path, "workspace");
|
||||
const skillContents = await Promise.all(
|
||||
skills.map(async (skill) => ({
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
content: await readFile(skill.path, "utf8"),
|
||||
})),
|
||||
);
|
||||
const commandContents = await Promise.all(
|
||||
commands.map(async (command) => ({
|
||||
name: command.name,
|
||||
description: command.description,
|
||||
template: command.template,
|
||||
})),
|
||||
);
|
||||
|
||||
return {
|
||||
workspaceId: workspace.id,
|
||||
exportedAt: Date.now(),
|
||||
opencode,
|
||||
openwork,
|
||||
skills: skillContents,
|
||||
commands: commandContents,
|
||||
};
|
||||
}
|
||||
|
||||
async function importWorkspace(workspace: WorkspaceInfo, payload: Record<string, unknown>): Promise<void> {
|
||||
const modes = (payload.mode as Record<string, string> | undefined) ?? {};
|
||||
const opencode = payload.opencode as Record<string, unknown> | undefined;
|
||||
const openwork = payload.openwork as Record<string, unknown> | undefined;
|
||||
const skills = (payload.skills as { name: string; content: string; description?: string }[] | undefined) ?? [];
|
||||
const commands = (payload.commands as { name: string; content?: string; description?: string; template?: string; agent?: string; model?: string | null; subtask?: boolean }[] | undefined) ?? [];
|
||||
|
||||
if (opencode) {
|
||||
if (modes.opencode === "replace") {
|
||||
await writeJsoncFile(opencodeConfigPath(workspace.path), opencode);
|
||||
} else {
|
||||
await updateJsoncTopLevel(opencodeConfigPath(workspace.path), opencode);
|
||||
}
|
||||
}
|
||||
|
||||
if (openwork) {
|
||||
if (modes.openwork === "replace") {
|
||||
await writeOpenworkConfig(workspace.path, openwork, false);
|
||||
} else {
|
||||
await writeOpenworkConfig(workspace.path, openwork, true);
|
||||
}
|
||||
}
|
||||
|
||||
if (skills.length > 0) {
|
||||
if (modes.skills === "replace") {
|
||||
await rm(projectSkillsDir(workspace.path), { recursive: true, force: true });
|
||||
}
|
||||
for (const skill of skills) {
|
||||
await upsertSkill(workspace.path, skill);
|
||||
}
|
||||
}
|
||||
|
||||
if (commands.length > 0) {
|
||||
if (modes.commands === "replace") {
|
||||
await rm(projectCommandsDir(workspace.path), { recursive: true, force: true });
|
||||
}
|
||||
for (const command of commands) {
|
||||
if (command.content) {
|
||||
const parsed = parseFrontmatter(command.content);
|
||||
const name = command.name || (typeof parsed.data.name === "string" ? parsed.data.name : "");
|
||||
const description = command.description || (typeof parsed.data.description === "string" ? parsed.data.description : undefined);
|
||||
if (!name) {
|
||||
throw new ApiError(400, "invalid_command", "Command name is required");
|
||||
}
|
||||
const template = parsed.body.trim();
|
||||
await upsertCommand(workspace.path, {
|
||||
name,
|
||||
description,
|
||||
template,
|
||||
agent: typeof parsed.data.agent === "string" ? parsed.data.agent : undefined,
|
||||
model: typeof parsed.data.model === "string" ? parsed.data.model : undefined,
|
||||
subtask: typeof parsed.data.subtask === "boolean" ? parsed.data.subtask : undefined,
|
||||
});
|
||||
} else {
|
||||
const name = command.name ?? "";
|
||||
const template = command.template ?? "";
|
||||
await upsertCommand(workspace.path, {
|
||||
name,
|
||||
description: command.description,
|
||||
template,
|
||||
agent: command.agent,
|
||||
model: command.model,
|
||||
subtask: command.subtask,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
117
packages/server/src/skills.ts
Normal file
117
packages/server/src/skills.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { readdir, readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import type { SkillItem } from "./types.js";
|
||||
import { parseFrontmatter, buildFrontmatter } from "./frontmatter.js";
|
||||
import { exists } from "./utils.js";
|
||||
import { validateDescription, validateSkillName } from "./validators.js";
|
||||
import { ApiError } from "./errors.js";
|
||||
import { projectSkillsDir } from "./workspace-files.js";
|
||||
|
||||
async function findWorkspaceRoots(workspaceRoot: string): Promise<string[]> {
|
||||
const roots: string[] = [];
|
||||
let current = resolve(workspaceRoot);
|
||||
while (true) {
|
||||
roots.push(current);
|
||||
const gitPath = join(current, ".git");
|
||||
if (await exists(gitPath)) break;
|
||||
const parent = resolve(current, "..");
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
async function listSkillsInDir(dir: string, scope: "project" | "global"): Promise<SkillItem[]> {
|
||||
if (!(await exists(dir))) return [];
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const items: SkillItem[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const skillPath = join(dir, entry.name, "SKILL.md");
|
||||
if (!(await exists(skillPath))) continue;
|
||||
const content = await readFile(skillPath, "utf8");
|
||||
const { data } = parseFrontmatter(content);
|
||||
const name = typeof data.name === "string" ? data.name : entry.name;
|
||||
const description = typeof data.description === "string" ? data.description : "";
|
||||
try {
|
||||
validateSkillName(name);
|
||||
validateDescription(description);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (name !== entry.name) continue;
|
||||
items.push({
|
||||
name,
|
||||
description,
|
||||
path: skillPath,
|
||||
scope,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export async function listSkills(workspaceRoot: string, includeGlobal: boolean): Promise<SkillItem[]> {
|
||||
const roots = await findWorkspaceRoots(workspaceRoot);
|
||||
const items: SkillItem[] = [];
|
||||
for (const root of roots) {
|
||||
const opencodeDir = join(root, ".opencode", "skills");
|
||||
const claudeDir = join(root, ".claude", "skills");
|
||||
items.push(...(await listSkillsInDir(opencodeDir, "project")));
|
||||
items.push(...(await listSkillsInDir(claudeDir, "project")));
|
||||
}
|
||||
|
||||
if (includeGlobal) {
|
||||
const globalOpenWork = join(homedir(), ".config", "opencode", "skills");
|
||||
const globalClaude = join(homedir(), ".claude", "skills");
|
||||
items.push(...(await listSkillsInDir(globalOpenWork, "global")));
|
||||
items.push(...(await listSkillsInDir(globalClaude, "global")));
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
return items.filter((item) => {
|
||||
if (seen.has(item.name)) return false;
|
||||
seen.add(item.name);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export async function upsertSkill(
|
||||
workspaceRoot: string,
|
||||
payload: { name: string; content: string; description?: string },
|
||||
): Promise<string> {
|
||||
const name = payload.name.trim();
|
||||
validateSkillName(name);
|
||||
if (!payload.content) {
|
||||
throw new ApiError(400, "invalid_skill_content", "Skill content is required");
|
||||
}
|
||||
|
||||
let content = payload.content;
|
||||
const { data, body } = parseFrontmatter(payload.content);
|
||||
if (Object.keys(data).length > 0) {
|
||||
const frontmatterName = typeof data.name === "string" ? data.name : "";
|
||||
const frontmatterDescription = typeof data.description === "string" ? data.description : "";
|
||||
if (frontmatterName && frontmatterName !== name) {
|
||||
throw new ApiError(400, "invalid_skill_name", "Skill frontmatter name must match payload name");
|
||||
}
|
||||
validateDescription(frontmatterDescription || payload.description);
|
||||
const nextDescription = frontmatterDescription || payload.description || "";
|
||||
const frontmatter = buildFrontmatter({
|
||||
...data,
|
||||
name,
|
||||
description: nextDescription,
|
||||
});
|
||||
content = frontmatter + body.replace(/^\n/, "");
|
||||
} else {
|
||||
validateDescription(payload.description);
|
||||
const frontmatter = buildFrontmatter({ name, description: payload.description });
|
||||
content = frontmatter + payload.content.replace(/^\n/, "");
|
||||
}
|
||||
|
||||
const baseDir = projectSkillsDir(workspaceRoot);
|
||||
const skillDir = join(baseDir, name);
|
||||
await mkdir(skillDir, { recursive: true });
|
||||
const skillPath = join(skillDir, "SKILL.md");
|
||||
await writeFile(skillPath, content.endsWith("\n") ? content : content + "\n", "utf8");
|
||||
return skillPath;
|
||||
}
|
||||
111
packages/server/src/types.ts
Normal file
111
packages/server/src/types.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
export type WorkspaceType = "local" | "remote";
|
||||
|
||||
export type ApprovalMode = "manual" | "auto";
|
||||
|
||||
export interface WorkspaceConfig {
|
||||
path: string;
|
||||
name?: string;
|
||||
workspaceType?: WorkspaceType;
|
||||
baseUrl?: string;
|
||||
directory?: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
workspaceType: WorkspaceType;
|
||||
baseUrl?: string;
|
||||
directory?: string;
|
||||
}
|
||||
|
||||
export interface ApprovalConfig {
|
||||
mode: ApprovalMode;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export interface ServerConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
token: string;
|
||||
hostToken: string;
|
||||
approval: ApprovalConfig;
|
||||
corsOrigins: string[];
|
||||
workspaces: WorkspaceInfo[];
|
||||
authorizedRoots: string[];
|
||||
readOnly: boolean;
|
||||
startedAt: number;
|
||||
tokenSource: "cli" | "env" | "file" | "generated";
|
||||
hostTokenSource: "cli" | "env" | "file" | "generated";
|
||||
}
|
||||
|
||||
export interface Capabilities {
|
||||
skills: { read: boolean; write: boolean; source: "openwork" | "opencode" };
|
||||
plugins: { read: boolean; write: boolean };
|
||||
mcp: { read: boolean; write: boolean };
|
||||
commands: { read: boolean; write: boolean };
|
||||
config: { read: boolean; write: boolean };
|
||||
}
|
||||
|
||||
export interface ApiErrorBody {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
export interface PluginItem {
|
||||
spec: string;
|
||||
source: "config" | "dir.project" | "dir.global";
|
||||
scope: "project" | "global";
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface McpItem {
|
||||
name: string;
|
||||
config: Record<string, unknown>;
|
||||
source: "config.project" | "config.global" | "config.remote";
|
||||
disabledByTools?: boolean;
|
||||
}
|
||||
|
||||
export interface SkillItem {
|
||||
name: string;
|
||||
path: string;
|
||||
description: string;
|
||||
scope: "project" | "global";
|
||||
}
|
||||
|
||||
export interface CommandItem {
|
||||
name: string;
|
||||
description?: string;
|
||||
template: string;
|
||||
agent?: string;
|
||||
model?: string | null;
|
||||
subtask?: boolean;
|
||||
scope: "workspace" | "global";
|
||||
}
|
||||
|
||||
export interface Actor {
|
||||
type: "remote" | "host";
|
||||
clientId?: string;
|
||||
tokenHash?: string;
|
||||
}
|
||||
|
||||
export interface ApprovalRequest {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
action: string;
|
||||
summary: string;
|
||||
paths: string[];
|
||||
createdAt: number;
|
||||
actor: Actor;
|
||||
}
|
||||
|
||||
export interface AuditEntry {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
actor: Actor;
|
||||
action: string;
|
||||
target: string;
|
||||
summary: string;
|
||||
timestamp: number;
|
||||
}
|
||||
52
packages/server/src/utils.ts
Normal file
52
packages/server/src/utils.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { mkdir, readFile, stat } from "node:fs/promises";
|
||||
|
||||
export async function exists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureDir(path: string): Promise<void> {
|
||||
await mkdir(path, { recursive: true });
|
||||
}
|
||||
|
||||
export async function readJsonFile<T>(path: string): Promise<T | null> {
|
||||
try {
|
||||
const raw = await readFile(path, "utf8");
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function hashToken(token: string): string {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export function shortId(): string {
|
||||
return randomUUID();
|
||||
}
|
||||
|
||||
export function parseList(input: string | undefined): string[] {
|
||||
if (!input) return [];
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return [];
|
||||
if (trimmed.startsWith("[")) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map((item) => String(item)).filter(Boolean);
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return trimmed
|
||||
.split(/[,;]/)
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
59
packages/server/src/validators.ts
Normal file
59
packages/server/src/validators.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { ApiError } from "./errors.js";
|
||||
|
||||
const SKILL_NAME_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
||||
const COMMAND_NAME_REGEX = /^[A-Za-z0-9_-]+$/;
|
||||
const MCP_NAME_REGEX = /^[A-Za-z0-9_-]+$/;
|
||||
|
||||
export function validateSkillName(name: string): void {
|
||||
if (!name || name.length < 1 || name.length > 64 || !SKILL_NAME_REGEX.test(name)) {
|
||||
throw new ApiError(400, "invalid_skill_name", "Skill name must be kebab-case (1-64 chars)");
|
||||
}
|
||||
}
|
||||
|
||||
export function validateDescription(description: string | undefined): void {
|
||||
if (!description || description.length < 1 || description.length > 1024) {
|
||||
throw new ApiError(422, "invalid_description", "Description must be 1-1024 characters");
|
||||
}
|
||||
}
|
||||
|
||||
export function validatePluginSpec(spec: string): void {
|
||||
if (!spec || spec.trim().length === 0) {
|
||||
throw new ApiError(400, "invalid_plugin_spec", "Plugin spec is required");
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeCommandName(name: string): string {
|
||||
const trimmed = name.trim().replace(/^\/+/, "");
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function validateCommandName(name: string): void {
|
||||
if (!name || !COMMAND_NAME_REGEX.test(name)) {
|
||||
throw new ApiError(400, "invalid_command_name", "Command name must be alphanumeric with _ or -");
|
||||
}
|
||||
}
|
||||
|
||||
export function validateMcpName(name: string): void {
|
||||
if (!name || name.startsWith("-") || !MCP_NAME_REGEX.test(name)) {
|
||||
throw new ApiError(400, "invalid_mcp_name", "MCP name must be alphanumeric and not start with -");
|
||||
}
|
||||
}
|
||||
|
||||
export function validateMcpConfig(config: Record<string, unknown>): void {
|
||||
const type = config.type;
|
||||
if (type !== "local" && type !== "remote") {
|
||||
throw new ApiError(400, "invalid_mcp_config", "MCP config type must be local or remote");
|
||||
}
|
||||
if (type === "local") {
|
||||
const command = config.command;
|
||||
if (!Array.isArray(command) || command.length === 0) {
|
||||
throw new ApiError(400, "invalid_mcp_config", "Local MCP requires command array");
|
||||
}
|
||||
}
|
||||
if (type === "remote") {
|
||||
const url = config.url;
|
||||
if (!url || typeof url !== "string") {
|
||||
throw new ApiError(400, "invalid_mcp_config", "Remote MCP requires url");
|
||||
}
|
||||
}
|
||||
}
|
||||
21
packages/server/src/workspace-files.ts
Normal file
21
packages/server/src/workspace-files.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
export function opencodeConfigPath(workspaceRoot: string): string {
|
||||
return join(workspaceRoot, "opencode.json");
|
||||
}
|
||||
|
||||
export function openworkConfigPath(workspaceRoot: string): string {
|
||||
return join(workspaceRoot, ".opencode", "openwork.json");
|
||||
}
|
||||
|
||||
export function projectSkillsDir(workspaceRoot: string): string {
|
||||
return join(workspaceRoot, ".opencode", "skills");
|
||||
}
|
||||
|
||||
export function projectCommandsDir(workspaceRoot: string): string {
|
||||
return join(workspaceRoot, ".opencode", "commands");
|
||||
}
|
||||
|
||||
export function projectPluginsDir(workspaceRoot: string): string {
|
||||
return join(workspaceRoot, ".opencode", "plugins");
|
||||
}
|
||||
25
packages/server/src/workspaces.ts
Normal file
25
packages/server/src/workspaces.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { basename, resolve } from "node:path";
|
||||
import type { WorkspaceConfig, WorkspaceInfo } from "./types.js";
|
||||
|
||||
export function workspaceIdForPath(path: string): string {
|
||||
const hash = createHash("sha256").update(path).digest("hex");
|
||||
return `ws_${hash.slice(0, 12)}`;
|
||||
}
|
||||
|
||||
export function buildWorkspaceInfos(
|
||||
workspaces: WorkspaceConfig[],
|
||||
cwd: string,
|
||||
): WorkspaceInfo[] {
|
||||
return workspaces.map((workspace) => {
|
||||
const resolvedPath = resolve(cwd, workspace.path);
|
||||
return {
|
||||
id: workspaceIdForPath(resolvedPath),
|
||||
name: workspace.name ?? basename(resolvedPath),
|
||||
path: resolvedPath,
|
||||
workspaceType: workspace.workspaceType ?? "local",
|
||||
baseUrl: workspace.baseUrl,
|
||||
directory: workspace.directory,
|
||||
};
|
||||
});
|
||||
}
|
||||
16
packages/server/tsconfig.json
Normal file
16
packages/server/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["bun", "node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
89
pnpm-lock.yaml
generated
89
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
Reference in New Issue
Block a user