@galda/cli 0.10.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 (34) hide show
  1. package/CLAUDE.md +44 -0
  2. package/README.md +83 -0
  3. package/app/fonts.css +8 -0
  4. package/app/index.html +7638 -0
  5. package/app/theme.css +126 -0
  6. package/app/wp/w1.jpg +0 -0
  7. package/app/wp/w2.jpg +0 -0
  8. package/bin/manager-for-ai.mjs +76 -0
  9. package/engine/lib.mjs +2378 -0
  10. package/engine/manager.mjs +115 -0
  11. package/engine/mcp.mjs +123 -0
  12. package/engine/pr.mjs +144 -0
  13. package/engine/relay-client.mjs +82 -0
  14. package/engine/server.mjs +3315 -0
  15. package/engine/verify.mjs +158 -0
  16. package/examples/task-001/runs/2026-07-03T06-23-57-441Z/attempt-1-proof.png +0 -0
  17. package/examples/task-001/runs/2026-07-03T06-23-57-441Z/report.json +20 -0
  18. package/examples/task-001/runs/2026-07-03T06-29-54-517Z/attempt-1-proof.png +0 -0
  19. package/examples/task-001/runs/2026-07-03T06-29-54-517Z/report.json +20 -0
  20. package/examples/task-001/runs/2026-07-03T06-40-23-231Z/attempt-1-proof.png +0 -0
  21. package/examples/task-001/runs/2026-07-03T06-40-23-231Z/report.json +20 -0
  22. package/examples/task-001/runs/2026-07-03T07-34-58-109Z/attempt-1-proof.png +0 -0
  23. package/examples/task-001/runs/2026-07-03T07-34-58-109Z/report.json +21 -0
  24. package/examples/task-001/runs/2026-07-03T07-53-44-639Z/attempt-1-proof.png +0 -0
  25. package/examples/task-001/runs/2026-07-03T07-53-44-639Z/report.json +21 -0
  26. package/examples/task-001/runs/2026-07-03T08-02-12-117Z/attempt-1-proof.png +0 -0
  27. package/examples/task-001/runs/2026-07-03T08-02-12-117Z/report.json +21 -0
  28. package/examples/task-001/task.json +11 -0
  29. package/examples/task-001/verify.mjs +16 -0
  30. package/examples/toast-app/app.js +23 -0
  31. package/examples/toast-app/index.html +28 -0
  32. package/examples/toast-app/test/guard.test.mjs +83 -0
  33. package/examples/toast-app/test/style.test.mjs +19 -0
  34. package/package.json +52 -0
@@ -0,0 +1,158 @@
1
+ // Manager for AI — deterministic verification core (no LLM at run time)
2
+ //
3
+ // Runs a verify(page, ui) function against an entry URL in headless Chrome,
4
+ // with a visible cursor so the recording reads as a real interaction.
5
+ // Produces: webm -> mp4 (QuickTime-safe) -> gif (auto-plays on GitHub).
6
+
7
+ import { spawnSync } from 'node:child_process';
8
+ import { createRequire } from 'node:module';
9
+
10
+ const require = createRequire(import.meta.url);
11
+ const puppeteer = require('puppeteer-core');
12
+ import { existsSync } from 'node:fs';
13
+
14
+ const CHROME_CANDIDATES = [
15
+ process.env.CHROME_PATH,
16
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
17
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
18
+ '/usr/bin/google-chrome',
19
+ '/usr/bin/chromium-browser',
20
+ '/usr/bin/chromium',
21
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
22
+ 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
23
+ ].filter(Boolean);
24
+ const CHROME = CHROME_CANDIDATES.find((c) => existsSync(c));
25
+ if (!CHROME) throw new Error('Chrome not found - set CHROME_PATH to your Chrome/Chromium binary');
26
+
27
+ export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
28
+
29
+ async function injectCursor(page) {
30
+ await page.evaluate(() => {
31
+ const c = document.createElement('div');
32
+ c.id = '__mgr_cursor';
33
+ c.style.cssText = [
34
+ 'position:fixed', 'left:-40px', 'top:-40px', 'width:18px', 'height:18px',
35
+ 'border-radius:50%', 'background:rgba(233,231,226,.92)',
36
+ 'border:2px solid rgba(124,196,255,.9)', 'box-shadow:0 0 14px rgba(124,196,255,.65)',
37
+ 'pointer-events:none', 'z-index:2147483647', 'transform:translate(-50%,-50%)',
38
+ 'transition:transform .12s ease', 'opacity:0',
39
+ ].join(';');
40
+ document.body.appendChild(c);
41
+ document.addEventListener('mousemove', (e) => {
42
+ c.style.opacity = '1'; c.style.left = e.clientX + 'px'; c.style.top = e.clientY + 'px';
43
+ }, true);
44
+ document.addEventListener('mousedown', () => { c.style.transform = 'translate(-50%,-50%) scale(.6)'; }, true);
45
+ document.addEventListener('mouseup', () => { c.style.transform = 'translate(-50%,-50%) scale(1)'; }, true);
46
+ });
47
+ }
48
+
49
+ function makeUiHelpers(page) {
50
+ async function glide(toX, toY, ms = 900) {
51
+ const from = { x: 80, y: 80 };
52
+ const steps = 32;
53
+ for (let i = 1; i <= steps; i++) {
54
+ const t = i / steps;
55
+ const e = t < 0.5 ? 2 * t * t : 1 - ((-2 * t + 2) ** 2) / 2; // easeInOut
56
+ await page.mouse.move(from.x + (toX - from.x) * e, from.y + (toY - from.y) * e);
57
+ await sleep(ms / steps);
58
+ }
59
+ }
60
+ return {
61
+ // Move a visible cursor to the element and click it. Returns false if missing.
62
+ async click(selector) {
63
+ const el = await page.$(selector);
64
+ if (!el) return false;
65
+ // Elements inside a scrollable list (e.g. the Tasks sidebar) can sit
66
+ // outside the viewport; scroll them into view first or the mouse glide
67
+ // lands on whatever else is at those coordinates and the click silently
68
+ // hits the wrong element.
69
+ await el.scrollIntoView().catch(() => {});
70
+ const box = await el.boundingBox();
71
+ if (!box) return false;
72
+ await glide(box.x + box.width / 2, box.y + box.height / 2);
73
+ await sleep(150);
74
+ await page.mouse.down(); await sleep(120); await page.mouse.up();
75
+ return true;
76
+ },
77
+ sleep,
78
+ };
79
+ }
80
+
81
+ // Generic "show the change, not an idle page" choreography for a capture
82
+ // that has no specific verify script driving it (the no-passCondition UI
83
+ // snapshot path). Works on any page: moves the visible cursor, then scrolls
84
+ // whichever thing actually scrolls (a single-page app usually scrolls an
85
+ // inner pane, not <body>) down and back — so the recording reads as a light
86
+ // tour of the current UI rather than a static screenshot.
87
+ export async function exerciseUi(page, ui) {
88
+ const vp = page.viewport() ?? { width: 900, height: 640 };
89
+ await page.mouse.move(vp.width * 0.2, vp.height * 0.3);
90
+ await ui.sleep(200);
91
+ await page.mouse.move(vp.width * 0.8, vp.height * 0.6, { steps: 20 });
92
+ await ui.sleep(300);
93
+ const scroll = (top) => page.evaluate((y) => {
94
+ const els = [document.scrollingElement, ...document.querySelectorAll('body *')]
95
+ .filter((el) => el && el.scrollHeight - el.clientHeight > 40)
96
+ .slice(0, 3);
97
+ for (const el of els) el.scrollTo({ top: y, behavior: 'instant' });
98
+ }, top).catch(() => {});
99
+ await scroll(400);
100
+ await ui.sleep(500);
101
+ await scroll(0);
102
+ await ui.sleep(300);
103
+ }
104
+
105
+ // verify: async (page, ui) => { pass, detail }
106
+ // outBase: absolute path prefix; artifacts are `${outBase}.webm/.mp4/.gif`
107
+ // and `${outBase}-proof.png` (or -fail.png).
108
+ // record: when false, skip the screencast + ffmpeg encode entirely (only a
109
+ // still screenshot is produced) — used for a cheap baseline ("before") shot
110
+ // where a comparison image is enough and a second full video isn't worth
111
+ // the extra time/flakiness.
112
+ export async function runVerification({ entryUrl, verify, outBase, record = true, viewport = null }) {
113
+ const browser = await puppeteer.launch({
114
+ executablePath: CHROME, headless: 'new',
115
+ args: ['--no-first-run', '--hide-scrollbars'],
116
+ });
117
+ try {
118
+ const page = await browser.newPage();
119
+ await page.setViewport(viewport ?? { width: 900, height: 640, deviceScaleFactor: 2 });
120
+ const consoleErrors = [];
121
+ page.on('pageerror', (e) => consoleErrors.push(String(e.message ?? e)));
122
+ let recorder = null;
123
+ const videoPath = `${outBase}.webm`;
124
+ if (record) { try { recorder = await page.screencast({ path: videoPath }); } catch { /* screenshot-only proof */ } }
125
+ await page.goto(entryUrl, { waitUntil: 'domcontentloaded' });
126
+ await injectCursor(page);
127
+ await sleep(800); // let the viewer see the starting state
128
+ let result;
129
+ try {
130
+ result = await verify(page, makeUiHelpers(page));
131
+ if (!result || typeof result.pass !== 'boolean') {
132
+ result = { pass: false, detail: `verify script returned an invalid result: ${JSON.stringify(result).slice(0, 200)}` };
133
+ }
134
+ } catch (e) {
135
+ result = { pass: false, detail: `verify script threw: ${String(e.message ?? e).slice(0, 300)}` };
136
+ }
137
+ await sleep(1200); // keep the outcome on screen in the video
138
+ if (recorder) await recorder.stop();
139
+ let finalVideo = null, gifPath = null;
140
+ if (recorder) {
141
+ const mp4Path = `${outBase}.mp4`;
142
+ const ff = spawnSync('ffmpeg', ['-v', 'error', '-i', videoPath,
143
+ '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', '-y', mp4Path]);
144
+ finalVideo = ff.status === 0 ? mp4Path : videoPath;
145
+ const gp = `${outBase}.gif`;
146
+ const fg = spawnSync('ffmpeg', ['-v', 'error', '-i', videoPath,
147
+ '-vf', 'fps=8,scale=640:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse',
148
+ '-y', gp]);
149
+ if (fg.status === 0) gifPath = gp;
150
+ }
151
+ const shotPath = `${outBase}-${result.pass ? 'proof' : 'fail'}.png`;
152
+ await page.screenshot({ path: shotPath });
153
+ if (consoleErrors.length) result.detail += ` | page errors: ${consoleErrors.join(' / ').slice(0, 300)}`;
154
+ return { ...result, shotPath, videoPath: finalVideo, gifPath };
155
+ } finally {
156
+ await browser.close();
157
+ }
158
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "task": {
3
+ "id": "task-001",
4
+ "title": "Save button toast",
5
+ "passCondition": "click the #save button, then an element matching .toast becomes visible with text containing 'Saved' within 2 seconds"
6
+ },
7
+ "status": "done",
8
+ "attempts": [
9
+ {
10
+ "attempt": 1,
11
+ "workerSecs": "21.7",
12
+ "pass": true,
13
+ "detail": "clicked #save → toast \"Saved!\" visible",
14
+ "proofScreenshot": "/Users/masakinakata/common/project/galda2/tasks/task-001/runs/2026-07-03T06-23-57-441Z/attempt-1-proof.png",
15
+ "proofVideo": "/Users/masakinakata/common/project/galda2/tasks/task-001/runs/2026-07-03T06-23-57-441Z/attempt-1.webm"
16
+ }
17
+ ],
18
+ "runDir": "/Users/masakinakata/common/project/galda2/tasks/task-001/runs/2026-07-03T06-23-57-441Z",
19
+ "finishedAt": "2026-07-03T06:24:22.279Z"
20
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "task": {
3
+ "id": "task-001",
4
+ "title": "Save button toast",
5
+ "passCondition": "click the #save button, then an element matching .toast becomes visible with text containing 'Saved' within 2 seconds"
6
+ },
7
+ "status": "done",
8
+ "attempts": [
9
+ {
10
+ "attempt": 1,
11
+ "workerSecs": "19.8",
12
+ "pass": true,
13
+ "detail": "clicked #save → toast \"Saved!\" visible",
14
+ "proofScreenshot": "/Users/masakinakata/common/project/galda2/tasks/task-001/runs/2026-07-03T06-29-54-517Z/attempt-1-proof.png",
15
+ "proofVideo": "/Users/masakinakata/common/project/galda2/tasks/task-001/runs/2026-07-03T06-29-54-517Z/attempt-1.webm"
16
+ }
17
+ ],
18
+ "runDir": "/Users/masakinakata/common/project/galda2/tasks/task-001/runs/2026-07-03T06-29-54-517Z",
19
+ "finishedAt": "2026-07-03T06:30:17.155Z"
20
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "task": {
3
+ "id": "task-001",
4
+ "title": "Save button toast",
5
+ "passCondition": "click the #save button, then an element matching .toast becomes visible with text containing 'Saved' within 2 seconds"
6
+ },
7
+ "status": "done",
8
+ "attempts": [
9
+ {
10
+ "attempt": 1,
11
+ "workerSecs": "18.2",
12
+ "pass": true,
13
+ "detail": "clicked #save → toast \"Saved!\" visible",
14
+ "proofScreenshot": "/Users/masakinakata/common/project/galda2/tasks/task-001/runs/2026-07-03T06-40-23-231Z/attempt-1-proof.png",
15
+ "proofVideo": "/Users/masakinakata/common/project/galda2/tasks/task-001/runs/2026-07-03T06-40-23-231Z/attempt-1.webm"
16
+ }
17
+ ],
18
+ "runDir": "/Users/masakinakata/common/project/galda2/tasks/task-001/runs/2026-07-03T06-40-23-231Z",
19
+ "finishedAt": "2026-07-03T06:40:46.785Z"
20
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "task": {
3
+ "id": "task-001",
4
+ "title": "Save button toast",
5
+ "passCondition": "click the #save button, then an element matching .toast becomes visible with text containing 'Saved' within 2 seconds"
6
+ },
7
+ "status": "done",
8
+ "attempts": [
9
+ {
10
+ "attempt": 1,
11
+ "workerSecs": "22.3",
12
+ "pass": true,
13
+ "detail": "clicked #save → toast \"Saved!\" visible",
14
+ "proofScreenshot": "/Users/masakinakata/common/project/galda2/tasks/task-001/runs/2026-07-03T07-34-58-109Z/attempt-1-proof.png",
15
+ "proofVideo": "/Users/masakinakata/common/project/galda2/tasks/task-001/runs/2026-07-03T07-34-58-109Z/attempt-1.mp4"
16
+ }
17
+ ],
18
+ "runDir": "/Users/masakinakata/common/project/galda2/tasks/task-001/runs/2026-07-03T07-34-58-109Z",
19
+ "finishedAt": "2026-07-03T07:35:25.892Z",
20
+ "pr": "https://github.com/kodo-inc/common/pull/1"
21
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "task": {
3
+ "id": "task-001",
4
+ "title": "Save button toast",
5
+ "passCondition": "click the #save button, then an element matching .toast becomes visible with text containing 'Saved' within 2 seconds"
6
+ },
7
+ "status": "done",
8
+ "attempts": [
9
+ {
10
+ "attempt": 1,
11
+ "workerSecs": "21.9",
12
+ "pass": true,
13
+ "detail": "clicked #save → toast \"Saved!\" visible",
14
+ "proofScreenshot": "/Users/masakinakata/galda2/tasks/task-001/runs/2026-07-03T07-53-44-639Z/attempt-1-proof.png",
15
+ "proofVideo": "/Users/masakinakata/galda2/tasks/task-001/runs/2026-07-03T07-53-44-639Z/attempt-1.mp4"
16
+ }
17
+ ],
18
+ "runDir": "/Users/masakinakata/galda2/tasks/task-001/runs/2026-07-03T07-53-44-639Z",
19
+ "finishedAt": "2026-07-03T07:54:11.642Z",
20
+ "pr": "https://github.com/kodo-inc/galda2/pull/1"
21
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "task": {
3
+ "id": "task-001",
4
+ "title": "Save button toast",
5
+ "passCondition": "click the #save button, then an element matching .toast becomes visible with text containing 'Saved' within 2 seconds"
6
+ },
7
+ "status": "done",
8
+ "attempts": [
9
+ {
10
+ "attempt": 1,
11
+ "workerSecs": "16.3",
12
+ "pass": true,
13
+ "detail": "clicked #save → toast \"Saved!\" visible",
14
+ "proofScreenshot": "/Users/masakinakata/galda2/tasks/task-001/runs/2026-07-03T08-02-12-117Z/attempt-1-proof.png",
15
+ "proofVideo": "/Users/masakinakata/galda2/tasks/task-001/runs/2026-07-03T08-02-12-117Z/attempt-1.mp4"
16
+ }
17
+ ],
18
+ "runDir": "/Users/masakinakata/galda2/tasks/task-001/runs/2026-07-03T08-02-12-117Z",
19
+ "finishedAt": "2026-07-03T08:02:33.935Z",
20
+ "pr": "https://github.com/kodo-inc/galda2/pull/2"
21
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "id": "task-001",
3
+ "title": "Save button toast",
4
+ "app": "examples/toast-app",
5
+ "entry": "index.html",
6
+ "prompt": "The Save button in this app is broken: clicking Save should show a 'Saved!' toast, but nothing happens. Find the bug and fix it with the minimal change. Do not rewrite or restructure the app.",
7
+ "passCondition": "click the #save button, then an element matching .toast becomes visible with text containing 'Saved' within 2 seconds",
8
+ "verify": "verify.mjs",
9
+ "maxAttempts": 3,
10
+ "pr": true
11
+ }
@@ -0,0 +1,16 @@
1
+ // Pass condition, compiled to an executable check.
2
+ // Runs in the Manager's browser — independent of the worker's self-report.
3
+ export default async function verify(page, ui) {
4
+ const clicked = await ui.click('#save'); // visible-cursor click → shows up in the proof video
5
+ if (!clicked) return { pass: false, detail: 'no #save button found on the page' };
6
+ try {
7
+ await page.waitForSelector('.toast', { visible: true, timeout: 2000 });
8
+ } catch {
9
+ return { pass: false, detail: 'clicked #save, but no visible .toast appeared within 2s' };
10
+ }
11
+ const text = await page.$eval('.toast', (el) => el.textContent.trim());
12
+ if (!/saved/i.test(text)) {
13
+ return { pass: false, detail: `toast visible but text was "${text}", expected it to contain "Saved"` };
14
+ }
15
+ return { pass: true, detail: `clicked #save → toast "${text}" visible` };
16
+ }
@@ -0,0 +1,23 @@
1
+ // Settings page logic
2
+ function showToast(message) {
3
+ const toast = document.getElementById('toast');
4
+ toast.textContent = message;
5
+ toast.classList.add('show');
6
+ setTimeout(() => toast.classList.remove('show'), 2200);
7
+ }
8
+
9
+ function init() {
10
+ const saveBtn = document.querySelector('#save');
11
+ saveBtn.addEventListener('click', () => {
12
+ const name = document.getElementById('name').value.trim();
13
+ if (!name) {
14
+ showToast('Error: display name is required');
15
+ return;
16
+ }
17
+ // pretend to persist settings
18
+ localStorage.setItem('displayName', name);
19
+ showToast('Saved!');
20
+ });
21
+ }
22
+
23
+ init();
@@ -0,0 +1,28 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>Demo App — Settings</title>
6
+ <style>
7
+ :root{--canvas:#0a0a0b;--paper:#0d0d0f;--hair:rgba(255,255,255,.09);--ink:#e9e7e2;--ink2:#a3a097;--green:#34d399}
8
+ body{margin:0;min-height:100vh;display:grid;place-items:center;background:var(--canvas);color:var(--ink);font-family:Inter,system-ui,sans-serif}
9
+ .card{width:360px;background:var(--paper);border:1px solid var(--hair);border-radius:15px;padding:22px}
10
+ h1{font-size:15px;margin:0 0 16px}
11
+ label{display:block;font-size:12px;color:var(--ink2);margin-bottom:6px}
12
+ input{width:100%;box-sizing:border-box;background:transparent;border:1px solid var(--hair);border-radius:9px;color:var(--ink);padding:8px 10px;font:inherit;font-size:13px;margin-bottom:16px}
13
+ button{height:32px;padding:0 16px;border:0;border-radius:9px;background:var(--green);color:#0a0a0b;font:inherit;font-size:12.5px;font-weight:600;cursor:pointer}
14
+ .toast{position:fixed;left:50%;bottom:28px;transform:translateX(-50%);background:rgba(22,22,27,.85);border:1px solid rgba(52,211,153,.4);color:var(--green);padding:8px 16px;border-radius:10px;font-size:13px;display:none}
15
+ .toast.show{display:block}
16
+ </style>
17
+ </head>
18
+ <body>
19
+ <div class="card">
20
+ <h1>設定</h1>
21
+ <label for="name">Display name</label>
22
+ <input id="name" value="Masa">
23
+ <button id="save">Save</button>
24
+ </div>
25
+ <div class="toast" id="toast">Saved!</div>
26
+ <script src="app.js"></script>
27
+ </body>
28
+ </html>
@@ -0,0 +1,83 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import path from 'node:path';
6
+ import vm from 'node:vm';
7
+
8
+ const dir = path.dirname(fileURLToPath(import.meta.url));
9
+ const appJs = readFileSync(path.join(dir, '..', 'app.js'), 'utf8');
10
+
11
+ function createEl(initial = {}) {
12
+ return {
13
+ value: initial.value ?? '',
14
+ textContent: '',
15
+ classList: {
16
+ list: new Set(),
17
+ add(c) { this.list.add(c); },
18
+ remove(c) { this.list.delete(c); },
19
+ contains(c) { return this.list.has(c); },
20
+ },
21
+ listeners: {},
22
+ addEventListener(evt, fn) { this.listeners[evt] = fn; },
23
+ click() { this.listeners.click(); },
24
+ };
25
+ }
26
+
27
+ function setupApp(nameValue) {
28
+ const nameEl = createEl({ value: nameValue });
29
+ const toastEl = createEl();
30
+ const saveEl = createEl();
31
+ const store = {};
32
+ const document = {
33
+ getElementById(id) {
34
+ if (id === 'name') return nameEl;
35
+ if (id === 'toast') return toastEl;
36
+ throw new Error('unknown id ' + id);
37
+ },
38
+ querySelector(sel) {
39
+ if (sel === '#save') return saveEl;
40
+ throw new Error('unknown selector ' + sel);
41
+ },
42
+ };
43
+ const localStorage = { setItem(k, v) { store[k] = v; } };
44
+ const context = { document, localStorage, setTimeout, clearTimeout, console };
45
+ vm.createContext(context);
46
+ vm.runInContext(appJs, context);
47
+ return { nameEl, toastEl, saveEl, store };
48
+ }
49
+
50
+ test('empty name: click #save shows .toast with Error, no persist', () => {
51
+ const { toastEl, saveEl, store } = setupApp('');
52
+ saveEl.click();
53
+ assert.ok(toastEl.classList.contains('show'));
54
+ assert.match(toastEl.textContent, /Error/);
55
+ assert.equal(store.displayName, undefined);
56
+ });
57
+
58
+ test('whitespace-only name is treated as empty', () => {
59
+ const { toastEl, saveEl, store } = setupApp(' ');
60
+ saveEl.click();
61
+ assert.ok(toastEl.classList.contains('show'));
62
+ assert.match(toastEl.textContent, /Error/);
63
+ assert.equal(store.displayName, undefined);
64
+ });
65
+
66
+ test('non-empty name: click #save shows Saved! and persists trimmed value', () => {
67
+ const { toastEl, saveEl, store } = setupApp(' Masa ');
68
+ saveEl.click();
69
+ assert.ok(toastEl.classList.contains('show'));
70
+ assert.equal(toastEl.textContent, 'Saved!');
71
+ assert.equal(store.displayName, 'Masa');
72
+ });
73
+
74
+ test('error toast text is replaced with Saved! on a subsequent valid click', () => {
75
+ const { nameEl, toastEl, saveEl, store } = setupApp('');
76
+ saveEl.click();
77
+ assert.match(toastEl.textContent, /Error/);
78
+
79
+ nameEl.value = 'Masa';
80
+ saveEl.click();
81
+ assert.equal(toastEl.textContent, 'Saved!');
82
+ assert.equal(store.displayName, 'Masa');
83
+ });
@@ -0,0 +1,19 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import path from 'node:path';
6
+
7
+ const dir = path.dirname(fileURLToPath(import.meta.url));
8
+ const html = readFileSync(path.join(dir, '..', 'index.html'), 'utf8');
9
+
10
+ test('heading is 設定 (not Settings)', () => {
11
+ assert.match(html, /<h1>設定<\/h1>/);
12
+ assert.doesNotMatch(html, /<h1>Settings<\/h1>/);
13
+ });
14
+
15
+ test('Save button background resolves to #34d399', () => {
16
+ assert.match(html, /--green:#34d399/);
17
+ const buttonRule = html.match(/button\{[^}]*\}/)[0];
18
+ assert.match(buttonRule, /background:var\(--green\)/);
19
+ });
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@galda/cli",
3
+ "version": "0.10.0",
4
+ "type": "module",
5
+ "description": "Galda - hand off work to your Claude Code, get proof back. Runs on your existing subscription, no extra API cost.",
6
+ "scripts": {
7
+ "start": "node engine/server.mjs",
8
+ "test": "node --test --test-concurrency=2 'engine/test/*.test.mjs'"
9
+ },
10
+ "dependencies": {
11
+ "puppeteer-core": "^23.11.1",
12
+ "ws": "^8.21.0"
13
+ },
14
+ "license": "UNLICENSED",
15
+ "bin": {
16
+ "galda": "bin/manager-for-ai.mjs"
17
+ },
18
+ "files": [
19
+ "app/",
20
+ "engine/",
21
+ "bin/manager-for-ai.mjs",
22
+ "examples/",
23
+ "README.md",
24
+ "CLAUDE.md",
25
+ "!engine/chat-runs",
26
+ "!engine/test",
27
+ "!engine/.tmp_*",
28
+ "!engine/projects.json",
29
+ "!engine/external-sync.json",
30
+ "!**/secret.key",
31
+ "!**/*.webm",
32
+ "!**/*.mp4",
33
+ "!**/*.gif",
34
+ "!**/*.log"
35
+ ],
36
+ "engines": {
37
+ "node": ">=20"
38
+ },
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "https://github.com/kodo-inc/agent-manager.git"
42
+ },
43
+ "keywords": [
44
+ "claude",
45
+ "claude-code",
46
+ "ai",
47
+ "agent",
48
+ "manager",
49
+ "verification",
50
+ "proof"
51
+ ]
52
+ }