cmdzero 0.7.2 → 0.8.1
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 +8 -2
- package/overlay/overlay.js +75 -7
- package/package.json +1 -1
- package/src/router.js +41 -0
- package/src/server.js +28 -2
- package/src/telemetry.js +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
+
# CmdZero
|
|
2
|
+
|
|
1
3
|
<p align="center">
|
|
2
|
-
<img src="https://raw.githubusercontent.com/adamcwade/cmdzero/main/assets/hero.png" alt="CmdZero —
|
|
4
|
+
<img src="https://raw.githubusercontent.com/adamcwade/cmdzero/main/assets/hero.png" alt="CmdZero — live in-browser UI editing with a style panel, inline copy editing, and a token-savings banner" width="900">
|
|
3
5
|
</p>
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
<p align="center">
|
|
8
|
+
<strong><a href="https://cmdzero.xyz/demo">▶︎ Try the live demo — no install</a></strong>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
Press `⌘0` and edit a real page with the real overlay, running entirely in your browser. Copy, style, reorder, delete and undo all work; natural-language prompts are scripted (no model is called) and nothing is written to disk.
|
|
6
12
|
|
|
7
13
|
Tweak your UI live in the browser — the changes are written straight to your source files.
|
|
8
14
|
|
package/overlay/overlay.js
CHANGED
|
@@ -81,6 +81,9 @@
|
|
|
81
81
|
.cz-banner .cz-stat b{color:#f1f5f9;font-weight:600;margin-left:5px}
|
|
82
82
|
.cz-banner .cz-stat.cz-accent b{color:#6ee7b7}
|
|
83
83
|
.cz-banner a{color:#6ee7b7;text-decoration:none;flex:none}
|
|
84
|
+
.cz-banner .cz-deploy{flex:none;background:#10b981;color:#052e1b;border:none;border-radius:7px;padding:5px 12px;font-size:11.5px;font-weight:700;cursor:pointer;pointer-events:auto}
|
|
85
|
+
.cz-banner .cz-deploy:hover{background:#34d399}
|
|
86
|
+
.cz-banner .cz-deploy:disabled{opacity:.6;cursor:default}
|
|
84
87
|
.cz-banner a:hover{text-decoration:underline}
|
|
85
88
|
.cz-banner .cz-idle{color:#64748b}
|
|
86
89
|
.cz-wrap{display:flex;flex-direction:column;align-items:flex-end;gap:6px}
|
|
@@ -262,6 +265,47 @@
|
|
|
262
265
|
try { localStorage.setItem('cz-panel-collapsed', v ? '1' : '0'); } catch { /* no storage */ }
|
|
263
266
|
applyPanelLayout();
|
|
264
267
|
}
|
|
268
|
+
// Everything the overlay draws — outline, popover, move bar, delete button,
|
|
269
|
+
// hover badge — is position:fixed at coordinates measured once from
|
|
270
|
+
// getBoundingClientRect(). The page moves under those coordinates constantly:
|
|
271
|
+
// opening the panel animates <body>'s margin for .26s, that push narrows the
|
|
272
|
+
// viewport so text re-wraps and blocks shift down, an edit changes an
|
|
273
|
+
// element's size, fonts and images land, the page runs its own animations.
|
|
274
|
+
// reposition() on scroll/resize/select catches none of that, which is why a
|
|
275
|
+
// box could sit up to PANEL_W px from its element until something unrelated
|
|
276
|
+
// happened to re-measure.
|
|
277
|
+
//
|
|
278
|
+
// Chasing each cause separately is a losing game, so watch the element
|
|
279
|
+
// itself: compare its rect every frame and redraw only when it actually
|
|
280
|
+
// moved. rAF is the right clock — it's gated on painting exactly like the
|
|
281
|
+
// transitions and reflows we're chasing, so we never measure a layout the
|
|
282
|
+
// user hasn't seen yet. Idle cost is one getBoundingClientRect and a string
|
|
283
|
+
// compare per frame, and the loop only runs while the overlay has something
|
|
284
|
+
// on screen to keep glued.
|
|
285
|
+
let watchRaf = 0;
|
|
286
|
+
let lastRect = '';
|
|
287
|
+
function watchLayout() {
|
|
288
|
+
if (watchRaf) return; // already running
|
|
289
|
+
const step = () => {
|
|
290
|
+
if (!state.selectMode && !state.selected) { watchRaf = 0; lastRect = ''; return; }
|
|
291
|
+
const target = state.selected?.el || state.hoverEl;
|
|
292
|
+
if (target && document.contains(target)) {
|
|
293
|
+
const r = target.getBoundingClientRect();
|
|
294
|
+
const key = `${r.left},${r.top},${r.width},${r.height}`;
|
|
295
|
+
if (key !== lastRect) {
|
|
296
|
+
lastRect = key;
|
|
297
|
+
// the same trio the scroll handler runs — a scroll strands these
|
|
298
|
+
// boxes for exactly the same reason any other layout shift does
|
|
299
|
+
reposition();
|
|
300
|
+
positionMulti();
|
|
301
|
+
setHover(state.hoverEl);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
watchRaf = requestAnimationFrame(step);
|
|
305
|
+
};
|
|
306
|
+
watchRaf = requestAnimationFrame(step);
|
|
307
|
+
}
|
|
308
|
+
|
|
265
309
|
// Slide the panel in/out and push the page so nothing is covered.
|
|
266
310
|
function applyPanelLayout() {
|
|
267
311
|
const open = !!state.selected && !state.panelCollapsed;
|
|
@@ -447,6 +491,7 @@
|
|
|
447
491
|
renderPopover();
|
|
448
492
|
renderPanel();
|
|
449
493
|
reposition();
|
|
494
|
+
watchLayout(); // renderPanel() just started the panel's .26s page push
|
|
450
495
|
loadMeta();
|
|
451
496
|
}
|
|
452
497
|
|
|
@@ -1083,11 +1128,26 @@
|
|
|
1083
1128
|
const brand = el('div', 'cz-brand');
|
|
1084
1129
|
brand.innerHTML = '<kbd>⌘0</kbd> CmdZero';
|
|
1085
1130
|
const bannerStats = el('div', 'cz-stats');
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1131
|
+
// Build & Deploy — commits the current tweaks and pushes them to the live site
|
|
1132
|
+
// (the daemon runs it through headless claude).
|
|
1133
|
+
const deployBtn = el('button', 'cz-deploy', 'Build & Deploy ↗');
|
|
1134
|
+
let deployId = null;
|
|
1135
|
+
deployBtn.onclick = async () => {
|
|
1136
|
+
if (deployBtn.disabled) return;
|
|
1137
|
+
if (!confirm('Build & deploy the current tweaks to your live site?')) return;
|
|
1138
|
+
deployBtn.disabled = true;
|
|
1139
|
+
deployBtn.textContent = 'Deploying…';
|
|
1140
|
+
try {
|
|
1141
|
+
const r = await api('deploy', {});
|
|
1142
|
+
deployId = String(r.id);
|
|
1143
|
+
addTweak({ id: r.id, status: 'queued', label: 'Build & Deploy — committing & deploying…' });
|
|
1144
|
+
} catch (e) {
|
|
1145
|
+
resetDeployBtn();
|
|
1146
|
+
addTweak({ id: 'x' + Date.now(), status: 'error', label: `deploy: ${e.message}` });
|
|
1147
|
+
}
|
|
1148
|
+
};
|
|
1149
|
+
function resetDeployBtn() { deployBtn.disabled = false; deployBtn.textContent = 'Build & Deploy ↗'; }
|
|
1150
|
+
banner.append(brand, bannerStats, deployBtn);
|
|
1091
1151
|
root.appendChild(banner);
|
|
1092
1152
|
// Push the page down so the banner sits ABOVE the site rather than over it.
|
|
1093
1153
|
// Inject a stylesheet instead of mutating body's style attribute: frameworks
|
|
@@ -1225,8 +1285,10 @@
|
|
|
1225
1285
|
row._dot.className = 'cz-dot ' + t.status;
|
|
1226
1286
|
const inFlight = t.status === 'queued' || t.status === 'running';
|
|
1227
1287
|
row._cancel.style.display = inFlight ? '' : 'none';
|
|
1228
|
-
const undoable = t.status === 'done' && !key.startsWith('x');
|
|
1288
|
+
const undoable = t.status === 'done' && !key.startsWith('x') && t.kind !== 'deploy';
|
|
1229
1289
|
row._undo.style.display = undoable ? '' : 'none';
|
|
1290
|
+
// re-enable the Build & Deploy button once its task settles
|
|
1291
|
+
if (typeof deployId !== 'undefined' && deployId && key === deployId && ['done', 'error', 'cancelled'].includes(t.status)) resetDeployBtn();
|
|
1230
1292
|
if (undoable && !undoStack.includes(key)) undoStack.push(key);
|
|
1231
1293
|
if (t.status === 'reverted') {
|
|
1232
1294
|
const i = undoStack.indexOf(key);
|
|
@@ -1277,6 +1339,11 @@
|
|
|
1277
1339
|
.then((h) => {
|
|
1278
1340
|
showTotals(h.totals);
|
|
1279
1341
|
if (h.tailwind === false) state.tailwind = false;
|
|
1342
|
+
// Show the running overlay version so a stale/cached overlay is obvious.
|
|
1343
|
+
if (h.version) {
|
|
1344
|
+
brand.innerHTML = `<kbd>⌘0</kbd> CmdZero <span style="opacity:.45;font-weight:400">v${h.version}</span>`;
|
|
1345
|
+
console.log(`[cmdzero] overlay v${h.version} — newest alerts at the bottom of the tray`);
|
|
1346
|
+
}
|
|
1280
1347
|
})
|
|
1281
1348
|
.catch(() => {});
|
|
1282
1349
|
|
|
@@ -1358,7 +1425,8 @@
|
|
|
1358
1425
|
function setMode(on) {
|
|
1359
1426
|
state.selectMode = on;
|
|
1360
1427
|
hint.textContent = on ? 'select mode — click · shift-click multi · Tab next · ⌘Z undo · Esc exit' : '⌘0 select mode';
|
|
1361
|
-
if (
|
|
1428
|
+
if (on) watchLayout(); // keep the boxes glued while anything on the page moves
|
|
1429
|
+
else { setHover(null); deselect(); clearMulti(); }
|
|
1362
1430
|
}
|
|
1363
1431
|
|
|
1364
1432
|
// Visible, stamped elements in document order — the Tab cycle.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cmdzero",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "Tweak your UI live in the browser and write the changes straight to source. Copy and Tailwind edits cost zero tokens; everything else routes to a right-sized model via headless claude.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/router.js
CHANGED
|
@@ -274,3 +274,44 @@ export function runClaude({ prompt, model, effort, cwd, onEvent, onSpawn }) {
|
|
|
274
274
|
onEvent?.({ status: 'running', model });
|
|
275
275
|
});
|
|
276
276
|
}
|
|
277
|
+
|
|
278
|
+
// "Build & Deploy": run the deploy through headless claude (Bash allowed) so it
|
|
279
|
+
// commits the current tweaks and pushes them to the live site.
|
|
280
|
+
const DEPLOY_PROMPT = `You are deploying this project to production. Using the Bash tool, do exactly this, in order:
|
|
281
|
+
1. Run \`git add -A\`, then commit with a short message describing recent UI tweaks. Do NOT add any AI attribution/co-author trailer. If there is nothing to commit, continue.
|
|
282
|
+
2. If a git remote named "origin" exists, run \`git push\`.
|
|
283
|
+
3. Deploy to production. This is a Vercel project — run: \`vercel --prod --yes\`
|
|
284
|
+
On the FINAL line of your reply, output the production URL exactly as: DEPLOY_URL=<url>
|
|
285
|
+
If any step fails, stop and report the exact error.`;
|
|
286
|
+
|
|
287
|
+
export function runDeploy({ cwd, model = 'claude-sonnet-5', onEvent, onSpawn }) {
|
|
288
|
+
return new Promise((resolve) => {
|
|
289
|
+
const started = Date.now();
|
|
290
|
+
const args = [
|
|
291
|
+
'-p', DEPLOY_PROMPT,
|
|
292
|
+
'--model', model,
|
|
293
|
+
'--allowedTools', 'Read,Edit,Bash',
|
|
294
|
+
'--dangerously-skip-permissions', // headless: commit/push/deploy without prompts
|
|
295
|
+
'--output-format', 'json',
|
|
296
|
+
];
|
|
297
|
+
const child = spawn('claude', args, { cwd, env: { ...process.env, CLAUDE_CODE_ENTRYPOINT: undefined }, detached: true });
|
|
298
|
+
let cancelled = false;
|
|
299
|
+
const signalGroup = (sig) => { try { process.kill(-child.pid, sig); } catch { try { child.kill(sig); } catch {} } };
|
|
300
|
+
child.__cancel = () => { cancelled = true; signalGroup('SIGTERM'); setTimeout(() => { if (!child.killed) signalGroup('SIGKILL'); }, 1500).unref?.(); };
|
|
301
|
+
onSpawn?.(child);
|
|
302
|
+
let out = '', err = '';
|
|
303
|
+
child.stdout.on('data', (d) => (out += d));
|
|
304
|
+
child.stderr.on('data', (d) => (err += d));
|
|
305
|
+
child.on('close', (code) => {
|
|
306
|
+
const durationMs = Date.now() - started;
|
|
307
|
+
if (cancelled) return resolve({ ok: false, cancelled: true, durationMs });
|
|
308
|
+
if (code !== 0) return resolve({ ok: false, error: err.trim() || `claude exited ${code}`, durationMs });
|
|
309
|
+
let result = out;
|
|
310
|
+
try { result = JSON.parse(out).result || out; } catch { /* raw text */ }
|
|
311
|
+
const m = /DEPLOY_URL=(\S+)/.exec(result);
|
|
312
|
+
resolve({ ok: true, durationMs, url: m ? m[1] : null, result: String(result).slice(0, 500) });
|
|
313
|
+
});
|
|
314
|
+
child.on('error', (e) => resolve({ ok: false, error: `failed to spawn claude: ${e.message}` }));
|
|
315
|
+
onEvent?.({ status: 'running' });
|
|
316
|
+
});
|
|
317
|
+
}
|
package/src/server.js
CHANGED
|
@@ -3,7 +3,7 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { describeTarget, applyTextEdit, applyClassEdit, applyStyleEdit, applyDeleteElement, applyMove, parseLoc, checkSyntax } from './resolver.js';
|
|
6
|
-
import { classify, buildPrompt, runClaude } from './router.js';
|
|
6
|
+
import { classify, buildPrompt, runClaude, runDeploy } from './router.js';
|
|
7
7
|
import { initTelemetry, DISCLOSURE } from './telemetry.js';
|
|
8
8
|
|
|
9
9
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -77,7 +77,7 @@ export function startServer({ root, port = 4100 }) {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
if (req.method === 'GET' && url.pathname === '/api/health') {
|
|
80
|
-
return json(res, { ok: true, root, totals, tailwind, telemetry: !telemetry.disabled });
|
|
80
|
+
return json(res, { ok: true, version: VERSION, root, totals, tailwind, telemetry: !telemetry.disabled });
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
if (req.method === 'GET' && url.pathname === '/api/events') {
|
|
@@ -184,6 +184,32 @@ export function startServer({ root, port = 4100 }) {
|
|
|
184
184
|
return json(res, { ok: true, ...route });
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
if (url.pathname === '/api/deploy') {
|
|
188
|
+
const id = nextId++;
|
|
189
|
+
telemetry.record('deploy');
|
|
190
|
+
broadcast({ type: 'tweak', id, kind: 'deploy', status: 'queued', label: 'Build & Deploy — committing & deploying…' });
|
|
191
|
+
json(res, { ok: true, id });
|
|
192
|
+
const result = await runDeploy({
|
|
193
|
+
cwd: root,
|
|
194
|
+
onEvent: (e) => broadcast({ type: 'tweak', id, ...e }),
|
|
195
|
+
onSpawn: (child) => running.set(String(id), child),
|
|
196
|
+
});
|
|
197
|
+
running.delete(String(id));
|
|
198
|
+
if (result.cancelled) {
|
|
199
|
+
broadcast({ type: 'tweak', id, status: 'cancelled', label: 'deploy cancelled' });
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
broadcast({
|
|
203
|
+
type: 'tweak', id,
|
|
204
|
+
kind: 'deploy',
|
|
205
|
+
status: result.ok ? 'done' : 'error',
|
|
206
|
+
durationMs: result.durationMs,
|
|
207
|
+
label: result.ok ? (result.url ? `deployed → ${result.url}` : 'deployed to production') : 'deploy failed',
|
|
208
|
+
error: result.ok ? undefined : (result.error || '').slice(0, 200),
|
|
209
|
+
});
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
187
213
|
if (url.pathname === '/api/nl') {
|
|
188
214
|
const id = nextId++;
|
|
189
215
|
const { file } = parseLoc(body.loc);
|
package/src/telemetry.js
CHANGED
|
@@ -53,7 +53,7 @@ export function initTelemetry({ version, tailwind }) {
|
|
|
53
53
|
saveConfig(config);
|
|
54
54
|
}
|
|
55
55
|
const disabled = telemetryDisabled();
|
|
56
|
-
const counts = { copy: 0, style: 0, delete: 0, nl: 0, move: 0 };
|
|
56
|
+
const counts = { copy: 0, style: 0, delete: 0, nl: 0, move: 0, deploy: 0 };
|
|
57
57
|
let flushTimer = null;
|
|
58
58
|
|
|
59
59
|
function send(event, extra = {}) {
|