cmdzero 0.5.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/src/router.js ADDED
@@ -0,0 +1,276 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Model routing — layered router (deterministic first, LLM tiebreak second).
5
+ //
6
+ // Tiers (see README for the routing table):
7
+ // 1 component tweaks / styles / copy / existing components → Sonnet-tier
8
+ // 2 new logic & functionality, low error rate → Opus-tier
9
+ // 3 multi-feature / cross-cutting / mission-critical → Opus xhigh → Fable
10
+ //
11
+ // "Speed" within a tier is the claude CLI's --effort flag (low..max), scaled
12
+ // by a complexity score. Layer 1 is lexicon+structure heuristics (<1ms, free);
13
+ // when its signals are ambiguous, Layer 2 asks Haiku to classify via
14
+ // --json-schema (~1-3s, ~$0.001). CMDZERO_ROUTER=heuristic|hybrid|llm.
15
+ // ---------------------------------------------------------------------------
16
+
17
+ const MODELS = {
18
+ t1: process.env.CMDZERO_T1_MODEL || process.env.CMDZERO_FAST_MODEL || 'claude-sonnet-5',
19
+ t2: process.env.CMDZERO_T2_MODEL || process.env.CMDZERO_SMART_MODEL || 'claude-opus-4-8',
20
+ t3: process.env.CMDZERO_T3_MODEL || 'claude-opus-4-8',
21
+ t3Critical: process.env.CMDZERO_T3_CRITICAL_MODEL || 'claude-fable-5',
22
+ };
23
+
24
+ // --- Layer 1 lexicons ------------------------------------------------------
25
+
26
+ const STYLE_COPY =
27
+ /(colou?r|\b(red|blue|green|yellow|orange|purple|pink|white|black|gr[ae]y|teal|indigo|emerald|amber|slate|navy|cream)\b|padding|margin|spacing|font|bigger|smaller|larger|tighter|wider|bold|italic|underline|round(ed)?|corner|radius|shadow|glow|gradient|background|border|align|cent(er|re)|width|height|gap|opacity|hover|focus|responsive|mobile|breakpoint|dark mode|reword|rewrite|rephrase|shorten|copy|headline|heading|title|caption|label|tone|punchier|catch(y|ier)|wording|typo|spelling)/i;
28
+
29
+ const EXISTING_COMPONENT =
30
+ /(add another|duplicate|a copy of|move (the|this|it)|reorder|swap|reuse|use the existing|another instance|same as the|like the one)/i;
31
+
32
+ const NEW_LOGIC =
33
+ /(implement|build|create|add a (new )?(feature|form|modal|dropdown|toggle|filter|search|carousel|slider|tab|accordion|tooltip|menu|pagination|stepper|wizard)|when (clicked|submitted|hovered|selected|scrolled)|on (click|submit|change|load|scroll)|fetch|\bapi\b|endpoint|request|\bstate\b|store|track|count(er|down)?|validat(e|ion)|debounce|localstorage|session storage|cookie|websocket|subscribe|drag|sortable|upload|download|timer|interval|keyboard shortcut)/i;
34
+
35
+ // "signup form" is a UI task (tier 2); "sign in with google" is auth (tier 3) —
36
+ // the sign in/up pattern only counts when followed by an auth-flow preposition.
37
+ const MISSION_CRITICAL =
38
+ /(\bauth\b|authentication|oauth|\bsso\b|log ?in|log ?out|sign ?(in|up) (with|via|using|flow)|password|payment|checkout|billing|stripe|subscription|credit card|security|permission|\brole\b|admin|database|migration|schema|production|deploy|delete (all|account|user)|gdpr|\bpii\b|encrypt)/i;
39
+
40
+ const MULTI_SCOPE =
41
+ /(across (the|all)|all pages|every (page|component|section)|entire (site|app|page)|throughout|end.to.end|site.wide|app.wide)/i;
42
+
43
+ const BIG_REWORK = /(refactor|redesign|rebuild|overhaul|rework|migrate)/i;
44
+
45
+ // --- Layer 1: deterministic scoring ---------------------------------------
46
+
47
+ function complexityOf(instruction) {
48
+ let score = 0;
49
+ const words = instruction.trim().split(/\s+/).length;
50
+ if (words > 12) score++;
51
+ if (words > 30) score++;
52
+ const clauses =
53
+ (instruction.match(/\b(and|then|also|plus|as well as|after that)\b/gi) || []).length +
54
+ (instruction.match(/[;,]/g) || []).length;
55
+ if (clauses >= 2) score++;
56
+ if (clauses >= 4) score++;
57
+ if (MULTI_SCOPE.test(instruction) || BIG_REWORK.test(instruction)) score++;
58
+ return score >= 3 ? 'high' : score >= 1 ? 'medium' : 'low';
59
+ }
60
+
61
+ function heuristicTier(instruction) {
62
+ const critical = MISSION_CRITICAL.test(instruction);
63
+ const multi = MULTI_SCOPE.test(instruction);
64
+ const rework = BIG_REWORK.test(instruction);
65
+ const logic = NEW_LOGIC.test(instruction);
66
+ const surface = STYLE_COPY.test(instruction) || EXISTING_COMPONENT.test(instruction);
67
+
68
+ if (critical) return { tier: 3, critical: true, confident: true };
69
+ if ((multi || rework) && (logic || rework || complexityOf(instruction) === 'high'))
70
+ return { tier: 3, critical: false, confident: true };
71
+ if (logic) return { tier: 2, critical: false, confident: true };
72
+ if (surface) return { tier: 1, critical: false, confident: true };
73
+ // no signal at all — default to the cheap tier but flag for the LLM tiebreak
74
+ return { tier: 1, critical: false, confident: false };
75
+ }
76
+
77
+ // --- Layer 2: Haiku classifier for ambiguous requests ----------------------
78
+
79
+ const CLASSIFY_SCHEMA = JSON.stringify({
80
+ type: 'object',
81
+ properties: {
82
+ tier: { type: 'integer', enum: [1, 2, 3] },
83
+ complexity: { type: 'string', enum: ['low', 'medium', 'high'] },
84
+ },
85
+ required: ['tier', 'complexity'],
86
+ additionalProperties: false,
87
+ });
88
+
89
+ function llmClassify(instruction, cwd) {
90
+ return new Promise((resolve) => {
91
+ const prompt = [
92
+ 'Classify this UI change request for model routing. Reply with JSON only.',
93
+ 'tier 1: visual tweaks, styles, copy edits, or rearranging existing components.',
94
+ 'tier 2: new logic or functionality within one area (handlers, state, forms, data fetching).',
95
+ 'tier 3: multi-feature or cross-cutting work, or anything touching auth/payments/data/security.',
96
+ 'complexity: low = one small change; medium = a few steps; high = many steps or broad scope.',
97
+ `Request: "${instruction.slice(0, 400)}"`,
98
+ ].join('\n');
99
+ const child = spawn(
100
+ 'claude',
101
+ ['-p', prompt, '--model', 'haiku', '--effort', 'low', '--json-schema', CLASSIFY_SCHEMA, '--output-format', 'json'],
102
+ { cwd, env: { ...process.env, CLAUDE_CODE_ENTRYPOINT: undefined } }
103
+ );
104
+ let out = '';
105
+ const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, 15000);
106
+ timer.unref?.();
107
+ child.stdout.on('data', (d) => (out += d));
108
+ child.on('close', () => {
109
+ clearTimeout(timer);
110
+ try {
111
+ const envelope = JSON.parse(out);
112
+ // --json-schema puts the validated object in structured_output
113
+ const parsed =
114
+ envelope.structured_output ??
115
+ (typeof envelope.result === 'string' && envelope.result ? JSON.parse(envelope.result) : envelope.result);
116
+ if (parsed && [1, 2, 3].includes(parsed.tier)) return resolve(parsed);
117
+ } catch { /* fall through */ }
118
+ resolve(null);
119
+ });
120
+ child.on('error', () => { clearTimeout(timer); resolve(null); });
121
+ });
122
+ }
123
+
124
+ // --- Route resolution -------------------------------------------------------
125
+
126
+ const EFFORT = {
127
+ 1: { low: 'low', medium: 'medium', high: 'high' },
128
+ 2: { low: 'medium', medium: 'high', high: 'xhigh' },
129
+ };
130
+
131
+ function resolveRoute(tier, complexity, critical, source) {
132
+ if (tier === 3) {
133
+ const frontier = critical || complexity === 'high';
134
+ return {
135
+ tier,
136
+ complexity,
137
+ model: frontier ? MODELS.t3Critical : MODELS.t3,
138
+ // Fable's thinking is always on; effort still scales depth. Opus tier-3
139
+ // runs at xhigh — the setting recommended for the hardest agentic work.
140
+ effort: frontier ? 'high' : 'xhigh',
141
+ kind: critical ? 'mission-critical' : 'multi-feature',
142
+ source,
143
+ };
144
+ }
145
+ return {
146
+ tier,
147
+ complexity,
148
+ model: tier === 1 ? MODELS.t1 : MODELS.t2,
149
+ effort: EFFORT[tier][complexity],
150
+ kind: tier === 1 ? 'tweak' : 'feature',
151
+ source,
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Route an instruction to {tier, complexity, model, effort}. Async because
157
+ * ambiguous requests may consult the Haiku classifier (hybrid mode).
158
+ */
159
+ export async function classify(instruction, { cwd } = {}) {
160
+ const mode = process.env.CMDZERO_ROUTER || 'hybrid';
161
+ const h = heuristicTier(instruction);
162
+ let tier = h.tier;
163
+ let critical = h.critical;
164
+ let complexity = complexityOf(instruction);
165
+ let source = 'heuristic';
166
+
167
+ const wantLLM = mode === 'llm' || (mode === 'hybrid' && !h.confident);
168
+ if (wantLLM) {
169
+ const llm = await llmClassify(instruction, cwd);
170
+ if (llm) {
171
+ tier = llm.tier;
172
+ complexity = llm.complexity;
173
+ critical = critical || (llm.tier === 3 && MISSION_CRITICAL.test(instruction));
174
+ source = 'llm';
175
+ }
176
+ }
177
+ return resolveRoute(tier, complexity, critical, source);
178
+ }
179
+
180
+ /** Synchronous heuristic-only classification (used by tests and dry runs). */
181
+ export function classifySync(instruction) {
182
+ const h = heuristicTier(instruction);
183
+ return resolveRoute(h.tier, complexityOf(instruction), h.critical, 'heuristic');
184
+ }
185
+
186
+ export function buildPrompt({ file, target, instruction, tailwind = true }) {
187
+ const styleRule = tailwind
188
+ ? 'use Tailwind classes for styling'
189
+ : "this project does NOT use Tailwind — match the file's existing styling approach (inline styles, CSS variables, or the project's stylesheets)";
190
+ return [
191
+ `You are making a surgical UI edit in a React codebase.`,
192
+ `Edit ONLY this file: ${file}`,
193
+ `The user selected this element (lines ${target.lines.start}-${target.lines.end}):`,
194
+ '```jsx',
195
+ target.snippet,
196
+ '```',
197
+ `Instruction: ${instruction}`,
198
+ `Rules: ${styleRule}, keep the change minimal and scoped to the selected element (and directly related code in the same file), do not reformat unrelated code, do not touch any other file.`,
199
+ 'The file must remain valid JSX after your edit — in particular, if you add a sibling element at the root of a component return, wrap the siblings in a fragment (<>...</>).',
200
+ ].join('\n');
201
+ }
202
+
203
+ /**
204
+ * Spawn a headless claude run. `onSpawn` receives the child process so the
205
+ * caller can register it for cancellation. If cancelled (child killed), the
206
+ * promise resolves with { ok: false, cancelled: true }.
207
+ */
208
+ export function runClaude({ prompt, model, effort, cwd, onEvent, onSpawn }) {
209
+ return new Promise((resolve) => {
210
+ const started = Date.now();
211
+ const args = [
212
+ '-p',
213
+ prompt,
214
+ '--model',
215
+ model,
216
+ '--allowedTools',
217
+ 'Read,Edit',
218
+ '--permission-mode',
219
+ 'acceptEdits',
220
+ '--output-format',
221
+ 'json',
222
+ ];
223
+ if (effort) args.push('--effort', effort);
224
+ const child = spawn(
225
+ 'claude',
226
+ args,
227
+ // detached → the child leads its own process group, so cancelling can
228
+ // signal the whole group (claude + any tool subprocesses it spawns).
229
+ // Killing only the top process orphans children that keep stdio pipes
230
+ // open, which would stall the 'close' event and the restore that follows.
231
+ { cwd, env: { ...process.env, CLAUDE_CODE_ENTRYPOINT: undefined }, detached: true }
232
+ );
233
+ let cancelled = false;
234
+ const signalGroup = (sig) => {
235
+ try { process.kill(-child.pid, sig); } // negative pid = process group
236
+ catch { try { child.kill(sig); } catch {} } // fall back to the single process
237
+ };
238
+ child.__cancel = () => {
239
+ cancelled = true;
240
+ signalGroup('SIGTERM');
241
+ setTimeout(() => { if (!child.killed) signalGroup('SIGKILL'); }, 1500).unref?.();
242
+ };
243
+ onSpawn?.(child);
244
+ let out = '';
245
+ let err = '';
246
+ child.stdout.on('data', (d) => (out += d));
247
+ child.stderr.on('data', (d) => (err += d));
248
+ child.on('close', (code) => {
249
+ const durationMs = Date.now() - started;
250
+ if (cancelled) {
251
+ resolve({ ok: false, cancelled: true, durationMs });
252
+ return;
253
+ }
254
+ if (code !== 0) {
255
+ resolve({ ok: false, error: err.trim() || `claude exited ${code}`, durationMs });
256
+ return;
257
+ }
258
+ let meta = {};
259
+ try {
260
+ const parsed = JSON.parse(out);
261
+ meta = {
262
+ costUSD: parsed.total_cost_usd,
263
+ turns: parsed.num_turns,
264
+ result: parsed.result,
265
+ };
266
+ } catch {
267
+ meta = { result: out.slice(0, 500) };
268
+ }
269
+ resolve({ ok: true, durationMs, ...meta });
270
+ });
271
+ child.on('error', (e) => {
272
+ resolve({ ok: false, error: `failed to spawn claude: ${e.message}` });
273
+ });
274
+ onEvent?.({ status: 'running', model });
275
+ });
276
+ }
package/src/server.js ADDED
@@ -0,0 +1,316 @@
1
+ import http from 'node:http';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { describeTarget, applyTextEdit, applyClassEdit, applyStyleEdit, applyDeleteElement, applyMove, parseLoc, checkSyntax } from './resolver.js';
6
+ import { classify, buildPrompt, runClaude } from './router.js';
7
+ import { initTelemetry, DISCLOSURE } from './telemetry.js';
8
+
9
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
+ const OVERLAY_PATH = path.join(__dirname, '..', 'overlay', 'overlay.js');
11
+ const VERSION = JSON.parse(
12
+ fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')
13
+ ).version;
14
+
15
+ // Baseline for "estimated savings": median unscoped-agent edit from
16
+ // packages/benchmark results (sonnet, Read/Edit/Glob/Grep). Overridable.
17
+ const BASELINE = {
18
+ usd: Number(process.env.CMDZERO_BASELINE_COST || 0.093),
19
+ ms: Number(process.env.CMDZERO_BASELINE_MS || 19000),
20
+ };
21
+
22
+ function detectTailwind(root) {
23
+ try {
24
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
25
+ return Boolean({ ...pkg.dependencies, ...pkg.devDependencies }.tailwindcss);
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ export function startServer({ root, port = 4100 }) {
32
+ const undoStack = new Map(); // id -> { abs, before }
33
+ const running = new Map(); // id -> child process (for cancellation)
34
+ const sseClients = new Set();
35
+ let nextId = 1;
36
+ const tailwind = detectTailwind(root);
37
+ const telemetry = initTelemetry({ version: VERSION, tailwind });
38
+
39
+ const savingsPath = path.join(root, '.cmdzero', 'savings.json');
40
+ let totals = { usd: 0, ms: 0, count: 0 };
41
+ try { totals = JSON.parse(fs.readFileSync(savingsPath, 'utf8')); } catch { /* first run */ }
42
+ function recordSavings(costUSD = 0, ms = 0) {
43
+ const saved = { usd: Math.max(0, BASELINE.usd - costUSD), ms: Math.max(0, BASELINE.ms - ms) };
44
+ totals.usd += saved.usd;
45
+ totals.ms += saved.ms;
46
+ totals.count += 1;
47
+ try {
48
+ fs.mkdirSync(path.dirname(savingsPath), { recursive: true });
49
+ fs.writeFileSync(savingsPath, JSON.stringify(totals));
50
+ } catch { /* savings persistence is best-effort */ }
51
+ return { saved, totals };
52
+ }
53
+
54
+ function broadcast(event) {
55
+ const line = `data: ${JSON.stringify(event)}\n\n`;
56
+ for (const res of sseClients) res.write(line);
57
+ }
58
+
59
+ function remember(id, write) {
60
+ undoStack.set(String(id), { abs: write.abs, before: write.before });
61
+ }
62
+
63
+ const server = http.createServer(async (req, res) => {
64
+ res.setHeader('Access-Control-Allow-Origin', '*');
65
+ res.setHeader('Access-Control-Allow-Headers', 'content-type');
66
+ if (req.method === 'OPTIONS') return res.end();
67
+
68
+ const url = new URL(req.url, `http://localhost:${port}`);
69
+
70
+ try {
71
+ if (req.method === 'GET' && url.pathname === '/overlay.js') {
72
+ res.setHeader('Content-Type', 'text/javascript');
73
+ return res.end(fs.readFileSync(OVERLAY_PATH));
74
+ }
75
+
76
+ if (req.method === 'GET' && url.pathname === '/api/health') {
77
+ return json(res, { ok: true, root, totals, tailwind, telemetry: !telemetry.disabled });
78
+ }
79
+
80
+ if (req.method === 'GET' && url.pathname === '/api/events') {
81
+ res.writeHead(200, {
82
+ 'Content-Type': 'text/event-stream',
83
+ 'Cache-Control': 'no-cache',
84
+ Connection: 'keep-alive',
85
+ });
86
+ res.write('\n');
87
+ sseClients.add(res);
88
+ req.on('close', () => sseClients.delete(res));
89
+ return;
90
+ }
91
+
92
+ if (req.method === 'POST') {
93
+ const body = await readJson(req);
94
+
95
+ if (url.pathname === '/api/resolve') {
96
+ return json(res, describeTarget(root, body.loc));
97
+ }
98
+
99
+ if (url.pathname === '/api/edit-text') {
100
+ const id = nextId++;
101
+ const write = applyTextEdit(root, body.loc, body.oldText, body.newText);
102
+ remember(id, write);
103
+ telemetry.record('copy');
104
+ broadcast({ type: 'tweak', id, kind: 'copy', status: 'done', tokens: 0, label: `copy: "${body.newText.slice(0, 40)}"`, ...recordSavings(0, 50) });
105
+ return json(res, { ok: true, id });
106
+ }
107
+
108
+ if (url.pathname === '/api/edit-class') {
109
+ const id = nextId++;
110
+ const write = applyClassEdit(root, body.loc, body.remove || [], body.add || []);
111
+ remember(id, write);
112
+ telemetry.record('style');
113
+ broadcast({ type: 'tweak', id, kind: 'style', status: 'done', tokens: 0, label: `style: ${(body.add || []).join(' ')}`, ...recordSavings(0, 50) });
114
+ return json(res, { ok: true, id });
115
+ }
116
+
117
+ if (url.pathname === '/api/edit-style') {
118
+ const id = nextId++;
119
+ const write = applyStyleEdit(root, body.loc, body.styles || {});
120
+ remember(id, write);
121
+ const desc = Object.entries(body.styles || {})
122
+ .map(([k, v]) => `${k}: ${v}`)
123
+ .join(', ');
124
+ telemetry.record('style');
125
+ broadcast({ type: 'tweak', id, kind: 'style', status: 'done', tokens: 0, label: `style: ${desc.slice(0, 50)}`, ...recordSavings(0, 50) });
126
+ return json(res, { ok: true, id });
127
+ }
128
+
129
+ if (url.pathname === '/api/delete') {
130
+ if (body.dryRun) {
131
+ applyDeleteElement(root, body.loc, { dryRun: true, index: body.index }); // throws if unsafe, writes nothing
132
+ return json(res, { ok: true, dryRun: true });
133
+ }
134
+ const id = nextId++;
135
+ const target = describeTarget(root, body.loc);
136
+ const write = applyDeleteElement(root, body.loc, { index: body.index });
137
+ remember(id, write);
138
+ telemetry.record('delete');
139
+ const label = write.removedUsage
140
+ ? `removed <${write.removedUsage}> usage`
141
+ : write.removedBlock
142
+ ? `deleted ${write.removedBlock}`
143
+ : `deleted <${target.tagName}>`;
144
+ broadcast({ type: 'tweak', id, kind: 'delete', status: 'done', tokens: 0, label, ...recordSavings(0, 50) });
145
+ return json(res, { ok: true, id });
146
+ }
147
+
148
+ if (url.pathname === '/api/move') {
149
+ if (body.dryRun) {
150
+ applyMove(root, body.loc, { dir: body.dir, index: body.index, toIndex: body.toIndex, dryRun: true });
151
+ return json(res, { ok: true, dryRun: true });
152
+ }
153
+ const id = nextId++;
154
+ const write = applyMove(root, body.loc, { dir: body.dir, index: body.index, toIndex: body.toIndex });
155
+ remember(id, write);
156
+ telemetry.record('move');
157
+ broadcast({ type: 'tweak', id, kind: 'move', status: 'done', tokens: 0, label: `moved ${write.moved}`, ...recordSavings(0, 50) });
158
+ return json(res, { ok: true, id });
159
+ }
160
+
161
+ if (url.pathname === '/api/undo') {
162
+ const entry = undoStack.get(String(body.id));
163
+ if (!entry) return json(res, { ok: false, error: 'unknown tweak id' }, 404);
164
+ fs.writeFileSync(entry.abs, entry.before);
165
+ undoStack.delete(String(body.id));
166
+ broadcast({ type: 'tweak', id: body.id, status: 'reverted' });
167
+ return json(res, { ok: true });
168
+ }
169
+
170
+ if (url.pathname === '/api/cancel') {
171
+ const child = running.get(String(body.id));
172
+ if (!child) return json(res, { ok: false, error: 'task not running' }, 404);
173
+ child.__cancel(); // kills the process; the nl handler restores the file
174
+ return json(res, { ok: true });
175
+ }
176
+
177
+ if (url.pathname === '/api/classify') {
178
+ // dry-run the router: no file writes, no model edit run (the hybrid
179
+ // router may still consult the Haiku classifier on ambiguous input)
180
+ const route = await classify(body.instruction || '', { cwd: root });
181
+ return json(res, { ok: true, ...route });
182
+ }
183
+
184
+ if (url.pathname === '/api/nl') {
185
+ const id = nextId++;
186
+ const { file } = parseLoc(body.loc);
187
+ const target = describeTarget(root, body.loc);
188
+ const route = await classify(body.instruction, { cwd: root });
189
+ // Manual model override from the picker: keep the router's effort/tier
190
+ // (still sized to the request) but force the model the user chose.
191
+ if (body.model && body.model !== 'auto') {
192
+ route.model = body.model;
193
+ route.tier = 'manual';
194
+ }
195
+ const abs = path.resolve(root, file);
196
+ remember(id, { abs, before: fs.readFileSync(abs, 'utf8') });
197
+ telemetry.record('nl');
198
+ broadcast({ type: 'tweak', id, kind: route.kind, status: 'queued', model: route.model, effort: route.effort, tier: route.tier, label: body.instruction.slice(0, 60) });
199
+ json(res, { ok: true, id, model: route.model, effort: route.effort, tier: route.tier, kind: route.kind });
200
+
201
+ const restore = () => {
202
+ const entry = undoStack.get(String(id));
203
+ if (entry) fs.writeFileSync(entry.abs, entry.before);
204
+ };
205
+
206
+ const prompt = buildPrompt({ file, target, instruction: body.instruction, tailwind });
207
+ const result = await runClaude({
208
+ prompt,
209
+ model: route.model,
210
+ effort: route.effort,
211
+ cwd: root,
212
+ onEvent: (e) => broadcast({ type: 'tweak', id, ...e }),
213
+ onSpawn: (child) => running.set(String(id), child),
214
+ });
215
+ running.delete(String(id));
216
+
217
+ // Cancelled mid-run: undo any partial edit and stop — no retry, no
218
+ // savings credit.
219
+ if (result.cancelled) {
220
+ restore();
221
+ undoStack.delete(String(id));
222
+ broadcast({ type: 'tweak', id, status: 'cancelled', model: route.model });
223
+ return;
224
+ }
225
+
226
+ // The model's edit must leave the file parseable: retry once with
227
+ // the parse error, then revert if it's still broken.
228
+ let parseErr = checkSyntax(root, file);
229
+ if (result.ok && parseErr) {
230
+ broadcast({ type: 'tweak', id, status: 'running', model: route.model, label: `${body.instruction.slice(0, 40)} (fixing syntax)` });
231
+ const retry = await runClaude({
232
+ prompt: `Your previous edit to ${file} left it with a JSX/JS syntax error:\n${parseErr}\n\nFix ${file} so it parses cleanly while preserving the intended change: ${body.instruction}\nEdit ONLY that file.`,
233
+ model: route.model,
234
+ effort: route.effort,
235
+ cwd: root,
236
+ onSpawn: (child) => running.set(String(id), child),
237
+ });
238
+ running.delete(String(id));
239
+ if (retry.cancelled) {
240
+ restore();
241
+ undoStack.delete(String(id));
242
+ broadcast({ type: 'tweak', id, status: 'cancelled', model: route.model });
243
+ return;
244
+ }
245
+ result.durationMs += retry.durationMs || 0;
246
+ if (retry.costUSD) result.costUSD = (result.costUSD || 0) + retry.costUSD;
247
+ parseErr = checkSyntax(root, file);
248
+ }
249
+ if (parseErr) {
250
+ restore();
251
+ result.ok = false;
252
+ result.error = `edit reverted — file was left unparseable: ${parseErr.slice(0, 120)}`;
253
+ }
254
+
255
+ broadcast({
256
+ type: 'tweak',
257
+ id,
258
+ status: result.ok ? 'done' : 'error',
259
+ model: route.model,
260
+ effort: route.effort,
261
+ tier: route.tier,
262
+ durationMs: result.durationMs,
263
+ costUSD: result.costUSD,
264
+ error: result.error,
265
+ ...(result.ok ? recordSavings(result.costUSD || 0, result.durationMs || 0) : {}),
266
+ });
267
+ return;
268
+ }
269
+ }
270
+
271
+ res.statusCode = 404;
272
+ res.end('not found');
273
+ } catch (e) {
274
+ json(res, { ok: false, error: e.message }, 400);
275
+ }
276
+ });
277
+
278
+ server.on('error', (e) => {
279
+ if (e.code === 'EADDRINUSE') {
280
+ console.error(
281
+ `[cmdzero] port ${port} is already in use (another daemon running?).\n` +
282
+ ` Stop it with: lsof -ti:${port} | xargs kill\n` +
283
+ ` Or pick another port: cmdzero --port ${port + 1} (and set window.CMDZERO_ORIGIN to match)`
284
+ );
285
+ process.exit(1);
286
+ }
287
+ throw e;
288
+ });
289
+ server.listen(port, () => {
290
+ console.log(`[cmdzero] daemon on http://localhost:${port} (root: ${root})`);
291
+ console.log('[cmdzero] docs & updates → https://cmdzero.dev');
292
+ if (telemetry.firstRun && !telemetry.disabled) console.log(DISCLOSURE);
293
+ });
294
+ return server;
295
+ }
296
+
297
+ function json(res, obj, status = 200) {
298
+ if (res.writableEnded) return;
299
+ res.statusCode = status;
300
+ res.setHeader('Content-Type', 'application/json');
301
+ res.end(JSON.stringify(obj));
302
+ }
303
+
304
+ function readJson(req) {
305
+ return new Promise((resolve, reject) => {
306
+ let data = '';
307
+ req.on('data', (c) => (data += c));
308
+ req.on('end', () => {
309
+ try {
310
+ resolve(data ? JSON.parse(data) : {});
311
+ } catch (e) {
312
+ reject(e);
313
+ }
314
+ });
315
+ });
316
+ }
@@ -0,0 +1,97 @@
1
+ // Anonymous usage telemetry — loud about itself, trivial to disable.
2
+ // Collected: package version, node version, OS platform, whether Tailwind was
3
+ // detected, and per-kind tweak COUNTS. Never: code, file paths, file names,
4
+ // prompts, or anything identifying. Disable with CMDZERO_TELEMETRY=0 or
5
+ // the DO_NOT_TRACK=1 convention.
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import crypto from 'node:crypto';
10
+
11
+ const ENDPOINT =
12
+ process.env.CMDZERO_TELEMETRY_URL || 'https://telemetry.cmdzero.dev/v1/events';
13
+ const CONFIG_PATH = path.join(os.homedir(), '.cmdzero', 'telemetry.json');
14
+ const FLUSH_MS = 60_000;
15
+
16
+ export function telemetryDisabled() {
17
+ const v = String(process.env.CMDZERO_TELEMETRY ?? '').toLowerCase();
18
+ if (v === '0' || v === 'false' || v === 'off') return true;
19
+ if (String(process.env.DO_NOT_TRACK) === '1') return true;
20
+ return false;
21
+ }
22
+
23
+ function loadConfig() {
24
+ try {
25
+ return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ function saveConfig(config) {
32
+ try {
33
+ fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
34
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config));
35
+ } catch {
36
+ /* telemetry must never break the daemon */
37
+ }
38
+ }
39
+
40
+ export const DISCLOSURE = [
41
+ '[cmdzero] Anonymous usage telemetry helps prioritize framework support.',
42
+ ' Collected: version, OS, tweak counts. Never: code, file paths,',
43
+ ' prompts, or anything identifying.',
44
+ ' Opt out any time: CMDZERO_TELEMETRY=0 (DO_NOT_TRACK=1 also respected)',
45
+ ].join('\n');
46
+
47
+ export function initTelemetry({ version, tailwind }) {
48
+ let config = loadConfig();
49
+ const firstRun = !config;
50
+ if (firstRun) {
51
+ // random id, derived from nothing — only distinguishes installs in aggregate
52
+ config = { anonymousId: crypto.randomUUID(), notifiedAt: new Date().toISOString() };
53
+ saveConfig(config);
54
+ }
55
+ const disabled = telemetryDisabled();
56
+ const counts = { copy: 0, style: 0, delete: 0, nl: 0, move: 0 };
57
+ let flushTimer = null;
58
+
59
+ function send(event, extra = {}) {
60
+ if (disabled) return;
61
+ try {
62
+ fetch(ENDPOINT, {
63
+ method: 'POST',
64
+ headers: { 'content-type': 'application/json' },
65
+ body: JSON.stringify({
66
+ event,
67
+ anonymousId: config.anonymousId,
68
+ version,
69
+ node: process.version,
70
+ platform: os.platform(),
71
+ tailwind,
72
+ ts: Date.now(),
73
+ ...extra,
74
+ }),
75
+ signal: AbortSignal.timeout(3000),
76
+ }).catch(() => {});
77
+ } catch {
78
+ /* fire and forget */
79
+ }
80
+ }
81
+
82
+ function record(kind) {
83
+ if (disabled || !(kind in counts)) return;
84
+ counts[kind]++;
85
+ if (!flushTimer) {
86
+ flushTimer = setTimeout(() => {
87
+ flushTimer = null;
88
+ send('tweaks', { counts: { ...counts } });
89
+ for (const k of Object.keys(counts)) counts[k] = 0;
90
+ }, FLUSH_MS);
91
+ flushTimer.unref?.();
92
+ }
93
+ }
94
+
95
+ send('boot');
96
+ return { firstRun, disabled, record };
97
+ }