@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.
- package/CLAUDE.md +44 -0
- package/README.md +83 -0
- package/app/fonts.css +8 -0
- package/app/index.html +7638 -0
- package/app/theme.css +126 -0
- package/app/wp/w1.jpg +0 -0
- package/app/wp/w2.jpg +0 -0
- package/bin/manager-for-ai.mjs +76 -0
- package/engine/lib.mjs +2378 -0
- package/engine/manager.mjs +115 -0
- package/engine/mcp.mjs +123 -0
- package/engine/pr.mjs +144 -0
- package/engine/relay-client.mjs +82 -0
- package/engine/server.mjs +3315 -0
- package/engine/verify.mjs +158 -0
- package/examples/task-001/runs/2026-07-03T06-23-57-441Z/attempt-1-proof.png +0 -0
- package/examples/task-001/runs/2026-07-03T06-23-57-441Z/report.json +20 -0
- package/examples/task-001/runs/2026-07-03T06-29-54-517Z/attempt-1-proof.png +0 -0
- package/examples/task-001/runs/2026-07-03T06-29-54-517Z/report.json +20 -0
- package/examples/task-001/runs/2026-07-03T06-40-23-231Z/attempt-1-proof.png +0 -0
- package/examples/task-001/runs/2026-07-03T06-40-23-231Z/report.json +20 -0
- package/examples/task-001/runs/2026-07-03T07-34-58-109Z/attempt-1-proof.png +0 -0
- package/examples/task-001/runs/2026-07-03T07-34-58-109Z/report.json +21 -0
- package/examples/task-001/runs/2026-07-03T07-53-44-639Z/attempt-1-proof.png +0 -0
- package/examples/task-001/runs/2026-07-03T07-53-44-639Z/report.json +21 -0
- package/examples/task-001/runs/2026-07-03T08-02-12-117Z/attempt-1-proof.png +0 -0
- package/examples/task-001/runs/2026-07-03T08-02-12-117Z/report.json +21 -0
- package/examples/task-001/task.json +11 -0
- package/examples/task-001/verify.mjs +16 -0
- package/examples/toast-app/app.js +23 -0
- package/examples/toast-app/index.html +28 -0
- package/examples/toast-app/test/guard.test.mjs +83 -0
- package/examples/toast-app/test/style.test.mjs +19 -0
- 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
|
+
}
|