@polderlabs/bizar 2.6.0 → 3.0.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.
Files changed (44) hide show
  1. package/cli/bin.mjs +158 -130
  2. package/cli/copy.mjs +39 -34
  3. package/cli/plan.test.mjs +2331 -0
  4. package/cli/service.mjs +309 -0
  5. package/package.json +19 -27
  6. package/cli/dashboard/api.mjs +0 -473
  7. package/cli/dashboard/browser.mjs +0 -40
  8. package/cli/dashboard/server.mjs +0 -366
  9. package/cli/dashboard/state.mjs +0 -438
  10. package/cli/dashboard/tasks-store.mjs +0 -203
  11. package/cli/dashboard/watcher.mjs +0 -81
  12. package/cli/dashboard.mjs +0 -97
  13. package/dist/assets/index-BVvY22Gt.css +0 -1
  14. package/dist/assets/index-CO3c8O32.js +0 -285
  15. package/dist/assets/index-CO3c8O32.js.map +0 -1
  16. package/dist/index.html +0 -18
  17. package/src/App.tsx +0 -233
  18. package/src/components/Button.tsx +0 -55
  19. package/src/components/Card.tsx +0 -40
  20. package/src/components/EmptyState.tsx +0 -30
  21. package/src/components/Modal.tsx +0 -137
  22. package/src/components/Spinner.tsx +0 -19
  23. package/src/components/StatusBadge.tsx +0 -25
  24. package/src/components/Tag.tsx +0 -28
  25. package/src/components/Toast.tsx +0 -142
  26. package/src/components/Topbar.tsx +0 -88
  27. package/src/index.html +0 -17
  28. package/src/lib/api.ts +0 -71
  29. package/src/lib/markdown.tsx +0 -59
  30. package/src/lib/types.ts +0 -200
  31. package/src/lib/utils.ts +0 -79
  32. package/src/lib/ws.ts +0 -132
  33. package/src/main.tsx +0 -12
  34. package/src/styles/main.css +0 -2324
  35. package/src/views/Agents.tsx +0 -199
  36. package/src/views/Chat.tsx +0 -255
  37. package/src/views/Config.tsx +0 -250
  38. package/src/views/Overview.tsx +0 -267
  39. package/src/views/Plans.tsx +0 -667
  40. package/src/views/Projects.tsx +0 -155
  41. package/src/views/Settings.tsx +0 -253
  42. package/src/views/Tasks.tsx +0 -567
  43. package/tsconfig.json +0 -23
  44. package/vite.config.ts +0 -24
@@ -1,473 +0,0 @@
1
- /**
2
- * cli/dashboard/api.mjs
3
- *
4
- * REST surface for the dashboard. Every route:
5
- * - Returns JSON
6
- * - Returns { error, message } with appropriate status on failure
7
- * - Never throws to Express — every handler is wrapped in try/catch
8
- *
9
- * The router is mounted at /api/* by cli/dashboard/server.mjs.
10
- */
11
- import express from 'express';
12
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
13
- import { join, dirname, basename } from 'node:path';
14
- import { createTask, updateTask, deleteTask, moveTask } from './tasks-store.mjs';
15
-
16
- /**
17
- * @param {object} deps
18
- * @param {ReturnType<import('./state.mjs').createState>} deps.state
19
- * @param {ReturnType<import('./watcher.mjs').createWatcher>} deps.watcher
20
- * @param {string} deps.projectRoot
21
- * @param {string} deps.opencodeConfigDir
22
- * @param {string} deps.bizarRoot
23
- * @param {function} deps.broadcast - WS broadcast function ({ type, ... }) => void
24
- */
25
- export function createApiRouter({
26
- state,
27
- watcher,
28
- projectRoot,
29
- opencodeConfigDir,
30
- bizarRoot,
31
- broadcast = () => {},
32
- }) {
33
- const router = express.Router();
34
-
35
- /** Safe async handler — never throws past Express. */
36
- const wrap =
37
- (fn) =>
38
- async (req, res, ...rest) => {
39
- try {
40
- await fn(req, res, ...rest);
41
- } catch (err) {
42
- const status = err?.status || 500;
43
- res.status(status).json({
44
- error: err?.code || 'internal_error',
45
- message: err?.message || String(err),
46
- });
47
- }
48
- };
49
-
50
- // ── /api/overview ──────────────────────────────────────────────────────────
51
- router.get('/overview', wrap(async (_req, res) => {
52
- res.json(state.getOverview());
53
- }));
54
-
55
- // ── /api/snapshot ──────────────────────────────────────────────────────────
56
- // Returns every dashboard panel in one round-trip — used by the React SPA
57
- // for its initial load. Mirrors the WS snapshot payload shape.
58
- router.get('/snapshot', wrap(async (_req, res) => {
59
- res.json({
60
- overview: state.getOverview(),
61
- agents: state.getAgents(),
62
- plans: state.getPlans(),
63
- projects: state.getProjects(),
64
- config: state.getConfig(),
65
- settings: state.getSettings(),
66
- tasks: state.getTasks(),
67
- });
68
- }));
69
-
70
- // ── /api/chat ──────────────────────────────────────────────────────────────
71
- router.get('/chat', wrap(async (req, res) => {
72
- const sessionId = req.query.session
73
- ? String(req.query.session)
74
- : null;
75
- const limit = req.query.limit ? Number(req.query.limit) : 200;
76
- res.json(state.getChat({ sessionId, limit }));
77
- }));
78
-
79
- router.post('/chat', wrap(async (req, res) => {
80
- const body = req.body || {};
81
- const message = typeof body.message === 'string' ? body.message.trim() : '';
82
- const agent = typeof body.agent === 'string' ? body.agent : null;
83
- if (!message) {
84
- res.status(400).json({
85
- error: 'bad_request',
86
- message: 'message is required',
87
- });
88
- return;
89
- }
90
- state.appendActivity({
91
- kind: 'chat.message',
92
- agent,
93
- message: message.slice(0, 500),
94
- });
95
- res.status(202).json({
96
- accepted: true,
97
- agent,
98
- queued: true,
99
- note:
100
- 'Live agent dispatch runs in the opencode TUI; the dashboard ' +
101
- 'records and broadcasts the message. Open the TUI to dispatch it.',
102
- });
103
- }));
104
-
105
- // ── /api/agents ────────────────────────────────────────────────────────────
106
- router.get('/agents', wrap(async (_req, res) => {
107
- const agents = state.getAgents();
108
- res.json({ agents });
109
- }));
110
-
111
- router.post(
112
- '/agents/:name/invoke',
113
- wrap(async (req, res) => {
114
- const name = req.params.name;
115
- const prompt = (req.body && req.body.prompt) || '';
116
- if (!prompt.trim()) {
117
- res.status(400).json({
118
- error: 'bad_request',
119
- message: 'prompt is required',
120
- });
121
- return;
122
- }
123
- state.appendActivity({
124
- kind: 'agent.invoke',
125
- agent: name,
126
- prompt: String(prompt).slice(0, 500),
127
- });
128
- res.status(202).json({
129
- accepted: true,
130
- agent: name,
131
- note:
132
- 'Agent dispatch is forwarded to the opencode TUI. The dashboard ' +
133
- 'records the invocation for the activity feed.',
134
- });
135
- }),
136
- );
137
-
138
- // ── /api/plans ─────────────────────────────────────────────────────────────
139
- router.get('/plans', wrap(async (_req, res) => {
140
- res.json({ plans: state.getPlans() });
141
- }));
142
-
143
- router.post('/plans', wrap(async (req, res) => {
144
- const slug = req.body?.slug;
145
- const title = req.body?.title || slug;
146
- if (
147
- typeof slug !== 'string' ||
148
- !/^[a-z0-9][a-z0-9-]{0,63}$/.test(slug)
149
- ) {
150
- res.status(400).json({
151
- error: 'bad_request',
152
- message:
153
- 'slug must match ^[a-z0-9][a-z0-9-]{0,63}$',
154
- });
155
- return;
156
- }
157
- const plansDir = state.paths.plansDir;
158
- const target = join(plansDir, slug);
159
- if (existsSync(target)) {
160
- res.status(409).json({
161
- error: 'exists',
162
- message: `plan "${slug}" already exists`,
163
- });
164
- return;
165
- }
166
- mkdirSync(target, { recursive: true });
167
- writeFileSync(
168
- join(target, 'meta.json'),
169
- JSON.stringify(
170
- {
171
- slug,
172
- title,
173
- status: 'draft',
174
- createdAt: new Date().toISOString(),
175
- },
176
- null,
177
- 2,
178
- ) + '\n',
179
- 'utf8',
180
- );
181
- state.appendActivity({ kind: 'plan.create', slug, title });
182
- watcher.poke('change', target);
183
- res.status(201).json({ slug, title, path: target });
184
- }));
185
-
186
- router.get('/plans/:slug', wrap(async (req, res) => {
187
- const slug = req.params.slug;
188
- const local = join(state.paths.plansDir, slug);
189
- const global = join(state.paths.globalPlansDir, slug);
190
- const dir = existsSync(local) ? local : existsSync(global) ? global : null;
191
- if (!dir) {
192
- res.status(404).json({
193
- error: 'not_found',
194
- message: `plan "${slug}" not found`,
195
- });
196
- return;
197
- }
198
- const meta = readMeta(join(dir, 'meta.json'));
199
- const planMdx = readMaybe(join(dir, 'plan.mdx'));
200
- res.json({
201
- slug,
202
- dir,
203
- meta,
204
- planMdx,
205
- });
206
- }));
207
-
208
- router.get('/plans/:slug/canvas', wrap(async (req, res) => {
209
- const slug = req.params.slug;
210
- const local = join(state.paths.plansDir, slug);
211
- const global = join(state.paths.globalPlansDir, slug);
212
- const dir = existsSync(local) ? local : existsSync(global) ? global : null;
213
- if (!dir) {
214
- res.status(404).json({
215
- error: 'not_found',
216
- message: `plan "${slug}" not found`,
217
- });
218
- return;
219
- }
220
- const planJson = join(dir, 'plan.json');
221
- const canvas = readMaybe(planJson);
222
- if (canvas === null) {
223
- res.json({
224
- slug,
225
- dir,
226
- canvas: {
227
- schemaVersion: 2,
228
- title: slug,
229
- elements: [],
230
- connections: [],
231
- comments: [],
232
- viewport: { x: 0, y: 0, zoom: 1 },
233
- },
234
- });
235
- return;
236
- }
237
- try {
238
- res.json({
239
- slug,
240
- dir,
241
- canvas: JSON.parse(canvas),
242
- });
243
- } catch {
244
- res.json({
245
- slug,
246
- dir,
247
- canvas: {
248
- schemaVersion: 2,
249
- title: slug,
250
- elements: [],
251
- connections: [],
252
- comments: [],
253
- viewport: { x: 0, y: 0, zoom: 1 },
254
- },
255
- });
256
- }
257
- }));
258
-
259
- router.put('/plans/:slug', wrap(async (req, res) => {
260
- const slug = req.params.slug;
261
- const local = join(state.paths.plansDir, slug);
262
- const global = join(state.paths.globalPlansDir, slug);
263
- const dir = existsSync(local) ? local : existsSync(global) ? local : null;
264
- if (!dir) {
265
- res.status(404).json({
266
- error: 'not_found',
267
- message: `plan "${slug}" not found`,
268
- });
269
- return;
270
- }
271
- const body = req.body || {};
272
- if (!body || typeof body !== 'object') {
273
- res.status(400).json({
274
- error: 'bad_request',
275
- message: 'body must be an object',
276
- });
277
- return;
278
- }
279
- if (body.meta && typeof body.meta === 'object') {
280
- const existing = readMeta(join(dir, 'meta.json')) || {};
281
- const merged = { ...existing, ...body.meta, updatedAt: new Date().toISOString() };
282
- mkdirSync(dir, { recursive: true });
283
- writeFileSync(
284
- join(dir, 'meta.json'),
285
- JSON.stringify(merged, null, 2) + '\n',
286
- 'utf8',
287
- );
288
- }
289
- if (typeof body.planMdx === 'string') {
290
- writeFileSync(join(dir, 'plan.mdx'), body.planMdx, 'utf8');
291
- }
292
- state.appendActivity({ kind: 'plan.update', slug });
293
- watcher.poke('change', dir);
294
- res.json({ ok: true, slug });
295
- }));
296
-
297
- // ── /api/projects ──────────────────────────────────────────────────────────
298
- router.get('/projects', wrap(async (_req, res) => {
299
- res.json({ projects: state.getProjects() });
300
- }));
301
-
302
- router.post(
303
- '/projects/:name/activate',
304
- wrap(async (req, res) => {
305
- const name = req.params.name;
306
- const projects = state.getProjects();
307
- const target = projects.find((p) => p.name === name);
308
- if (!target) {
309
- res.status(404).json({
310
- error: 'not_found',
311
- message: `project "${name}" not found`,
312
- });
313
- return;
314
- }
315
- state.appendActivity({
316
- kind: 'project.activate',
317
- name,
318
- path: target.path,
319
- });
320
- res.json({
321
- activated: name,
322
- path: target.path,
323
- note:
324
- 'The dashboard exposes the project; the opencode TUI must be ' +
325
- 'restarted in the new directory to fully activate.',
326
- });
327
- }),
328
- );
329
-
330
- // ── /api/config ────────────────────────────────────────────────────────────
331
- router.get('/config', wrap(async (_req, res) => {
332
- res.json(state.getConfig());
333
- }));
334
-
335
- router.put('/config', wrap(async (req, res) => {
336
- const body = req.body;
337
- let parsed;
338
- if (typeof body === 'string') {
339
- try {
340
- parsed = JSON.parse(body);
341
- } catch (err) {
342
- res.status(400).json({
343
- error: 'invalid_json',
344
- message: err.message,
345
- });
346
- return;
347
- }
348
- } else if (body && typeof body === 'object') {
349
- parsed = body;
350
- } else {
351
- res.status(400).json({
352
- error: 'bad_request',
353
- message: 'body must be a JSON object or string',
354
- });
355
- return;
356
- }
357
- const updated = state.setConfig(parsed);
358
- state.appendActivity({ kind: 'config.update' });
359
- watcher.poke('change', state.paths.opencodeJson);
360
- res.json(updated);
361
- }));
362
-
363
- router.post('/config/reload', wrap(async (_req, res) => {
364
- const snapshot = state.getConfig();
365
- state.appendActivity({ kind: 'config.reload' });
366
- res.json(snapshot);
367
- }));
368
-
369
- // ── /api/settings ──────────────────────────────────────────────────────────
370
- router.get('/settings', wrap(async (_req, res) => {
371
- res.json(state.getSettings());
372
- }));
373
-
374
- router.put('/settings', wrap(async (req, res) => {
375
- const body = req.body;
376
- if (!body || typeof body !== 'object') {
377
- res.status(400).json({
378
- error: 'bad_request',
379
- message: 'body must be an object',
380
- });
381
- return;
382
- }
383
- const updated = state.setSettings(body);
384
- state.appendActivity({ kind: 'settings.update' });
385
- res.json(updated);
386
- }));
387
-
388
- // ── /api/tasks ────────────────────────────────────────────────────────────
389
- router.get('/tasks', wrap(async (_req, res) => {
390
- res.json(state.getTasks());
391
- }));
392
-
393
- router.post('/tasks', wrap(async (req, res) => {
394
- const { title, description, status, tags, priority } = req.body || {};
395
- if (!title || typeof title !== 'string' || title.length > 200) {
396
- res.status(400).json({ error: 'bad_request', message: 'title required (1-200 chars)' });
397
- return;
398
- }
399
- const task = await createTask({ title, description, status, tags, priority });
400
- broadcast({ type: 'tasks:change', task });
401
- res.status(201).json(task);
402
- }));
403
-
404
- router.put('/tasks/:id', wrap(async (req, res) => {
405
- const task = await updateTask(req.params.id, req.body || {});
406
- if (!task) {
407
- res.status(404).json({ error: 'not_found' });
408
- return;
409
- }
410
- broadcast({ type: 'tasks:change', task });
411
- res.json(task);
412
- }));
413
-
414
- router.patch('/tasks/:id/status', wrap(async (req, res) => {
415
- const { status } = req.body || {};
416
- if (!['queued', 'doing', 'done'].includes(status)) {
417
- res.status(400).json({ error: 'bad_request', message: 'invalid status' });
418
- return;
419
- }
420
- const task = await moveTask(req.params.id, status);
421
- if (!task) {
422
- res.status(404).json({ error: 'not_found' });
423
- return;
424
- }
425
- broadcast({ type: 'tasks:change', task });
426
- res.json(task);
427
- }));
428
-
429
- router.delete('/tasks/:id', wrap(async (req, res) => {
430
- const ok = await deleteTask(req.params.id);
431
- if (!ok) {
432
- res.status(404).json({ error: 'not_found' });
433
- return;
434
- }
435
- broadcast({ type: 'tasks:delete', id: req.params.id });
436
- res.status(204).end();
437
- }));
438
-
439
- // ── health ────────────────────────────────────────────────────────────────
440
- router.get('/health', (_req, res) => {
441
- res.json({ ok: true, ts: Date.now() });
442
- });
443
-
444
- // ── fallback ──────────────────────────────────────────────────────────────
445
- router.use((req, res) => {
446
- res.status(404).json({
447
- error: 'not_found',
448
- message: `no route for ${req.method} ${req.originalUrl}`,
449
- });
450
- });
451
-
452
- return router;
453
- }
454
-
455
- function readMeta(file) {
456
- try {
457
- if (!existsSync(file)) return null;
458
- const txt = readFileSync(file, 'utf8');
459
- if (!txt.trim()) return null;
460
- return JSON.parse(txt);
461
- } catch {
462
- return null;
463
- }
464
- }
465
-
466
- function readMaybe(file) {
467
- try {
468
- if (!existsSync(file)) return null;
469
- return readFileSync(file, 'utf8');
470
- } catch {
471
- return null;
472
- }
473
- }
@@ -1,40 +0,0 @@
1
- /**
2
- * cli/dashboard/browser.mjs
3
- *
4
- * Cross-platform best-effort browser launcher. On failure (no graphical
5
- * session, headless server), we just log the URL so the user can open it
6
- * manually.
7
- */
8
- import { spawn } from 'node:child_process';
9
-
10
- export async function launchBrowser(url) {
11
- const platform = process.platform;
12
- let cmd;
13
- let args;
14
-
15
- if (platform === 'darwin') {
16
- cmd = 'open';
17
- args = [url];
18
- } else if (platform === 'win32') {
19
- // Windows `start` is a shell builtin; spawn it via cmd.exe.
20
- cmd = 'cmd';
21
- args = ['/c', 'start', '""', url];
22
- } else {
23
- cmd = 'xdg-open';
24
- args = [url];
25
- }
26
-
27
- try {
28
- const child = spawn(cmd, args, {
29
- detached: true,
30
- stdio: 'ignore',
31
- });
32
- child.on('error', () => {
33
- /* swallowed — best effort */
34
- });
35
- child.unref();
36
- } catch (_err) {
37
- // Browser launch failed — print URL for manual opening
38
- console.log(`Open ${url} in your browser`);
39
- }
40
- }