wmdev 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,267 @@
1
+ # wmdev
2
+
3
+ Web dashboard for [workmux](https://github.com/raine/workmux). Provides a browser UI with embedded terminals, PR status monitoring, and CI integration on top of workmux's worktree + tmux orchestration.
4
+
5
+ ## What is workmux?
6
+
7
+ [workmux](https://github.com/raine/workmux) is a CLI tool that orchestrates git worktrees and tmux. It pairs each worktree with a tmux window, provisions files (copy/symlink), runs lifecycle hooks, and has first-class AI agent support. A single `workmux add` creates the worktree, opens a tmux window with configured panes, and starts your agent. `workmux merge` merges the branch, deletes the worktree, closes the window, and cleans up branches.
8
+
9
+ workmux is configured via `.workmux.yaml` in the project root. See the [workmux README](https://github.com/raine/workmux) for full documentation.
10
+
11
+ ## What wmdev adds
12
+
13
+ wmdev is a web UI that wraps workmux. It delegates core worktree lifecycle operations to the `workmux` CLI and adds browser-based features on top:
14
+
15
+ | Responsibility | Handled by |
16
+ |---|---|
17
+ | Create/remove/merge worktrees | **workmux** (wmdev calls `workmux add`, `workmux rm`, `workmux merge`) |
18
+ | Pane layout and agent launch (default profile) | **workmux** (uses `.workmux.yaml` pane config) |
19
+ | Open/focus a worktree's tmux window | **workmux** (`workmux open`) |
20
+ | List worktrees and agent status | **workmux** (`workmux list`, `workmux status`) |
21
+ | File provisioning and lifecycle hooks | **workmux** (`.workmux.yaml` `files` and `post_create`) |
22
+ | Browser terminal (xterm.js ↔ tmux) | **wmdev** |
23
+ | Service health monitoring (port polling) | **wmdev** |
24
+ | PR status tracking and badges | **wmdev** (polls `gh pr list`) |
25
+ | CI check status and failed log viewing | **wmdev** (calls `gh run view`) |
26
+ | Send prompts / PR comments to agents | **wmdev** (via `tmux load-buffer`) |
27
+ | Docker sandbox container lifecycle | **wmdev** (manages `docker run/rm` directly) |
28
+
29
+ ## Quick start
30
+
31
+ ```bash
32
+ # 1. Install prerequisites
33
+ cargo install workmux # or: brew install raine/workmux/workmux
34
+ sudo apt install tmux # or: brew install tmux
35
+ curl -fsSL https://bun.sh/install | bash
36
+
37
+ # 2. Install wmdev
38
+ bun install -g wmdev
39
+
40
+ # 3. Set up your project
41
+ cd /path/to/your/project
42
+ workmux init # creates .workmux.yaml with sensible defaults
43
+
44
+ # 4. (Optional) Create a .wmdev.yaml for dashboard-specific config
45
+ # See Configuration below
46
+
47
+ # 5. Start the dashboard
48
+ wmdev # UI on http://localhost:5111
49
+ wmdev --port 8080 # custom port
50
+ ```
51
+
52
+ ## Configuration
53
+
54
+ wmdev uses two config files in the project root:
55
+
56
+ - **`.workmux.yaml`** — workmux's own config. Controls worktree directory, pane layout, agent selection, file provisioning, lifecycle hooks, merge strategy, and more. See the [workmux docs](https://github.com/raine/workmux).
57
+ - **`.wmdev.yaml`** — dashboard-specific config. Controls service health checks, worktree profiles, linked repos for PR monitoring, and Docker sandbox settings.
58
+
59
+ ### `.wmdev.yaml` schema
60
+
61
+ ```yaml
62
+ # Services to monitor — each maps a display name to a port env var.
63
+ # The dashboard polls these ports and shows health status badges.
64
+ services:
65
+ - name: string # Display name (e.g. "BE", "FE")
66
+ portEnv: string # Env var holding the port (e.g. "BACKEND_PORT")
67
+
68
+ # Profiles define the environment when creating a worktree via the dashboard.
69
+ profiles:
70
+ default: # Required — used when no profile is specified
71
+ name: string # Profile identifier
72
+ systemPrompt: string # (optional) Instructions for the AI agent.
73
+ # Supports ${VAR} placeholders expanded from .env.local.
74
+ envPassthrough: string[] # (optional) Env vars to pass to the agent process
75
+
76
+ sandbox: # (optional) Docker-based sandboxed profile
77
+ name: string # Profile identifier
78
+ image: string # Docker image name (must be pre-built)
79
+ systemPrompt: string # (optional) Agent instructions (supports ${VAR})
80
+ envPassthrough: string[] # (optional) Host env vars forwarded into the container
81
+ extraMounts: # (optional) Additional bind mounts
82
+ - hostPath: string # Host path (supports ~ for $HOME)
83
+ guestPath: string # (optional) Mount point inside container (defaults to hostPath)
84
+ writable: boolean # (optional) true = read-write, false/omit = read-only
85
+
86
+ # Monitor PRs from other GitHub repos and show their status alongside
87
+ # worktree branches that share the same branch name.
88
+ linkedRepos:
89
+ - repo: string # GitHub repo slug (e.g. "org/repo")
90
+ alias: string # (optional) Short label shown in the UI
91
+ ```
92
+
93
+ ### Defaults
94
+
95
+ If `.wmdev.yaml` is missing or empty:
96
+
97
+ ```yaml
98
+ services: []
99
+ profiles:
100
+ default:
101
+ name: default
102
+ linkedRepos: []
103
+ ```
104
+
105
+ ### Example
106
+
107
+ ```yaml
108
+ services:
109
+ - name: BE
110
+ portEnv: DASHBOARD_PORT
111
+ - name: FE
112
+ portEnv: FRONTEND_PORT
113
+
114
+ profiles:
115
+ default:
116
+ name: default
117
+
118
+ sandbox:
119
+ name: sandbox
120
+ image: my-sandbox
121
+ envPassthrough:
122
+ - AWS_ACCESS_KEY_ID
123
+ - AWS_SECRET_ACCESS_KEY
124
+ extraMounts:
125
+ - hostPath: ~/.codex
126
+ guestPath: /root/.codex
127
+ writable: true
128
+ systemPrompt: >
129
+ You are running inside a sandboxed container.
130
+ Backend port: ${DASHBOARD_PORT}. Frontend port: ${FRONTEND_PORT}.
131
+
132
+ linkedRepos:
133
+ - repo: myorg/related-service
134
+ alias: svc
135
+ ```
136
+
137
+ ### Parameter reference
138
+
139
+ | Parameter | Type | Required | Description |
140
+ |-----------|------|----------|-------------|
141
+ | `services[].name` | string | yes | Display name shown in the dashboard |
142
+ | `services[].portEnv` | string | yes | Env var containing the service port (read from each worktree's `.env.local`) |
143
+ | `profiles.default.name` | string | yes | Identifier for the default profile |
144
+ | `profiles.default.systemPrompt` | string | no | System prompt for the agent; `${VAR}` placeholders expanded at runtime |
145
+ | `profiles.default.envPassthrough` | string[] | no | Env vars passed through to the agent process |
146
+ | `profiles.sandbox.name` | string | yes (if used) | Identifier for the sandbox profile |
147
+ | `profiles.sandbox.image` | string | yes (if used) | Docker image for containers |
148
+ | `profiles.sandbox.systemPrompt` | string | no | System prompt for sandbox agents; `${VAR}` placeholders expanded at runtime |
149
+ | `profiles.sandbox.envPassthrough` | string[] | no | Host env vars forwarded into the Docker container |
150
+ | `profiles.sandbox.extraMounts[].hostPath` | string | yes | Host path to mount (`~` expands to `$HOME`) |
151
+ | `profiles.sandbox.extraMounts[].guestPath` | string | no | Container mount path (defaults to `hostPath`) |
152
+ | `profiles.sandbox.extraMounts[].writable` | boolean | no | `true` for read-write; omit or `false` for read-only |
153
+ | `linkedRepos[].repo` | string | yes | GitHub repo slug (e.g. `org/repo`) |
154
+ | `linkedRepos[].alias` | string | no | Short label for the UI (defaults to repo name) |
155
+
156
+ ### Auto-generated branch names
157
+
158
+ If your `.workmux.yaml` has `auto_name.model` configured, the create-worktree dialog will automatically generate a branch name from the prompt using that LLM. This is a workmux feature — wmdev detects it and enables the UI flow accordingly.
159
+
160
+ ## Architecture
161
+
162
+ ```
163
+ Browser (localhost:5111)
164
+
165
+ ├── REST API (/api/*) ──┐
166
+ └── WebSocket (/ws/*) ──┤
167
+
168
+ Backend (Bun HTTP server)
169
+
170
+ ┌──────────────┼──────────────┐
171
+ │ │ │
172
+ workmux CLI tmux sessions Docker
173
+ (worktree (terminal (sandbox
174
+ lifecycle) access) containers)
175
+ ```
176
+
177
+ **Backend** — Bun/TypeScript HTTP + WebSocket server (`backend/src/server.ts`):
178
+
179
+ - **REST API** (`/api/*`) — CRUD for worktrees. Wraps the `workmux` CLI to create/remove/merge worktrees. Enriches each worktree with directory, assigned ports, service health, PR status, and agent state.
180
+ - **WebSocket** (`/ws/*`) — Bidirectional terminal bridge between xterm.js in the browser and tmux sessions on the server.
181
+
182
+ **Frontend** — Svelte 5 SPA with Tailwind CSS and xterm.js (`frontend/src/`). Two-panel layout: worktree sidebar + embedded terminal. Polls the REST API for status updates. Responsive with mobile pane navigation.
183
+
184
+ ### Terminal streaming
185
+
186
+ ```
187
+ Browser (xterm.js) ←— WebSocket —→ Backend ←— stdin/stdout pipes —→ script (PTY) ←— tmux attach —→ tmux grouped session
188
+ ```
189
+
190
+ When a worktree is selected, the frontend opens a WebSocket to `/ws/<worktree>`. The backend spawns a PTY via `script` and attaches to a **grouped tmux session** — a separate view into the same windows. This allows the dashboard and a real terminal to view the same worktree simultaneously.
191
+
192
+ Output is buffered (up to 1 MB) so reconnecting clients receive recent history immediately.
193
+
194
+ ### Worktree profiles
195
+
196
+ | Profile | What it does |
197
+ |---------|-------------|
198
+ | `default` | Delegates to workmux — uses the pane layout and commands from `.workmux.yaml`. wmdev doesn't manage panes or processes; workmux handles it all. |
199
+ | `sandbox` | Managed by wmdev. Launches a Docker container, sets up agent + shell panes, and publishes service ports via `docker run -p`. |
200
+
201
+ ### Docker sandbox containers
202
+
203
+ For sandbox profiles, wmdev manages Docker containers directly:
204
+
205
+ 1. **Launch** — `docker run -d -p <ports>` with the configured image, mounts, and env vars. Runs as the host user (`--user uid:gid`) so file ownership matches.
206
+ 2. **Mounts** — Worktree dir (rw), main repo `.git` (rw), main repo root (ro), `~/.claude` (dir) and `~/.claude.json` (settings file), plus any `extraMounts`. Conditionally mounts `~/.gitconfig`, `~/.ssh`, `~/.config/gh` if they exist.
207
+ 3. **Environment** — All `.env.local` vars + `envPassthrough` vars + `HOME`, `TERM`, `IS_SANDBOX=1`.
208
+ 4. **Cleanup** — Containers are removed when the worktree is removed or merged.
209
+
210
+ ## Prerequisites
211
+
212
+ | Tool | Min version | Purpose |
213
+ |------|-------------|---------|
214
+ | [**bun**](https://bun.sh) | 1.3.5+ | Runtime for backend and frontend |
215
+ | [**workmux**](https://github.com/raine/workmux) | latest | Worktree + tmux orchestration |
216
+ | **tmux** | 3.x | Terminal multiplexer |
217
+ | **git** | 2.x | Worktree management |
218
+ | **gh** | 2.x | PR and CI status (optional — needed for PR badges and CI logs) |
219
+ | **docker** | 28+ | Only needed for sandbox profile |
220
+
221
+ ## Environment variables
222
+
223
+ | Variable | Default | Description |
224
+ |----------|---------|-------------|
225
+ | `DASHBOARD_PORT` | `5111` | Backend API port (also configurable via `--port`) |
226
+
227
+ ## Keyboard shortcuts
228
+
229
+ | Shortcut | Action |
230
+ |----------|--------|
231
+ | `Cmd+Up/Down` | Navigate between worktrees |
232
+ | `Cmd+K` | Create new worktree |
233
+ | `Cmd+M` | Merge selected worktree |
234
+ | `Cmd+D` | Remove selected worktree |
235
+
236
+ ## API
237
+
238
+ | Method | Endpoint | Description |
239
+ |--------|----------|-------------|
240
+ | `GET` | `/api/config` | Load dashboard config |
241
+ | `GET` | `/api/worktrees` | List all worktrees with status, ports, service health, and PR data |
242
+ | `POST` | `/api/worktrees` | Create a worktree (`{ branch?, profile?, agent?, prompt? }`) |
243
+ | `DELETE` | `/api/worktrees/:name` | Remove a worktree |
244
+ | `POST` | `/api/worktrees/:name/open` | Open/focus a worktree's tmux window |
245
+ | `POST` | `/api/worktrees/:name/merge` | Merge worktree into main + cleanup |
246
+ | `GET` | `/api/worktrees/:name/status` | Get agent status for a worktree |
247
+ | `POST` | `/api/worktrees/:name/send` | Send a prompt to the agent's tmux pane |
248
+ | `GET` | `/api/ci-logs/:runId` | Fetch failed CI run logs |
249
+ | `WS` | `/ws/:worktree` | Terminal WebSocket (xterm.js ↔ tmux) |
250
+
251
+ ## Development
252
+
253
+ ```bash
254
+ ./dev.sh # backend + frontend with hot reload, UI on :5112
255
+ ```
256
+
257
+ Or start them separately:
258
+
259
+ ```bash
260
+ # Terminal 1: backend (auto-reloads on save)
261
+ cd backend && bun run dev
262
+
263
+ # Terminal 2: frontend (Vite dev server)
264
+ cd frontend && bun run dev
265
+ ```
266
+
267
+ The frontend dev server runs on port `5112` and proxies `/api/*` and `/ws/*` to the backend on `5111`.
@@ -0,0 +1,101 @@
1
+ import { join } from "node:path";
2
+ import { parse as parseYaml } from "yaml";
3
+
4
+ export interface ServiceConfig {
5
+ name: string;
6
+ portEnv: string;
7
+ }
8
+
9
+ export interface ProfileConfig {
10
+ name: string;
11
+ systemPrompt?: string;
12
+ envPassthrough?: string[];
13
+ }
14
+
15
+ export interface SandboxProfileConfig extends ProfileConfig {
16
+ image: string;
17
+ extraMounts?: { hostPath: string; guestPath?: string; writable?: boolean }[];
18
+ }
19
+
20
+ export interface LinkedRepoConfig {
21
+ repo: string;
22
+ alias: string;
23
+ }
24
+
25
+ export interface WmdevConfig {
26
+ services: ServiceConfig[];
27
+ profiles: {
28
+ default: ProfileConfig;
29
+ sandbox?: SandboxProfileConfig;
30
+ };
31
+ autoName: boolean;
32
+ linkedRepos: LinkedRepoConfig[];
33
+ }
34
+
35
+ const DEFAULT_CONFIG: WmdevConfig = {
36
+ services: [],
37
+ profiles: { default: { name: "default" } },
38
+ autoName: false,
39
+ linkedRepos: [],
40
+ };
41
+
42
+ /** Check if .workmux.yaml has auto_name configured. */
43
+ function hasAutoName(dir: string): boolean {
44
+ try {
45
+ const filePath = join(gitRoot(dir), ".workmux.yaml");
46
+ const result = Bun.spawnSync(["cat", filePath], { stdout: "pipe", stderr: "pipe" });
47
+ const text = new TextDecoder().decode(result.stdout).trim();
48
+ if (!text) return false;
49
+ const parsed = parseYaml(text) as Record<string, unknown>;
50
+ const autoName = parsed.auto_name as Record<string, unknown> | undefined;
51
+ return !!autoName?.model;
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ /** Resolve the git repository root from a directory. */
58
+ export function gitRoot(dir: string): string {
59
+ const result = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], { stdout: "pipe", cwd: dir });
60
+ return new TextDecoder().decode(result.stdout).trim() || dir;
61
+ }
62
+
63
+ /** Load .wmdev.yaml from the git root, merging with defaults. */
64
+ export function loadConfig(dir: string): WmdevConfig {
65
+ try {
66
+ const root = gitRoot(dir);
67
+ const filePath = join(root, ".wmdev.yaml");
68
+ const result = Bun.spawnSync(["cat", filePath], { stdout: "pipe" });
69
+ const text = new TextDecoder().decode(result.stdout).trim();
70
+ if (!text) return DEFAULT_CONFIG;
71
+ const parsed = parseYaml(text) as Record<string, unknown>;
72
+ const profiles = parsed.profiles as Record<string, unknown> | undefined;
73
+ const defaultProfile = profiles?.default as ProfileConfig | undefined;
74
+ const sandboxProfile = profiles?.sandbox as SandboxProfileConfig | undefined;
75
+ const autoName = hasAutoName(dir);
76
+ const linkedRepos: LinkedRepoConfig[] = Array.isArray(parsed.linkedRepos)
77
+ ? (parsed.linkedRepos as Array<Record<string, unknown>>)
78
+ .filter((r) => typeof r === "object" && r !== null && typeof r.repo === "string")
79
+ .map((r) => ({
80
+ repo: r.repo as string,
81
+ alias: typeof r.alias === "string" ? r.alias : (r.repo as string).split("/").pop()!,
82
+ }))
83
+ : [];
84
+ return {
85
+ services: Array.isArray(parsed.services) ? parsed.services as ServiceConfig[] : DEFAULT_CONFIG.services,
86
+ profiles: {
87
+ default: defaultProfile?.name ? defaultProfile : DEFAULT_CONFIG.profiles.default,
88
+ ...(sandboxProfile?.name && sandboxProfile?.image ? { sandbox: sandboxProfile } : {}),
89
+ },
90
+ autoName,
91
+ linkedRepos,
92
+ };
93
+ } catch {
94
+ return DEFAULT_CONFIG;
95
+ }
96
+ }
97
+
98
+ /** Expand ${VAR} placeholders in a template string using an env map. */
99
+ export function expandTemplate(template: string, env: Record<string, string>): string {
100
+ return template.replace(/\$\{(\w+)\}/g, (_, key: string) => env[key] ?? "");
101
+ }