tweaklocal 0.1.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/README.md +37 -0
- package/bin/tweaklocal.js +13 -0
- package/overlay/overlay.js +804 -0
- package/package.json +14 -0
- package/src/resolver.js +234 -0
- package/src/router.js +86 -0
- package/src/server.js +243 -0
- package/src/telemetry.js +97 -0
package/package.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tweaklocal",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": { "tweaklocal": "bin/tweaklocal.js" },
|
|
8
|
+
"scripts": { "start": "node bin/tweaklocal.js" },
|
|
9
|
+
"files": ["bin", "src", "overlay", "README.md"],
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@babel/parser": "^7.26.0"
|
|
12
|
+
},
|
|
13
|
+
"keywords": ["devtools", "tailwind", "visual-editor", "claude", "ai", "nextjs", "vite", "react"]
|
|
14
|
+
}
|
package/src/resolver.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { parse } from '@babel/parser';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
function parseSource(content, file) {
|
|
6
|
+
const plugins = /\.tsx?$/.test(file) ? ['typescript', 'jsx'] : ['jsx'];
|
|
7
|
+
return parse(content, { sourceType: 'module', plugins, errorRecovery: true });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function walk(node, cb) {
|
|
11
|
+
if (!node || typeof node.type !== 'string') return;
|
|
12
|
+
cb(node);
|
|
13
|
+
for (const key of Object.keys(node)) {
|
|
14
|
+
if (key === 'loc') continue;
|
|
15
|
+
const v = node[key];
|
|
16
|
+
if (Array.isArray(v)) v.forEach((c) => c && typeof c.type === 'string' && walk(c, cb));
|
|
17
|
+
else if (v && typeof v.type === 'string') walk(v, cb);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** loc format: "<relative file>:<line 1-based>:<col 0-based>" (matches the babel stamp) */
|
|
22
|
+
export function parseLoc(loc) {
|
|
23
|
+
const m = /^(.*):(\d+):(\d+)$/.exec(loc);
|
|
24
|
+
if (!m) throw new Error(`bad loc: ${loc}`);
|
|
25
|
+
return { file: m[1], line: Number(m[2]), col: Number(m[3]) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function loadTarget(root, loc) {
|
|
29
|
+
const parsed = parseLoc(loc);
|
|
30
|
+
const { line, col } = parsed;
|
|
31
|
+
// Stamps come from two sources: the babel plugin (root-relative path,
|
|
32
|
+
// 0-based column) and the @tweaklocal/react dev runtime (absolute path,
|
|
33
|
+
// 1-based column). Normalize the path and match either column convention.
|
|
34
|
+
const file = path.isAbsolute(parsed.file)
|
|
35
|
+
? path.relative(root, parsed.file)
|
|
36
|
+
: parsed.file;
|
|
37
|
+
if (file.startsWith('..')) throw new Error('file outside root');
|
|
38
|
+
const abs = path.resolve(root, file);
|
|
39
|
+
const content = fs.readFileSync(abs, 'utf8');
|
|
40
|
+
const ast = parseSource(content, file);
|
|
41
|
+
let element = null;
|
|
42
|
+
let nearMiss = null;
|
|
43
|
+
walk(ast, (n) => {
|
|
44
|
+
if (n.type !== 'JSXElement' || !n.openingElement.loc) return;
|
|
45
|
+
const s = n.openingElement.loc.start;
|
|
46
|
+
if (s.line !== line) return;
|
|
47
|
+
if (s.column === col) element = n;
|
|
48
|
+
else if (s.column === col - 1 && !nearMiss) nearMiss = n;
|
|
49
|
+
});
|
|
50
|
+
element = element || nearMiss;
|
|
51
|
+
if (!element) throw new Error(`no JSX element at ${loc}`);
|
|
52
|
+
return { file, abs, content, element };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function describeTarget(root, loc) {
|
|
56
|
+
const { file, content, element } = loadTarget(root, loc);
|
|
57
|
+
const opening = element.openingElement;
|
|
58
|
+
const texts = element.children
|
|
59
|
+
.filter((c) => c.type === 'JSXText' && c.value.trim())
|
|
60
|
+
.map((c) => ({ value: c.value.trim(), start: c.start, end: c.end }));
|
|
61
|
+
const classAttr = opening.attributes.find(
|
|
62
|
+
(a) =>
|
|
63
|
+
a.type === 'JSXAttribute' &&
|
|
64
|
+
a.name.name === 'className' &&
|
|
65
|
+
a.value &&
|
|
66
|
+
a.value.type === 'StringLiteral'
|
|
67
|
+
);
|
|
68
|
+
return {
|
|
69
|
+
file,
|
|
70
|
+
tagName: opening.name.name,
|
|
71
|
+
span: { start: element.start, end: element.end },
|
|
72
|
+
lines: { start: element.loc.start.line, end: element.loc.end.line },
|
|
73
|
+
snippet: content.slice(element.start, element.end),
|
|
74
|
+
texts,
|
|
75
|
+
className: classAttr ? classAttr.value.value : null,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function applyTextEdit(root, loc, oldText, newText) {
|
|
80
|
+
const { abs, content, element } = loadTarget(root, loc);
|
|
81
|
+
const node = element.children.find(
|
|
82
|
+
(c) => c.type === 'JSXText' && c.value.trim() === oldText.trim()
|
|
83
|
+
);
|
|
84
|
+
if (!node) throw new Error(`text "${oldText}" not found in element at ${loc}`);
|
|
85
|
+
const raw = content.slice(node.start, node.end);
|
|
86
|
+
const leading = raw.match(/^\s*/)[0];
|
|
87
|
+
const trailing = raw.match(/\s*$/)[0];
|
|
88
|
+
const next =
|
|
89
|
+
content.slice(0, node.start) + leading + newText + trailing + content.slice(node.end);
|
|
90
|
+
return writeChecked(abs, content, next);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function applyClassEdit(root, loc, removes = [], adds = []) {
|
|
94
|
+
const { abs, content, element } = loadTarget(root, loc);
|
|
95
|
+
const opening = element.openingElement;
|
|
96
|
+
const attr = opening.attributes.find(
|
|
97
|
+
(a) =>
|
|
98
|
+
a.type === 'JSXAttribute' &&
|
|
99
|
+
a.name.name === 'className' &&
|
|
100
|
+
a.value &&
|
|
101
|
+
a.value.type === 'StringLiteral'
|
|
102
|
+
);
|
|
103
|
+
let next;
|
|
104
|
+
if (attr) {
|
|
105
|
+
let classes = attr.value.value.split(/\s+/).filter(Boolean);
|
|
106
|
+
classes = classes.filter((c) => !removes.includes(c));
|
|
107
|
+
for (const a of adds) if (!classes.includes(a)) classes.push(a);
|
|
108
|
+
next =
|
|
109
|
+
content.slice(0, attr.value.start + 1) +
|
|
110
|
+
classes.join(' ') +
|
|
111
|
+
content.slice(attr.value.end - 1);
|
|
112
|
+
} else {
|
|
113
|
+
const pos = opening.name.end;
|
|
114
|
+
next =
|
|
115
|
+
content.slice(0, pos) + ` className="${adds.join(' ')}"` + content.slice(pos);
|
|
116
|
+
}
|
|
117
|
+
return writeChecked(abs, content, next);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Merge CSS properties into the element's inline style prop — the
|
|
122
|
+
* styling-system-agnostic edit lane (works with CSS modules, styled-components,
|
|
123
|
+
* vanilla CSS, anything). styles: { camelCaseProp: value | null }, null removes.
|
|
124
|
+
* Appended keys win in React's style object, so replacements are drop+append.
|
|
125
|
+
*/
|
|
126
|
+
export function applyStyleEdit(root, loc, styles) {
|
|
127
|
+
const { abs, content, element } = loadTarget(root, loc);
|
|
128
|
+
const opening = element.openingElement;
|
|
129
|
+
const toSrc = (v) => (typeof v === 'number' ? String(v) : `'${String(v).replace(/'/g, "\\'")}'`);
|
|
130
|
+
const attr = opening.attributes.find(
|
|
131
|
+
(a) => a.type === 'JSXAttribute' && a.name.name === 'style'
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
if (!attr) {
|
|
135
|
+
const entries = Object.entries(styles).filter(([, v]) => v != null);
|
|
136
|
+
if (!entries.length) return writeChecked(abs, content, content);
|
|
137
|
+
const obj = entries.map(([k, v]) => `${k}: ${toSrc(v)}`).join(', ');
|
|
138
|
+
const pos = opening.name.end;
|
|
139
|
+
return writeChecked(
|
|
140
|
+
abs,
|
|
141
|
+
content,
|
|
142
|
+
content.slice(0, pos) + ` style={{ ${obj} }}` + content.slice(pos)
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const expr =
|
|
147
|
+
attr.value && attr.value.type === 'JSXExpressionContainer' ? attr.value.expression : null;
|
|
148
|
+
if (!expr || expr.type !== 'ObjectExpression')
|
|
149
|
+
throw new Error('style prop is not an object literal — describe the change instead');
|
|
150
|
+
|
|
151
|
+
// Rebuild the object: keep untouched properties (and spreads) verbatim,
|
|
152
|
+
// drop replaced/removed keys, append new values last.
|
|
153
|
+
const parts = [];
|
|
154
|
+
for (const p of expr.properties) {
|
|
155
|
+
if (p.type === 'ObjectProperty' && !p.computed) {
|
|
156
|
+
const name =
|
|
157
|
+
p.key.type === 'Identifier' ? p.key.name : p.key.type === 'StringLiteral' ? p.key.value : null;
|
|
158
|
+
if (name && name in styles) continue;
|
|
159
|
+
}
|
|
160
|
+
parts.push(content.slice(p.start, p.end));
|
|
161
|
+
}
|
|
162
|
+
for (const [k, v] of Object.entries(styles)) if (v != null) parts.push(`${k}: ${toSrc(v)}`);
|
|
163
|
+
|
|
164
|
+
// Preserve multi-line formatting when the original object was multi-line.
|
|
165
|
+
const original = content.slice(expr.start, expr.end);
|
|
166
|
+
let objSrc;
|
|
167
|
+
if (original.includes('\n') && expr.properties.length) {
|
|
168
|
+
const firstProp = expr.properties[0];
|
|
169
|
+
const lineStart = content.lastIndexOf('\n', firstProp.start) + 1;
|
|
170
|
+
const indent = content.slice(lineStart, firstProp.start).match(/^\s*/)[0];
|
|
171
|
+
const braceLineStart = content.lastIndexOf('\n', expr.end - 1) + 1;
|
|
172
|
+
const braceIndent = content.slice(braceLineStart).match(/^\s*/)[0];
|
|
173
|
+
objSrc = `{\n${indent}${parts.join(`,\n${indent}`)},\n${braceIndent}}`;
|
|
174
|
+
} else {
|
|
175
|
+
objSrc = `{ ${parts.join(', ')} }`;
|
|
176
|
+
}
|
|
177
|
+
return writeChecked(
|
|
178
|
+
abs,
|
|
179
|
+
content,
|
|
180
|
+
content.slice(0, expr.start) + objSrc + content.slice(expr.end)
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Delete the element from source, consuming its whole line(s) when it sits
|
|
186
|
+
* alone on them. Refuses (throws) if the removal would leave the file
|
|
187
|
+
* unparseable — e.g. deleting a component's only root element.
|
|
188
|
+
*/
|
|
189
|
+
export function applyDeleteElement(root, loc, { dryRun = false } = {}) {
|
|
190
|
+
const { abs, content, element, file } = loadTarget(root, loc);
|
|
191
|
+
let start = element.start;
|
|
192
|
+
let end = element.end;
|
|
193
|
+
const lineStart = content.lastIndexOf('\n', start - 1) + 1;
|
|
194
|
+
const afterMatch = content.slice(end).match(/^[ \t]*\n/);
|
|
195
|
+
if (/^[ \t]*$/.test(content.slice(lineStart, start)) && afterMatch) {
|
|
196
|
+
start = lineStart;
|
|
197
|
+
end += afterMatch[0].length;
|
|
198
|
+
}
|
|
199
|
+
const next = content.slice(0, start) + content.slice(end);
|
|
200
|
+
try {
|
|
201
|
+
parseSource(next, file);
|
|
202
|
+
} catch {
|
|
203
|
+
// Almost always: this element is the ONLY thing its component (or a
|
|
204
|
+
// .map() callback) renders, so removing it would leave an empty
|
|
205
|
+
// `return ()` or `.map(() => ())` — invalid JS. Detect that specific,
|
|
206
|
+
// near-universal case to give an actionable message instead of a
|
|
207
|
+
// generic parse error.
|
|
208
|
+
const before = content.slice(0, start).trimEnd();
|
|
209
|
+
const soleReturn = /(return\s*\(?|=>\s*\(?)$/.test(before);
|
|
210
|
+
throw new Error(
|
|
211
|
+
soleReturn
|
|
212
|
+
? "can't delete — it's the only thing this component (or list item) renders; deleting it would leave nothing to return. Describe the change instead (e.g. \"remove this from the list\") and it'll route through the model."
|
|
213
|
+
: 'deleting this element would break the file — describe the change instead'
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
if (dryRun) return { abs, before: content, after: next, wouldWrite: false };
|
|
217
|
+
return writeChecked(abs, content, next);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Returns null if the file parses, else the parse error message. */
|
|
221
|
+
export function checkSyntax(root, file) {
|
|
222
|
+
const abs = path.resolve(root, file);
|
|
223
|
+
try {
|
|
224
|
+
parseSource(fs.readFileSync(abs, 'utf8'), file);
|
|
225
|
+
return null;
|
|
226
|
+
} catch (e) {
|
|
227
|
+
return e.message;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function writeChecked(abs, before, after) {
|
|
232
|
+
fs.writeFileSync(abs, after);
|
|
233
|
+
return { abs, before, after };
|
|
234
|
+
}
|
package/src/router.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
// Behavior verbs only — visual states like "on hover"/"on focus" are style
|
|
4
|
+
// (Tailwind variants), not functionality.
|
|
5
|
+
const FUNCTIONALITY = /(click|toggle|open|close|submit|fetch|load|save|state|counter|count|when |scroll to|navigate|link to|form|validate|sort|filter|search|api|localstorage|modal|dropdown|expand|collapse|disable|enable|animate|animation|add a |remove the )/i;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Route a natural-language tweak to a model tier.
|
|
9
|
+
* Style/copy → fast model, functionality → reasoning model.
|
|
10
|
+
* Overridable via TWEAKLOCAL_FAST_MODEL / TWEAKLOCAL_SMART_MODEL.
|
|
11
|
+
*/
|
|
12
|
+
export function classify(instruction) {
|
|
13
|
+
const isFunc = FUNCTIONALITY.test(instruction);
|
|
14
|
+
return {
|
|
15
|
+
kind: isFunc ? 'functionality' : 'style/copy',
|
|
16
|
+
model: isFunc
|
|
17
|
+
? process.env.TWEAKLOCAL_SMART_MODEL || 'sonnet'
|
|
18
|
+
: process.env.TWEAKLOCAL_FAST_MODEL || 'haiku',
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function buildPrompt({ file, target, instruction, tailwind = true }) {
|
|
23
|
+
const styleRule = tailwind
|
|
24
|
+
? 'use Tailwind classes for styling'
|
|
25
|
+
: "this project does NOT use Tailwind — match the file's existing styling approach (inline styles, CSS variables, or the project's stylesheets)";
|
|
26
|
+
return [
|
|
27
|
+
`You are making a surgical UI edit in a React codebase.`,
|
|
28
|
+
`Edit ONLY this file: ${file}`,
|
|
29
|
+
`The user selected this element (lines ${target.lines.start}-${target.lines.end}):`,
|
|
30
|
+
'```jsx',
|
|
31
|
+
target.snippet,
|
|
32
|
+
'```',
|
|
33
|
+
`Instruction: ${instruction}`,
|
|
34
|
+
`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.`,
|
|
35
|
+
'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 (<>...</>).',
|
|
36
|
+
].join('\n');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function runClaude({ prompt, model, cwd, onEvent }) {
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
const started = Date.now();
|
|
42
|
+
const child = spawn(
|
|
43
|
+
'claude',
|
|
44
|
+
[
|
|
45
|
+
'-p',
|
|
46
|
+
prompt,
|
|
47
|
+
'--model',
|
|
48
|
+
model,
|
|
49
|
+
'--allowedTools',
|
|
50
|
+
'Read,Edit',
|
|
51
|
+
'--permission-mode',
|
|
52
|
+
'acceptEdits',
|
|
53
|
+
'--output-format',
|
|
54
|
+
'json',
|
|
55
|
+
],
|
|
56
|
+
{ cwd, env: { ...process.env, CLAUDE_CODE_ENTRYPOINT: undefined } }
|
|
57
|
+
);
|
|
58
|
+
let out = '';
|
|
59
|
+
let err = '';
|
|
60
|
+
child.stdout.on('data', (d) => (out += d));
|
|
61
|
+
child.stderr.on('data', (d) => (err += d));
|
|
62
|
+
child.on('close', (code) => {
|
|
63
|
+
const durationMs = Date.now() - started;
|
|
64
|
+
if (code !== 0) {
|
|
65
|
+
resolve({ ok: false, error: err.trim() || `claude exited ${code}`, durationMs });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
let meta = {};
|
|
69
|
+
try {
|
|
70
|
+
const parsed = JSON.parse(out);
|
|
71
|
+
meta = {
|
|
72
|
+
costUSD: parsed.total_cost_usd,
|
|
73
|
+
turns: parsed.num_turns,
|
|
74
|
+
result: parsed.result,
|
|
75
|
+
};
|
|
76
|
+
} catch {
|
|
77
|
+
meta = { result: out.slice(0, 500) };
|
|
78
|
+
}
|
|
79
|
+
resolve({ ok: true, durationMs, ...meta });
|
|
80
|
+
});
|
|
81
|
+
child.on('error', (e) => {
|
|
82
|
+
resolve({ ok: false, error: `failed to spawn claude: ${e.message}` });
|
|
83
|
+
});
|
|
84
|
+
onEvent?.({ status: 'running', model });
|
|
85
|
+
});
|
|
86
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
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, 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.TWEAKLOCAL_BASELINE_COST || 0.093),
|
|
19
|
+
ms: Number(process.env.TWEAKLOCAL_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 sseClients = new Set();
|
|
34
|
+
let nextId = 1;
|
|
35
|
+
const tailwind = detectTailwind(root);
|
|
36
|
+
const telemetry = initTelemetry({ version: VERSION, tailwind });
|
|
37
|
+
|
|
38
|
+
const savingsPath = path.join(root, '.tweaklocal', 'savings.json');
|
|
39
|
+
let totals = { usd: 0, ms: 0, count: 0 };
|
|
40
|
+
try { totals = JSON.parse(fs.readFileSync(savingsPath, 'utf8')); } catch { /* first run */ }
|
|
41
|
+
function recordSavings(costUSD = 0, ms = 0) {
|
|
42
|
+
const saved = { usd: Math.max(0, BASELINE.usd - costUSD), ms: Math.max(0, BASELINE.ms - ms) };
|
|
43
|
+
totals.usd += saved.usd;
|
|
44
|
+
totals.ms += saved.ms;
|
|
45
|
+
totals.count += 1;
|
|
46
|
+
try {
|
|
47
|
+
fs.mkdirSync(path.dirname(savingsPath), { recursive: true });
|
|
48
|
+
fs.writeFileSync(savingsPath, JSON.stringify(totals));
|
|
49
|
+
} catch { /* savings persistence is best-effort */ }
|
|
50
|
+
return { saved, totals };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function broadcast(event) {
|
|
54
|
+
const line = `data: ${JSON.stringify(event)}\n\n`;
|
|
55
|
+
for (const res of sseClients) res.write(line);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function remember(id, write) {
|
|
59
|
+
undoStack.set(String(id), { abs: write.abs, before: write.before });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const server = http.createServer(async (req, res) => {
|
|
63
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
64
|
+
res.setHeader('Access-Control-Allow-Headers', 'content-type');
|
|
65
|
+
if (req.method === 'OPTIONS') return res.end();
|
|
66
|
+
|
|
67
|
+
const url = new URL(req.url, `http://localhost:${port}`);
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
if (req.method === 'GET' && url.pathname === '/overlay.js') {
|
|
71
|
+
res.setHeader('Content-Type', 'text/javascript');
|
|
72
|
+
return res.end(fs.readFileSync(OVERLAY_PATH));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (req.method === 'GET' && url.pathname === '/api/health') {
|
|
76
|
+
return json(res, { ok: true, root, totals, tailwind });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (req.method === 'GET' && url.pathname === '/api/events') {
|
|
80
|
+
res.writeHead(200, {
|
|
81
|
+
'Content-Type': 'text/event-stream',
|
|
82
|
+
'Cache-Control': 'no-cache',
|
|
83
|
+
Connection: 'keep-alive',
|
|
84
|
+
});
|
|
85
|
+
res.write('\n');
|
|
86
|
+
sseClients.add(res);
|
|
87
|
+
req.on('close', () => sseClients.delete(res));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (req.method === 'POST') {
|
|
92
|
+
const body = await readJson(req);
|
|
93
|
+
|
|
94
|
+
if (url.pathname === '/api/resolve') {
|
|
95
|
+
return json(res, describeTarget(root, body.loc));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (url.pathname === '/api/edit-text') {
|
|
99
|
+
const id = nextId++;
|
|
100
|
+
const write = applyTextEdit(root, body.loc, body.oldText, body.newText);
|
|
101
|
+
remember(id, write);
|
|
102
|
+
broadcast({ type: 'tweak', id, kind: 'copy', status: 'done', tokens: 0, label: `copy: "${body.newText.slice(0, 40)}"`, ...recordSavings(0, 50) });
|
|
103
|
+
return json(res, { ok: true, id });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (url.pathname === '/api/edit-class') {
|
|
107
|
+
const id = nextId++;
|
|
108
|
+
const write = applyClassEdit(root, body.loc, body.remove || [], body.add || []);
|
|
109
|
+
remember(id, write);
|
|
110
|
+
broadcast({ type: 'tweak', id, kind: 'style', status: 'done', tokens: 0, label: `style: ${(body.add || []).join(' ')}`, ...recordSavings(0, 50) });
|
|
111
|
+
return json(res, { ok: true, id });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (url.pathname === '/api/edit-style') {
|
|
115
|
+
const id = nextId++;
|
|
116
|
+
const write = applyStyleEdit(root, body.loc, body.styles || {});
|
|
117
|
+
remember(id, write);
|
|
118
|
+
const desc = Object.entries(body.styles || {})
|
|
119
|
+
.map(([k, v]) => `${k}: ${v}`)
|
|
120
|
+
.join(', ');
|
|
121
|
+
broadcast({ type: 'tweak', id, kind: 'style', status: 'done', tokens: 0, label: `style: ${desc.slice(0, 50)}`, ...recordSavings(0, 50) });
|
|
122
|
+
return json(res, { ok: true, id });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (url.pathname === '/api/delete') {
|
|
126
|
+
if (body.dryRun) {
|
|
127
|
+
applyDeleteElement(root, body.loc, { dryRun: true }); // throws if unsafe, writes nothing
|
|
128
|
+
return json(res, { ok: true, dryRun: true });
|
|
129
|
+
}
|
|
130
|
+
const id = nextId++;
|
|
131
|
+
const target = describeTarget(root, body.loc);
|
|
132
|
+
const write = applyDeleteElement(root, body.loc);
|
|
133
|
+
remember(id, write);
|
|
134
|
+
broadcast({ type: 'tweak', id, kind: 'delete', status: 'done', tokens: 0, label: `deleted <${target.tagName}>`, ...recordSavings(0, 50) });
|
|
135
|
+
return json(res, { ok: true, id });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (url.pathname === '/api/undo') {
|
|
139
|
+
const entry = undoStack.get(String(body.id));
|
|
140
|
+
if (!entry) return json(res, { ok: false, error: 'unknown tweak id' }, 404);
|
|
141
|
+
fs.writeFileSync(entry.abs, entry.before);
|
|
142
|
+
undoStack.delete(String(body.id));
|
|
143
|
+
broadcast({ type: 'tweak', id: body.id, status: 'reverted' });
|
|
144
|
+
return json(res, { ok: true });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (url.pathname === '/api/nl') {
|
|
148
|
+
const id = nextId++;
|
|
149
|
+
const { file } = parseLoc(body.loc);
|
|
150
|
+
const target = describeTarget(root, body.loc);
|
|
151
|
+
const route = classify(body.instruction);
|
|
152
|
+
const abs = path.resolve(root, file);
|
|
153
|
+
remember(id, { abs, before: fs.readFileSync(abs, 'utf8') });
|
|
154
|
+
broadcast({ type: 'tweak', id, kind: route.kind, status: 'queued', model: route.model, label: body.instruction.slice(0, 60) });
|
|
155
|
+
json(res, { ok: true, id, model: route.model, kind: route.kind });
|
|
156
|
+
|
|
157
|
+
const prompt = buildPrompt({ file, target, instruction: body.instruction, tailwind });
|
|
158
|
+
const result = await runClaude({
|
|
159
|
+
prompt,
|
|
160
|
+
model: route.model,
|
|
161
|
+
cwd: root,
|
|
162
|
+
onEvent: (e) => broadcast({ type: 'tweak', id, ...e }),
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// The model's edit must leave the file parseable: retry once with
|
|
166
|
+
// the parse error, then revert if it's still broken.
|
|
167
|
+
let parseErr = checkSyntax(root, file);
|
|
168
|
+
if (result.ok && parseErr) {
|
|
169
|
+
broadcast({ type: 'tweak', id, status: 'running', model: route.model, label: `${body.instruction.slice(0, 40)} (fixing syntax)` });
|
|
170
|
+
const retry = await runClaude({
|
|
171
|
+
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.`,
|
|
172
|
+
model: route.model,
|
|
173
|
+
cwd: root,
|
|
174
|
+
});
|
|
175
|
+
result.durationMs += retry.durationMs || 0;
|
|
176
|
+
if (retry.costUSD) result.costUSD = (result.costUSD || 0) + retry.costUSD;
|
|
177
|
+
parseErr = checkSyntax(root, file);
|
|
178
|
+
}
|
|
179
|
+
if (parseErr) {
|
|
180
|
+
const entry = undoStack.get(String(id));
|
|
181
|
+
fs.writeFileSync(entry.abs, entry.before);
|
|
182
|
+
result.ok = false;
|
|
183
|
+
result.error = `edit reverted — file was left unparseable: ${parseErr.slice(0, 120)}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
broadcast({
|
|
187
|
+
type: 'tweak',
|
|
188
|
+
id,
|
|
189
|
+
status: result.ok ? 'done' : 'error',
|
|
190
|
+
model: route.model,
|
|
191
|
+
durationMs: result.durationMs,
|
|
192
|
+
costUSD: result.costUSD,
|
|
193
|
+
error: result.error,
|
|
194
|
+
...(result.ok ? recordSavings(result.costUSD || 0, result.durationMs || 0) : {}),
|
|
195
|
+
});
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
res.statusCode = 404;
|
|
201
|
+
res.end('not found');
|
|
202
|
+
} catch (e) {
|
|
203
|
+
json(res, { ok: false, error: e.message }, 400);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
server.on('error', (e) => {
|
|
208
|
+
if (e.code === 'EADDRINUSE') {
|
|
209
|
+
console.error(
|
|
210
|
+
`[tweaklocal] port ${port} is already in use (another daemon running?).\n` +
|
|
211
|
+
` Stop it with: lsof -ti:${port} | xargs kill\n` +
|
|
212
|
+
` Or pick another port: tweaklocal --port ${port + 1} (and set window.TWEAKLOCAL_ORIGIN to match)`
|
|
213
|
+
);
|
|
214
|
+
process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
throw e;
|
|
217
|
+
});
|
|
218
|
+
server.listen(port, () => {
|
|
219
|
+
console.log(`[tweaklocal] daemon on http://localhost:${port} (root: ${root})`);
|
|
220
|
+
});
|
|
221
|
+
return server;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function json(res, obj, status = 200) {
|
|
225
|
+
if (res.writableEnded) return;
|
|
226
|
+
res.statusCode = status;
|
|
227
|
+
res.setHeader('Content-Type', 'application/json');
|
|
228
|
+
res.end(JSON.stringify(obj));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function readJson(req) {
|
|
232
|
+
return new Promise((resolve, reject) => {
|
|
233
|
+
let data = '';
|
|
234
|
+
req.on('data', (c) => (data += c));
|
|
235
|
+
req.on('end', () => {
|
|
236
|
+
try {
|
|
237
|
+
resolve(data ? JSON.parse(data) : {});
|
|
238
|
+
} catch (e) {
|
|
239
|
+
reject(e);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
}
|
package/src/telemetry.js
ADDED
|
@@ -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 TWEAKLOCAL_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.TWEAKLOCAL_TELEMETRY_URL || 'https://telemetry.tweaklocal.dev/v1/events';
|
|
13
|
+
const CONFIG_PATH = path.join(os.homedir(), '.tweaklocal', 'telemetry.json');
|
|
14
|
+
const FLUSH_MS = 60_000;
|
|
15
|
+
|
|
16
|
+
export function telemetryDisabled() {
|
|
17
|
+
const v = String(process.env.TWEAKLOCAL_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
|
+
'[tweaklocal] 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: TWEAKLOCAL_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 };
|
|
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
|
+
}
|