Replace WebSocket with SSE, move live scripts into skill (self-contained)

Two architectural changes that make the live variant mode self-contained:

1. SSE replaces WebSocket: the server now uses Server-Sent Events for
   server→browser push and regular fetch POST for browser→server
   events. This eliminates the ws npm dependency entirely. The live
   server is now zero-dependency pure Node.js (http, crypto, fs, net).

   Browser: EventSource replaces WebSocket. sendEvent() uses fetch POST.
   Server: GET /events returns SSE stream, POST /events receives browser
   events. All other endpoints (poll, source, health, stop) unchanged.

2. Scripts moved to source/skills/impeccable/scripts/: live-server.mjs,
   live-poll.mjs, live-wrap.mjs, live-browser.js are now part of the
   skill itself. Users who install the skill via npx skills get the live
   mode without needing npm install impeccable separately.

   The skill reference uses {{scripts_path}}/live-server.mjs etc.
   The CLI (bin/cli.js) delegates to the skill scripts as a convenience.

   Removed ws from package.json dependencies.

The old src/live/ files remain as the development copy. The build system
syncs source/skills/ to all harness dirs (11 providers).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-04-12 19:19:36 -07:00
co-authored by Claude Opus 4.6
parent a832fe778c
commit 5bad08723d
63 changed files with 24842 additions and 112 deletions
+11 -7
View File
@@ -10,7 +10,7 @@ Launch interactive live variant mode: select elements in the browser, pick a des
1. Read `.impeccable.md` if it exists. Keep the design context in mind for variant generation.
2. Start the live variant server:
```bash
npx impeccable live &
node {{scripts_path}}/live-server.mjs &
```
3. Note the **port** and **token** printed to stdout.
@@ -53,7 +53,7 @@ Run a blocking poll loop. On each iteration, wait for a browser event and respon
```
LOOP:
Run: npx impeccable poll
Run: node {{scripts_path}}/live-poll.mjs
Read the JSON output. Dispatch based on the "type" field:
TYPE "generate":
@@ -85,7 +85,7 @@ The event contains: `{id, action, freeformPrompt, count, pageUrl, element}`.
Use the `wrap` helper to find the element and create the variant container:
```bash
npx impeccable wrap --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
node {{scripts_path}}/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
```
Pass the element's id (`event.element.id`), classes (`event.element.classes` joined with commas), and tag name. The command searches in priority order: ID match first, then class names, then tag+class combo. If `event.pageUrl` hints at the file (e.g., `/` is usually `index.html`), pass `--file PATH` to skip the search.
@@ -136,10 +136,14 @@ The first variant should NOT have `style="display: none"` (it should be visible
### Step 3: Signal completion
Include `--file` so the browser can fetch variants directly if the dev server lacks HMR:
```bash
npx impeccable poll --reply EVENT_ID done
node {{scripts_path}}/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH
```
The file path should be relative to the project root (e.g., `public/index.html`, `src/App.tsx`).
## Handle Accept
The event contains: `{id, variantId}`.
@@ -151,7 +155,7 @@ The user accepted a specific variant. For v1 (inspection mode):
4. Remove any scoped CSS blocks (between `impeccable-variants-css-start` and `impeccable-variants-css-end` markers).
5. Reply:
```bash
npx impeccable poll --reply SESSION_ID done
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
## Handle Discard
@@ -163,7 +167,7 @@ The event contains: `{id}`.
3. Remove any scoped CSS blocks for this session.
4. Reply:
```bash
npx impeccable poll --reply SESSION_ID done
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
## Cleanup (on exit)
@@ -174,7 +178,7 @@ When the loop ends:
2. **Remove any leftover variant wrappers** (search for `impeccable-variants-start` markers and clean up).
3. **Stop the server**:
```bash
npx impeccable live stop
node {{scripts_path}}/live-server.mjs stop
```
## Variant Generation Guidelines
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,123 @@
/**
* CLI client for the live variant mode poll/reply protocol.
*
* Usage:
* npx impeccable poll # Block until browser event, print JSON
* npx impeccable poll --timeout=60000 # Custom timeout (ms)
* npx impeccable poll --reply <id> done # Reply "done" to event <id>
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
const LIVE_PID_FILE = path.join(os.tmpdir(), 'impeccable-live.json');
function readServerInfo() {
try {
return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
} catch {
console.error('No running live server found. Start one with: npx impeccable live');
process.exit(1);
}
}
export async function pollCli() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: impeccable poll [options]
Wait for a browser event from the live variant server, or reply to one.
Modes:
poll Block until a browser event arrives, print JSON
poll --reply <id> done Reply "done" to event <id>
poll --reply <id> error "msg" Reply with an error message
Options:
--timeout=MS Poll timeout in milliseconds (default: 120000)
--help Show this help message`);
process.exit(0);
}
const info = readServerInfo();
const base = `http://localhost:${info.port}`;
// Reply mode: npx impeccable poll --reply <id> <status> [--file path] [message]
const replyIdx = args.indexOf('--reply');
if (replyIdx !== -1) {
const id = args[replyIdx + 1];
const status = args[replyIdx + 2] || 'done';
const fileIdx = args.indexOf('--file');
const filePath = fileIdx !== -1 && fileIdx + 1 < args.length ? args[fileIdx + 1] : undefined;
// Message is any remaining positional arg that isn't a flag
const message = args.find((a, i) => i > replyIdx + 2 && !a.startsWith('--') && i !== fileIdx + 1) || undefined;
if (!id) {
console.error('Usage: npx impeccable poll --reply <id> <status> [--file path] [message]');
process.exit(1);
}
try {
const res = await fetch(`${base}/poll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: info.token,
id,
type: status,
message,
file: filePath,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
console.error(`Reply failed (${res.status}):`, body.error || res.statusText);
process.exit(1);
}
// Success — silent exit (agent doesn't need output for replies)
} catch (err) {
if (err.cause?.code === 'ECONNREFUSED') {
console.error('Live server not running. Start one with: npx impeccable live');
} else {
console.error('Reply failed:', err.message);
}
process.exit(1);
}
return;
}
// Poll mode: block until browser event
const timeoutArg = args.find(a => a.startsWith('--timeout='));
const timeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 120000;
try {
const res = await fetch(`${base}/poll?token=${info.token}&timeout=${timeout}`);
if (res.status === 401) {
console.error('Authentication failed. The server token may have changed.');
console.error('Try restarting: npx impeccable live stop && npx impeccable live');
process.exit(1);
}
if (!res.ok) {
console.error(`Poll failed: ${res.status} ${res.statusText}`);
process.exit(1);
}
const event = await res.json();
// Print the event as JSON — the agent reads this from stdout
console.log(JSON.stringify(event));
} catch (err) {
if (err.cause?.code === 'ECONNREFUSED') {
console.error('Live server not running. Start one with: npx impeccable live');
} else {
console.error('Poll failed:', err.message);
}
process.exit(1);
}
}
@@ -0,0 +1,416 @@
#!/usr/bin/env node
/**
* Live variant mode server (self-contained, zero dependencies).
*
* Serves the browser script (/live.js), the detection overlay (/detect.js),
* uses Server-Sent Events (SSE) for server→browser push, and HTTP POST for
* browser→server events. Agent communicates via HTTP long-poll (/poll).
*
* Usage:
* node <scripts_path>/live-server.mjs # start
* node <scripts_path>/live-server.mjs stop # stop
* node <scripts_path>/live-server.mjs --help
*/
import http from 'node:http';
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import net from 'node:net';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LIVE_PID_FILE = path.join(os.tmpdir(), 'impeccable-live.json');
const DEFAULT_POLL_TIMEOUT = 120_000;
// ---------------------------------------------------------------------------
// Port detection
// ---------------------------------------------------------------------------
async function findOpenPort(start = 8400) {
return new Promise((resolve) => {
const srv = net.createServer();
srv.listen(start, '127.0.0.1', () => {
const port = srv.address().port;
srv.close(() => resolve(port));
});
srv.on('error', () => resolve(findOpenPort(start + 1)));
});
}
// ---------------------------------------------------------------------------
// Session state
// ---------------------------------------------------------------------------
const state = {
token: null,
port: null,
sseClients: new Set(), // SSE response objects (server→browser push)
pendingEvents: [], // browser events waiting for agent poll
pendingPolls: [], // agent poll callbacks waiting for browser events
exitTimer: null,
};
function enqueueEvent(event) {
if (state.pendingPolls.length > 0) {
state.pendingPolls.shift()(event);
} else {
state.pendingEvents.push(event);
}
}
/** Push a message to all connected SSE clients. */
function broadcast(msg) {
const data = 'data: ' + JSON.stringify(msg) + '\n\n';
for (const res of state.sseClients) {
try { res.write(data); } catch { /* client gone */ }
}
}
// ---------------------------------------------------------------------------
// Load scripts
// ---------------------------------------------------------------------------
function loadBrowserScripts() {
// Detection script: look relative to the skill scripts dir, then fall back
// to the npm package location (src/detect-antipatterns-browser.js)
const detectPaths = [
path.join(__dirname, '..', '..', '..', '..', 'src', 'detect-antipatterns-browser.js'),
path.join(process.cwd(), 'node_modules', 'impeccable', 'src', 'detect-antipatterns-browser.js'),
];
let detectScript = '';
for (const p of detectPaths) {
try { detectScript = fs.readFileSync(p, 'utf-8'); break; } catch { /* try next */ }
}
const livePath = path.join(__dirname, 'live-browser.js');
let liveScript = '';
try {
liveScript = fs.readFileSync(livePath, 'utf-8');
} catch {
process.stderr.write('Error: live-browser.js not found at ' + livePath + '\n');
process.exit(1);
}
return { detectScript, liveScript };
}
function hasProjectContext() {
try {
fs.accessSync(path.join(process.cwd(), '.impeccable.md'), fs.constants.R_OK);
return true;
} catch { return false; }
}
// ---------------------------------------------------------------------------
// Validation (inline — no external import needed for self-contained script)
// ---------------------------------------------------------------------------
const VISUAL_ACTIONS = [
'impeccable', 'bolder', 'quieter', 'distill', 'polish', 'typeset',
'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive',
];
function validateEvent(msg) {
if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message';
switch (msg.type) {
case 'generate':
if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id';
if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action';
if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8';
if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context';
return null;
case 'accept':
if (!msg.id) return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
return null;
case 'discard':
return msg.id ? null : 'discard: missing id';
case 'exit':
return null;
default:
return 'Unknown event type: ' + msg.type;
}
}
// ---------------------------------------------------------------------------
// HTTP request handler
// ---------------------------------------------------------------------------
function createRequestHandler({ detectScript, liveScriptWithToken }) {
return (req, res) => {
const url = new URL(req.url, `http://localhost:${state.port}`);
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
const p = url.pathname;
// --- Scripts ---
if (p === '/live.js') {
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.end(liveScriptWithToken);
return;
}
if (p === '/detect.js' || p === '/') {
if (!detectScript) { res.writeHead(404); res.end('Not available'); return; }
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.end(detectScript);
return;
}
// --- Health ---
if (p === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
status: 'ok', port: state.port, mode: 'variant',
hasProjectContext: hasProjectContext(),
connectedClients: state.sseClients.size,
}));
return;
}
// --- Source file (no-HMR fallback) ---
if (p === '/source') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
const filePath = url.searchParams.get('path');
if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
const absPath = path.resolve(process.cwd(), filePath);
if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; }
try {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(fs.readFileSync(absPath, 'utf-8'));
} catch { res.writeHead(404); res.end('File not found'); }
return;
}
// --- SSE: server→browser push (replaces WebSocket) ---
if (p === '/events' && req.method === 'GET') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
res.write('data: ' + JSON.stringify({
type: 'connected',
hasProjectContext: hasProjectContext(),
}) + '\n\n');
state.sseClients.add(res);
clearTimeout(state.exitTimer);
req.on('close', () => {
state.sseClients.delete(res);
if (state.sseClients.size === 0) {
clearTimeout(state.exitTimer);
state.exitTimer = setTimeout(() => {
if (state.sseClients.size === 0) enqueueEvent({ type: 'exit' });
}, 8000);
}
});
return;
}
// --- Browser→server events (replaces WebSocket messages) ---
if (p === '/events' && req.method === 'POST') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
let msg;
try { msg = JSON.parse(body); } catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON' }));
return;
}
if (msg.token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
const error = validateEvent(msg);
if (error) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error }));
return;
}
enqueueEvent(msg);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
return;
}
// --- Stop ---
if (p === '/stop') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('stopping');
shutdown();
return;
}
// --- Agent poll ---
if (p === '/poll' && req.method === 'GET') {
handlePollGet(req, res, url);
return;
}
if (p === '/poll' && req.method === 'POST') {
handlePollPost(req, res);
return;
}
res.writeHead(404); res.end('Not found');
};
}
// ---------------------------------------------------------------------------
// Agent poll endpoints (unchanged from WS version)
// ---------------------------------------------------------------------------
function handlePollGet(req, res, url) {
const token = url.searchParams.get('token');
if (token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10);
if (state.pendingEvents.length > 0) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(state.pendingEvents.shift()));
return;
}
const timer = setTimeout(() => {
const idx = state.pendingPolls.indexOf(resolve);
if (idx !== -1) state.pendingPolls.splice(idx, 1);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ type: 'timeout' }));
}, timeout);
function resolve(event) {
clearTimeout(timer);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(event));
}
state.pendingPolls.push(resolve);
req.on('close', () => {
clearTimeout(timer);
const idx = state.pendingPolls.indexOf(resolve);
if (idx !== -1) state.pendingPolls.splice(idx, 1);
});
}
function handlePollPost(req, res) {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
let msg;
try { msg = JSON.parse(body); } catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON' }));
return;
}
if (msg.token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
// Forward the reply to the browser via SSE
broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
let httpServer = null;
function shutdown() {
try { fs.unlinkSync(LIVE_PID_FILE); } catch {}
for (const res of state.sseClients) { try { res.end(); } catch {} }
state.sseClients.clear();
for (const resolve of state.pendingPolls) resolve({ type: 'exit' });
state.pendingPolls.length = 0;
if (httpServer) httpServer.close();
process.exit(0);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: node live-server.mjs [options]
Start the live variant mode server (zero dependencies).
Commands:
(default) Start the server
stop Stop a running server
Options:
--port=PORT Use a specific port (default: auto-detect starting at 8400)
--help Show this help
Endpoints:
/live.js Browser script (element picker + variant cycling)
/detect.js Detection overlay (backwards compatible)
/events SSE stream (server→browser) + POST (browser→server)
/poll Long-poll for agent CLI
/source Raw source file reader (no-HMR fallback)
/health Health check`);
process.exit(0);
}
if (args.includes('stop')) {
try {
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`);
if (res.ok) console.log(`Stopped live server on port ${info.port}.`);
} catch { console.log('No running live server found.'); }
process.exit(0);
}
// Check for existing session
try {
const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
try { process.kill(existing.pid, 0);
console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`);
console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop');
process.exit(1);
} catch { fs.unlinkSync(LIVE_PID_FILE); }
} catch {}
state.token = randomUUID();
const portArg = args.find(a => a.startsWith('--port='));
state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort();
const { detectScript, liveScript } = loadBrowserScripts();
const liveScriptWithToken =
`window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` +
`window.__IMPECCABLE_PORT__ = ${state.port};\n` +
liveScript;
httpServer = http.createServer(createRequestHandler({ detectScript, liveScriptWithToken }));
httpServer.listen(state.port, '127.0.0.1', () => {
fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token }));
const url = `http://localhost:${state.port}`;
console.log(`\nImpeccable live server running on ${url}`);
console.log(`Token: ${state.token}\n`);
console.log(`Inject: <script src="${url}/live.js"><\/script>`);
console.log(`Stop: node ${path.basename(fileURLToPath(import.meta.url))} stop`);
});
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
@@ -0,0 +1,298 @@
/**
* CLI helper: find an element in source and wrap it in a variant container.
*
* Usage:
* npx impeccable wrap --id SESSION_ID --count N --query "hero-combined-left" [--file path]
*
* Searches project files for the element matching the query (class name, ID, or
* text snippet), wraps it with the variant scaffolding, and prints the file path
* + line range where the agent should insert variant HTML.
*
* This replaces 3-4 agent tool calls (grep + read + edit) with a single CLI call.
*/
import fs from 'node:fs';
import path from 'node:path';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
export async function wrapCli() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: impeccable wrap [options]
Find an element in source and wrap it in a variant container.
Required:
--id ID Session ID for the variant wrapper
--count N Number of expected variants (1-8)
Element identification (at least one required):
--element-id ID HTML id attribute of the element
--classes A,B,C Comma-separated CSS class names
--tag TAG Tag name (div, section, etc.)
--query TEXT Fallback: raw text to search for
Optional:
--file PATH Source file to search in (skips auto-detection)
--help Show this help message
Output (JSON):
{ file, startLine, endLine, insertLine, commentSyntax }
The agent should insert variant HTML at insertLine.`);
process.exit(0);
}
const id = argVal(args, '--id');
const count = parseInt(argVal(args, '--count') || '3');
const elementId = argVal(args, '--element-id');
const classes = argVal(args, '--classes');
const tag = argVal(args, '--tag');
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!elementId && !classes && !query) {
console.error('Need at least one of: --element-id, --classes, --query');
process.exit(1);
}
// Build search queries in priority order (most specific first)
const queries = buildSearchQueries(elementId, classes, tag, query);
// Find the source file
let targetFile = filePath;
let matchedQuery = null;
if (!targetFile) {
for (const q of queries) {
targetFile = findFileWithQuery(q, process.cwd());
if (targetFile) { matchedQuery = q; break; }
}
if (!targetFile) {
console.error(JSON.stringify({ error: 'Could not find element in project files. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
} else {
matchedQuery = queries[0];
}
const content = fs.readFileSync(targetFile, 'utf-8');
const lines = content.split('\n');
// Find the element, trying each query in priority order
let match = null;
for (const q of queries) {
match = findElement(lines, q);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
const { startLine, endLine } = match;
const commentSyntax = detectCommentSyntax(targetFile);
const indent = lines[startLine].match(/^(\s*)/)[1];
// Extract the original element
const originalLines = lines.slice(startLine, endLine + 1);
const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
// Build the wrapper
const wrapperLines = [
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" style="display: contents">',
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
indent + ' <div data-impeccable-variant="original">',
originalIndented,
indent + ' </div>',
indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,
indent + '</div>',
indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
];
// Replace the original element with the wrapper
const newLines = [
...lines.slice(0, startLine),
...wrapperLines,
...lines.slice(endLine + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment)
const insertLine = startLine + 6; // 0-indexed in the new file
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
originalLineCount: originalLines.length,
}));
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function argVal(args, flag) {
const idx = args.indexOf(flag);
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
}
/**
* Build search query strings in priority order (most specific first).
* ID is most reliable, then specific class combos, then single classes, then raw query.
*/
function buildSearchQueries(elementId, classes, tag, query) {
const queries = [];
// 1. ID is the most specific
if (elementId) {
queries.push('id="' + elementId + '"');
}
// 2. Full class attribute match (for elements with distinctive multi-class combos)
if (classes) {
const classList = classes.split(',').map(c => c.trim()).filter(Boolean);
if (classList.length > 1) {
// Try the most distinctive class first (longest, most specific)
const sorted = [...classList].sort((a, b) => b.length - a.length);
queries.push('class="' + classList.join(' ') + '"'); // exact full match
queries.push(sorted[0]); // most distinctive single class
} else if (classList.length === 1) {
queries.push(classList[0]);
}
}
// 3. Tag + class combo (e.g., <section class="hero">)
if (tag && classes) {
const firstClass = classes.split(',')[0].trim();
queries.push('<' + tag + ' class="' + firstClass);
}
// 4. Raw fallback query
if (query) {
queries.push(query);
}
return queries;
}
function detectCommentSyntax(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.jsx' || ext === '.tsx') {
return { open: '{/*', close: '*/}' };
}
// HTML, Vue, Svelte, Astro all use HTML comments
return { open: '<!--', close: '-->' };
}
/**
* Search project files for the query string (class name, ID, etc.)
* Returns the first matching file path, or null.
*/
function findFileWithQuery(query, cwd) {
const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];
const seen = new Set();
for (const dir of searchDirs) {
const absDir = path.join(cwd, dir);
if (!fs.existsSync(absDir)) continue;
const result = searchDir(absDir, query, seen, 0);
if (result) return result;
}
return null;
}
function searchDir(dir, query, seen, depth) {
if (depth > 5) return null; // don't go too deep
const realDir = fs.realpathSync(dir);
if (seen.has(realDir)) return null;
seen.add(realDir);
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return null; }
// Check files first
for (const entry of entries) {
if (!entry.isFile()) continue;
const ext = path.extname(entry.name).toLowerCase();
if (!EXTENSIONS.includes(ext)) continue;
const filePath = path.join(dir, entry.name);
try {
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes(query)) return filePath;
} catch { /* skip unreadable files */ }
}
// Then recurse into directories
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'build') continue;
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
if (result) return result;
}
return null;
}
/**
* Find the element's start and end line in the file.
* The query is a class name, ID, or text snippet.
* We find the line containing the query, then find the matching closing tag.
*/
function findElement(lines, query) {
// Find the line containing the query
let startLine = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes(query)) {
// Make sure this looks like a tag opening, not a comment or string
const line = lines[i].trim();
if (line.startsWith('<!--') || line.startsWith('{/*') || line.startsWith('//')) continue;
// Skip lines inside data-impeccable-variant containers (already wrapped)
if (lines[i].includes('data-impeccable-variant')) continue;
startLine = i;
break;
}
}
if (startLine === -1) return null;
// Find the end of this element by counting open/close tags
const endLine = findClosingLine(lines, startLine);
return { startLine, endLine };
}
/**
* Starting from a line with an opening tag, find the line with the matching
* closing tag by counting tag nesting depth.
*/
function findClosingLine(lines, start) {
// Extract the tag name from the opening line
const openMatch = lines[start].match(/<(\w+)[\s>]/);
if (!openMatch) return start; // self-closing or text-only line
const tagName = openMatch[1];
let depth = 0;
for (let i = start; i < lines.length; i++) {
const line = lines[i];
// Count opening tags (not self-closing)
const opens = (line.match(new RegExp('<' + tagName + '[\\s>]', 'g')) || []).length;
const selfCloses = (line.match(new RegExp('<' + tagName + '[^>]*/>', 'g')) || []).length;
const closes = (line.match(new RegExp('</' + tagName + '\\s*>', 'g')) || []).length;
depth += opens - selfCloses - closes;
if (depth <= 0) return i;
}
// If we can't find the close, return a reasonable guess
return Math.min(start + 50, lines.length - 1);
}
+11 -7
View File
@@ -10,7 +10,7 @@ Launch interactive live variant mode: select elements in the browser, pick a des
1. Read `.impeccable.md` if it exists. Keep the design context in mind for variant generation.
2. Start the live variant server:
```bash
npx impeccable live &
node {{scripts_path}}/live-server.mjs &
```
3. Note the **port** and **token** printed to stdout.
@@ -53,7 +53,7 @@ Run a blocking poll loop. On each iteration, wait for a browser event and respon
```
LOOP:
Run: npx impeccable poll
Run: node {{scripts_path}}/live-poll.mjs
Read the JSON output. Dispatch based on the "type" field:
TYPE "generate":
@@ -85,7 +85,7 @@ The event contains: `{id, action, freeformPrompt, count, pageUrl, element}`.
Use the `wrap` helper to find the element and create the variant container:
```bash
npx impeccable wrap --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
node {{scripts_path}}/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
```
Pass the element's id (`event.element.id`), classes (`event.element.classes` joined with commas), and tag name. The command searches in priority order: ID match first, then class names, then tag+class combo. If `event.pageUrl` hints at the file (e.g., `/` is usually `index.html`), pass `--file PATH` to skip the search.
@@ -136,10 +136,14 @@ The first variant should NOT have `style="display: none"` (it should be visible
### Step 3: Signal completion
Include `--file` so the browser can fetch variants directly if the dev server lacks HMR:
```bash
npx impeccable poll --reply EVENT_ID done
node {{scripts_path}}/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH
```
The file path should be relative to the project root (e.g., `public/index.html`, `src/App.tsx`).
## Handle Accept
The event contains: `{id, variantId}`.
@@ -151,7 +155,7 @@ The user accepted a specific variant. For v1 (inspection mode):
4. Remove any scoped CSS blocks (between `impeccable-variants-css-start` and `impeccable-variants-css-end` markers).
5. Reply:
```bash
npx impeccable poll --reply SESSION_ID done
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
## Handle Discard
@@ -163,7 +167,7 @@ The event contains: `{id}`.
3. Remove any scoped CSS blocks for this session.
4. Reply:
```bash
npx impeccable poll --reply SESSION_ID done
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
## Cleanup (on exit)
@@ -174,7 +178,7 @@ When the loop ends:
2. **Remove any leftover variant wrappers** (search for `impeccable-variants-start` markers and clean up).
3. **Stop the server**:
```bash
npx impeccable live stop
node {{scripts_path}}/live-server.mjs stop
```
## Variant Generation Guidelines
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,123 @@
/**
* CLI client for the live variant mode poll/reply protocol.
*
* Usage:
* npx impeccable poll # Block until browser event, print JSON
* npx impeccable poll --timeout=60000 # Custom timeout (ms)
* npx impeccable poll --reply <id> done # Reply "done" to event <id>
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
const LIVE_PID_FILE = path.join(os.tmpdir(), 'impeccable-live.json');
function readServerInfo() {
try {
return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
} catch {
console.error('No running live server found. Start one with: npx impeccable live');
process.exit(1);
}
}
export async function pollCli() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: impeccable poll [options]
Wait for a browser event from the live variant server, or reply to one.
Modes:
poll Block until a browser event arrives, print JSON
poll --reply <id> done Reply "done" to event <id>
poll --reply <id> error "msg" Reply with an error message
Options:
--timeout=MS Poll timeout in milliseconds (default: 120000)
--help Show this help message`);
process.exit(0);
}
const info = readServerInfo();
const base = `http://localhost:${info.port}`;
// Reply mode: npx impeccable poll --reply <id> <status> [--file path] [message]
const replyIdx = args.indexOf('--reply');
if (replyIdx !== -1) {
const id = args[replyIdx + 1];
const status = args[replyIdx + 2] || 'done';
const fileIdx = args.indexOf('--file');
const filePath = fileIdx !== -1 && fileIdx + 1 < args.length ? args[fileIdx + 1] : undefined;
// Message is any remaining positional arg that isn't a flag
const message = args.find((a, i) => i > replyIdx + 2 && !a.startsWith('--') && i !== fileIdx + 1) || undefined;
if (!id) {
console.error('Usage: npx impeccable poll --reply <id> <status> [--file path] [message]');
process.exit(1);
}
try {
const res = await fetch(`${base}/poll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: info.token,
id,
type: status,
message,
file: filePath,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
console.error(`Reply failed (${res.status}):`, body.error || res.statusText);
process.exit(1);
}
// Success — silent exit (agent doesn't need output for replies)
} catch (err) {
if (err.cause?.code === 'ECONNREFUSED') {
console.error('Live server not running. Start one with: npx impeccable live');
} else {
console.error('Reply failed:', err.message);
}
process.exit(1);
}
return;
}
// Poll mode: block until browser event
const timeoutArg = args.find(a => a.startsWith('--timeout='));
const timeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 120000;
try {
const res = await fetch(`${base}/poll?token=${info.token}&timeout=${timeout}`);
if (res.status === 401) {
console.error('Authentication failed. The server token may have changed.');
console.error('Try restarting: npx impeccable live stop && npx impeccable live');
process.exit(1);
}
if (!res.ok) {
console.error(`Poll failed: ${res.status} ${res.statusText}`);
process.exit(1);
}
const event = await res.json();
// Print the event as JSON — the agent reads this from stdout
console.log(JSON.stringify(event));
} catch (err) {
if (err.cause?.code === 'ECONNREFUSED') {
console.error('Live server not running. Start one with: npx impeccable live');
} else {
console.error('Poll failed:', err.message);
}
process.exit(1);
}
}
@@ -0,0 +1,416 @@
#!/usr/bin/env node
/**
* Live variant mode server (self-contained, zero dependencies).
*
* Serves the browser script (/live.js), the detection overlay (/detect.js),
* uses Server-Sent Events (SSE) for server→browser push, and HTTP POST for
* browser→server events. Agent communicates via HTTP long-poll (/poll).
*
* Usage:
* node <scripts_path>/live-server.mjs # start
* node <scripts_path>/live-server.mjs stop # stop
* node <scripts_path>/live-server.mjs --help
*/
import http from 'node:http';
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import net from 'node:net';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LIVE_PID_FILE = path.join(os.tmpdir(), 'impeccable-live.json');
const DEFAULT_POLL_TIMEOUT = 120_000;
// ---------------------------------------------------------------------------
// Port detection
// ---------------------------------------------------------------------------
async function findOpenPort(start = 8400) {
return new Promise((resolve) => {
const srv = net.createServer();
srv.listen(start, '127.0.0.1', () => {
const port = srv.address().port;
srv.close(() => resolve(port));
});
srv.on('error', () => resolve(findOpenPort(start + 1)));
});
}
// ---------------------------------------------------------------------------
// Session state
// ---------------------------------------------------------------------------
const state = {
token: null,
port: null,
sseClients: new Set(), // SSE response objects (server→browser push)
pendingEvents: [], // browser events waiting for agent poll
pendingPolls: [], // agent poll callbacks waiting for browser events
exitTimer: null,
};
function enqueueEvent(event) {
if (state.pendingPolls.length > 0) {
state.pendingPolls.shift()(event);
} else {
state.pendingEvents.push(event);
}
}
/** Push a message to all connected SSE clients. */
function broadcast(msg) {
const data = 'data: ' + JSON.stringify(msg) + '\n\n';
for (const res of state.sseClients) {
try { res.write(data); } catch { /* client gone */ }
}
}
// ---------------------------------------------------------------------------
// Load scripts
// ---------------------------------------------------------------------------
function loadBrowserScripts() {
// Detection script: look relative to the skill scripts dir, then fall back
// to the npm package location (src/detect-antipatterns-browser.js)
const detectPaths = [
path.join(__dirname, '..', '..', '..', '..', 'src', 'detect-antipatterns-browser.js'),
path.join(process.cwd(), 'node_modules', 'impeccable', 'src', 'detect-antipatterns-browser.js'),
];
let detectScript = '';
for (const p of detectPaths) {
try { detectScript = fs.readFileSync(p, 'utf-8'); break; } catch { /* try next */ }
}
const livePath = path.join(__dirname, 'live-browser.js');
let liveScript = '';
try {
liveScript = fs.readFileSync(livePath, 'utf-8');
} catch {
process.stderr.write('Error: live-browser.js not found at ' + livePath + '\n');
process.exit(1);
}
return { detectScript, liveScript };
}
function hasProjectContext() {
try {
fs.accessSync(path.join(process.cwd(), '.impeccable.md'), fs.constants.R_OK);
return true;
} catch { return false; }
}
// ---------------------------------------------------------------------------
// Validation (inline — no external import needed for self-contained script)
// ---------------------------------------------------------------------------
const VISUAL_ACTIONS = [
'impeccable', 'bolder', 'quieter', 'distill', 'polish', 'typeset',
'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive',
];
function validateEvent(msg) {
if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message';
switch (msg.type) {
case 'generate':
if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id';
if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action';
if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8';
if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context';
return null;
case 'accept':
if (!msg.id) return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
return null;
case 'discard':
return msg.id ? null : 'discard: missing id';
case 'exit':
return null;
default:
return 'Unknown event type: ' + msg.type;
}
}
// ---------------------------------------------------------------------------
// HTTP request handler
// ---------------------------------------------------------------------------
function createRequestHandler({ detectScript, liveScriptWithToken }) {
return (req, res) => {
const url = new URL(req.url, `http://localhost:${state.port}`);
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
const p = url.pathname;
// --- Scripts ---
if (p === '/live.js') {
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.end(liveScriptWithToken);
return;
}
if (p === '/detect.js' || p === '/') {
if (!detectScript) { res.writeHead(404); res.end('Not available'); return; }
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.end(detectScript);
return;
}
// --- Health ---
if (p === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
status: 'ok', port: state.port, mode: 'variant',
hasProjectContext: hasProjectContext(),
connectedClients: state.sseClients.size,
}));
return;
}
// --- Source file (no-HMR fallback) ---
if (p === '/source') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
const filePath = url.searchParams.get('path');
if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
const absPath = path.resolve(process.cwd(), filePath);
if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; }
try {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(fs.readFileSync(absPath, 'utf-8'));
} catch { res.writeHead(404); res.end('File not found'); }
return;
}
// --- SSE: server→browser push (replaces WebSocket) ---
if (p === '/events' && req.method === 'GET') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
res.write('data: ' + JSON.stringify({
type: 'connected',
hasProjectContext: hasProjectContext(),
}) + '\n\n');
state.sseClients.add(res);
clearTimeout(state.exitTimer);
req.on('close', () => {
state.sseClients.delete(res);
if (state.sseClients.size === 0) {
clearTimeout(state.exitTimer);
state.exitTimer = setTimeout(() => {
if (state.sseClients.size === 0) enqueueEvent({ type: 'exit' });
}, 8000);
}
});
return;
}
// --- Browser→server events (replaces WebSocket messages) ---
if (p === '/events' && req.method === 'POST') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
let msg;
try { msg = JSON.parse(body); } catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON' }));
return;
}
if (msg.token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
const error = validateEvent(msg);
if (error) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error }));
return;
}
enqueueEvent(msg);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
return;
}
// --- Stop ---
if (p === '/stop') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('stopping');
shutdown();
return;
}
// --- Agent poll ---
if (p === '/poll' && req.method === 'GET') {
handlePollGet(req, res, url);
return;
}
if (p === '/poll' && req.method === 'POST') {
handlePollPost(req, res);
return;
}
res.writeHead(404); res.end('Not found');
};
}
// ---------------------------------------------------------------------------
// Agent poll endpoints (unchanged from WS version)
// ---------------------------------------------------------------------------
function handlePollGet(req, res, url) {
const token = url.searchParams.get('token');
if (token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10);
if (state.pendingEvents.length > 0) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(state.pendingEvents.shift()));
return;
}
const timer = setTimeout(() => {
const idx = state.pendingPolls.indexOf(resolve);
if (idx !== -1) state.pendingPolls.splice(idx, 1);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ type: 'timeout' }));
}, timeout);
function resolve(event) {
clearTimeout(timer);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(event));
}
state.pendingPolls.push(resolve);
req.on('close', () => {
clearTimeout(timer);
const idx = state.pendingPolls.indexOf(resolve);
if (idx !== -1) state.pendingPolls.splice(idx, 1);
});
}
function handlePollPost(req, res) {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
let msg;
try { msg = JSON.parse(body); } catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON' }));
return;
}
if (msg.token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
// Forward the reply to the browser via SSE
broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
let httpServer = null;
function shutdown() {
try { fs.unlinkSync(LIVE_PID_FILE); } catch {}
for (const res of state.sseClients) { try { res.end(); } catch {} }
state.sseClients.clear();
for (const resolve of state.pendingPolls) resolve({ type: 'exit' });
state.pendingPolls.length = 0;
if (httpServer) httpServer.close();
process.exit(0);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: node live-server.mjs [options]
Start the live variant mode server (zero dependencies).
Commands:
(default) Start the server
stop Stop a running server
Options:
--port=PORT Use a specific port (default: auto-detect starting at 8400)
--help Show this help
Endpoints:
/live.js Browser script (element picker + variant cycling)
/detect.js Detection overlay (backwards compatible)
/events SSE stream (server→browser) + POST (browser→server)
/poll Long-poll for agent CLI
/source Raw source file reader (no-HMR fallback)
/health Health check`);
process.exit(0);
}
if (args.includes('stop')) {
try {
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`);
if (res.ok) console.log(`Stopped live server on port ${info.port}.`);
} catch { console.log('No running live server found.'); }
process.exit(0);
}
// Check for existing session
try {
const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
try { process.kill(existing.pid, 0);
console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`);
console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop');
process.exit(1);
} catch { fs.unlinkSync(LIVE_PID_FILE); }
} catch {}
state.token = randomUUID();
const portArg = args.find(a => a.startsWith('--port='));
state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort();
const { detectScript, liveScript } = loadBrowserScripts();
const liveScriptWithToken =
`window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` +
`window.__IMPECCABLE_PORT__ = ${state.port};\n` +
liveScript;
httpServer = http.createServer(createRequestHandler({ detectScript, liveScriptWithToken }));
httpServer.listen(state.port, '127.0.0.1', () => {
fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token }));
const url = `http://localhost:${state.port}`;
console.log(`\nImpeccable live server running on ${url}`);
console.log(`Token: ${state.token}\n`);
console.log(`Inject: <script src="${url}/live.js"><\/script>`);
console.log(`Stop: node ${path.basename(fileURLToPath(import.meta.url))} stop`);
});
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
@@ -0,0 +1,298 @@
/**
* CLI helper: find an element in source and wrap it in a variant container.
*
* Usage:
* npx impeccable wrap --id SESSION_ID --count N --query "hero-combined-left" [--file path]
*
* Searches project files for the element matching the query (class name, ID, or
* text snippet), wraps it with the variant scaffolding, and prints the file path
* + line range where the agent should insert variant HTML.
*
* This replaces 3-4 agent tool calls (grep + read + edit) with a single CLI call.
*/
import fs from 'node:fs';
import path from 'node:path';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
export async function wrapCli() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: impeccable wrap [options]
Find an element in source and wrap it in a variant container.
Required:
--id ID Session ID for the variant wrapper
--count N Number of expected variants (1-8)
Element identification (at least one required):
--element-id ID HTML id attribute of the element
--classes A,B,C Comma-separated CSS class names
--tag TAG Tag name (div, section, etc.)
--query TEXT Fallback: raw text to search for
Optional:
--file PATH Source file to search in (skips auto-detection)
--help Show this help message
Output (JSON):
{ file, startLine, endLine, insertLine, commentSyntax }
The agent should insert variant HTML at insertLine.`);
process.exit(0);
}
const id = argVal(args, '--id');
const count = parseInt(argVal(args, '--count') || '3');
const elementId = argVal(args, '--element-id');
const classes = argVal(args, '--classes');
const tag = argVal(args, '--tag');
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!elementId && !classes && !query) {
console.error('Need at least one of: --element-id, --classes, --query');
process.exit(1);
}
// Build search queries in priority order (most specific first)
const queries = buildSearchQueries(elementId, classes, tag, query);
// Find the source file
let targetFile = filePath;
let matchedQuery = null;
if (!targetFile) {
for (const q of queries) {
targetFile = findFileWithQuery(q, process.cwd());
if (targetFile) { matchedQuery = q; break; }
}
if (!targetFile) {
console.error(JSON.stringify({ error: 'Could not find element in project files. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
} else {
matchedQuery = queries[0];
}
const content = fs.readFileSync(targetFile, 'utf-8');
const lines = content.split('\n');
// Find the element, trying each query in priority order
let match = null;
for (const q of queries) {
match = findElement(lines, q);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
const { startLine, endLine } = match;
const commentSyntax = detectCommentSyntax(targetFile);
const indent = lines[startLine].match(/^(\s*)/)[1];
// Extract the original element
const originalLines = lines.slice(startLine, endLine + 1);
const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
// Build the wrapper
const wrapperLines = [
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" style="display: contents">',
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
indent + ' <div data-impeccable-variant="original">',
originalIndented,
indent + ' </div>',
indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,
indent + '</div>',
indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
];
// Replace the original element with the wrapper
const newLines = [
...lines.slice(0, startLine),
...wrapperLines,
...lines.slice(endLine + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment)
const insertLine = startLine + 6; // 0-indexed in the new file
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
originalLineCount: originalLines.length,
}));
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function argVal(args, flag) {
const idx = args.indexOf(flag);
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
}
/**
* Build search query strings in priority order (most specific first).
* ID is most reliable, then specific class combos, then single classes, then raw query.
*/
function buildSearchQueries(elementId, classes, tag, query) {
const queries = [];
// 1. ID is the most specific
if (elementId) {
queries.push('id="' + elementId + '"');
}
// 2. Full class attribute match (for elements with distinctive multi-class combos)
if (classes) {
const classList = classes.split(',').map(c => c.trim()).filter(Boolean);
if (classList.length > 1) {
// Try the most distinctive class first (longest, most specific)
const sorted = [...classList].sort((a, b) => b.length - a.length);
queries.push('class="' + classList.join(' ') + '"'); // exact full match
queries.push(sorted[0]); // most distinctive single class
} else if (classList.length === 1) {
queries.push(classList[0]);
}
}
// 3. Tag + class combo (e.g., <section class="hero">)
if (tag && classes) {
const firstClass = classes.split(',')[0].trim();
queries.push('<' + tag + ' class="' + firstClass);
}
// 4. Raw fallback query
if (query) {
queries.push(query);
}
return queries;
}
function detectCommentSyntax(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.jsx' || ext === '.tsx') {
return { open: '{/*', close: '*/}' };
}
// HTML, Vue, Svelte, Astro all use HTML comments
return { open: '<!--', close: '-->' };
}
/**
* Search project files for the query string (class name, ID, etc.)
* Returns the first matching file path, or null.
*/
function findFileWithQuery(query, cwd) {
const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];
const seen = new Set();
for (const dir of searchDirs) {
const absDir = path.join(cwd, dir);
if (!fs.existsSync(absDir)) continue;
const result = searchDir(absDir, query, seen, 0);
if (result) return result;
}
return null;
}
function searchDir(dir, query, seen, depth) {
if (depth > 5) return null; // don't go too deep
const realDir = fs.realpathSync(dir);
if (seen.has(realDir)) return null;
seen.add(realDir);
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return null; }
// Check files first
for (const entry of entries) {
if (!entry.isFile()) continue;
const ext = path.extname(entry.name).toLowerCase();
if (!EXTENSIONS.includes(ext)) continue;
const filePath = path.join(dir, entry.name);
try {
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes(query)) return filePath;
} catch { /* skip unreadable files */ }
}
// Then recurse into directories
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'build') continue;
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
if (result) return result;
}
return null;
}
/**
* Find the element's start and end line in the file.
* The query is a class name, ID, or text snippet.
* We find the line containing the query, then find the matching closing tag.
*/
function findElement(lines, query) {
// Find the line containing the query
let startLine = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes(query)) {
// Make sure this looks like a tag opening, not a comment or string
const line = lines[i].trim();
if (line.startsWith('<!--') || line.startsWith('{/*') || line.startsWith('//')) continue;
// Skip lines inside data-impeccable-variant containers (already wrapped)
if (lines[i].includes('data-impeccable-variant')) continue;
startLine = i;
break;
}
}
if (startLine === -1) return null;
// Find the end of this element by counting open/close tags
const endLine = findClosingLine(lines, startLine);
return { startLine, endLine };
}
/**
* Starting from a line with an opening tag, find the line with the matching
* closing tag by counting tag nesting depth.
*/
function findClosingLine(lines, start) {
// Extract the tag name from the opening line
const openMatch = lines[start].match(/<(\w+)[\s>]/);
if (!openMatch) return start; // self-closing or text-only line
const tagName = openMatch[1];
let depth = 0;
for (let i = start; i < lines.length; i++) {
const line = lines[i];
// Count opening tags (not self-closing)
const opens = (line.match(new RegExp('<' + tagName + '[\\s>]', 'g')) || []).length;
const selfCloses = (line.match(new RegExp('<' + tagName + '[^>]*/>', 'g')) || []).length;
const closes = (line.match(new RegExp('</' + tagName + '\\s*>', 'g')) || []).length;
depth += opens - selfCloses - closes;
if (depth <= 0) return i;
}
// If we can't find the close, return a reasonable guess
return Math.min(start + 50, lines.length - 1);
}
+11 -7
View File
@@ -10,7 +10,7 @@ Launch interactive live variant mode: select elements in the browser, pick a des
1. Read `.impeccable.md` if it exists. Keep the design context in mind for variant generation.
2. Start the live variant server:
```bash
npx impeccable live &
node {{scripts_path}}/live-server.mjs &
```
3. Note the **port** and **token** printed to stdout.
@@ -53,7 +53,7 @@ Run a blocking poll loop. On each iteration, wait for a browser event and respon
```
LOOP:
Run: npx impeccable poll
Run: node {{scripts_path}}/live-poll.mjs
Read the JSON output. Dispatch based on the "type" field:
TYPE "generate":
@@ -85,7 +85,7 @@ The event contains: `{id, action, freeformPrompt, count, pageUrl, element}`.
Use the `wrap` helper to find the element and create the variant container:
```bash
npx impeccable wrap --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
node {{scripts_path}}/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
```
Pass the element's id (`event.element.id`), classes (`event.element.classes` joined with commas), and tag name. The command searches in priority order: ID match first, then class names, then tag+class combo. If `event.pageUrl` hints at the file (e.g., `/` is usually `index.html`), pass `--file PATH` to skip the search.
@@ -136,10 +136,14 @@ The first variant should NOT have `style="display: none"` (it should be visible
### Step 3: Signal completion
Include `--file` so the browser can fetch variants directly if the dev server lacks HMR:
```bash
npx impeccable poll --reply EVENT_ID done
node {{scripts_path}}/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH
```
The file path should be relative to the project root (e.g., `public/index.html`, `src/App.tsx`).
## Handle Accept
The event contains: `{id, variantId}`.
@@ -151,7 +155,7 @@ The user accepted a specific variant. For v1 (inspection mode):
4. Remove any scoped CSS blocks (between `impeccable-variants-css-start` and `impeccable-variants-css-end` markers).
5. Reply:
```bash
npx impeccable poll --reply SESSION_ID done
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
## Handle Discard
@@ -163,7 +167,7 @@ The event contains: `{id}`.
3. Remove any scoped CSS blocks for this session.
4. Reply:
```bash
npx impeccable poll --reply SESSION_ID done
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
## Cleanup (on exit)
@@ -174,7 +178,7 @@ When the loop ends:
2. **Remove any leftover variant wrappers** (search for `impeccable-variants-start` markers and clean up).
3. **Stop the server**:
```bash
npx impeccable live stop
node {{scripts_path}}/live-server.mjs stop
```
## Variant Generation Guidelines
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,123 @@
/**
* CLI client for the live variant mode poll/reply protocol.
*
* Usage:
* npx impeccable poll # Block until browser event, print JSON
* npx impeccable poll --timeout=60000 # Custom timeout (ms)
* npx impeccable poll --reply <id> done # Reply "done" to event <id>
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
const LIVE_PID_FILE = path.join(os.tmpdir(), 'impeccable-live.json');
function readServerInfo() {
try {
return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
} catch {
console.error('No running live server found. Start one with: npx impeccable live');
process.exit(1);
}
}
export async function pollCli() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: impeccable poll [options]
Wait for a browser event from the live variant server, or reply to one.
Modes:
poll Block until a browser event arrives, print JSON
poll --reply <id> done Reply "done" to event <id>
poll --reply <id> error "msg" Reply with an error message
Options:
--timeout=MS Poll timeout in milliseconds (default: 120000)
--help Show this help message`);
process.exit(0);
}
const info = readServerInfo();
const base = `http://localhost:${info.port}`;
// Reply mode: npx impeccable poll --reply <id> <status> [--file path] [message]
const replyIdx = args.indexOf('--reply');
if (replyIdx !== -1) {
const id = args[replyIdx + 1];
const status = args[replyIdx + 2] || 'done';
const fileIdx = args.indexOf('--file');
const filePath = fileIdx !== -1 && fileIdx + 1 < args.length ? args[fileIdx + 1] : undefined;
// Message is any remaining positional arg that isn't a flag
const message = args.find((a, i) => i > replyIdx + 2 && !a.startsWith('--') && i !== fileIdx + 1) || undefined;
if (!id) {
console.error('Usage: npx impeccable poll --reply <id> <status> [--file path] [message]');
process.exit(1);
}
try {
const res = await fetch(`${base}/poll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: info.token,
id,
type: status,
message,
file: filePath,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
console.error(`Reply failed (${res.status}):`, body.error || res.statusText);
process.exit(1);
}
// Success — silent exit (agent doesn't need output for replies)
} catch (err) {
if (err.cause?.code === 'ECONNREFUSED') {
console.error('Live server not running. Start one with: npx impeccable live');
} else {
console.error('Reply failed:', err.message);
}
process.exit(1);
}
return;
}
// Poll mode: block until browser event
const timeoutArg = args.find(a => a.startsWith('--timeout='));
const timeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 120000;
try {
const res = await fetch(`${base}/poll?token=${info.token}&timeout=${timeout}`);
if (res.status === 401) {
console.error('Authentication failed. The server token may have changed.');
console.error('Try restarting: npx impeccable live stop && npx impeccable live');
process.exit(1);
}
if (!res.ok) {
console.error(`Poll failed: ${res.status} ${res.statusText}`);
process.exit(1);
}
const event = await res.json();
// Print the event as JSON — the agent reads this from stdout
console.log(JSON.stringify(event));
} catch (err) {
if (err.cause?.code === 'ECONNREFUSED') {
console.error('Live server not running. Start one with: npx impeccable live');
} else {
console.error('Poll failed:', err.message);
}
process.exit(1);
}
}
@@ -0,0 +1,416 @@
#!/usr/bin/env node
/**
* Live variant mode server (self-contained, zero dependencies).
*
* Serves the browser script (/live.js), the detection overlay (/detect.js),
* uses Server-Sent Events (SSE) for server→browser push, and HTTP POST for
* browser→server events. Agent communicates via HTTP long-poll (/poll).
*
* Usage:
* node <scripts_path>/live-server.mjs # start
* node <scripts_path>/live-server.mjs stop # stop
* node <scripts_path>/live-server.mjs --help
*/
import http from 'node:http';
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import net from 'node:net';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LIVE_PID_FILE = path.join(os.tmpdir(), 'impeccable-live.json');
const DEFAULT_POLL_TIMEOUT = 120_000;
// ---------------------------------------------------------------------------
// Port detection
// ---------------------------------------------------------------------------
async function findOpenPort(start = 8400) {
return new Promise((resolve) => {
const srv = net.createServer();
srv.listen(start, '127.0.0.1', () => {
const port = srv.address().port;
srv.close(() => resolve(port));
});
srv.on('error', () => resolve(findOpenPort(start + 1)));
});
}
// ---------------------------------------------------------------------------
// Session state
// ---------------------------------------------------------------------------
const state = {
token: null,
port: null,
sseClients: new Set(), // SSE response objects (server→browser push)
pendingEvents: [], // browser events waiting for agent poll
pendingPolls: [], // agent poll callbacks waiting for browser events
exitTimer: null,
};
function enqueueEvent(event) {
if (state.pendingPolls.length > 0) {
state.pendingPolls.shift()(event);
} else {
state.pendingEvents.push(event);
}
}
/** Push a message to all connected SSE clients. */
function broadcast(msg) {
const data = 'data: ' + JSON.stringify(msg) + '\n\n';
for (const res of state.sseClients) {
try { res.write(data); } catch { /* client gone */ }
}
}
// ---------------------------------------------------------------------------
// Load scripts
// ---------------------------------------------------------------------------
function loadBrowserScripts() {
// Detection script: look relative to the skill scripts dir, then fall back
// to the npm package location (src/detect-antipatterns-browser.js)
const detectPaths = [
path.join(__dirname, '..', '..', '..', '..', 'src', 'detect-antipatterns-browser.js'),
path.join(process.cwd(), 'node_modules', 'impeccable', 'src', 'detect-antipatterns-browser.js'),
];
let detectScript = '';
for (const p of detectPaths) {
try { detectScript = fs.readFileSync(p, 'utf-8'); break; } catch { /* try next */ }
}
const livePath = path.join(__dirname, 'live-browser.js');
let liveScript = '';
try {
liveScript = fs.readFileSync(livePath, 'utf-8');
} catch {
process.stderr.write('Error: live-browser.js not found at ' + livePath + '\n');
process.exit(1);
}
return { detectScript, liveScript };
}
function hasProjectContext() {
try {
fs.accessSync(path.join(process.cwd(), '.impeccable.md'), fs.constants.R_OK);
return true;
} catch { return false; }
}
// ---------------------------------------------------------------------------
// Validation (inline — no external import needed for self-contained script)
// ---------------------------------------------------------------------------
const VISUAL_ACTIONS = [
'impeccable', 'bolder', 'quieter', 'distill', 'polish', 'typeset',
'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive',
];
function validateEvent(msg) {
if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message';
switch (msg.type) {
case 'generate':
if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id';
if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action';
if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8';
if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context';
return null;
case 'accept':
if (!msg.id) return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
return null;
case 'discard':
return msg.id ? null : 'discard: missing id';
case 'exit':
return null;
default:
return 'Unknown event type: ' + msg.type;
}
}
// ---------------------------------------------------------------------------
// HTTP request handler
// ---------------------------------------------------------------------------
function createRequestHandler({ detectScript, liveScriptWithToken }) {
return (req, res) => {
const url = new URL(req.url, `http://localhost:${state.port}`);
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
const p = url.pathname;
// --- Scripts ---
if (p === '/live.js') {
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.end(liveScriptWithToken);
return;
}
if (p === '/detect.js' || p === '/') {
if (!detectScript) { res.writeHead(404); res.end('Not available'); return; }
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.end(detectScript);
return;
}
// --- Health ---
if (p === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
status: 'ok', port: state.port, mode: 'variant',
hasProjectContext: hasProjectContext(),
connectedClients: state.sseClients.size,
}));
return;
}
// --- Source file (no-HMR fallback) ---
if (p === '/source') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
const filePath = url.searchParams.get('path');
if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
const absPath = path.resolve(process.cwd(), filePath);
if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; }
try {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(fs.readFileSync(absPath, 'utf-8'));
} catch { res.writeHead(404); res.end('File not found'); }
return;
}
// --- SSE: server→browser push (replaces WebSocket) ---
if (p === '/events' && req.method === 'GET') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
res.write('data: ' + JSON.stringify({
type: 'connected',
hasProjectContext: hasProjectContext(),
}) + '\n\n');
state.sseClients.add(res);
clearTimeout(state.exitTimer);
req.on('close', () => {
state.sseClients.delete(res);
if (state.sseClients.size === 0) {
clearTimeout(state.exitTimer);
state.exitTimer = setTimeout(() => {
if (state.sseClients.size === 0) enqueueEvent({ type: 'exit' });
}, 8000);
}
});
return;
}
// --- Browser→server events (replaces WebSocket messages) ---
if (p === '/events' && req.method === 'POST') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
let msg;
try { msg = JSON.parse(body); } catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON' }));
return;
}
if (msg.token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
const error = validateEvent(msg);
if (error) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error }));
return;
}
enqueueEvent(msg);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
return;
}
// --- Stop ---
if (p === '/stop') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('stopping');
shutdown();
return;
}
// --- Agent poll ---
if (p === '/poll' && req.method === 'GET') {
handlePollGet(req, res, url);
return;
}
if (p === '/poll' && req.method === 'POST') {
handlePollPost(req, res);
return;
}
res.writeHead(404); res.end('Not found');
};
}
// ---------------------------------------------------------------------------
// Agent poll endpoints (unchanged from WS version)
// ---------------------------------------------------------------------------
function handlePollGet(req, res, url) {
const token = url.searchParams.get('token');
if (token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10);
if (state.pendingEvents.length > 0) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(state.pendingEvents.shift()));
return;
}
const timer = setTimeout(() => {
const idx = state.pendingPolls.indexOf(resolve);
if (idx !== -1) state.pendingPolls.splice(idx, 1);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ type: 'timeout' }));
}, timeout);
function resolve(event) {
clearTimeout(timer);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(event));
}
state.pendingPolls.push(resolve);
req.on('close', () => {
clearTimeout(timer);
const idx = state.pendingPolls.indexOf(resolve);
if (idx !== -1) state.pendingPolls.splice(idx, 1);
});
}
function handlePollPost(req, res) {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
let msg;
try { msg = JSON.parse(body); } catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON' }));
return;
}
if (msg.token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
// Forward the reply to the browser via SSE
broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
let httpServer = null;
function shutdown() {
try { fs.unlinkSync(LIVE_PID_FILE); } catch {}
for (const res of state.sseClients) { try { res.end(); } catch {} }
state.sseClients.clear();
for (const resolve of state.pendingPolls) resolve({ type: 'exit' });
state.pendingPolls.length = 0;
if (httpServer) httpServer.close();
process.exit(0);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: node live-server.mjs [options]
Start the live variant mode server (zero dependencies).
Commands:
(default) Start the server
stop Stop a running server
Options:
--port=PORT Use a specific port (default: auto-detect starting at 8400)
--help Show this help
Endpoints:
/live.js Browser script (element picker + variant cycling)
/detect.js Detection overlay (backwards compatible)
/events SSE stream (server→browser) + POST (browser→server)
/poll Long-poll for agent CLI
/source Raw source file reader (no-HMR fallback)
/health Health check`);
process.exit(0);
}
if (args.includes('stop')) {
try {
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`);
if (res.ok) console.log(`Stopped live server on port ${info.port}.`);
} catch { console.log('No running live server found.'); }
process.exit(0);
}
// Check for existing session
try {
const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
try { process.kill(existing.pid, 0);
console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`);
console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop');
process.exit(1);
} catch { fs.unlinkSync(LIVE_PID_FILE); }
} catch {}
state.token = randomUUID();
const portArg = args.find(a => a.startsWith('--port='));
state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort();
const { detectScript, liveScript } = loadBrowserScripts();
const liveScriptWithToken =
`window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` +
`window.__IMPECCABLE_PORT__ = ${state.port};\n` +
liveScript;
httpServer = http.createServer(createRequestHandler({ detectScript, liveScriptWithToken }));
httpServer.listen(state.port, '127.0.0.1', () => {
fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token }));
const url = `http://localhost:${state.port}`;
console.log(`\nImpeccable live server running on ${url}`);
console.log(`Token: ${state.token}\n`);
console.log(`Inject: <script src="${url}/live.js"><\/script>`);
console.log(`Stop: node ${path.basename(fileURLToPath(import.meta.url))} stop`);
});
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
@@ -0,0 +1,298 @@
/**
* CLI helper: find an element in source and wrap it in a variant container.
*
* Usage:
* npx impeccable wrap --id SESSION_ID --count N --query "hero-combined-left" [--file path]
*
* Searches project files for the element matching the query (class name, ID, or
* text snippet), wraps it with the variant scaffolding, and prints the file path
* + line range where the agent should insert variant HTML.
*
* This replaces 3-4 agent tool calls (grep + read + edit) with a single CLI call.
*/
import fs from 'node:fs';
import path from 'node:path';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
export async function wrapCli() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: impeccable wrap [options]
Find an element in source and wrap it in a variant container.
Required:
--id ID Session ID for the variant wrapper
--count N Number of expected variants (1-8)
Element identification (at least one required):
--element-id ID HTML id attribute of the element
--classes A,B,C Comma-separated CSS class names
--tag TAG Tag name (div, section, etc.)
--query TEXT Fallback: raw text to search for
Optional:
--file PATH Source file to search in (skips auto-detection)
--help Show this help message
Output (JSON):
{ file, startLine, endLine, insertLine, commentSyntax }
The agent should insert variant HTML at insertLine.`);
process.exit(0);
}
const id = argVal(args, '--id');
const count = parseInt(argVal(args, '--count') || '3');
const elementId = argVal(args, '--element-id');
const classes = argVal(args, '--classes');
const tag = argVal(args, '--tag');
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!elementId && !classes && !query) {
console.error('Need at least one of: --element-id, --classes, --query');
process.exit(1);
}
// Build search queries in priority order (most specific first)
const queries = buildSearchQueries(elementId, classes, tag, query);
// Find the source file
let targetFile = filePath;
let matchedQuery = null;
if (!targetFile) {
for (const q of queries) {
targetFile = findFileWithQuery(q, process.cwd());
if (targetFile) { matchedQuery = q; break; }
}
if (!targetFile) {
console.error(JSON.stringify({ error: 'Could not find element in project files. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
} else {
matchedQuery = queries[0];
}
const content = fs.readFileSync(targetFile, 'utf-8');
const lines = content.split('\n');
// Find the element, trying each query in priority order
let match = null;
for (const q of queries) {
match = findElement(lines, q);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
const { startLine, endLine } = match;
const commentSyntax = detectCommentSyntax(targetFile);
const indent = lines[startLine].match(/^(\s*)/)[1];
// Extract the original element
const originalLines = lines.slice(startLine, endLine + 1);
const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
// Build the wrapper
const wrapperLines = [
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" style="display: contents">',
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
indent + ' <div data-impeccable-variant="original">',
originalIndented,
indent + ' </div>',
indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,
indent + '</div>',
indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
];
// Replace the original element with the wrapper
const newLines = [
...lines.slice(0, startLine),
...wrapperLines,
...lines.slice(endLine + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment)
const insertLine = startLine + 6; // 0-indexed in the new file
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
originalLineCount: originalLines.length,
}));
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function argVal(args, flag) {
const idx = args.indexOf(flag);
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
}
/**
* Build search query strings in priority order (most specific first).
* ID is most reliable, then specific class combos, then single classes, then raw query.
*/
function buildSearchQueries(elementId, classes, tag, query) {
const queries = [];
// 1. ID is the most specific
if (elementId) {
queries.push('id="' + elementId + '"');
}
// 2. Full class attribute match (for elements with distinctive multi-class combos)
if (classes) {
const classList = classes.split(',').map(c => c.trim()).filter(Boolean);
if (classList.length > 1) {
// Try the most distinctive class first (longest, most specific)
const sorted = [...classList].sort((a, b) => b.length - a.length);
queries.push('class="' + classList.join(' ') + '"'); // exact full match
queries.push(sorted[0]); // most distinctive single class
} else if (classList.length === 1) {
queries.push(classList[0]);
}
}
// 3. Tag + class combo (e.g., <section class="hero">)
if (tag && classes) {
const firstClass = classes.split(',')[0].trim();
queries.push('<' + tag + ' class="' + firstClass);
}
// 4. Raw fallback query
if (query) {
queries.push(query);
}
return queries;
}
function detectCommentSyntax(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.jsx' || ext === '.tsx') {
return { open: '{/*', close: '*/}' };
}
// HTML, Vue, Svelte, Astro all use HTML comments
return { open: '<!--', close: '-->' };
}
/**
* Search project files for the query string (class name, ID, etc.)
* Returns the first matching file path, or null.
*/
function findFileWithQuery(query, cwd) {
const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];
const seen = new Set();
for (const dir of searchDirs) {
const absDir = path.join(cwd, dir);
if (!fs.existsSync(absDir)) continue;
const result = searchDir(absDir, query, seen, 0);
if (result) return result;
}
return null;
}
function searchDir(dir, query, seen, depth) {
if (depth > 5) return null; // don't go too deep
const realDir = fs.realpathSync(dir);
if (seen.has(realDir)) return null;
seen.add(realDir);
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return null; }
// Check files first
for (const entry of entries) {
if (!entry.isFile()) continue;
const ext = path.extname(entry.name).toLowerCase();
if (!EXTENSIONS.includes(ext)) continue;
const filePath = path.join(dir, entry.name);
try {
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes(query)) return filePath;
} catch { /* skip unreadable files */ }
}
// Then recurse into directories
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'build') continue;
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
if (result) return result;
}
return null;
}
/**
* Find the element's start and end line in the file.
* The query is a class name, ID, or text snippet.
* We find the line containing the query, then find the matching closing tag.
*/
function findElement(lines, query) {
// Find the line containing the query
let startLine = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes(query)) {
// Make sure this looks like a tag opening, not a comment or string
const line = lines[i].trim();
if (line.startsWith('<!--') || line.startsWith('{/*') || line.startsWith('//')) continue;
// Skip lines inside data-impeccable-variant containers (already wrapped)
if (lines[i].includes('data-impeccable-variant')) continue;
startLine = i;
break;
}
}
if (startLine === -1) return null;
// Find the end of this element by counting open/close tags
const endLine = findClosingLine(lines, startLine);
return { startLine, endLine };
}
/**
* Starting from a line with an opening tag, find the line with the matching
* closing tag by counting tag nesting depth.
*/
function findClosingLine(lines, start) {
// Extract the tag name from the opening line
const openMatch = lines[start].match(/<(\w+)[\s>]/);
if (!openMatch) return start; // self-closing or text-only line
const tagName = openMatch[1];
let depth = 0;
for (let i = start; i < lines.length; i++) {
const line = lines[i];
// Count opening tags (not self-closing)
const opens = (line.match(new RegExp('<' + tagName + '[\\s>]', 'g')) || []).length;
const selfCloses = (line.match(new RegExp('<' + tagName + '[^>]*/>', 'g')) || []).length;
const closes = (line.match(new RegExp('</' + tagName + '\\s*>', 'g')) || []).length;
depth += opens - selfCloses - closes;
if (depth <= 0) return i;
}
// If we can't find the close, return a reasonable guess
return Math.min(start + 50, lines.length - 1);
}
+11 -7
View File
@@ -10,7 +10,7 @@ Launch interactive live variant mode: select elements in the browser, pick a des
1. Read `.impeccable.md` if it exists. Keep the design context in mind for variant generation.
2. Start the live variant server:
```bash
npx impeccable live &
node {{scripts_path}}/live-server.mjs &
```
3. Note the **port** and **token** printed to stdout.
@@ -53,7 +53,7 @@ Run a blocking poll loop. On each iteration, wait for a browser event and respon
```
LOOP:
Run: npx impeccable poll
Run: node {{scripts_path}}/live-poll.mjs
Read the JSON output. Dispatch based on the "type" field:
TYPE "generate":
@@ -85,7 +85,7 @@ The event contains: `{id, action, freeformPrompt, count, pageUrl, element}`.
Use the `wrap` helper to find the element and create the variant container:
```bash
npx impeccable wrap --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
node {{scripts_path}}/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
```
Pass the element's id (`event.element.id`), classes (`event.element.classes` joined with commas), and tag name. The command searches in priority order: ID match first, then class names, then tag+class combo. If `event.pageUrl` hints at the file (e.g., `/` is usually `index.html`), pass `--file PATH` to skip the search.
@@ -136,10 +136,14 @@ The first variant should NOT have `style="display: none"` (it should be visible
### Step 3: Signal completion
Include `--file` so the browser can fetch variants directly if the dev server lacks HMR:
```bash
npx impeccable poll --reply EVENT_ID done
node {{scripts_path}}/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH
```
The file path should be relative to the project root (e.g., `public/index.html`, `src/App.tsx`).
## Handle Accept
The event contains: `{id, variantId}`.
@@ -151,7 +155,7 @@ The user accepted a specific variant. For v1 (inspection mode):
4. Remove any scoped CSS blocks (between `impeccable-variants-css-start` and `impeccable-variants-css-end` markers).
5. Reply:
```bash
npx impeccable poll --reply SESSION_ID done
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
## Handle Discard
@@ -163,7 +167,7 @@ The event contains: `{id}`.
3. Remove any scoped CSS blocks for this session.
4. Reply:
```bash
npx impeccable poll --reply SESSION_ID done
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
## Cleanup (on exit)
@@ -174,7 +178,7 @@ When the loop ends:
2. **Remove any leftover variant wrappers** (search for `impeccable-variants-start` markers and clean up).
3. **Stop the server**:
```bash
npx impeccable live stop
node {{scripts_path}}/live-server.mjs stop
```
## Variant Generation Guidelines
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,123 @@
/**
* CLI client for the live variant mode poll/reply protocol.
*
* Usage:
* npx impeccable poll # Block until browser event, print JSON
* npx impeccable poll --timeout=60000 # Custom timeout (ms)
* npx impeccable poll --reply <id> done # Reply "done" to event <id>
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
const LIVE_PID_FILE = path.join(os.tmpdir(), 'impeccable-live.json');
function readServerInfo() {
try {
return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
} catch {
console.error('No running live server found. Start one with: npx impeccable live');
process.exit(1);
}
}
export async function pollCli() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: impeccable poll [options]
Wait for a browser event from the live variant server, or reply to one.
Modes:
poll Block until a browser event arrives, print JSON
poll --reply <id> done Reply "done" to event <id>
poll --reply <id> error "msg" Reply with an error message
Options:
--timeout=MS Poll timeout in milliseconds (default: 120000)
--help Show this help message`);
process.exit(0);
}
const info = readServerInfo();
const base = `http://localhost:${info.port}`;
// Reply mode: npx impeccable poll --reply <id> <status> [--file path] [message]
const replyIdx = args.indexOf('--reply');
if (replyIdx !== -1) {
const id = args[replyIdx + 1];
const status = args[replyIdx + 2] || 'done';
const fileIdx = args.indexOf('--file');
const filePath = fileIdx !== -1 && fileIdx + 1 < args.length ? args[fileIdx + 1] : undefined;
// Message is any remaining positional arg that isn't a flag
const message = args.find((a, i) => i > replyIdx + 2 && !a.startsWith('--') && i !== fileIdx + 1) || undefined;
if (!id) {
console.error('Usage: npx impeccable poll --reply <id> <status> [--file path] [message]');
process.exit(1);
}
try {
const res = await fetch(`${base}/poll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: info.token,
id,
type: status,
message,
file: filePath,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
console.error(`Reply failed (${res.status}):`, body.error || res.statusText);
process.exit(1);
}
// Success — silent exit (agent doesn't need output for replies)
} catch (err) {
if (err.cause?.code === 'ECONNREFUSED') {
console.error('Live server not running. Start one with: npx impeccable live');
} else {
console.error('Reply failed:', err.message);
}
process.exit(1);
}
return;
}
// Poll mode: block until browser event
const timeoutArg = args.find(a => a.startsWith('--timeout='));
const timeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 120000;
try {
const res = await fetch(`${base}/poll?token=${info.token}&timeout=${timeout}`);
if (res.status === 401) {
console.error('Authentication failed. The server token may have changed.');
console.error('Try restarting: npx impeccable live stop && npx impeccable live');
process.exit(1);
}
if (!res.ok) {
console.error(`Poll failed: ${res.status} ${res.statusText}`);
process.exit(1);
}
const event = await res.json();
// Print the event as JSON — the agent reads this from stdout
console.log(JSON.stringify(event));
} catch (err) {
if (err.cause?.code === 'ECONNREFUSED') {
console.error('Live server not running. Start one with: npx impeccable live');
} else {
console.error('Poll failed:', err.message);
}
process.exit(1);
}
}
@@ -0,0 +1,416 @@
#!/usr/bin/env node
/**
* Live variant mode server (self-contained, zero dependencies).
*
* Serves the browser script (/live.js), the detection overlay (/detect.js),
* uses Server-Sent Events (SSE) for server→browser push, and HTTP POST for
* browser→server events. Agent communicates via HTTP long-poll (/poll).
*
* Usage:
* node <scripts_path>/live-server.mjs # start
* node <scripts_path>/live-server.mjs stop # stop
* node <scripts_path>/live-server.mjs --help
*/
import http from 'node:http';
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import net from 'node:net';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LIVE_PID_FILE = path.join(os.tmpdir(), 'impeccable-live.json');
const DEFAULT_POLL_TIMEOUT = 120_000;
// ---------------------------------------------------------------------------
// Port detection
// ---------------------------------------------------------------------------
async function findOpenPort(start = 8400) {
return new Promise((resolve) => {
const srv = net.createServer();
srv.listen(start, '127.0.0.1', () => {
const port = srv.address().port;
srv.close(() => resolve(port));
});
srv.on('error', () => resolve(findOpenPort(start + 1)));
});
}
// ---------------------------------------------------------------------------
// Session state
// ---------------------------------------------------------------------------
const state = {
token: null,
port: null,
sseClients: new Set(), // SSE response objects (server→browser push)
pendingEvents: [], // browser events waiting for agent poll
pendingPolls: [], // agent poll callbacks waiting for browser events
exitTimer: null,
};
function enqueueEvent(event) {
if (state.pendingPolls.length > 0) {
state.pendingPolls.shift()(event);
} else {
state.pendingEvents.push(event);
}
}
/** Push a message to all connected SSE clients. */
function broadcast(msg) {
const data = 'data: ' + JSON.stringify(msg) + '\n\n';
for (const res of state.sseClients) {
try { res.write(data); } catch { /* client gone */ }
}
}
// ---------------------------------------------------------------------------
// Load scripts
// ---------------------------------------------------------------------------
function loadBrowserScripts() {
// Detection script: look relative to the skill scripts dir, then fall back
// to the npm package location (src/detect-antipatterns-browser.js)
const detectPaths = [
path.join(__dirname, '..', '..', '..', '..', 'src', 'detect-antipatterns-browser.js'),
path.join(process.cwd(), 'node_modules', 'impeccable', 'src', 'detect-antipatterns-browser.js'),
];
let detectScript = '';
for (const p of detectPaths) {
try { detectScript = fs.readFileSync(p, 'utf-8'); break; } catch { /* try next */ }
}
const livePath = path.join(__dirname, 'live-browser.js');
let liveScript = '';
try {
liveScript = fs.readFileSync(livePath, 'utf-8');
} catch {
process.stderr.write('Error: live-browser.js not found at ' + livePath + '\n');
process.exit(1);
}
return { detectScript, liveScript };
}
function hasProjectContext() {
try {
fs.accessSync(path.join(process.cwd(), '.impeccable.md'), fs.constants.R_OK);
return true;
} catch { return false; }
}
// ---------------------------------------------------------------------------
// Validation (inline — no external import needed for self-contained script)
// ---------------------------------------------------------------------------
const VISUAL_ACTIONS = [
'impeccable', 'bolder', 'quieter', 'distill', 'polish', 'typeset',
'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive',
];
function validateEvent(msg) {
if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message';
switch (msg.type) {
case 'generate':
if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id';
if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action';
if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8';
if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context';
return null;
case 'accept':
if (!msg.id) return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
return null;
case 'discard':
return msg.id ? null : 'discard: missing id';
case 'exit':
return null;
default:
return 'Unknown event type: ' + msg.type;
}
}
// ---------------------------------------------------------------------------
// HTTP request handler
// ---------------------------------------------------------------------------
function createRequestHandler({ detectScript, liveScriptWithToken }) {
return (req, res) => {
const url = new URL(req.url, `http://localhost:${state.port}`);
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
const p = url.pathname;
// --- Scripts ---
if (p === '/live.js') {
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.end(liveScriptWithToken);
return;
}
if (p === '/detect.js' || p === '/') {
if (!detectScript) { res.writeHead(404); res.end('Not available'); return; }
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.end(detectScript);
return;
}
// --- Health ---
if (p === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
status: 'ok', port: state.port, mode: 'variant',
hasProjectContext: hasProjectContext(),
connectedClients: state.sseClients.size,
}));
return;
}
// --- Source file (no-HMR fallback) ---
if (p === '/source') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
const filePath = url.searchParams.get('path');
if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
const absPath = path.resolve(process.cwd(), filePath);
if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; }
try {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(fs.readFileSync(absPath, 'utf-8'));
} catch { res.writeHead(404); res.end('File not found'); }
return;
}
// --- SSE: server→browser push (replaces WebSocket) ---
if (p === '/events' && req.method === 'GET') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
res.write('data: ' + JSON.stringify({
type: 'connected',
hasProjectContext: hasProjectContext(),
}) + '\n\n');
state.sseClients.add(res);
clearTimeout(state.exitTimer);
req.on('close', () => {
state.sseClients.delete(res);
if (state.sseClients.size === 0) {
clearTimeout(state.exitTimer);
state.exitTimer = setTimeout(() => {
if (state.sseClients.size === 0) enqueueEvent({ type: 'exit' });
}, 8000);
}
});
return;
}
// --- Browser→server events (replaces WebSocket messages) ---
if (p === '/events' && req.method === 'POST') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
let msg;
try { msg = JSON.parse(body); } catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON' }));
return;
}
if (msg.token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
const error = validateEvent(msg);
if (error) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error }));
return;
}
enqueueEvent(msg);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
return;
}
// --- Stop ---
if (p === '/stop') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('stopping');
shutdown();
return;
}
// --- Agent poll ---
if (p === '/poll' && req.method === 'GET') {
handlePollGet(req, res, url);
return;
}
if (p === '/poll' && req.method === 'POST') {
handlePollPost(req, res);
return;
}
res.writeHead(404); res.end('Not found');
};
}
// ---------------------------------------------------------------------------
// Agent poll endpoints (unchanged from WS version)
// ---------------------------------------------------------------------------
function handlePollGet(req, res, url) {
const token = url.searchParams.get('token');
if (token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10);
if (state.pendingEvents.length > 0) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(state.pendingEvents.shift()));
return;
}
const timer = setTimeout(() => {
const idx = state.pendingPolls.indexOf(resolve);
if (idx !== -1) state.pendingPolls.splice(idx, 1);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ type: 'timeout' }));
}, timeout);
function resolve(event) {
clearTimeout(timer);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(event));
}
state.pendingPolls.push(resolve);
req.on('close', () => {
clearTimeout(timer);
const idx = state.pendingPolls.indexOf(resolve);
if (idx !== -1) state.pendingPolls.splice(idx, 1);
});
}
function handlePollPost(req, res) {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
let msg;
try { msg = JSON.parse(body); } catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON' }));
return;
}
if (msg.token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
// Forward the reply to the browser via SSE
broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
let httpServer = null;
function shutdown() {
try { fs.unlinkSync(LIVE_PID_FILE); } catch {}
for (const res of state.sseClients) { try { res.end(); } catch {} }
state.sseClients.clear();
for (const resolve of state.pendingPolls) resolve({ type: 'exit' });
state.pendingPolls.length = 0;
if (httpServer) httpServer.close();
process.exit(0);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: node live-server.mjs [options]
Start the live variant mode server (zero dependencies).
Commands:
(default) Start the server
stop Stop a running server
Options:
--port=PORT Use a specific port (default: auto-detect starting at 8400)
--help Show this help
Endpoints:
/live.js Browser script (element picker + variant cycling)
/detect.js Detection overlay (backwards compatible)
/events SSE stream (server→browser) + POST (browser→server)
/poll Long-poll for agent CLI
/source Raw source file reader (no-HMR fallback)
/health Health check`);
process.exit(0);
}
if (args.includes('stop')) {
try {
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`);
if (res.ok) console.log(`Stopped live server on port ${info.port}.`);
} catch { console.log('No running live server found.'); }
process.exit(0);
}
// Check for existing session
try {
const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
try { process.kill(existing.pid, 0);
console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`);
console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop');
process.exit(1);
} catch { fs.unlinkSync(LIVE_PID_FILE); }
} catch {}
state.token = randomUUID();
const portArg = args.find(a => a.startsWith('--port='));
state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort();
const { detectScript, liveScript } = loadBrowserScripts();
const liveScriptWithToken =
`window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` +
`window.__IMPECCABLE_PORT__ = ${state.port};\n` +
liveScript;
httpServer = http.createServer(createRequestHandler({ detectScript, liveScriptWithToken }));
httpServer.listen(state.port, '127.0.0.1', () => {
fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token }));
const url = `http://localhost:${state.port}`;
console.log(`\nImpeccable live server running on ${url}`);
console.log(`Token: ${state.token}\n`);
console.log(`Inject: <script src="${url}/live.js"><\/script>`);
console.log(`Stop: node ${path.basename(fileURLToPath(import.meta.url))} stop`);
});
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
@@ -0,0 +1,298 @@
/**
* CLI helper: find an element in source and wrap it in a variant container.
*
* Usage:
* npx impeccable wrap --id SESSION_ID --count N --query "hero-combined-left" [--file path]
*
* Searches project files for the element matching the query (class name, ID, or
* text snippet), wraps it with the variant scaffolding, and prints the file path
* + line range where the agent should insert variant HTML.
*
* This replaces 3-4 agent tool calls (grep + read + edit) with a single CLI call.
*/
import fs from 'node:fs';
import path from 'node:path';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
export async function wrapCli() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: impeccable wrap [options]
Find an element in source and wrap it in a variant container.
Required:
--id ID Session ID for the variant wrapper
--count N Number of expected variants (1-8)
Element identification (at least one required):
--element-id ID HTML id attribute of the element
--classes A,B,C Comma-separated CSS class names
--tag TAG Tag name (div, section, etc.)
--query TEXT Fallback: raw text to search for
Optional:
--file PATH Source file to search in (skips auto-detection)
--help Show this help message
Output (JSON):
{ file, startLine, endLine, insertLine, commentSyntax }
The agent should insert variant HTML at insertLine.`);
process.exit(0);
}
const id = argVal(args, '--id');
const count = parseInt(argVal(args, '--count') || '3');
const elementId = argVal(args, '--element-id');
const classes = argVal(args, '--classes');
const tag = argVal(args, '--tag');
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!elementId && !classes && !query) {
console.error('Need at least one of: --element-id, --classes, --query');
process.exit(1);
}
// Build search queries in priority order (most specific first)
const queries = buildSearchQueries(elementId, classes, tag, query);
// Find the source file
let targetFile = filePath;
let matchedQuery = null;
if (!targetFile) {
for (const q of queries) {
targetFile = findFileWithQuery(q, process.cwd());
if (targetFile) { matchedQuery = q; break; }
}
if (!targetFile) {
console.error(JSON.stringify({ error: 'Could not find element in project files. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
} else {
matchedQuery = queries[0];
}
const content = fs.readFileSync(targetFile, 'utf-8');
const lines = content.split('\n');
// Find the element, trying each query in priority order
let match = null;
for (const q of queries) {
match = findElement(lines, q);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
const { startLine, endLine } = match;
const commentSyntax = detectCommentSyntax(targetFile);
const indent = lines[startLine].match(/^(\s*)/)[1];
// Extract the original element
const originalLines = lines.slice(startLine, endLine + 1);
const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
// Build the wrapper
const wrapperLines = [
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" style="display: contents">',
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
indent + ' <div data-impeccable-variant="original">',
originalIndented,
indent + ' </div>',
indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,
indent + '</div>',
indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
];
// Replace the original element with the wrapper
const newLines = [
...lines.slice(0, startLine),
...wrapperLines,
...lines.slice(endLine + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment)
const insertLine = startLine + 6; // 0-indexed in the new file
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
originalLineCount: originalLines.length,
}));
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function argVal(args, flag) {
const idx = args.indexOf(flag);
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
}
/**
* Build search query strings in priority order (most specific first).
* ID is most reliable, then specific class combos, then single classes, then raw query.
*/
function buildSearchQueries(elementId, classes, tag, query) {
const queries = [];
// 1. ID is the most specific
if (elementId) {
queries.push('id="' + elementId + '"');
}
// 2. Full class attribute match (for elements with distinctive multi-class combos)
if (classes) {
const classList = classes.split(',').map(c => c.trim()).filter(Boolean);
if (classList.length > 1) {
// Try the most distinctive class first (longest, most specific)
const sorted = [...classList].sort((a, b) => b.length - a.length);
queries.push('class="' + classList.join(' ') + '"'); // exact full match
queries.push(sorted[0]); // most distinctive single class
} else if (classList.length === 1) {
queries.push(classList[0]);
}
}
// 3. Tag + class combo (e.g., <section class="hero">)
if (tag && classes) {
const firstClass = classes.split(',')[0].trim();
queries.push('<' + tag + ' class="' + firstClass);
}
// 4. Raw fallback query
if (query) {
queries.push(query);
}
return queries;
}
function detectCommentSyntax(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.jsx' || ext === '.tsx') {
return { open: '{/*', close: '*/}' };
}
// HTML, Vue, Svelte, Astro all use HTML comments
return { open: '<!--', close: '-->' };
}
/**
* Search project files for the query string (class name, ID, etc.)
* Returns the first matching file path, or null.
*/
function findFileWithQuery(query, cwd) {
const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];
const seen = new Set();
for (const dir of searchDirs) {
const absDir = path.join(cwd, dir);
if (!fs.existsSync(absDir)) continue;
const result = searchDir(absDir, query, seen, 0);
if (result) return result;
}
return null;
}
function searchDir(dir, query, seen, depth) {
if (depth > 5) return null; // don't go too deep
const realDir = fs.realpathSync(dir);
if (seen.has(realDir)) return null;
seen.add(realDir);
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return null; }
// Check files first
for (const entry of entries) {
if (!entry.isFile()) continue;
const ext = path.extname(entry.name).toLowerCase();
if (!EXTENSIONS.includes(ext)) continue;
const filePath = path.join(dir, entry.name);
try {
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes(query)) return filePath;
} catch { /* skip unreadable files */ }
}
// Then recurse into directories
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'build') continue;
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
if (result) return result;
}
return null;
}
/**
* Find the element's start and end line in the file.
* The query is a class name, ID, or text snippet.
* We find the line containing the query, then find the matching closing tag.
*/
function findElement(lines, query) {
// Find the line containing the query
let startLine = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes(query)) {
// Make sure this looks like a tag opening, not a comment or string
const line = lines[i].trim();
if (line.startsWith('<!--') || line.startsWith('{/*') || line.startsWith('//')) continue;
// Skip lines inside data-impeccable-variant containers (already wrapped)
if (lines[i].includes('data-impeccable-variant')) continue;
startLine = i;
break;
}
}
if (startLine === -1) return null;
// Find the end of this element by counting open/close tags
const endLine = findClosingLine(lines, startLine);
return { startLine, endLine };
}
/**
* Starting from a line with an opening tag, find the line with the matching
* closing tag by counting tag nesting depth.
*/
function findClosingLine(lines, start) {
// Extract the tag name from the opening line
const openMatch = lines[start].match(/<(\w+)[\s>]/);
if (!openMatch) return start; // self-closing or text-only line
const tagName = openMatch[1];
let depth = 0;
for (let i = start; i < lines.length; i++) {
const line = lines[i];
// Count opening tags (not self-closing)
const opens = (line.match(new RegExp('<' + tagName + '[\\s>]', 'g')) || []).length;
const selfCloses = (line.match(new RegExp('<' + tagName + '[^>]*/>', 'g')) || []).length;
const closes = (line.match(new RegExp('</' + tagName + '\\s*>', 'g')) || []).length;
depth += opens - selfCloses - closes;
if (depth <= 0) return i;
}
// If we can't find the close, return a reasonable guess
return Math.min(start + 50, lines.length - 1);
}
+11 -7
View File
@@ -10,7 +10,7 @@ Launch interactive live variant mode: select elements in the browser, pick a des
1. Read `.impeccable.md` if it exists. Keep the design context in mind for variant generation.
2. Start the live variant server:
```bash
npx impeccable live &
node {{scripts_path}}/live-server.mjs &
```
3. Note the **port** and **token** printed to stdout.
@@ -53,7 +53,7 @@ Run a blocking poll loop. On each iteration, wait for a browser event and respon
```
LOOP:
Run: npx impeccable poll
Run: node {{scripts_path}}/live-poll.mjs
Read the JSON output. Dispatch based on the "type" field:
TYPE "generate":
@@ -85,7 +85,7 @@ The event contains: `{id, action, freeformPrompt, count, pageUrl, element}`.
Use the `wrap` helper to find the element and create the variant container:
```bash
npx impeccable wrap --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
node {{scripts_path}}/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
```
Pass the element's id (`event.element.id`), classes (`event.element.classes` joined with commas), and tag name. The command searches in priority order: ID match first, then class names, then tag+class combo. If `event.pageUrl` hints at the file (e.g., `/` is usually `index.html`), pass `--file PATH` to skip the search.
@@ -136,10 +136,14 @@ The first variant should NOT have `style="display: none"` (it should be visible
### Step 3: Signal completion
Include `--file` so the browser can fetch variants directly if the dev server lacks HMR:
```bash
npx impeccable poll --reply EVENT_ID done
node {{scripts_path}}/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH
```
The file path should be relative to the project root (e.g., `public/index.html`, `src/App.tsx`).
## Handle Accept
The event contains: `{id, variantId}`.
@@ -151,7 +155,7 @@ The user accepted a specific variant. For v1 (inspection mode):
4. Remove any scoped CSS blocks (between `impeccable-variants-css-start` and `impeccable-variants-css-end` markers).
5. Reply:
```bash
npx impeccable poll --reply SESSION_ID done
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
## Handle Discard
@@ -163,7 +167,7 @@ The event contains: `{id}`.
3. Remove any scoped CSS blocks for this session.
4. Reply:
```bash
npx impeccable poll --reply SESSION_ID done
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
## Cleanup (on exit)
@@ -174,7 +178,7 @@ When the loop ends:
2. **Remove any leftover variant wrappers** (search for `impeccable-variants-start` markers and clean up).
3. **Stop the server**:
```bash
npx impeccable live stop
node {{scripts_path}}/live-server.mjs stop
```
## Variant Generation Guidelines
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,123 @@
/**
* CLI client for the live variant mode poll/reply protocol.
*
* Usage:
* npx impeccable poll # Block until browser event, print JSON
* npx impeccable poll --timeout=60000 # Custom timeout (ms)
* npx impeccable poll --reply <id> done # Reply "done" to event <id>
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
const LIVE_PID_FILE = path.join(os.tmpdir(), 'impeccable-live.json');
function readServerInfo() {
try {
return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
} catch {
console.error('No running live server found. Start one with: npx impeccable live');
process.exit(1);
}
}
export async function pollCli() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: impeccable poll [options]
Wait for a browser event from the live variant server, or reply to one.
Modes:
poll Block until a browser event arrives, print JSON
poll --reply <id> done Reply "done" to event <id>
poll --reply <id> error "msg" Reply with an error message
Options:
--timeout=MS Poll timeout in milliseconds (default: 120000)
--help Show this help message`);
process.exit(0);
}
const info = readServerInfo();
const base = `http://localhost:${info.port}`;
// Reply mode: npx impeccable poll --reply <id> <status> [--file path] [message]
const replyIdx = args.indexOf('--reply');
if (replyIdx !== -1) {
const id = args[replyIdx + 1];
const status = args[replyIdx + 2] || 'done';
const fileIdx = args.indexOf('--file');
const filePath = fileIdx !== -1 && fileIdx + 1 < args.length ? args[fileIdx + 1] : undefined;
// Message is any remaining positional arg that isn't a flag
const message = args.find((a, i) => i > replyIdx + 2 && !a.startsWith('--') && i !== fileIdx + 1) || undefined;
if (!id) {
console.error('Usage: npx impeccable poll --reply <id> <status> [--file path] [message]');
process.exit(1);
}
try {
const res = await fetch(`${base}/poll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: info.token,
id,
type: status,
message,
file: filePath,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
console.error(`Reply failed (${res.status}):`, body.error || res.statusText);
process.exit(1);
}
// Success — silent exit (agent doesn't need output for replies)
} catch (err) {
if (err.cause?.code === 'ECONNREFUSED') {
console.error('Live server not running. Start one with: npx impeccable live');
} else {
console.error('Reply failed:', err.message);
}
process.exit(1);
}
return;
}
// Poll mode: block until browser event
const timeoutArg = args.find(a => a.startsWith('--timeout='));
const timeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 120000;
try {
const res = await fetch(`${base}/poll?token=${info.token}&timeout=${timeout}`);
if (res.status === 401) {
console.error('Authentication failed. The server token may have changed.');
console.error('Try restarting: npx impeccable live stop && npx impeccable live');
process.exit(1);
}
if (!res.ok) {
console.error(`Poll failed: ${res.status} ${res.statusText}`);
process.exit(1);
}
const event = await res.json();
// Print the event as JSON — the agent reads this from stdout
console.log(JSON.stringify(event));
} catch (err) {
if (err.cause?.code === 'ECONNREFUSED') {
console.error('Live server not running. Start one with: npx impeccable live');
} else {
console.error('Poll failed:', err.message);
}
process.exit(1);
}
}
@@ -0,0 +1,416 @@
#!/usr/bin/env node
/**
* Live variant mode server (self-contained, zero dependencies).
*
* Serves the browser script (/live.js), the detection overlay (/detect.js),
* uses Server-Sent Events (SSE) for server→browser push, and HTTP POST for
* browser→server events. Agent communicates via HTTP long-poll (/poll).
*
* Usage:
* node <scripts_path>/live-server.mjs # start
* node <scripts_path>/live-server.mjs stop # stop
* node <scripts_path>/live-server.mjs --help
*/
import http from 'node:http';
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import net from 'node:net';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LIVE_PID_FILE = path.join(os.tmpdir(), 'impeccable-live.json');
const DEFAULT_POLL_TIMEOUT = 120_000;
// ---------------------------------------------------------------------------
// Port detection
// ---------------------------------------------------------------------------
async function findOpenPort(start = 8400) {
return new Promise((resolve) => {
const srv = net.createServer();
srv.listen(start, '127.0.0.1', () => {
const port = srv.address().port;
srv.close(() => resolve(port));
});
srv.on('error', () => resolve(findOpenPort(start + 1)));
});
}
// ---------------------------------------------------------------------------
// Session state
// ---------------------------------------------------------------------------
const state = {
token: null,
port: null,
sseClients: new Set(), // SSE response objects (server→browser push)
pendingEvents: [], // browser events waiting for agent poll
pendingPolls: [], // agent poll callbacks waiting for browser events
exitTimer: null,
};
function enqueueEvent(event) {
if (state.pendingPolls.length > 0) {
state.pendingPolls.shift()(event);
} else {
state.pendingEvents.push(event);
}
}
/** Push a message to all connected SSE clients. */
function broadcast(msg) {
const data = 'data: ' + JSON.stringify(msg) + '\n\n';
for (const res of state.sseClients) {
try { res.write(data); } catch { /* client gone */ }
}
}
// ---------------------------------------------------------------------------
// Load scripts
// ---------------------------------------------------------------------------
function loadBrowserScripts() {
// Detection script: look relative to the skill scripts dir, then fall back
// to the npm package location (src/detect-antipatterns-browser.js)
const detectPaths = [
path.join(__dirname, '..', '..', '..', '..', 'src', 'detect-antipatterns-browser.js'),
path.join(process.cwd(), 'node_modules', 'impeccable', 'src', 'detect-antipatterns-browser.js'),
];
let detectScript = '';
for (const p of detectPaths) {
try { detectScript = fs.readFileSync(p, 'utf-8'); break; } catch { /* try next */ }
}
const livePath = path.join(__dirname, 'live-browser.js');
let liveScript = '';
try {
liveScript = fs.readFileSync(livePath, 'utf-8');
} catch {
process.stderr.write('Error: live-browser.js not found at ' + livePath + '\n');
process.exit(1);
}
return { detectScript, liveScript };
}
function hasProjectContext() {
try {
fs.accessSync(path.join(process.cwd(), '.impeccable.md'), fs.constants.R_OK);
return true;
} catch { return false; }
}
// ---------------------------------------------------------------------------
// Validation (inline — no external import needed for self-contained script)
// ---------------------------------------------------------------------------
const VISUAL_ACTIONS = [
'impeccable', 'bolder', 'quieter', 'distill', 'polish', 'typeset',
'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive',
];
function validateEvent(msg) {
if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message';
switch (msg.type) {
case 'generate':
if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id';
if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action';
if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8';
if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context';
return null;
case 'accept':
if (!msg.id) return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
return null;
case 'discard':
return msg.id ? null : 'discard: missing id';
case 'exit':
return null;
default:
return 'Unknown event type: ' + msg.type;
}
}
// ---------------------------------------------------------------------------
// HTTP request handler
// ---------------------------------------------------------------------------
function createRequestHandler({ detectScript, liveScriptWithToken }) {
return (req, res) => {
const url = new URL(req.url, `http://localhost:${state.port}`);
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
const p = url.pathname;
// --- Scripts ---
if (p === '/live.js') {
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.end(liveScriptWithToken);
return;
}
if (p === '/detect.js' || p === '/') {
if (!detectScript) { res.writeHead(404); res.end('Not available'); return; }
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.end(detectScript);
return;
}
// --- Health ---
if (p === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
status: 'ok', port: state.port, mode: 'variant',
hasProjectContext: hasProjectContext(),
connectedClients: state.sseClients.size,
}));
return;
}
// --- Source file (no-HMR fallback) ---
if (p === '/source') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
const filePath = url.searchParams.get('path');
if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
const absPath = path.resolve(process.cwd(), filePath);
if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; }
try {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(fs.readFileSync(absPath, 'utf-8'));
} catch { res.writeHead(404); res.end('File not found'); }
return;
}
// --- SSE: server→browser push (replaces WebSocket) ---
if (p === '/events' && req.method === 'GET') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
res.write('data: ' + JSON.stringify({
type: 'connected',
hasProjectContext: hasProjectContext(),
}) + '\n\n');
state.sseClients.add(res);
clearTimeout(state.exitTimer);
req.on('close', () => {
state.sseClients.delete(res);
if (state.sseClients.size === 0) {
clearTimeout(state.exitTimer);
state.exitTimer = setTimeout(() => {
if (state.sseClients.size === 0) enqueueEvent({ type: 'exit' });
}, 8000);
}
});
return;
}
// --- Browser→server events (replaces WebSocket messages) ---
if (p === '/events' && req.method === 'POST') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
let msg;
try { msg = JSON.parse(body); } catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON' }));
return;
}
if (msg.token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
const error = validateEvent(msg);
if (error) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error }));
return;
}
enqueueEvent(msg);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
return;
}
// --- Stop ---
if (p === '/stop') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('stopping');
shutdown();
return;
}
// --- Agent poll ---
if (p === '/poll' && req.method === 'GET') {
handlePollGet(req, res, url);
return;
}
if (p === '/poll' && req.method === 'POST') {
handlePollPost(req, res);
return;
}
res.writeHead(404); res.end('Not found');
};
}
// ---------------------------------------------------------------------------
// Agent poll endpoints (unchanged from WS version)
// ---------------------------------------------------------------------------
function handlePollGet(req, res, url) {
const token = url.searchParams.get('token');
if (token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10);
if (state.pendingEvents.length > 0) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(state.pendingEvents.shift()));
return;
}
const timer = setTimeout(() => {
const idx = state.pendingPolls.indexOf(resolve);
if (idx !== -1) state.pendingPolls.splice(idx, 1);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ type: 'timeout' }));
}, timeout);
function resolve(event) {
clearTimeout(timer);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(event));
}
state.pendingPolls.push(resolve);
req.on('close', () => {
clearTimeout(timer);
const idx = state.pendingPolls.indexOf(resolve);
if (idx !== -1) state.pendingPolls.splice(idx, 1);
});
}
function handlePollPost(req, res) {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
let msg;
try { msg = JSON.parse(body); } catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON' }));
return;
}
if (msg.token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
// Forward the reply to the browser via SSE
broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
let httpServer = null;
function shutdown() {
try { fs.unlinkSync(LIVE_PID_FILE); } catch {}
for (const res of state.sseClients) { try { res.end(); } catch {} }
state.sseClients.clear();
for (const resolve of state.pendingPolls) resolve({ type: 'exit' });
state.pendingPolls.length = 0;
if (httpServer) httpServer.close();
process.exit(0);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: node live-server.mjs [options]
Start the live variant mode server (zero dependencies).
Commands:
(default) Start the server
stop Stop a running server
Options:
--port=PORT Use a specific port (default: auto-detect starting at 8400)
--help Show this help
Endpoints:
/live.js Browser script (element picker + variant cycling)
/detect.js Detection overlay (backwards compatible)
/events SSE stream (server→browser) + POST (browser→server)
/poll Long-poll for agent CLI
/source Raw source file reader (no-HMR fallback)
/health Health check`);
process.exit(0);
}
if (args.includes('stop')) {
try {
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`);
if (res.ok) console.log(`Stopped live server on port ${info.port}.`);
} catch { console.log('No running live server found.'); }
process.exit(0);
}
// Check for existing session
try {
const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
try { process.kill(existing.pid, 0);
console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`);
console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop');
process.exit(1);
} catch { fs.unlinkSync(LIVE_PID_FILE); }
} catch {}
state.token = randomUUID();
const portArg = args.find(a => a.startsWith('--port='));
state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort();
const { detectScript, liveScript } = loadBrowserScripts();
const liveScriptWithToken =
`window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` +
`window.__IMPECCABLE_PORT__ = ${state.port};\n` +
liveScript;
httpServer = http.createServer(createRequestHandler({ detectScript, liveScriptWithToken }));
httpServer.listen(state.port, '127.0.0.1', () => {
fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token }));
const url = `http://localhost:${state.port}`;
console.log(`\nImpeccable live server running on ${url}`);
console.log(`Token: ${state.token}\n`);
console.log(`Inject: <script src="${url}/live.js"><\/script>`);
console.log(`Stop: node ${path.basename(fileURLToPath(import.meta.url))} stop`);
});
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
@@ -0,0 +1,298 @@
/**
* CLI helper: find an element in source and wrap it in a variant container.
*
* Usage:
* npx impeccable wrap --id SESSION_ID --count N --query "hero-combined-left" [--file path]
*
* Searches project files for the element matching the query (class name, ID, or
* text snippet), wraps it with the variant scaffolding, and prints the file path
* + line range where the agent should insert variant HTML.
*
* This replaces 3-4 agent tool calls (grep + read + edit) with a single CLI call.
*/
import fs from 'node:fs';
import path from 'node:path';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
export async function wrapCli() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: impeccable wrap [options]
Find an element in source and wrap it in a variant container.
Required:
--id ID Session ID for the variant wrapper
--count N Number of expected variants (1-8)
Element identification (at least one required):
--element-id ID HTML id attribute of the element
--classes A,B,C Comma-separated CSS class names
--tag TAG Tag name (div, section, etc.)
--query TEXT Fallback: raw text to search for
Optional:
--file PATH Source file to search in (skips auto-detection)
--help Show this help message
Output (JSON):
{ file, startLine, endLine, insertLine, commentSyntax }
The agent should insert variant HTML at insertLine.`);
process.exit(0);
}
const id = argVal(args, '--id');
const count = parseInt(argVal(args, '--count') || '3');
const elementId = argVal(args, '--element-id');
const classes = argVal(args, '--classes');
const tag = argVal(args, '--tag');
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!elementId && !classes && !query) {
console.error('Need at least one of: --element-id, --classes, --query');
process.exit(1);
}
// Build search queries in priority order (most specific first)
const queries = buildSearchQueries(elementId, classes, tag, query);
// Find the source file
let targetFile = filePath;
let matchedQuery = null;
if (!targetFile) {
for (const q of queries) {
targetFile = findFileWithQuery(q, process.cwd());
if (targetFile) { matchedQuery = q; break; }
}
if (!targetFile) {
console.error(JSON.stringify({ error: 'Could not find element in project files. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
} else {
matchedQuery = queries[0];
}
const content = fs.readFileSync(targetFile, 'utf-8');
const lines = content.split('\n');
// Find the element, trying each query in priority order
let match = null;
for (const q of queries) {
match = findElement(lines, q);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
const { startLine, endLine } = match;
const commentSyntax = detectCommentSyntax(targetFile);
const indent = lines[startLine].match(/^(\s*)/)[1];
// Extract the original element
const originalLines = lines.slice(startLine, endLine + 1);
const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
// Build the wrapper
const wrapperLines = [
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" style="display: contents">',
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
indent + ' <div data-impeccable-variant="original">',
originalIndented,
indent + ' </div>',
indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,
indent + '</div>',
indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
];
// Replace the original element with the wrapper
const newLines = [
...lines.slice(0, startLine),
...wrapperLines,
...lines.slice(endLine + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment)
const insertLine = startLine + 6; // 0-indexed in the new file
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
originalLineCount: originalLines.length,
}));
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function argVal(args, flag) {
const idx = args.indexOf(flag);
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
}
/**
* Build search query strings in priority order (most specific first).
* ID is most reliable, then specific class combos, then single classes, then raw query.
*/
function buildSearchQueries(elementId, classes, tag, query) {
const queries = [];
// 1. ID is the most specific
if (elementId) {
queries.push('id="' + elementId + '"');
}
// 2. Full class attribute match (for elements with distinctive multi-class combos)
if (classes) {
const classList = classes.split(',').map(c => c.trim()).filter(Boolean);
if (classList.length > 1) {
// Try the most distinctive class first (longest, most specific)
const sorted = [...classList].sort((a, b) => b.length - a.length);
queries.push('class="' + classList.join(' ') + '"'); // exact full match
queries.push(sorted[0]); // most distinctive single class
} else if (classList.length === 1) {
queries.push(classList[0]);
}
}
// 3. Tag + class combo (e.g., <section class="hero">)
if (tag && classes) {
const firstClass = classes.split(',')[0].trim();
queries.push('<' + tag + ' class="' + firstClass);
}
// 4. Raw fallback query
if (query) {
queries.push(query);
}
return queries;
}
function detectCommentSyntax(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.jsx' || ext === '.tsx') {
return { open: '{/*', close: '*/}' };
}
// HTML, Vue, Svelte, Astro all use HTML comments
return { open: '<!--', close: '-->' };
}
/**
* Search project files for the query string (class name, ID, etc.)
* Returns the first matching file path, or null.
*/
function findFileWithQuery(query, cwd) {
const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];
const seen = new Set();
for (const dir of searchDirs) {
const absDir = path.join(cwd, dir);
if (!fs.existsSync(absDir)) continue;
const result = searchDir(absDir, query, seen, 0);
if (result) return result;
}
return null;
}
function searchDir(dir, query, seen, depth) {
if (depth > 5) return null; // don't go too deep
const realDir = fs.realpathSync(dir);
if (seen.has(realDir)) return null;
seen.add(realDir);
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return null; }
// Check files first
for (const entry of entries) {
if (!entry.isFile()) continue;
const ext = path.extname(entry.name).toLowerCase();
if (!EXTENSIONS.includes(ext)) continue;
const filePath = path.join(dir, entry.name);
try {
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes(query)) return filePath;
} catch { /* skip unreadable files */ }
}
// Then recurse into directories
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'build') continue;
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
if (result) return result;
}
return null;
}
/**
* Find the element's start and end line in the file.
* The query is a class name, ID, or text snippet.
* We find the line containing the query, then find the matching closing tag.
*/
function findElement(lines, query) {
// Find the line containing the query
let startLine = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes(query)) {
// Make sure this looks like a tag opening, not a comment or string
const line = lines[i].trim();
if (line.startsWith('<!--') || line.startsWith('{/*') || line.startsWith('//')) continue;
// Skip lines inside data-impeccable-variant containers (already wrapped)
if (lines[i].includes('data-impeccable-variant')) continue;
startLine = i;
break;
}
}
if (startLine === -1) return null;
// Find the end of this element by counting open/close tags
const endLine = findClosingLine(lines, startLine);
return { startLine, endLine };
}
/**
* Starting from a line with an opening tag, find the line with the matching
* closing tag by counting tag nesting depth.
*/
function findClosingLine(lines, start) {
// Extract the tag name from the opening line
const openMatch = lines[start].match(/<(\w+)[\s>]/);
if (!openMatch) return start; // self-closing or text-only line
const tagName = openMatch[1];
let depth = 0;
for (let i = start; i < lines.length; i++) {
const line = lines[i];
// Count opening tags (not self-closing)
const opens = (line.match(new RegExp('<' + tagName + '[\\s>]', 'g')) || []).length;
const selfCloses = (line.match(new RegExp('<' + tagName + '[^>]*/>', 'g')) || []).length;
const closes = (line.match(new RegExp('</' + tagName + '\\s*>', 'g')) || []).length;
depth += opens - selfCloses - closes;
if (depth <= 0) return i;
}
// If we can't find the close, return a reasonable guess
return Math.min(start + 50, lines.length - 1);
}
+11 -7
View File
@@ -10,7 +10,7 @@ Launch interactive live variant mode: select elements in the browser, pick a des
1. Read `.impeccable.md` if it exists. Keep the design context in mind for variant generation.
2. Start the live variant server:
```bash
npx impeccable live &
node {{scripts_path}}/live-server.mjs &
```
3. Note the **port** and **token** printed to stdout.
@@ -53,7 +53,7 @@ Run a blocking poll loop. On each iteration, wait for a browser event and respon
```
LOOP:
Run: npx impeccable poll
Run: node {{scripts_path}}/live-poll.mjs
Read the JSON output. Dispatch based on the "type" field:
TYPE "generate":
@@ -85,7 +85,7 @@ The event contains: `{id, action, freeformPrompt, count, pageUrl, element}`.
Use the `wrap` helper to find the element and create the variant container:
```bash
npx impeccable wrap --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
node {{scripts_path}}/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
```
Pass the element's id (`event.element.id`), classes (`event.element.classes` joined with commas), and tag name. The command searches in priority order: ID match first, then class names, then tag+class combo. If `event.pageUrl` hints at the file (e.g., `/` is usually `index.html`), pass `--file PATH` to skip the search.
@@ -136,10 +136,14 @@ The first variant should NOT have `style="display: none"` (it should be visible
### Step 3: Signal completion
Include `--file` so the browser can fetch variants directly if the dev server lacks HMR:
```bash
npx impeccable poll --reply EVENT_ID done
node {{scripts_path}}/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH
```
The file path should be relative to the project root (e.g., `public/index.html`, `src/App.tsx`).
## Handle Accept
The event contains: `{id, variantId}`.
@@ -151,7 +155,7 @@ The user accepted a specific variant. For v1 (inspection mode):
4. Remove any scoped CSS blocks (between `impeccable-variants-css-start` and `impeccable-variants-css-end` markers).
5. Reply:
```bash
npx impeccable poll --reply SESSION_ID done
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
## Handle Discard
@@ -163,7 +167,7 @@ The event contains: `{id}`.
3. Remove any scoped CSS blocks for this session.
4. Reply:
```bash
npx impeccable poll --reply SESSION_ID done
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
## Cleanup (on exit)
@@ -174,7 +178,7 @@ When the loop ends:
2. **Remove any leftover variant wrappers** (search for `impeccable-variants-start` markers and clean up).
3. **Stop the server**:
```bash
npx impeccable live stop
node {{scripts_path}}/live-server.mjs stop
```
## Variant Generation Guidelines
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,123 @@
/**
* CLI client for the live variant mode poll/reply protocol.
*
* Usage:
* npx impeccable poll # Block until browser event, print JSON
* npx impeccable poll --timeout=60000 # Custom timeout (ms)
* npx impeccable poll --reply <id> done # Reply "done" to event <id>
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
const LIVE_PID_FILE = path.join(os.tmpdir(), 'impeccable-live.json');
function readServerInfo() {
try {
return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
} catch {
console.error('No running live server found. Start one with: npx impeccable live');
process.exit(1);
}
}
export async function pollCli() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: impeccable poll [options]
Wait for a browser event from the live variant server, or reply to one.
Modes:
poll Block until a browser event arrives, print JSON
poll --reply <id> done Reply "done" to event <id>
poll --reply <id> error "msg" Reply with an error message
Options:
--timeout=MS Poll timeout in milliseconds (default: 120000)
--help Show this help message`);
process.exit(0);
}
const info = readServerInfo();
const base = `http://localhost:${info.port}`;
// Reply mode: npx impeccable poll --reply <id> <status> [--file path] [message]
const replyIdx = args.indexOf('--reply');
if (replyIdx !== -1) {
const id = args[replyIdx + 1];
const status = args[replyIdx + 2] || 'done';
const fileIdx = args.indexOf('--file');
const filePath = fileIdx !== -1 && fileIdx + 1 < args.length ? args[fileIdx + 1] : undefined;
// Message is any remaining positional arg that isn't a flag
const message = args.find((a, i) => i > replyIdx + 2 && !a.startsWith('--') && i !== fileIdx + 1) || undefined;
if (!id) {
console.error('Usage: npx impeccable poll --reply <id> <status> [--file path] [message]');
process.exit(1);
}
try {
const res = await fetch(`${base}/poll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: info.token,
id,
type: status,
message,
file: filePath,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
console.error(`Reply failed (${res.status}):`, body.error || res.statusText);
process.exit(1);
}
// Success — silent exit (agent doesn't need output for replies)
} catch (err) {
if (err.cause?.code === 'ECONNREFUSED') {
console.error('Live server not running. Start one with: npx impeccable live');
} else {
console.error('Reply failed:', err.message);
}
process.exit(1);
}
return;
}
// Poll mode: block until browser event
const timeoutArg = args.find(a => a.startsWith('--timeout='));
const timeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 120000;
try {
const res = await fetch(`${base}/poll?token=${info.token}&timeout=${timeout}`);
if (res.status === 401) {
console.error('Authentication failed. The server token may have changed.');
console.error('Try restarting: npx impeccable live stop && npx impeccable live');
process.exit(1);
}
if (!res.ok) {
console.error(`Poll failed: ${res.status} ${res.statusText}`);
process.exit(1);
}
const event = await res.json();
// Print the event as JSON — the agent reads this from stdout
console.log(JSON.stringify(event));
} catch (err) {
if (err.cause?.code === 'ECONNREFUSED') {
console.error('Live server not running. Start one with: npx impeccable live');
} else {
console.error('Poll failed:', err.message);
}
process.exit(1);
}
}
@@ -0,0 +1,416 @@
#!/usr/bin/env node
/**
* Live variant mode server (self-contained, zero dependencies).
*
* Serves the browser script (/live.js), the detection overlay (/detect.js),
* uses Server-Sent Events (SSE) for server→browser push, and HTTP POST for
* browser→server events. Agent communicates via HTTP long-poll (/poll).
*
* Usage:
* node <scripts_path>/live-server.mjs # start
* node <scripts_path>/live-server.mjs stop # stop
* node <scripts_path>/live-server.mjs --help
*/
import http from 'node:http';
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import net from 'node:net';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LIVE_PID_FILE = path.join(os.tmpdir(), 'impeccable-live.json');
const DEFAULT_POLL_TIMEOUT = 120_000;
// ---------------------------------------------------------------------------
// Port detection
// ---------------------------------------------------------------------------
async function findOpenPort(start = 8400) {
return new Promise((resolve) => {
const srv = net.createServer();
srv.listen(start, '127.0.0.1', () => {
const port = srv.address().port;
srv.close(() => resolve(port));
});
srv.on('error', () => resolve(findOpenPort(start + 1)));
});
}
// ---------------------------------------------------------------------------
// Session state
// ---------------------------------------------------------------------------
const state = {
token: null,
port: null,
sseClients: new Set(), // SSE response objects (server→browser push)
pendingEvents: [], // browser events waiting for agent poll
pendingPolls: [], // agent poll callbacks waiting for browser events
exitTimer: null,
};
function enqueueEvent(event) {
if (state.pendingPolls.length > 0) {
state.pendingPolls.shift()(event);
} else {
state.pendingEvents.push(event);
}
}
/** Push a message to all connected SSE clients. */
function broadcast(msg) {
const data = 'data: ' + JSON.stringify(msg) + '\n\n';
for (const res of state.sseClients) {
try { res.write(data); } catch { /* client gone */ }
}
}
// ---------------------------------------------------------------------------
// Load scripts
// ---------------------------------------------------------------------------
function loadBrowserScripts() {
// Detection script: look relative to the skill scripts dir, then fall back
// to the npm package location (src/detect-antipatterns-browser.js)
const detectPaths = [
path.join(__dirname, '..', '..', '..', '..', 'src', 'detect-antipatterns-browser.js'),
path.join(process.cwd(), 'node_modules', 'impeccable', 'src', 'detect-antipatterns-browser.js'),
];
let detectScript = '';
for (const p of detectPaths) {
try { detectScript = fs.readFileSync(p, 'utf-8'); break; } catch { /* try next */ }
}
const livePath = path.join(__dirname, 'live-browser.js');
let liveScript = '';
try {
liveScript = fs.readFileSync(livePath, 'utf-8');
} catch {
process.stderr.write('Error: live-browser.js not found at ' + livePath + '\n');
process.exit(1);
}
return { detectScript, liveScript };
}
function hasProjectContext() {
try {
fs.accessSync(path.join(process.cwd(), '.impeccable.md'), fs.constants.R_OK);
return true;
} catch { return false; }
}
// ---------------------------------------------------------------------------
// Validation (inline — no external import needed for self-contained script)
// ---------------------------------------------------------------------------
const VISUAL_ACTIONS = [
'impeccable', 'bolder', 'quieter', 'distill', 'polish', 'typeset',
'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive',
];
function validateEvent(msg) {
if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message';
switch (msg.type) {
case 'generate':
if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id';
if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action';
if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8';
if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context';
return null;
case 'accept':
if (!msg.id) return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
return null;
case 'discard':
return msg.id ? null : 'discard: missing id';
case 'exit':
return null;
default:
return 'Unknown event type: ' + msg.type;
}
}
// ---------------------------------------------------------------------------
// HTTP request handler
// ---------------------------------------------------------------------------
function createRequestHandler({ detectScript, liveScriptWithToken }) {
return (req, res) => {
const url = new URL(req.url, `http://localhost:${state.port}`);
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
const p = url.pathname;
// --- Scripts ---
if (p === '/live.js') {
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.end(liveScriptWithToken);
return;
}
if (p === '/detect.js' || p === '/') {
if (!detectScript) { res.writeHead(404); res.end('Not available'); return; }
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.end(detectScript);
return;
}
// --- Health ---
if (p === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
status: 'ok', port: state.port, mode: 'variant',
hasProjectContext: hasProjectContext(),
connectedClients: state.sseClients.size,
}));
return;
}
// --- Source file (no-HMR fallback) ---
if (p === '/source') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
const filePath = url.searchParams.get('path');
if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
const absPath = path.resolve(process.cwd(), filePath);
if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; }
try {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(fs.readFileSync(absPath, 'utf-8'));
} catch { res.writeHead(404); res.end('File not found'); }
return;
}
// --- SSE: server→browser push (replaces WebSocket) ---
if (p === '/events' && req.method === 'GET') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
res.write('data: ' + JSON.stringify({
type: 'connected',
hasProjectContext: hasProjectContext(),
}) + '\n\n');
state.sseClients.add(res);
clearTimeout(state.exitTimer);
req.on('close', () => {
state.sseClients.delete(res);
if (state.sseClients.size === 0) {
clearTimeout(state.exitTimer);
state.exitTimer = setTimeout(() => {
if (state.sseClients.size === 0) enqueueEvent({ type: 'exit' });
}, 8000);
}
});
return;
}
// --- Browser→server events (replaces WebSocket messages) ---
if (p === '/events' && req.method === 'POST') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
let msg;
try { msg = JSON.parse(body); } catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON' }));
return;
}
if (msg.token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
const error = validateEvent(msg);
if (error) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error }));
return;
}
enqueueEvent(msg);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
return;
}
// --- Stop ---
if (p === '/stop') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('stopping');
shutdown();
return;
}
// --- Agent poll ---
if (p === '/poll' && req.method === 'GET') {
handlePollGet(req, res, url);
return;
}
if (p === '/poll' && req.method === 'POST') {
handlePollPost(req, res);
return;
}
res.writeHead(404); res.end('Not found');
};
}
// ---------------------------------------------------------------------------
// Agent poll endpoints (unchanged from WS version)
// ---------------------------------------------------------------------------
function handlePollGet(req, res, url) {
const token = url.searchParams.get('token');
if (token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10);
if (state.pendingEvents.length > 0) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(state.pendingEvents.shift()));
return;
}
const timer = setTimeout(() => {
const idx = state.pendingPolls.indexOf(resolve);
if (idx !== -1) state.pendingPolls.splice(idx, 1);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ type: 'timeout' }));
}, timeout);
function resolve(event) {
clearTimeout(timer);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(event));
}
state.pendingPolls.push(resolve);
req.on('close', () => {
clearTimeout(timer);
const idx = state.pendingPolls.indexOf(resolve);
if (idx !== -1) state.pendingPolls.splice(idx, 1);
});
}
function handlePollPost(req, res) {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
let msg;
try { msg = JSON.parse(body); } catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON' }));
return;
}
if (msg.token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
// Forward the reply to the browser via SSE
broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
let httpServer = null;
function shutdown() {
try { fs.unlinkSync(LIVE_PID_FILE); } catch {}
for (const res of state.sseClients) { try { res.end(); } catch {} }
state.sseClients.clear();
for (const resolve of state.pendingPolls) resolve({ type: 'exit' });
state.pendingPolls.length = 0;
if (httpServer) httpServer.close();
process.exit(0);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: node live-server.mjs [options]
Start the live variant mode server (zero dependencies).
Commands:
(default) Start the server
stop Stop a running server
Options:
--port=PORT Use a specific port (default: auto-detect starting at 8400)
--help Show this help
Endpoints:
/live.js Browser script (element picker + variant cycling)
/detect.js Detection overlay (backwards compatible)
/events SSE stream (server→browser) + POST (browser→server)
/poll Long-poll for agent CLI
/source Raw source file reader (no-HMR fallback)
/health Health check`);
process.exit(0);
}
if (args.includes('stop')) {
try {
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`);
if (res.ok) console.log(`Stopped live server on port ${info.port}.`);
} catch { console.log('No running live server found.'); }
process.exit(0);
}
// Check for existing session
try {
const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
try { process.kill(existing.pid, 0);
console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`);
console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop');
process.exit(1);
} catch { fs.unlinkSync(LIVE_PID_FILE); }
} catch {}
state.token = randomUUID();
const portArg = args.find(a => a.startsWith('--port='));
state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort();
const { detectScript, liveScript } = loadBrowserScripts();
const liveScriptWithToken =
`window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` +
`window.__IMPECCABLE_PORT__ = ${state.port};\n` +
liveScript;
httpServer = http.createServer(createRequestHandler({ detectScript, liveScriptWithToken }));
httpServer.listen(state.port, '127.0.0.1', () => {
fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token }));
const url = `http://localhost:${state.port}`;
console.log(`\nImpeccable live server running on ${url}`);
console.log(`Token: ${state.token}\n`);
console.log(`Inject: <script src="${url}/live.js"><\/script>`);
console.log(`Stop: node ${path.basename(fileURLToPath(import.meta.url))} stop`);
});
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
@@ -0,0 +1,298 @@
/**
* CLI helper: find an element in source and wrap it in a variant container.
*
* Usage:
* npx impeccable wrap --id SESSION_ID --count N --query "hero-combined-left" [--file path]
*
* Searches project files for the element matching the query (class name, ID, or
* text snippet), wraps it with the variant scaffolding, and prints the file path
* + line range where the agent should insert variant HTML.
*
* This replaces 3-4 agent tool calls (grep + read + edit) with a single CLI call.
*/
import fs from 'node:fs';
import path from 'node:path';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
export async function wrapCli() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: impeccable wrap [options]
Find an element in source and wrap it in a variant container.
Required:
--id ID Session ID for the variant wrapper
--count N Number of expected variants (1-8)
Element identification (at least one required):
--element-id ID HTML id attribute of the element
--classes A,B,C Comma-separated CSS class names
--tag TAG Tag name (div, section, etc.)
--query TEXT Fallback: raw text to search for
Optional:
--file PATH Source file to search in (skips auto-detection)
--help Show this help message
Output (JSON):
{ file, startLine, endLine, insertLine, commentSyntax }
The agent should insert variant HTML at insertLine.`);
process.exit(0);
}
const id = argVal(args, '--id');
const count = parseInt(argVal(args, '--count') || '3');
const elementId = argVal(args, '--element-id');
const classes = argVal(args, '--classes');
const tag = argVal(args, '--tag');
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!elementId && !classes && !query) {
console.error('Need at least one of: --element-id, --classes, --query');
process.exit(1);
}
// Build search queries in priority order (most specific first)
const queries = buildSearchQueries(elementId, classes, tag, query);
// Find the source file
let targetFile = filePath;
let matchedQuery = null;
if (!targetFile) {
for (const q of queries) {
targetFile = findFileWithQuery(q, process.cwd());
if (targetFile) { matchedQuery = q; break; }
}
if (!targetFile) {
console.error(JSON.stringify({ error: 'Could not find element in project files. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
} else {
matchedQuery = queries[0];
}
const content = fs.readFileSync(targetFile, 'utf-8');
const lines = content.split('\n');
// Find the element, trying each query in priority order
let match = null;
for (const q of queries) {
match = findElement(lines, q);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
const { startLine, endLine } = match;
const commentSyntax = detectCommentSyntax(targetFile);
const indent = lines[startLine].match(/^(\s*)/)[1];
// Extract the original element
const originalLines = lines.slice(startLine, endLine + 1);
const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
// Build the wrapper
const wrapperLines = [
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" style="display: contents">',
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
indent + ' <div data-impeccable-variant="original">',
originalIndented,
indent + ' </div>',
indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,
indent + '</div>',
indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
];
// Replace the original element with the wrapper
const newLines = [
...lines.slice(0, startLine),
...wrapperLines,
...lines.slice(endLine + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment)
const insertLine = startLine + 6; // 0-indexed in the new file
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
originalLineCount: originalLines.length,
}));
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function argVal(args, flag) {
const idx = args.indexOf(flag);
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
}
/**
* Build search query strings in priority order (most specific first).
* ID is most reliable, then specific class combos, then single classes, then raw query.
*/
function buildSearchQueries(elementId, classes, tag, query) {
const queries = [];
// 1. ID is the most specific
if (elementId) {
queries.push('id="' + elementId + '"');
}
// 2. Full class attribute match (for elements with distinctive multi-class combos)
if (classes) {
const classList = classes.split(',').map(c => c.trim()).filter(Boolean);
if (classList.length > 1) {
// Try the most distinctive class first (longest, most specific)
const sorted = [...classList].sort((a, b) => b.length - a.length);
queries.push('class="' + classList.join(' ') + '"'); // exact full match
queries.push(sorted[0]); // most distinctive single class
} else if (classList.length === 1) {
queries.push(classList[0]);
}
}
// 3. Tag + class combo (e.g., <section class="hero">)
if (tag && classes) {
const firstClass = classes.split(',')[0].trim();
queries.push('<' + tag + ' class="' + firstClass);
}
// 4. Raw fallback query
if (query) {
queries.push(query);
}
return queries;
}
function detectCommentSyntax(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.jsx' || ext === '.tsx') {
return { open: '{/*', close: '*/}' };
}
// HTML, Vue, Svelte, Astro all use HTML comments
return { open: '<!--', close: '-->' };
}
/**
* Search project files for the query string (class name, ID, etc.)
* Returns the first matching file path, or null.
*/
function findFileWithQuery(query, cwd) {
const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];
const seen = new Set();
for (const dir of searchDirs) {
const absDir = path.join(cwd, dir);
if (!fs.existsSync(absDir)) continue;
const result = searchDir(absDir, query, seen, 0);
if (result) return result;
}
return null;
}
function searchDir(dir, query, seen, depth) {
if (depth > 5) return null; // don't go too deep
const realDir = fs.realpathSync(dir);
if (seen.has(realDir)) return null;
seen.add(realDir);
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return null; }
// Check files first
for (const entry of entries) {
if (!entry.isFile()) continue;
const ext = path.extname(entry.name).toLowerCase();
if (!EXTENSIONS.includes(ext)) continue;
const filePath = path.join(dir, entry.name);
try {
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes(query)) return filePath;
} catch { /* skip unreadable files */ }
}
// Then recurse into directories
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'build') continue;
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
if (result) return result;
}
return null;
}
/**
* Find the element's start and end line in the file.
* The query is a class name, ID, or text snippet.
* We find the line containing the query, then find the matching closing tag.
*/
function findElement(lines, query) {
// Find the line containing the query
let startLine = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes(query)) {
// Make sure this looks like a tag opening, not a comment or string
const line = lines[i].trim();
if (line.startsWith('<!--') || line.startsWith('{/*') || line.startsWith('//')) continue;
// Skip lines inside data-impeccable-variant containers (already wrapped)
if (lines[i].includes('data-impeccable-variant')) continue;
startLine = i;
break;
}
}
if (startLine === -1) return null;
// Find the end of this element by counting open/close tags
const endLine = findClosingLine(lines, startLine);
return { startLine, endLine };
}
/**
* Starting from a line with an opening tag, find the line with the matching
* closing tag by counting tag nesting depth.
*/
function findClosingLine(lines, start) {
// Extract the tag name from the opening line
const openMatch = lines[start].match(/<(\w+)[\s>]/);
if (!openMatch) return start; // self-closing or text-only line
const tagName = openMatch[1];
let depth = 0;
for (let i = start; i < lines.length; i++) {
const line = lines[i];
// Count opening tags (not self-closing)
const opens = (line.match(new RegExp('<' + tagName + '[\\s>]', 'g')) || []).length;
const selfCloses = (line.match(new RegExp('<' + tagName + '[^>]*/>', 'g')) || []).length;
const closes = (line.match(new RegExp('</' + tagName + '\\s*>', 'g')) || []).length;
depth += opens - selfCloses - closes;
if (depth <= 0) return i;
}
// If we can't find the close, return a reasonable guess
return Math.min(start + 50, lines.length - 1);
}
+11 -7
View File
@@ -10,7 +10,7 @@ Launch interactive live variant mode: select elements in the browser, pick a des
1. Read `.impeccable.md` if it exists. Keep the design context in mind for variant generation.
2. Start the live variant server:
```bash
npx impeccable live &
node {{scripts_path}}/live-server.mjs &
```
3. Note the **port** and **token** printed to stdout.
@@ -53,7 +53,7 @@ Run a blocking poll loop. On each iteration, wait for a browser event and respon
```
LOOP:
Run: npx impeccable poll
Run: node {{scripts_path}}/live-poll.mjs
Read the JSON output. Dispatch based on the "type" field:
TYPE "generate":
@@ -85,7 +85,7 @@ The event contains: `{id, action, freeformPrompt, count, pageUrl, element}`.
Use the `wrap` helper to find the element and create the variant container:
```bash
npx impeccable wrap --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
node {{scripts_path}}/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
```
Pass the element's id (`event.element.id`), classes (`event.element.classes` joined with commas), and tag name. The command searches in priority order: ID match first, then class names, then tag+class combo. If `event.pageUrl` hints at the file (e.g., `/` is usually `index.html`), pass `--file PATH` to skip the search.
@@ -136,10 +136,14 @@ The first variant should NOT have `style="display: none"` (it should be visible
### Step 3: Signal completion
Include `--file` so the browser can fetch variants directly if the dev server lacks HMR:
```bash
npx impeccable poll --reply EVENT_ID done
node {{scripts_path}}/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH
```
The file path should be relative to the project root (e.g., `public/index.html`, `src/App.tsx`).
## Handle Accept
The event contains: `{id, variantId}`.
@@ -151,7 +155,7 @@ The user accepted a specific variant. For v1 (inspection mode):
4. Remove any scoped CSS blocks (between `impeccable-variants-css-start` and `impeccable-variants-css-end` markers).
5. Reply:
```bash
npx impeccable poll --reply SESSION_ID done
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
## Handle Discard
@@ -163,7 +167,7 @@ The event contains: `{id}`.
3. Remove any scoped CSS blocks for this session.
4. Reply:
```bash
npx impeccable poll --reply SESSION_ID done
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
## Cleanup (on exit)
@@ -174,7 +178,7 @@ When the loop ends:
2. **Remove any leftover variant wrappers** (search for `impeccable-variants-start` markers and clean up).
3. **Stop the server**:
```bash
npx impeccable live stop
node {{scripts_path}}/live-server.mjs stop
```
## Variant Generation Guidelines
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,123 @@
/**
* CLI client for the live variant mode poll/reply protocol.
*
* Usage:
* npx impeccable poll # Block until browser event, print JSON
* npx impeccable poll --timeout=60000 # Custom timeout (ms)
* npx impeccable poll --reply <id> done # Reply "done" to event <id>
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
const LIVE_PID_FILE = path.join(os.tmpdir(), 'impeccable-live.json');
function readServerInfo() {
try {
return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
} catch {
console.error('No running live server found. Start one with: npx impeccable live');
process.exit(1);
}
}
export async function pollCli() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: impeccable poll [options]
Wait for a browser event from the live variant server, or reply to one.
Modes:
poll Block until a browser event arrives, print JSON
poll --reply <id> done Reply "done" to event <id>
poll --reply <id> error "msg" Reply with an error message
Options:
--timeout=MS Poll timeout in milliseconds (default: 120000)
--help Show this help message`);
process.exit(0);
}
const info = readServerInfo();
const base = `http://localhost:${info.port}`;
// Reply mode: npx impeccable poll --reply <id> <status> [--file path] [message]
const replyIdx = args.indexOf('--reply');
if (replyIdx !== -1) {
const id = args[replyIdx + 1];
const status = args[replyIdx + 2] || 'done';
const fileIdx = args.indexOf('--file');
const filePath = fileIdx !== -1 && fileIdx + 1 < args.length ? args[fileIdx + 1] : undefined;
// Message is any remaining positional arg that isn't a flag
const message = args.find((a, i) => i > replyIdx + 2 && !a.startsWith('--') && i !== fileIdx + 1) || undefined;
if (!id) {
console.error('Usage: npx impeccable poll --reply <id> <status> [--file path] [message]');
process.exit(1);
}
try {
const res = await fetch(`${base}/poll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: info.token,
id,
type: status,
message,
file: filePath,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
console.error(`Reply failed (${res.status}):`, body.error || res.statusText);
process.exit(1);
}
// Success — silent exit (agent doesn't need output for replies)
} catch (err) {
if (err.cause?.code === 'ECONNREFUSED') {
console.error('Live server not running. Start one with: npx impeccable live');
} else {
console.error('Reply failed:', err.message);
}
process.exit(1);
}
return;
}
// Poll mode: block until browser event
const timeoutArg = args.find(a => a.startsWith('--timeout='));
const timeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 120000;
try {
const res = await fetch(`${base}/poll?token=${info.token}&timeout=${timeout}`);
if (res.status === 401) {
console.error('Authentication failed. The server token may have changed.');
console.error('Try restarting: npx impeccable live stop && npx impeccable live');
process.exit(1);
}
if (!res.ok) {
console.error(`Poll failed: ${res.status} ${res.statusText}`);
process.exit(1);
}
const event = await res.json();
// Print the event as JSON — the agent reads this from stdout
console.log(JSON.stringify(event));
} catch (err) {
if (err.cause?.code === 'ECONNREFUSED') {
console.error('Live server not running. Start one with: npx impeccable live');
} else {
console.error('Poll failed:', err.message);
}
process.exit(1);
}
}
@@ -0,0 +1,416 @@
#!/usr/bin/env node
/**
* Live variant mode server (self-contained, zero dependencies).
*
* Serves the browser script (/live.js), the detection overlay (/detect.js),
* uses Server-Sent Events (SSE) for server→browser push, and HTTP POST for
* browser→server events. Agent communicates via HTTP long-poll (/poll).
*
* Usage:
* node <scripts_path>/live-server.mjs # start
* node <scripts_path>/live-server.mjs stop # stop
* node <scripts_path>/live-server.mjs --help
*/
import http from 'node:http';
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import net from 'node:net';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LIVE_PID_FILE = path.join(os.tmpdir(), 'impeccable-live.json');
const DEFAULT_POLL_TIMEOUT = 120_000;
// ---------------------------------------------------------------------------
// Port detection
// ---------------------------------------------------------------------------
async function findOpenPort(start = 8400) {
return new Promise((resolve) => {
const srv = net.createServer();
srv.listen(start, '127.0.0.1', () => {
const port = srv.address().port;
srv.close(() => resolve(port));
});
srv.on('error', () => resolve(findOpenPort(start + 1)));
});
}
// ---------------------------------------------------------------------------
// Session state
// ---------------------------------------------------------------------------
const state = {
token: null,
port: null,
sseClients: new Set(), // SSE response objects (server→browser push)
pendingEvents: [], // browser events waiting for agent poll
pendingPolls: [], // agent poll callbacks waiting for browser events
exitTimer: null,
};
function enqueueEvent(event) {
if (state.pendingPolls.length > 0) {
state.pendingPolls.shift()(event);
} else {
state.pendingEvents.push(event);
}
}
/** Push a message to all connected SSE clients. */
function broadcast(msg) {
const data = 'data: ' + JSON.stringify(msg) + '\n\n';
for (const res of state.sseClients) {
try { res.write(data); } catch { /* client gone */ }
}
}
// ---------------------------------------------------------------------------
// Load scripts
// ---------------------------------------------------------------------------
function loadBrowserScripts() {
// Detection script: look relative to the skill scripts dir, then fall back
// to the npm package location (src/detect-antipatterns-browser.js)
const detectPaths = [
path.join(__dirname, '..', '..', '..', '..', 'src', 'detect-antipatterns-browser.js'),
path.join(process.cwd(), 'node_modules', 'impeccable', 'src', 'detect-antipatterns-browser.js'),
];
let detectScript = '';
for (const p of detectPaths) {
try { detectScript = fs.readFileSync(p, 'utf-8'); break; } catch { /* try next */ }
}
const livePath = path.join(__dirname, 'live-browser.js');
let liveScript = '';
try {
liveScript = fs.readFileSync(livePath, 'utf-8');
} catch {
process.stderr.write('Error: live-browser.js not found at ' + livePath + '\n');
process.exit(1);
}
return { detectScript, liveScript };
}
function hasProjectContext() {
try {
fs.accessSync(path.join(process.cwd(), '.impeccable.md'), fs.constants.R_OK);
return true;
} catch { return false; }
}
// ---------------------------------------------------------------------------
// Validation (inline — no external import needed for self-contained script)
// ---------------------------------------------------------------------------
const VISUAL_ACTIONS = [
'impeccable', 'bolder', 'quieter', 'distill', 'polish', 'typeset',
'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive',
];
function validateEvent(msg) {
if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message';
switch (msg.type) {
case 'generate':
if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id';
if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action';
if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8';
if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context';
return null;
case 'accept':
if (!msg.id) return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
return null;
case 'discard':
return msg.id ? null : 'discard: missing id';
case 'exit':
return null;
default:
return 'Unknown event type: ' + msg.type;
}
}
// ---------------------------------------------------------------------------
// HTTP request handler
// ---------------------------------------------------------------------------
function createRequestHandler({ detectScript, liveScriptWithToken }) {
return (req, res) => {
const url = new URL(req.url, `http://localhost:${state.port}`);
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
const p = url.pathname;
// --- Scripts ---
if (p === '/live.js') {
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.end(liveScriptWithToken);
return;
}
if (p === '/detect.js' || p === '/') {
if (!detectScript) { res.writeHead(404); res.end('Not available'); return; }
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.end(detectScript);
return;
}
// --- Health ---
if (p === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
status: 'ok', port: state.port, mode: 'variant',
hasProjectContext: hasProjectContext(),
connectedClients: state.sseClients.size,
}));
return;
}
// --- Source file (no-HMR fallback) ---
if (p === '/source') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
const filePath = url.searchParams.get('path');
if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
const absPath = path.resolve(process.cwd(), filePath);
if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; }
try {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(fs.readFileSync(absPath, 'utf-8'));
} catch { res.writeHead(404); res.end('File not found'); }
return;
}
// --- SSE: server→browser push (replaces WebSocket) ---
if (p === '/events' && req.method === 'GET') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
res.write('data: ' + JSON.stringify({
type: 'connected',
hasProjectContext: hasProjectContext(),
}) + '\n\n');
state.sseClients.add(res);
clearTimeout(state.exitTimer);
req.on('close', () => {
state.sseClients.delete(res);
if (state.sseClients.size === 0) {
clearTimeout(state.exitTimer);
state.exitTimer = setTimeout(() => {
if (state.sseClients.size === 0) enqueueEvent({ type: 'exit' });
}, 8000);
}
});
return;
}
// --- Browser→server events (replaces WebSocket messages) ---
if (p === '/events' && req.method === 'POST') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
let msg;
try { msg = JSON.parse(body); } catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON' }));
return;
}
if (msg.token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
const error = validateEvent(msg);
if (error) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error }));
return;
}
enqueueEvent(msg);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
return;
}
// --- Stop ---
if (p === '/stop') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('stopping');
shutdown();
return;
}
// --- Agent poll ---
if (p === '/poll' && req.method === 'GET') {
handlePollGet(req, res, url);
return;
}
if (p === '/poll' && req.method === 'POST') {
handlePollPost(req, res);
return;
}
res.writeHead(404); res.end('Not found');
};
}
// ---------------------------------------------------------------------------
// Agent poll endpoints (unchanged from WS version)
// ---------------------------------------------------------------------------
function handlePollGet(req, res, url) {
const token = url.searchParams.get('token');
if (token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10);
if (state.pendingEvents.length > 0) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(state.pendingEvents.shift()));
return;
}
const timer = setTimeout(() => {
const idx = state.pendingPolls.indexOf(resolve);
if (idx !== -1) state.pendingPolls.splice(idx, 1);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ type: 'timeout' }));
}, timeout);
function resolve(event) {
clearTimeout(timer);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(event));
}
state.pendingPolls.push(resolve);
req.on('close', () => {
clearTimeout(timer);
const idx = state.pendingPolls.indexOf(resolve);
if (idx !== -1) state.pendingPolls.splice(idx, 1);
});
}
function handlePollPost(req, res) {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
let msg;
try { msg = JSON.parse(body); } catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON' }));
return;
}
if (msg.token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
// Forward the reply to the browser via SSE
broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
let httpServer = null;
function shutdown() {
try { fs.unlinkSync(LIVE_PID_FILE); } catch {}
for (const res of state.sseClients) { try { res.end(); } catch {} }
state.sseClients.clear();
for (const resolve of state.pendingPolls) resolve({ type: 'exit' });
state.pendingPolls.length = 0;
if (httpServer) httpServer.close();
process.exit(0);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: node live-server.mjs [options]
Start the live variant mode server (zero dependencies).
Commands:
(default) Start the server
stop Stop a running server
Options:
--port=PORT Use a specific port (default: auto-detect starting at 8400)
--help Show this help
Endpoints:
/live.js Browser script (element picker + variant cycling)
/detect.js Detection overlay (backwards compatible)
/events SSE stream (server→browser) + POST (browser→server)
/poll Long-poll for agent CLI
/source Raw source file reader (no-HMR fallback)
/health Health check`);
process.exit(0);
}
if (args.includes('stop')) {
try {
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`);
if (res.ok) console.log(`Stopped live server on port ${info.port}.`);
} catch { console.log('No running live server found.'); }
process.exit(0);
}
// Check for existing session
try {
const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
try { process.kill(existing.pid, 0);
console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`);
console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop');
process.exit(1);
} catch { fs.unlinkSync(LIVE_PID_FILE); }
} catch {}
state.token = randomUUID();
const portArg = args.find(a => a.startsWith('--port='));
state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort();
const { detectScript, liveScript } = loadBrowserScripts();
const liveScriptWithToken =
`window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` +
`window.__IMPECCABLE_PORT__ = ${state.port};\n` +
liveScript;
httpServer = http.createServer(createRequestHandler({ detectScript, liveScriptWithToken }));
httpServer.listen(state.port, '127.0.0.1', () => {
fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token }));
const url = `http://localhost:${state.port}`;
console.log(`\nImpeccable live server running on ${url}`);
console.log(`Token: ${state.token}\n`);
console.log(`Inject: <script src="${url}/live.js"><\/script>`);
console.log(`Stop: node ${path.basename(fileURLToPath(import.meta.url))} stop`);
});
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
@@ -0,0 +1,298 @@
/**
* CLI helper: find an element in source and wrap it in a variant container.
*
* Usage:
* npx impeccable wrap --id SESSION_ID --count N --query "hero-combined-left" [--file path]
*
* Searches project files for the element matching the query (class name, ID, or
* text snippet), wraps it with the variant scaffolding, and prints the file path
* + line range where the agent should insert variant HTML.
*
* This replaces 3-4 agent tool calls (grep + read + edit) with a single CLI call.
*/
import fs from 'node:fs';
import path from 'node:path';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
export async function wrapCli() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: impeccable wrap [options]
Find an element in source and wrap it in a variant container.
Required:
--id ID Session ID for the variant wrapper
--count N Number of expected variants (1-8)
Element identification (at least one required):
--element-id ID HTML id attribute of the element
--classes A,B,C Comma-separated CSS class names
--tag TAG Tag name (div, section, etc.)
--query TEXT Fallback: raw text to search for
Optional:
--file PATH Source file to search in (skips auto-detection)
--help Show this help message
Output (JSON):
{ file, startLine, endLine, insertLine, commentSyntax }
The agent should insert variant HTML at insertLine.`);
process.exit(0);
}
const id = argVal(args, '--id');
const count = parseInt(argVal(args, '--count') || '3');
const elementId = argVal(args, '--element-id');
const classes = argVal(args, '--classes');
const tag = argVal(args, '--tag');
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!elementId && !classes && !query) {
console.error('Need at least one of: --element-id, --classes, --query');
process.exit(1);
}
// Build search queries in priority order (most specific first)
const queries = buildSearchQueries(elementId, classes, tag, query);
// Find the source file
let targetFile = filePath;
let matchedQuery = null;
if (!targetFile) {
for (const q of queries) {
targetFile = findFileWithQuery(q, process.cwd());
if (targetFile) { matchedQuery = q; break; }
}
if (!targetFile) {
console.error(JSON.stringify({ error: 'Could not find element in project files. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
} else {
matchedQuery = queries[0];
}
const content = fs.readFileSync(targetFile, 'utf-8');
const lines = content.split('\n');
// Find the element, trying each query in priority order
let match = null;
for (const q of queries) {
match = findElement(lines, q);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
const { startLine, endLine } = match;
const commentSyntax = detectCommentSyntax(targetFile);
const indent = lines[startLine].match(/^(\s*)/)[1];
// Extract the original element
const originalLines = lines.slice(startLine, endLine + 1);
const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
// Build the wrapper
const wrapperLines = [
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" style="display: contents">',
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
indent + ' <div data-impeccable-variant="original">',
originalIndented,
indent + ' </div>',
indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,
indent + '</div>',
indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
];
// Replace the original element with the wrapper
const newLines = [
...lines.slice(0, startLine),
...wrapperLines,
...lines.slice(endLine + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment)
const insertLine = startLine + 6; // 0-indexed in the new file
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
originalLineCount: originalLines.length,
}));
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function argVal(args, flag) {
const idx = args.indexOf(flag);
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
}
/**
* Build search query strings in priority order (most specific first).
* ID is most reliable, then specific class combos, then single classes, then raw query.
*/
function buildSearchQueries(elementId, classes, tag, query) {
const queries = [];
// 1. ID is the most specific
if (elementId) {
queries.push('id="' + elementId + '"');
}
// 2. Full class attribute match (for elements with distinctive multi-class combos)
if (classes) {
const classList = classes.split(',').map(c => c.trim()).filter(Boolean);
if (classList.length > 1) {
// Try the most distinctive class first (longest, most specific)
const sorted = [...classList].sort((a, b) => b.length - a.length);
queries.push('class="' + classList.join(' ') + '"'); // exact full match
queries.push(sorted[0]); // most distinctive single class
} else if (classList.length === 1) {
queries.push(classList[0]);
}
}
// 3. Tag + class combo (e.g., <section class="hero">)
if (tag && classes) {
const firstClass = classes.split(',')[0].trim();
queries.push('<' + tag + ' class="' + firstClass);
}
// 4. Raw fallback query
if (query) {
queries.push(query);
}
return queries;
}
function detectCommentSyntax(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.jsx' || ext === '.tsx') {
return { open: '{/*', close: '*/}' };
}
// HTML, Vue, Svelte, Astro all use HTML comments
return { open: '<!--', close: '-->' };
}
/**
* Search project files for the query string (class name, ID, etc.)
* Returns the first matching file path, or null.
*/
function findFileWithQuery(query, cwd) {
const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];
const seen = new Set();
for (const dir of searchDirs) {
const absDir = path.join(cwd, dir);
if (!fs.existsSync(absDir)) continue;
const result = searchDir(absDir, query, seen, 0);
if (result) return result;
}
return null;
}
function searchDir(dir, query, seen, depth) {
if (depth > 5) return null; // don't go too deep
const realDir = fs.realpathSync(dir);
if (seen.has(realDir)) return null;
seen.add(realDir);
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return null; }
// Check files first
for (const entry of entries) {
if (!entry.isFile()) continue;
const ext = path.extname(entry.name).toLowerCase();
if (!EXTENSIONS.includes(ext)) continue;
const filePath = path.join(dir, entry.name);
try {
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes(query)) return filePath;
} catch { /* skip unreadable files */ }
}
// Then recurse into directories
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'build') continue;
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
if (result) return result;
}
return null;
}
/**
* Find the element's start and end line in the file.
* The query is a class name, ID, or text snippet.
* We find the line containing the query, then find the matching closing tag.
*/
function findElement(lines, query) {
// Find the line containing the query
let startLine = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes(query)) {
// Make sure this looks like a tag opening, not a comment or string
const line = lines[i].trim();
if (line.startsWith('<!--') || line.startsWith('{/*') || line.startsWith('//')) continue;
// Skip lines inside data-impeccable-variant containers (already wrapped)
if (lines[i].includes('data-impeccable-variant')) continue;
startLine = i;
break;
}
}
if (startLine === -1) return null;
// Find the end of this element by counting open/close tags
const endLine = findClosingLine(lines, startLine);
return { startLine, endLine };
}
/**
* Starting from a line with an opening tag, find the line with the matching
* closing tag by counting tag nesting depth.
*/
function findClosingLine(lines, start) {
// Extract the tag name from the opening line
const openMatch = lines[start].match(/<(\w+)[\s>]/);
if (!openMatch) return start; // self-closing or text-only line
const tagName = openMatch[1];
let depth = 0;
for (let i = start; i < lines.length; i++) {
const line = lines[i];
// Count opening tags (not self-closing)
const opens = (line.match(new RegExp('<' + tagName + '[\\s>]', 'g')) || []).length;
const selfCloses = (line.match(new RegExp('<' + tagName + '[^>]*/>', 'g')) || []).length;
const closes = (line.match(new RegExp('</' + tagName + '\\s*>', 'g')) || []).length;
depth += opens - selfCloses - closes;
if (depth <= 0) return i;
}
// If we can't find the close, return a reasonable guess
return Math.min(start + 50, lines.length - 1);
}
+11 -7
View File
@@ -10,7 +10,7 @@ Launch interactive live variant mode: select elements in the browser, pick a des
1. Read `.impeccable.md` if it exists. Keep the design context in mind for variant generation.
2. Start the live variant server:
```bash
npx impeccable live &
node {{scripts_path}}/live-server.mjs &
```
3. Note the **port** and **token** printed to stdout.
@@ -53,7 +53,7 @@ Run a blocking poll loop. On each iteration, wait for a browser event and respon
```
LOOP:
Run: npx impeccable poll
Run: node {{scripts_path}}/live-poll.mjs
Read the JSON output. Dispatch based on the "type" field:
TYPE "generate":
@@ -85,7 +85,7 @@ The event contains: `{id, action, freeformPrompt, count, pageUrl, element}`.
Use the `wrap` helper to find the element and create the variant container:
```bash
npx impeccable wrap --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
node {{scripts_path}}/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
```
Pass the element's id (`event.element.id`), classes (`event.element.classes` joined with commas), and tag name. The command searches in priority order: ID match first, then class names, then tag+class combo. If `event.pageUrl` hints at the file (e.g., `/` is usually `index.html`), pass `--file PATH` to skip the search.
@@ -136,10 +136,14 @@ The first variant should NOT have `style="display: none"` (it should be visible
### Step 3: Signal completion
Include `--file` so the browser can fetch variants directly if the dev server lacks HMR:
```bash
npx impeccable poll --reply EVENT_ID done
node {{scripts_path}}/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH
```
The file path should be relative to the project root (e.g., `public/index.html`, `src/App.tsx`).
## Handle Accept
The event contains: `{id, variantId}`.
@@ -151,7 +155,7 @@ The user accepted a specific variant. For v1 (inspection mode):
4. Remove any scoped CSS blocks (between `impeccable-variants-css-start` and `impeccable-variants-css-end` markers).
5. Reply:
```bash
npx impeccable poll --reply SESSION_ID done
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
## Handle Discard
@@ -163,7 +167,7 @@ The event contains: `{id}`.
3. Remove any scoped CSS blocks for this session.
4. Reply:
```bash
npx impeccable poll --reply SESSION_ID done
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
## Cleanup (on exit)
@@ -174,7 +178,7 @@ When the loop ends:
2. **Remove any leftover variant wrappers** (search for `impeccable-variants-start` markers and clean up).
3. **Stop the server**:
```bash
npx impeccable live stop
node {{scripts_path}}/live-server.mjs stop
```
## Variant Generation Guidelines
File diff suppressed because it is too large Load Diff
+123
View File
@@ -0,0 +1,123 @@
/**
* CLI client for the live variant mode poll/reply protocol.
*
* Usage:
* npx impeccable poll # Block until browser event, print JSON
* npx impeccable poll --timeout=60000 # Custom timeout (ms)
* npx impeccable poll --reply <id> done # Reply "done" to event <id>
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
const LIVE_PID_FILE = path.join(os.tmpdir(), 'impeccable-live.json');
function readServerInfo() {
try {
return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
} catch {
console.error('No running live server found. Start one with: npx impeccable live');
process.exit(1);
}
}
export async function pollCli() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: impeccable poll [options]
Wait for a browser event from the live variant server, or reply to one.
Modes:
poll Block until a browser event arrives, print JSON
poll --reply <id> done Reply "done" to event <id>
poll --reply <id> error "msg" Reply with an error message
Options:
--timeout=MS Poll timeout in milliseconds (default: 120000)
--help Show this help message`);
process.exit(0);
}
const info = readServerInfo();
const base = `http://localhost:${info.port}`;
// Reply mode: npx impeccable poll --reply <id> <status> [--file path] [message]
const replyIdx = args.indexOf('--reply');
if (replyIdx !== -1) {
const id = args[replyIdx + 1];
const status = args[replyIdx + 2] || 'done';
const fileIdx = args.indexOf('--file');
const filePath = fileIdx !== -1 && fileIdx + 1 < args.length ? args[fileIdx + 1] : undefined;
// Message is any remaining positional arg that isn't a flag
const message = args.find((a, i) => i > replyIdx + 2 && !a.startsWith('--') && i !== fileIdx + 1) || undefined;
if (!id) {
console.error('Usage: npx impeccable poll --reply <id> <status> [--file path] [message]');
process.exit(1);
}
try {
const res = await fetch(`${base}/poll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: info.token,
id,
type: status,
message,
file: filePath,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
console.error(`Reply failed (${res.status}):`, body.error || res.statusText);
process.exit(1);
}
// Success — silent exit (agent doesn't need output for replies)
} catch (err) {
if (err.cause?.code === 'ECONNREFUSED') {
console.error('Live server not running. Start one with: npx impeccable live');
} else {
console.error('Reply failed:', err.message);
}
process.exit(1);
}
return;
}
// Poll mode: block until browser event
const timeoutArg = args.find(a => a.startsWith('--timeout='));
const timeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 120000;
try {
const res = await fetch(`${base}/poll?token=${info.token}&timeout=${timeout}`);
if (res.status === 401) {
console.error('Authentication failed. The server token may have changed.');
console.error('Try restarting: npx impeccable live stop && npx impeccable live');
process.exit(1);
}
if (!res.ok) {
console.error(`Poll failed: ${res.status} ${res.statusText}`);
process.exit(1);
}
const event = await res.json();
// Print the event as JSON — the agent reads this from stdout
console.log(JSON.stringify(event));
} catch (err) {
if (err.cause?.code === 'ECONNREFUSED') {
console.error('Live server not running. Start one with: npx impeccable live');
} else {
console.error('Poll failed:', err.message);
}
process.exit(1);
}
}
@@ -0,0 +1,416 @@
#!/usr/bin/env node
/**
* Live variant mode server (self-contained, zero dependencies).
*
* Serves the browser script (/live.js), the detection overlay (/detect.js),
* uses Server-Sent Events (SSE) for serverbrowser push, and HTTP POST for
* browserserver events. Agent communicates via HTTP long-poll (/poll).
*
* Usage:
* node <scripts_path>/live-server.mjs # start
* node <scripts_path>/live-server.mjs stop # stop
* node <scripts_path>/live-server.mjs --help
*/
import http from 'node:http';
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import net from 'node:net';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LIVE_PID_FILE = path.join(os.tmpdir(), 'impeccable-live.json');
const DEFAULT_POLL_TIMEOUT = 120_000;
// ---------------------------------------------------------------------------
// Port detection
// ---------------------------------------------------------------------------
async function findOpenPort(start = 8400) {
return new Promise((resolve) => {
const srv = net.createServer();
srv.listen(start, '127.0.0.1', () => {
const port = srv.address().port;
srv.close(() => resolve(port));
});
srv.on('error', () => resolve(findOpenPort(start + 1)));
});
}
// ---------------------------------------------------------------------------
// Session state
// ---------------------------------------------------------------------------
const state = {
token: null,
port: null,
sseClients: new Set(), // SSE response objects (server→browser push)
pendingEvents: [], // browser events waiting for agent poll
pendingPolls: [], // agent poll callbacks waiting for browser events
exitTimer: null,
};
function enqueueEvent(event) {
if (state.pendingPolls.length > 0) {
state.pendingPolls.shift()(event);
} else {
state.pendingEvents.push(event);
}
}
/** Push a message to all connected SSE clients. */
function broadcast(msg) {
const data = 'data: ' + JSON.stringify(msg) + '\n\n';
for (const res of state.sseClients) {
try { res.write(data); } catch { /* client gone */ }
}
}
// ---------------------------------------------------------------------------
// Load scripts
// ---------------------------------------------------------------------------
function loadBrowserScripts() {
// Detection script: look relative to the skill scripts dir, then fall back
// to the npm package location (src/detect-antipatterns-browser.js)
const detectPaths = [
path.join(__dirname, '..', '..', '..', '..', 'src', 'detect-antipatterns-browser.js'),
path.join(process.cwd(), 'node_modules', 'impeccable', 'src', 'detect-antipatterns-browser.js'),
];
let detectScript = '';
for (const p of detectPaths) {
try { detectScript = fs.readFileSync(p, 'utf-8'); break; } catch { /* try next */ }
}
const livePath = path.join(__dirname, 'live-browser.js');
let liveScript = '';
try {
liveScript = fs.readFileSync(livePath, 'utf-8');
} catch {
process.stderr.write('Error: live-browser.js not found at ' + livePath + '\n');
process.exit(1);
}
return { detectScript, liveScript };
}
function hasProjectContext() {
try {
fs.accessSync(path.join(process.cwd(), '.impeccable.md'), fs.constants.R_OK);
return true;
} catch { return false; }
}
// ---------------------------------------------------------------------------
// Validation (inline — no external import needed for self-contained script)
// ---------------------------------------------------------------------------
const VISUAL_ACTIONS = [
'impeccable', 'bolder', 'quieter', 'distill', 'polish', 'typeset',
'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive',
];
function validateEvent(msg) {
if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message';
switch (msg.type) {
case 'generate':
if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id';
if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action';
if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8';
if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context';
return null;
case 'accept':
if (!msg.id) return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
return null;
case 'discard':
return msg.id ? null : 'discard: missing id';
case 'exit':
return null;
default:
return 'Unknown event type: ' + msg.type;
}
}
// ---------------------------------------------------------------------------
// HTTP request handler
// ---------------------------------------------------------------------------
function createRequestHandler({ detectScript, liveScriptWithToken }) {
return (req, res) => {
const url = new URL(req.url, `http://localhost:${state.port}`);
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
const p = url.pathname;
// --- Scripts ---
if (p === '/live.js') {
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.end(liveScriptWithToken);
return;
}
if (p === '/detect.js' || p === '/') {
if (!detectScript) { res.writeHead(404); res.end('Not available'); return; }
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.end(detectScript);
return;
}
// --- Health ---
if (p === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
status: 'ok', port: state.port, mode: 'variant',
hasProjectContext: hasProjectContext(),
connectedClients: state.sseClients.size,
}));
return;
}
// --- Source file (no-HMR fallback) ---
if (p === '/source') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
const filePath = url.searchParams.get('path');
if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
const absPath = path.resolve(process.cwd(), filePath);
if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; }
try {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(fs.readFileSync(absPath, 'utf-8'));
} catch { res.writeHead(404); res.end('File not found'); }
return;
}
// --- SSE: server→browser push (replaces WebSocket) ---
if (p === '/events' && req.method === 'GET') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
res.write('data: ' + JSON.stringify({
type: 'connected',
hasProjectContext: hasProjectContext(),
}) + '\n\n');
state.sseClients.add(res);
clearTimeout(state.exitTimer);
req.on('close', () => {
state.sseClients.delete(res);
if (state.sseClients.size === 0) {
clearTimeout(state.exitTimer);
state.exitTimer = setTimeout(() => {
if (state.sseClients.size === 0) enqueueEvent({ type: 'exit' });
}, 8000);
}
});
return;
}
// --- Browser→server events (replaces WebSocket messages) ---
if (p === '/events' && req.method === 'POST') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
let msg;
try { msg = JSON.parse(body); } catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON' }));
return;
}
if (msg.token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
const error = validateEvent(msg);
if (error) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error }));
return;
}
enqueueEvent(msg);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
return;
}
// --- Stop ---
if (p === '/stop') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('stopping');
shutdown();
return;
}
// --- Agent poll ---
if (p === '/poll' && req.method === 'GET') {
handlePollGet(req, res, url);
return;
}
if (p === '/poll' && req.method === 'POST') {
handlePollPost(req, res);
return;
}
res.writeHead(404); res.end('Not found');
};
}
// ---------------------------------------------------------------------------
// Agent poll endpoints (unchanged from WS version)
// ---------------------------------------------------------------------------
function handlePollGet(req, res, url) {
const token = url.searchParams.get('token');
if (token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10);
if (state.pendingEvents.length > 0) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(state.pendingEvents.shift()));
return;
}
const timer = setTimeout(() => {
const idx = state.pendingPolls.indexOf(resolve);
if (idx !== -1) state.pendingPolls.splice(idx, 1);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ type: 'timeout' }));
}, timeout);
function resolve(event) {
clearTimeout(timer);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(event));
}
state.pendingPolls.push(resolve);
req.on('close', () => {
clearTimeout(timer);
const idx = state.pendingPolls.indexOf(resolve);
if (idx !== -1) state.pendingPolls.splice(idx, 1);
});
}
function handlePollPost(req, res) {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
let msg;
try { msg = JSON.parse(body); } catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON' }));
return;
}
if (msg.token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
// Forward the reply to the browser via SSE
broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
let httpServer = null;
function shutdown() {
try { fs.unlinkSync(LIVE_PID_FILE); } catch {}
for (const res of state.sseClients) { try { res.end(); } catch {} }
state.sseClients.clear();
for (const resolve of state.pendingPolls) resolve({ type: 'exit' });
state.pendingPolls.length = 0;
if (httpServer) httpServer.close();
process.exit(0);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: node live-server.mjs [options]
Start the live variant mode server (zero dependencies).
Commands:
(default) Start the server
stop Stop a running server
Options:
--port=PORT Use a specific port (default: auto-detect starting at 8400)
--help Show this help
Endpoints:
/live.js Browser script (element picker + variant cycling)
/detect.js Detection overlay (backwards compatible)
/events SSE stream (serverbrowser) + POST (browserserver)
/poll Long-poll for agent CLI
/source Raw source file reader (no-HMR fallback)
/health Health check`);
process.exit(0);
}
if (args.includes('stop')) {
try {
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`);
if (res.ok) console.log(`Stopped live server on port ${info.port}.`);
} catch { console.log('No running live server found.'); }
process.exit(0);
}
// Check for existing session
try {
const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
try { process.kill(existing.pid, 0);
console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`);
console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop');
process.exit(1);
} catch { fs.unlinkSync(LIVE_PID_FILE); }
} catch {}
state.token = randomUUID();
const portArg = args.find(a => a.startsWith('--port='));
state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort();
const { detectScript, liveScript } = loadBrowserScripts();
const liveScriptWithToken =
`window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` +
`window.__IMPECCABLE_PORT__ = ${state.port};\n` +
liveScript;
httpServer = http.createServer(createRequestHandler({ detectScript, liveScriptWithToken }));
httpServer.listen(state.port, '127.0.0.1', () => {
fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token }));
const url = `http://localhost:${state.port}`;
console.log(`\nImpeccable live server running on ${url}`);
console.log(`Token: ${state.token}\n`);
console.log(`Inject: <script src="${url}/live.js"><\/script>`);
console.log(`Stop: node ${path.basename(fileURLToPath(import.meta.url))} stop`);
});
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
+298
View File
@@ -0,0 +1,298 @@
/**
* CLI helper: find an element in source and wrap it in a variant container.
*
* Usage:
* npx impeccable wrap --id SESSION_ID --count N --query "hero-combined-left" [--file path]
*
* Searches project files for the element matching the query (class name, ID, or
* text snippet), wraps it with the variant scaffolding, and prints the file path
* + line range where the agent should insert variant HTML.
*
* This replaces 3-4 agent tool calls (grep + read + edit) with a single CLI call.
*/
import fs from 'node:fs';
import path from 'node:path';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
export async function wrapCli() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: impeccable wrap [options]
Find an element in source and wrap it in a variant container.
Required:
--id ID Session ID for the variant wrapper
--count N Number of expected variants (1-8)
Element identification (at least one required):
--element-id ID HTML id attribute of the element
--classes A,B,C Comma-separated CSS class names
--tag TAG Tag name (div, section, etc.)
--query TEXT Fallback: raw text to search for
Optional:
--file PATH Source file to search in (skips auto-detection)
--help Show this help message
Output (JSON):
{ file, startLine, endLine, insertLine, commentSyntax }
The agent should insert variant HTML at insertLine.`);
process.exit(0);
}
const id = argVal(args, '--id');
const count = parseInt(argVal(args, '--count') || '3');
const elementId = argVal(args, '--element-id');
const classes = argVal(args, '--classes');
const tag = argVal(args, '--tag');
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!elementId && !classes && !query) {
console.error('Need at least one of: --element-id, --classes, --query');
process.exit(1);
}
// Build search queries in priority order (most specific first)
const queries = buildSearchQueries(elementId, classes, tag, query);
// Find the source file
let targetFile = filePath;
let matchedQuery = null;
if (!targetFile) {
for (const q of queries) {
targetFile = findFileWithQuery(q, process.cwd());
if (targetFile) { matchedQuery = q; break; }
}
if (!targetFile) {
console.error(JSON.stringify({ error: 'Could not find element in project files. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
} else {
matchedQuery = queries[0];
}
const content = fs.readFileSync(targetFile, 'utf-8');
const lines = content.split('\n');
// Find the element, trying each query in priority order
let match = null;
for (const q of queries) {
match = findElement(lines, q);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
const { startLine, endLine } = match;
const commentSyntax = detectCommentSyntax(targetFile);
const indent = lines[startLine].match(/^(\s*)/)[1];
// Extract the original element
const originalLines = lines.slice(startLine, endLine + 1);
const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
// Build the wrapper
const wrapperLines = [
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" style="display: contents">',
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
indent + ' <div data-impeccable-variant="original">',
originalIndented,
indent + ' </div>',
indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,
indent + '</div>',
indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
];
// Replace the original element with the wrapper
const newLines = [
...lines.slice(0, startLine),
...wrapperLines,
...lines.slice(endLine + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment)
const insertLine = startLine + 6; // 0-indexed in the new file
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
originalLineCount: originalLines.length,
}));
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function argVal(args, flag) {
const idx = args.indexOf(flag);
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
}
/**
* Build search query strings in priority order (most specific first).
* ID is most reliable, then specific class combos, then single classes, then raw query.
*/
function buildSearchQueries(elementId, classes, tag, query) {
const queries = [];
// 1. ID is the most specific
if (elementId) {
queries.push('id="' + elementId + '"');
}
// 2. Full class attribute match (for elements with distinctive multi-class combos)
if (classes) {
const classList = classes.split(',').map(c => c.trim()).filter(Boolean);
if (classList.length > 1) {
// Try the most distinctive class first (longest, most specific)
const sorted = [...classList].sort((a, b) => b.length - a.length);
queries.push('class="' + classList.join(' ') + '"'); // exact full match
queries.push(sorted[0]); // most distinctive single class
} else if (classList.length === 1) {
queries.push(classList[0]);
}
}
// 3. Tag + class combo (e.g., <section class="hero">)
if (tag && classes) {
const firstClass = classes.split(',')[0].trim();
queries.push('<' + tag + ' class="' + firstClass);
}
// 4. Raw fallback query
if (query) {
queries.push(query);
}
return queries;
}
function detectCommentSyntax(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.jsx' || ext === '.tsx') {
return { open: '{/*', close: '*/}' };
}
// HTML, Vue, Svelte, Astro all use HTML comments
return { open: '<!--', close: '-->' };
}
/**
* Search project files for the query string (class name, ID, etc.)
* Returns the first matching file path, or null.
*/
function findFileWithQuery(query, cwd) {
const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];
const seen = new Set();
for (const dir of searchDirs) {
const absDir = path.join(cwd, dir);
if (!fs.existsSync(absDir)) continue;
const result = searchDir(absDir, query, seen, 0);
if (result) return result;
}
return null;
}
function searchDir(dir, query, seen, depth) {
if (depth > 5) return null; // don't go too deep
const realDir = fs.realpathSync(dir);
if (seen.has(realDir)) return null;
seen.add(realDir);
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return null; }
// Check files first
for (const entry of entries) {
if (!entry.isFile()) continue;
const ext = path.extname(entry.name).toLowerCase();
if (!EXTENSIONS.includes(ext)) continue;
const filePath = path.join(dir, entry.name);
try {
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes(query)) return filePath;
} catch { /* skip unreadable files */ }
}
// Then recurse into directories
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'build') continue;
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
if (result) return result;
}
return null;
}
/**
* Find the element's start and end line in the file.
* The query is a class name, ID, or text snippet.
* We find the line containing the query, then find the matching closing tag.
*/
function findElement(lines, query) {
// Find the line containing the query
let startLine = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes(query)) {
// Make sure this looks like a tag opening, not a comment or string
const line = lines[i].trim();
if (line.startsWith('<!--') || line.startsWith('{/*') || line.startsWith('//')) continue;
// Skip lines inside data-impeccable-variant containers (already wrapped)
if (lines[i].includes('data-impeccable-variant')) continue;
startLine = i;
break;
}
}
if (startLine === -1) return null;
// Find the end of this element by counting open/close tags
const endLine = findClosingLine(lines, startLine);
return { startLine, endLine };
}
/**
* Starting from a line with an opening tag, find the line with the matching
* closing tag by counting tag nesting depth.
*/
function findClosingLine(lines, start) {
// Extract the tag name from the opening line
const openMatch = lines[start].match(/<(\w+)[\s>]/);
if (!openMatch) return start; // self-closing or text-only line
const tagName = openMatch[1];
let depth = 0;
for (let i = start; i < lines.length; i++) {
const line = lines[i];
// Count opening tags (not self-closing)
const opens = (line.match(new RegExp('<' + tagName + '[\\s>]', 'g')) || []).length;
const selfCloses = (line.match(new RegExp('<' + tagName + '[^>]*/>', 'g')) || []).length;
const closes = (line.match(new RegExp('</' + tagName + '\\s*>', 'g')) || []).length;
depth += opens - selfCloses - closes;
if (depth <= 0) return i;
}
// If we can't find the close, return a reasonable guess
return Math.min(start + 50, lines.length - 1);
}
+11 -7
View File
@@ -10,7 +10,7 @@ Launch interactive live variant mode: select elements in the browser, pick a des
1. Read `.impeccable.md` if it exists. Keep the design context in mind for variant generation.
2. Start the live variant server:
```bash
npx impeccable live &
node {{scripts_path}}/live-server.mjs &
```
3. Note the **port** and **token** printed to stdout.
@@ -53,7 +53,7 @@ Run a blocking poll loop. On each iteration, wait for a browser event and respon
```
LOOP:
Run: npx impeccable poll
Run: node {{scripts_path}}/live-poll.mjs
Read the JSON output. Dispatch based on the "type" field:
TYPE "generate":
@@ -85,7 +85,7 @@ The event contains: `{id, action, freeformPrompt, count, pageUrl, element}`.
Use the `wrap` helper to find the element and create the variant container:
```bash
npx impeccable wrap --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
node {{scripts_path}}/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
```
Pass the element's id (`event.element.id`), classes (`event.element.classes` joined with commas), and tag name. The command searches in priority order: ID match first, then class names, then tag+class combo. If `event.pageUrl` hints at the file (e.g., `/` is usually `index.html`), pass `--file PATH` to skip the search.
@@ -136,10 +136,14 @@ The first variant should NOT have `style="display: none"` (it should be visible
### Step 3: Signal completion
Include `--file` so the browser can fetch variants directly if the dev server lacks HMR:
```bash
npx impeccable poll --reply EVENT_ID done
node {{scripts_path}}/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH
```
The file path should be relative to the project root (e.g., `public/index.html`, `src/App.tsx`).
## Handle Accept
The event contains: `{id, variantId}`.
@@ -151,7 +155,7 @@ The user accepted a specific variant. For v1 (inspection mode):
4. Remove any scoped CSS blocks (between `impeccable-variants-css-start` and `impeccable-variants-css-end` markers).
5. Reply:
```bash
npx impeccable poll --reply SESSION_ID done
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
## Handle Discard
@@ -163,7 +167,7 @@ The event contains: `{id}`.
3. Remove any scoped CSS blocks for this session.
4. Reply:
```bash
npx impeccable poll --reply SESSION_ID done
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
## Cleanup (on exit)
@@ -174,7 +178,7 @@ When the loop ends:
2. **Remove any leftover variant wrappers** (search for `impeccable-variants-start` markers and clean up).
3. **Stop the server**:
```bash
npx impeccable live stop
node {{scripts_path}}/live-server.mjs stop
```
## Variant Generation Guidelines
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,123 @@
/**
* CLI client for the live variant mode poll/reply protocol.
*
* Usage:
* npx impeccable poll # Block until browser event, print JSON
* npx impeccable poll --timeout=60000 # Custom timeout (ms)
* npx impeccable poll --reply <id> done # Reply "done" to event <id>
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
const LIVE_PID_FILE = path.join(os.tmpdir(), 'impeccable-live.json');
function readServerInfo() {
try {
return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
} catch {
console.error('No running live server found. Start one with: npx impeccable live');
process.exit(1);
}
}
export async function pollCli() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: impeccable poll [options]
Wait for a browser event from the live variant server, or reply to one.
Modes:
poll Block until a browser event arrives, print JSON
poll --reply <id> done Reply "done" to event <id>
poll --reply <id> error "msg" Reply with an error message
Options:
--timeout=MS Poll timeout in milliseconds (default: 120000)
--help Show this help message`);
process.exit(0);
}
const info = readServerInfo();
const base = `http://localhost:${info.port}`;
// Reply mode: npx impeccable poll --reply <id> <status> [--file path] [message]
const replyIdx = args.indexOf('--reply');
if (replyIdx !== -1) {
const id = args[replyIdx + 1];
const status = args[replyIdx + 2] || 'done';
const fileIdx = args.indexOf('--file');
const filePath = fileIdx !== -1 && fileIdx + 1 < args.length ? args[fileIdx + 1] : undefined;
// Message is any remaining positional arg that isn't a flag
const message = args.find((a, i) => i > replyIdx + 2 && !a.startsWith('--') && i !== fileIdx + 1) || undefined;
if (!id) {
console.error('Usage: npx impeccable poll --reply <id> <status> [--file path] [message]');
process.exit(1);
}
try {
const res = await fetch(`${base}/poll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: info.token,
id,
type: status,
message,
file: filePath,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
console.error(`Reply failed (${res.status}):`, body.error || res.statusText);
process.exit(1);
}
// Success — silent exit (agent doesn't need output for replies)
} catch (err) {
if (err.cause?.code === 'ECONNREFUSED') {
console.error('Live server not running. Start one with: npx impeccable live');
} else {
console.error('Reply failed:', err.message);
}
process.exit(1);
}
return;
}
// Poll mode: block until browser event
const timeoutArg = args.find(a => a.startsWith('--timeout='));
const timeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 120000;
try {
const res = await fetch(`${base}/poll?token=${info.token}&timeout=${timeout}`);
if (res.status === 401) {
console.error('Authentication failed. The server token may have changed.');
console.error('Try restarting: npx impeccable live stop && npx impeccable live');
process.exit(1);
}
if (!res.ok) {
console.error(`Poll failed: ${res.status} ${res.statusText}`);
process.exit(1);
}
const event = await res.json();
// Print the event as JSON — the agent reads this from stdout
console.log(JSON.stringify(event));
} catch (err) {
if (err.cause?.code === 'ECONNREFUSED') {
console.error('Live server not running. Start one with: npx impeccable live');
} else {
console.error('Poll failed:', err.message);
}
process.exit(1);
}
}
@@ -0,0 +1,416 @@
#!/usr/bin/env node
/**
* Live variant mode server (self-contained, zero dependencies).
*
* Serves the browser script (/live.js), the detection overlay (/detect.js),
* uses Server-Sent Events (SSE) for serverbrowser push, and HTTP POST for
* browserserver events. Agent communicates via HTTP long-poll (/poll).
*
* Usage:
* node <scripts_path>/live-server.mjs # start
* node <scripts_path>/live-server.mjs stop # stop
* node <scripts_path>/live-server.mjs --help
*/
import http from 'node:http';
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import net from 'node:net';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LIVE_PID_FILE = path.join(os.tmpdir(), 'impeccable-live.json');
const DEFAULT_POLL_TIMEOUT = 120_000;
// ---------------------------------------------------------------------------
// Port detection
// ---------------------------------------------------------------------------
async function findOpenPort(start = 8400) {
return new Promise((resolve) => {
const srv = net.createServer();
srv.listen(start, '127.0.0.1', () => {
const port = srv.address().port;
srv.close(() => resolve(port));
});
srv.on('error', () => resolve(findOpenPort(start + 1)));
});
}
// ---------------------------------------------------------------------------
// Session state
// ---------------------------------------------------------------------------
const state = {
token: null,
port: null,
sseClients: new Set(), // SSE response objects (server→browser push)
pendingEvents: [], // browser events waiting for agent poll
pendingPolls: [], // agent poll callbacks waiting for browser events
exitTimer: null,
};
function enqueueEvent(event) {
if (state.pendingPolls.length > 0) {
state.pendingPolls.shift()(event);
} else {
state.pendingEvents.push(event);
}
}
/** Push a message to all connected SSE clients. */
function broadcast(msg) {
const data = 'data: ' + JSON.stringify(msg) + '\n\n';
for (const res of state.sseClients) {
try { res.write(data); } catch { /* client gone */ }
}
}
// ---------------------------------------------------------------------------
// Load scripts
// ---------------------------------------------------------------------------
function loadBrowserScripts() {
// Detection script: look relative to the skill scripts dir, then fall back
// to the npm package location (src/detect-antipatterns-browser.js)
const detectPaths = [
path.join(__dirname, '..', '..', '..', '..', 'src', 'detect-antipatterns-browser.js'),
path.join(process.cwd(), 'node_modules', 'impeccable', 'src', 'detect-antipatterns-browser.js'),
];
let detectScript = '';
for (const p of detectPaths) {
try { detectScript = fs.readFileSync(p, 'utf-8'); break; } catch { /* try next */ }
}
const livePath = path.join(__dirname, 'live-browser.js');
let liveScript = '';
try {
liveScript = fs.readFileSync(livePath, 'utf-8');
} catch {
process.stderr.write('Error: live-browser.js not found at ' + livePath + '\n');
process.exit(1);
}
return { detectScript, liveScript };
}
function hasProjectContext() {
try {
fs.accessSync(path.join(process.cwd(), '.impeccable.md'), fs.constants.R_OK);
return true;
} catch { return false; }
}
// ---------------------------------------------------------------------------
// Validation (inline — no external import needed for self-contained script)
// ---------------------------------------------------------------------------
const VISUAL_ACTIONS = [
'impeccable', 'bolder', 'quieter', 'distill', 'polish', 'typeset',
'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive',
];
function validateEvent(msg) {
if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message';
switch (msg.type) {
case 'generate':
if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id';
if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action';
if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8';
if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context';
return null;
case 'accept':
if (!msg.id) return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
return null;
case 'discard':
return msg.id ? null : 'discard: missing id';
case 'exit':
return null;
default:
return 'Unknown event type: ' + msg.type;
}
}
// ---------------------------------------------------------------------------
// HTTP request handler
// ---------------------------------------------------------------------------
function createRequestHandler({ detectScript, liveScriptWithToken }) {
return (req, res) => {
const url = new URL(req.url, `http://localhost:${state.port}`);
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
const p = url.pathname;
// --- Scripts ---
if (p === '/live.js') {
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.end(liveScriptWithToken);
return;
}
if (p === '/detect.js' || p === '/') {
if (!detectScript) { res.writeHead(404); res.end('Not available'); return; }
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.end(detectScript);
return;
}
// --- Health ---
if (p === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
status: 'ok', port: state.port, mode: 'variant',
hasProjectContext: hasProjectContext(),
connectedClients: state.sseClients.size,
}));
return;
}
// --- Source file (no-HMR fallback) ---
if (p === '/source') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
const filePath = url.searchParams.get('path');
if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
const absPath = path.resolve(process.cwd(), filePath);
if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; }
try {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(fs.readFileSync(absPath, 'utf-8'));
} catch { res.writeHead(404); res.end('File not found'); }
return;
}
// --- SSE: server→browser push (replaces WebSocket) ---
if (p === '/events' && req.method === 'GET') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
res.write('data: ' + JSON.stringify({
type: 'connected',
hasProjectContext: hasProjectContext(),
}) + '\n\n');
state.sseClients.add(res);
clearTimeout(state.exitTimer);
req.on('close', () => {
state.sseClients.delete(res);
if (state.sseClients.size === 0) {
clearTimeout(state.exitTimer);
state.exitTimer = setTimeout(() => {
if (state.sseClients.size === 0) enqueueEvent({ type: 'exit' });
}, 8000);
}
});
return;
}
// --- Browser→server events (replaces WebSocket messages) ---
if (p === '/events' && req.method === 'POST') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
let msg;
try { msg = JSON.parse(body); } catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON' }));
return;
}
if (msg.token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
const error = validateEvent(msg);
if (error) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error }));
return;
}
enqueueEvent(msg);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
return;
}
// --- Stop ---
if (p === '/stop') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('stopping');
shutdown();
return;
}
// --- Agent poll ---
if (p === '/poll' && req.method === 'GET') {
handlePollGet(req, res, url);
return;
}
if (p === '/poll' && req.method === 'POST') {
handlePollPost(req, res);
return;
}
res.writeHead(404); res.end('Not found');
};
}
// ---------------------------------------------------------------------------
// Agent poll endpoints (unchanged from WS version)
// ---------------------------------------------------------------------------
function handlePollGet(req, res, url) {
const token = url.searchParams.get('token');
if (token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10);
if (state.pendingEvents.length > 0) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(state.pendingEvents.shift()));
return;
}
const timer = setTimeout(() => {
const idx = state.pendingPolls.indexOf(resolve);
if (idx !== -1) state.pendingPolls.splice(idx, 1);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ type: 'timeout' }));
}, timeout);
function resolve(event) {
clearTimeout(timer);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(event));
}
state.pendingPolls.push(resolve);
req.on('close', () => {
clearTimeout(timer);
const idx = state.pendingPolls.indexOf(resolve);
if (idx !== -1) state.pendingPolls.splice(idx, 1);
});
}
function handlePollPost(req, res) {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
let msg;
try { msg = JSON.parse(body); } catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON' }));
return;
}
if (msg.token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
// Forward the reply to the browser via SSE
broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
let httpServer = null;
function shutdown() {
try { fs.unlinkSync(LIVE_PID_FILE); } catch {}
for (const res of state.sseClients) { try { res.end(); } catch {} }
state.sseClients.clear();
for (const resolve of state.pendingPolls) resolve({ type: 'exit' });
state.pendingPolls.length = 0;
if (httpServer) httpServer.close();
process.exit(0);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: node live-server.mjs [options]
Start the live variant mode server (zero dependencies).
Commands:
(default) Start the server
stop Stop a running server
Options:
--port=PORT Use a specific port (default: auto-detect starting at 8400)
--help Show this help
Endpoints:
/live.js Browser script (element picker + variant cycling)
/detect.js Detection overlay (backwards compatible)
/events SSE stream (serverbrowser) + POST (browserserver)
/poll Long-poll for agent CLI
/source Raw source file reader (no-HMR fallback)
/health Health check`);
process.exit(0);
}
if (args.includes('stop')) {
try {
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`);
if (res.ok) console.log(`Stopped live server on port ${info.port}.`);
} catch { console.log('No running live server found.'); }
process.exit(0);
}
// Check for existing session
try {
const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
try { process.kill(existing.pid, 0);
console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`);
console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop');
process.exit(1);
} catch { fs.unlinkSync(LIVE_PID_FILE); }
} catch {}
state.token = randomUUID();
const portArg = args.find(a => a.startsWith('--port='));
state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort();
const { detectScript, liveScript } = loadBrowserScripts();
const liveScriptWithToken =
`window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` +
`window.__IMPECCABLE_PORT__ = ${state.port};\n` +
liveScript;
httpServer = http.createServer(createRequestHandler({ detectScript, liveScriptWithToken }));
httpServer.listen(state.port, '127.0.0.1', () => {
fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token }));
const url = `http://localhost:${state.port}`;
console.log(`\nImpeccable live server running on ${url}`);
console.log(`Token: ${state.token}\n`);
console.log(`Inject: <script src="${url}/live.js"><\/script>`);
console.log(`Stop: node ${path.basename(fileURLToPath(import.meta.url))} stop`);
});
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
@@ -0,0 +1,298 @@
/**
* CLI helper: find an element in source and wrap it in a variant container.
*
* Usage:
* npx impeccable wrap --id SESSION_ID --count N --query "hero-combined-left" [--file path]
*
* Searches project files for the element matching the query (class name, ID, or
* text snippet), wraps it with the variant scaffolding, and prints the file path
* + line range where the agent should insert variant HTML.
*
* This replaces 3-4 agent tool calls (grep + read + edit) with a single CLI call.
*/
import fs from 'node:fs';
import path from 'node:path';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
export async function wrapCli() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: impeccable wrap [options]
Find an element in source and wrap it in a variant container.
Required:
--id ID Session ID for the variant wrapper
--count N Number of expected variants (1-8)
Element identification (at least one required):
--element-id ID HTML id attribute of the element
--classes A,B,C Comma-separated CSS class names
--tag TAG Tag name (div, section, etc.)
--query TEXT Fallback: raw text to search for
Optional:
--file PATH Source file to search in (skips auto-detection)
--help Show this help message
Output (JSON):
{ file, startLine, endLine, insertLine, commentSyntax }
The agent should insert variant HTML at insertLine.`);
process.exit(0);
}
const id = argVal(args, '--id');
const count = parseInt(argVal(args, '--count') || '3');
const elementId = argVal(args, '--element-id');
const classes = argVal(args, '--classes');
const tag = argVal(args, '--tag');
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!elementId && !classes && !query) {
console.error('Need at least one of: --element-id, --classes, --query');
process.exit(1);
}
// Build search queries in priority order (most specific first)
const queries = buildSearchQueries(elementId, classes, tag, query);
// Find the source file
let targetFile = filePath;
let matchedQuery = null;
if (!targetFile) {
for (const q of queries) {
targetFile = findFileWithQuery(q, process.cwd());
if (targetFile) { matchedQuery = q; break; }
}
if (!targetFile) {
console.error(JSON.stringify({ error: 'Could not find element in project files. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
} else {
matchedQuery = queries[0];
}
const content = fs.readFileSync(targetFile, 'utf-8');
const lines = content.split('\n');
// Find the element, trying each query in priority order
let match = null;
for (const q of queries) {
match = findElement(lines, q);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
const { startLine, endLine } = match;
const commentSyntax = detectCommentSyntax(targetFile);
const indent = lines[startLine].match(/^(\s*)/)[1];
// Extract the original element
const originalLines = lines.slice(startLine, endLine + 1);
const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
// Build the wrapper
const wrapperLines = [
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" style="display: contents">',
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
indent + ' <div data-impeccable-variant="original">',
originalIndented,
indent + ' </div>',
indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,
indent + '</div>',
indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
];
// Replace the original element with the wrapper
const newLines = [
...lines.slice(0, startLine),
...wrapperLines,
...lines.slice(endLine + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment)
const insertLine = startLine + 6; // 0-indexed in the new file
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
originalLineCount: originalLines.length,
}));
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function argVal(args, flag) {
const idx = args.indexOf(flag);
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
}
/**
* Build search query strings in priority order (most specific first).
* ID is most reliable, then specific class combos, then single classes, then raw query.
*/
function buildSearchQueries(elementId, classes, tag, query) {
const queries = [];
// 1. ID is the most specific
if (elementId) {
queries.push('id="' + elementId + '"');
}
// 2. Full class attribute match (for elements with distinctive multi-class combos)
if (classes) {
const classList = classes.split(',').map(c => c.trim()).filter(Boolean);
if (classList.length > 1) {
// Try the most distinctive class first (longest, most specific)
const sorted = [...classList].sort((a, b) => b.length - a.length);
queries.push('class="' + classList.join(' ') + '"'); // exact full match
queries.push(sorted[0]); // most distinctive single class
} else if (classList.length === 1) {
queries.push(classList[0]);
}
}
// 3. Tag + class combo (e.g., <section class="hero">)
if (tag && classes) {
const firstClass = classes.split(',')[0].trim();
queries.push('<' + tag + ' class="' + firstClass);
}
// 4. Raw fallback query
if (query) {
queries.push(query);
}
return queries;
}
function detectCommentSyntax(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.jsx' || ext === '.tsx') {
return { open: '{/*', close: '*/}' };
}
// HTML, Vue, Svelte, Astro all use HTML comments
return { open: '<!--', close: '-->' };
}
/**
* Search project files for the query string (class name, ID, etc.)
* Returns the first matching file path, or null.
*/
function findFileWithQuery(query, cwd) {
const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];
const seen = new Set();
for (const dir of searchDirs) {
const absDir = path.join(cwd, dir);
if (!fs.existsSync(absDir)) continue;
const result = searchDir(absDir, query, seen, 0);
if (result) return result;
}
return null;
}
function searchDir(dir, query, seen, depth) {
if (depth > 5) return null; // don't go too deep
const realDir = fs.realpathSync(dir);
if (seen.has(realDir)) return null;
seen.add(realDir);
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return null; }
// Check files first
for (const entry of entries) {
if (!entry.isFile()) continue;
const ext = path.extname(entry.name).toLowerCase();
if (!EXTENSIONS.includes(ext)) continue;
const filePath = path.join(dir, entry.name);
try {
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes(query)) return filePath;
} catch { /* skip unreadable files */ }
}
// Then recurse into directories
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'build') continue;
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
if (result) return result;
}
return null;
}
/**
* Find the element's start and end line in the file.
* The query is a class name, ID, or text snippet.
* We find the line containing the query, then find the matching closing tag.
*/
function findElement(lines, query) {
// Find the line containing the query
let startLine = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes(query)) {
// Make sure this looks like a tag opening, not a comment or string
const line = lines[i].trim();
if (line.startsWith('<!--') || line.startsWith('{/*') || line.startsWith('//')) continue;
// Skip lines inside data-impeccable-variant containers (already wrapped)
if (lines[i].includes('data-impeccable-variant')) continue;
startLine = i;
break;
}
}
if (startLine === -1) return null;
// Find the end of this element by counting open/close tags
const endLine = findClosingLine(lines, startLine);
return { startLine, endLine };
}
/**
* Starting from a line with an opening tag, find the line with the matching
* closing tag by counting tag nesting depth.
*/
function findClosingLine(lines, start) {
// Extract the tag name from the opening line
const openMatch = lines[start].match(/<(\w+)[\s>]/);
if (!openMatch) return start; // self-closing or text-only line
const tagName = openMatch[1];
let depth = 0;
for (let i = start; i < lines.length; i++) {
const line = lines[i];
// Count opening tags (not self-closing)
const opens = (line.match(new RegExp('<' + tagName + '[\\s>]', 'g')) || []).length;
const selfCloses = (line.match(new RegExp('<' + tagName + '[^>]*/>', 'g')) || []).length;
const closes = (line.match(new RegExp('</' + tagName + '\\s*>', 'g')) || []).length;
depth += opens - selfCloses - closes;
if (depth <= 0) return i;
}
// If we can't find the close, return a reasonable guess
return Math.min(start + 50, lines.length - 1);
}
+11 -7
View File
@@ -10,7 +10,7 @@ Launch interactive live variant mode: select elements in the browser, pick a des
1. Read `.impeccable.md` if it exists. Keep the design context in mind for variant generation.
2. Start the live variant server:
```bash
npx impeccable live &
node {{scripts_path}}/live-server.mjs &
```
3. Note the **port** and **token** printed to stdout.
@@ -53,7 +53,7 @@ Run a blocking poll loop. On each iteration, wait for a browser event and respon
```
LOOP:
Run: npx impeccable poll
Run: node {{scripts_path}}/live-poll.mjs
Read the JSON output. Dispatch based on the "type" field:
TYPE "generate":
@@ -85,7 +85,7 @@ The event contains: `{id, action, freeformPrompt, count, pageUrl, element}`.
Use the `wrap` helper to find the element and create the variant container:
```bash
npx impeccable wrap --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
node {{scripts_path}}/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
```
Pass the element's id (`event.element.id`), classes (`event.element.classes` joined with commas), and tag name. The command searches in priority order: ID match first, then class names, then tag+class combo. If `event.pageUrl` hints at the file (e.g., `/` is usually `index.html`), pass `--file PATH` to skip the search.
@@ -136,10 +136,14 @@ The first variant should NOT have `style="display: none"` (it should be visible
### Step 3: Signal completion
Include `--file` so the browser can fetch variants directly if the dev server lacks HMR:
```bash
npx impeccable poll --reply EVENT_ID done
node {{scripts_path}}/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH
```
The file path should be relative to the project root (e.g., `public/index.html`, `src/App.tsx`).
## Handle Accept
The event contains: `{id, variantId}`.
@@ -151,7 +155,7 @@ The user accepted a specific variant. For v1 (inspection mode):
4. Remove any scoped CSS blocks (between `impeccable-variants-css-start` and `impeccable-variants-css-end` markers).
5. Reply:
```bash
npx impeccable poll --reply SESSION_ID done
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
## Handle Discard
@@ -163,7 +167,7 @@ The event contains: `{id}`.
3. Remove any scoped CSS blocks for this session.
4. Reply:
```bash
npx impeccable poll --reply SESSION_ID done
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
## Cleanup (on exit)
@@ -174,7 +178,7 @@ When the loop ends:
2. **Remove any leftover variant wrappers** (search for `impeccable-variants-start` markers and clean up).
3. **Stop the server**:
```bash
npx impeccable live stop
node {{scripts_path}}/live-server.mjs stop
```
## Variant Generation Guidelines
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,123 @@
/**
* CLI client for the live variant mode poll/reply protocol.
*
* Usage:
* npx impeccable poll # Block until browser event, print JSON
* npx impeccable poll --timeout=60000 # Custom timeout (ms)
* npx impeccable poll --reply <id> done # Reply "done" to event <id>
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
const LIVE_PID_FILE = path.join(os.tmpdir(), 'impeccable-live.json');
function readServerInfo() {
try {
return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
} catch {
console.error('No running live server found. Start one with: npx impeccable live');
process.exit(1);
}
}
export async function pollCli() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: impeccable poll [options]
Wait for a browser event from the live variant server, or reply to one.
Modes:
poll Block until a browser event arrives, print JSON
poll --reply <id> done Reply "done" to event <id>
poll --reply <id> error "msg" Reply with an error message
Options:
--timeout=MS Poll timeout in milliseconds (default: 120000)
--help Show this help message`);
process.exit(0);
}
const info = readServerInfo();
const base = `http://localhost:${info.port}`;
// Reply mode: npx impeccable poll --reply <id> <status> [--file path] [message]
const replyIdx = args.indexOf('--reply');
if (replyIdx !== -1) {
const id = args[replyIdx + 1];
const status = args[replyIdx + 2] || 'done';
const fileIdx = args.indexOf('--file');
const filePath = fileIdx !== -1 && fileIdx + 1 < args.length ? args[fileIdx + 1] : undefined;
// Message is any remaining positional arg that isn't a flag
const message = args.find((a, i) => i > replyIdx + 2 && !a.startsWith('--') && i !== fileIdx + 1) || undefined;
if (!id) {
console.error('Usage: npx impeccable poll --reply <id> <status> [--file path] [message]');
process.exit(1);
}
try {
const res = await fetch(`${base}/poll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: info.token,
id,
type: status,
message,
file: filePath,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
console.error(`Reply failed (${res.status}):`, body.error || res.statusText);
process.exit(1);
}
// Success — silent exit (agent doesn't need output for replies)
} catch (err) {
if (err.cause?.code === 'ECONNREFUSED') {
console.error('Live server not running. Start one with: npx impeccable live');
} else {
console.error('Reply failed:', err.message);
}
process.exit(1);
}
return;
}
// Poll mode: block until browser event
const timeoutArg = args.find(a => a.startsWith('--timeout='));
const timeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 120000;
try {
const res = await fetch(`${base}/poll?token=${info.token}&timeout=${timeout}`);
if (res.status === 401) {
console.error('Authentication failed. The server token may have changed.');
console.error('Try restarting: npx impeccable live stop && npx impeccable live');
process.exit(1);
}
if (!res.ok) {
console.error(`Poll failed: ${res.status} ${res.statusText}`);
process.exit(1);
}
const event = await res.json();
// Print the event as JSON — the agent reads this from stdout
console.log(JSON.stringify(event));
} catch (err) {
if (err.cause?.code === 'ECONNREFUSED') {
console.error('Live server not running. Start one with: npx impeccable live');
} else {
console.error('Poll failed:', err.message);
}
process.exit(1);
}
}
@@ -0,0 +1,416 @@
#!/usr/bin/env node
/**
* Live variant mode server (self-contained, zero dependencies).
*
* Serves the browser script (/live.js), the detection overlay (/detect.js),
* uses Server-Sent Events (SSE) for serverbrowser push, and HTTP POST for
* browserserver events. Agent communicates via HTTP long-poll (/poll).
*
* Usage:
* node <scripts_path>/live-server.mjs # start
* node <scripts_path>/live-server.mjs stop # stop
* node <scripts_path>/live-server.mjs --help
*/
import http from 'node:http';
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import net from 'node:net';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LIVE_PID_FILE = path.join(os.tmpdir(), 'impeccable-live.json');
const DEFAULT_POLL_TIMEOUT = 120_000;
// ---------------------------------------------------------------------------
// Port detection
// ---------------------------------------------------------------------------
async function findOpenPort(start = 8400) {
return new Promise((resolve) => {
const srv = net.createServer();
srv.listen(start, '127.0.0.1', () => {
const port = srv.address().port;
srv.close(() => resolve(port));
});
srv.on('error', () => resolve(findOpenPort(start + 1)));
});
}
// ---------------------------------------------------------------------------
// Session state
// ---------------------------------------------------------------------------
const state = {
token: null,
port: null,
sseClients: new Set(), // SSE response objects (server→browser push)
pendingEvents: [], // browser events waiting for agent poll
pendingPolls: [], // agent poll callbacks waiting for browser events
exitTimer: null,
};
function enqueueEvent(event) {
if (state.pendingPolls.length > 0) {
state.pendingPolls.shift()(event);
} else {
state.pendingEvents.push(event);
}
}
/** Push a message to all connected SSE clients. */
function broadcast(msg) {
const data = 'data: ' + JSON.stringify(msg) + '\n\n';
for (const res of state.sseClients) {
try { res.write(data); } catch { /* client gone */ }
}
}
// ---------------------------------------------------------------------------
// Load scripts
// ---------------------------------------------------------------------------
function loadBrowserScripts() {
// Detection script: look relative to the skill scripts dir, then fall back
// to the npm package location (src/detect-antipatterns-browser.js)
const detectPaths = [
path.join(__dirname, '..', '..', '..', '..', 'src', 'detect-antipatterns-browser.js'),
path.join(process.cwd(), 'node_modules', 'impeccable', 'src', 'detect-antipatterns-browser.js'),
];
let detectScript = '';
for (const p of detectPaths) {
try { detectScript = fs.readFileSync(p, 'utf-8'); break; } catch { /* try next */ }
}
const livePath = path.join(__dirname, 'live-browser.js');
let liveScript = '';
try {
liveScript = fs.readFileSync(livePath, 'utf-8');
} catch {
process.stderr.write('Error: live-browser.js not found at ' + livePath + '\n');
process.exit(1);
}
return { detectScript, liveScript };
}
function hasProjectContext() {
try {
fs.accessSync(path.join(process.cwd(), '.impeccable.md'), fs.constants.R_OK);
return true;
} catch { return false; }
}
// ---------------------------------------------------------------------------
// Validation (inline — no external import needed for self-contained script)
// ---------------------------------------------------------------------------
const VISUAL_ACTIONS = [
'impeccable', 'bolder', 'quieter', 'distill', 'polish', 'typeset',
'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive',
];
function validateEvent(msg) {
if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message';
switch (msg.type) {
case 'generate':
if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id';
if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action';
if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8';
if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context';
return null;
case 'accept':
if (!msg.id) return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
return null;
case 'discard':
return msg.id ? null : 'discard: missing id';
case 'exit':
return null;
default:
return 'Unknown event type: ' + msg.type;
}
}
// ---------------------------------------------------------------------------
// HTTP request handler
// ---------------------------------------------------------------------------
function createRequestHandler({ detectScript, liveScriptWithToken }) {
return (req, res) => {
const url = new URL(req.url, `http://localhost:${state.port}`);
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
const p = url.pathname;
// --- Scripts ---
if (p === '/live.js') {
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.end(liveScriptWithToken);
return;
}
if (p === '/detect.js' || p === '/') {
if (!detectScript) { res.writeHead(404); res.end('Not available'); return; }
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.end(detectScript);
return;
}
// --- Health ---
if (p === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
status: 'ok', port: state.port, mode: 'variant',
hasProjectContext: hasProjectContext(),
connectedClients: state.sseClients.size,
}));
return;
}
// --- Source file (no-HMR fallback) ---
if (p === '/source') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
const filePath = url.searchParams.get('path');
if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
const absPath = path.resolve(process.cwd(), filePath);
if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; }
try {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(fs.readFileSync(absPath, 'utf-8'));
} catch { res.writeHead(404); res.end('File not found'); }
return;
}
// --- SSE: server→browser push (replaces WebSocket) ---
if (p === '/events' && req.method === 'GET') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
res.write('data: ' + JSON.stringify({
type: 'connected',
hasProjectContext: hasProjectContext(),
}) + '\n\n');
state.sseClients.add(res);
clearTimeout(state.exitTimer);
req.on('close', () => {
state.sseClients.delete(res);
if (state.sseClients.size === 0) {
clearTimeout(state.exitTimer);
state.exitTimer = setTimeout(() => {
if (state.sseClients.size === 0) enqueueEvent({ type: 'exit' });
}, 8000);
}
});
return;
}
// --- Browser→server events (replaces WebSocket messages) ---
if (p === '/events' && req.method === 'POST') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
let msg;
try { msg = JSON.parse(body); } catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON' }));
return;
}
if (msg.token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
const error = validateEvent(msg);
if (error) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error }));
return;
}
enqueueEvent(msg);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
return;
}
// --- Stop ---
if (p === '/stop') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('stopping');
shutdown();
return;
}
// --- Agent poll ---
if (p === '/poll' && req.method === 'GET') {
handlePollGet(req, res, url);
return;
}
if (p === '/poll' && req.method === 'POST') {
handlePollPost(req, res);
return;
}
res.writeHead(404); res.end('Not found');
};
}
// ---------------------------------------------------------------------------
// Agent poll endpoints (unchanged from WS version)
// ---------------------------------------------------------------------------
function handlePollGet(req, res, url) {
const token = url.searchParams.get('token');
if (token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10);
if (state.pendingEvents.length > 0) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(state.pendingEvents.shift()));
return;
}
const timer = setTimeout(() => {
const idx = state.pendingPolls.indexOf(resolve);
if (idx !== -1) state.pendingPolls.splice(idx, 1);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ type: 'timeout' }));
}, timeout);
function resolve(event) {
clearTimeout(timer);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(event));
}
state.pendingPolls.push(resolve);
req.on('close', () => {
clearTimeout(timer);
const idx = state.pendingPolls.indexOf(resolve);
if (idx !== -1) state.pendingPolls.splice(idx, 1);
});
}
function handlePollPost(req, res) {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
let msg;
try { msg = JSON.parse(body); } catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON' }));
return;
}
if (msg.token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
// Forward the reply to the browser via SSE
broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
let httpServer = null;
function shutdown() {
try { fs.unlinkSync(LIVE_PID_FILE); } catch {}
for (const res of state.sseClients) { try { res.end(); } catch {} }
state.sseClients.clear();
for (const resolve of state.pendingPolls) resolve({ type: 'exit' });
state.pendingPolls.length = 0;
if (httpServer) httpServer.close();
process.exit(0);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: node live-server.mjs [options]
Start the live variant mode server (zero dependencies).
Commands:
(default) Start the server
stop Stop a running server
Options:
--port=PORT Use a specific port (default: auto-detect starting at 8400)
--help Show this help
Endpoints:
/live.js Browser script (element picker + variant cycling)
/detect.js Detection overlay (backwards compatible)
/events SSE stream (serverbrowser) + POST (browserserver)
/poll Long-poll for agent CLI
/source Raw source file reader (no-HMR fallback)
/health Health check`);
process.exit(0);
}
if (args.includes('stop')) {
try {
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`);
if (res.ok) console.log(`Stopped live server on port ${info.port}.`);
} catch { console.log('No running live server found.'); }
process.exit(0);
}
// Check for existing session
try {
const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
try { process.kill(existing.pid, 0);
console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`);
console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop');
process.exit(1);
} catch { fs.unlinkSync(LIVE_PID_FILE); }
} catch {}
state.token = randomUUID();
const portArg = args.find(a => a.startsWith('--port='));
state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort();
const { detectScript, liveScript } = loadBrowserScripts();
const liveScriptWithToken =
`window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` +
`window.__IMPECCABLE_PORT__ = ${state.port};\n` +
liveScript;
httpServer = http.createServer(createRequestHandler({ detectScript, liveScriptWithToken }));
httpServer.listen(state.port, '127.0.0.1', () => {
fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token }));
const url = `http://localhost:${state.port}`;
console.log(`\nImpeccable live server running on ${url}`);
console.log(`Token: ${state.token}\n`);
console.log(`Inject: <script src="${url}/live.js"><\/script>`);
console.log(`Stop: node ${path.basename(fileURLToPath(import.meta.url))} stop`);
});
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
@@ -0,0 +1,298 @@
/**
* CLI helper: find an element in source and wrap it in a variant container.
*
* Usage:
* npx impeccable wrap --id SESSION_ID --count N --query "hero-combined-left" [--file path]
*
* Searches project files for the element matching the query (class name, ID, or
* text snippet), wraps it with the variant scaffolding, and prints the file path
* + line range where the agent should insert variant HTML.
*
* This replaces 3-4 agent tool calls (grep + read + edit) with a single CLI call.
*/
import fs from 'node:fs';
import path from 'node:path';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
export async function wrapCli() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: impeccable wrap [options]
Find an element in source and wrap it in a variant container.
Required:
--id ID Session ID for the variant wrapper
--count N Number of expected variants (1-8)
Element identification (at least one required):
--element-id ID HTML id attribute of the element
--classes A,B,C Comma-separated CSS class names
--tag TAG Tag name (div, section, etc.)
--query TEXT Fallback: raw text to search for
Optional:
--file PATH Source file to search in (skips auto-detection)
--help Show this help message
Output (JSON):
{ file, startLine, endLine, insertLine, commentSyntax }
The agent should insert variant HTML at insertLine.`);
process.exit(0);
}
const id = argVal(args, '--id');
const count = parseInt(argVal(args, '--count') || '3');
const elementId = argVal(args, '--element-id');
const classes = argVal(args, '--classes');
const tag = argVal(args, '--tag');
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!elementId && !classes && !query) {
console.error('Need at least one of: --element-id, --classes, --query');
process.exit(1);
}
// Build search queries in priority order (most specific first)
const queries = buildSearchQueries(elementId, classes, tag, query);
// Find the source file
let targetFile = filePath;
let matchedQuery = null;
if (!targetFile) {
for (const q of queries) {
targetFile = findFileWithQuery(q, process.cwd());
if (targetFile) { matchedQuery = q; break; }
}
if (!targetFile) {
console.error(JSON.stringify({ error: 'Could not find element in project files. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
} else {
matchedQuery = queries[0];
}
const content = fs.readFileSync(targetFile, 'utf-8');
const lines = content.split('\n');
// Find the element, trying each query in priority order
let match = null;
for (const q of queries) {
match = findElement(lines, q);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
const { startLine, endLine } = match;
const commentSyntax = detectCommentSyntax(targetFile);
const indent = lines[startLine].match(/^(\s*)/)[1];
// Extract the original element
const originalLines = lines.slice(startLine, endLine + 1);
const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
// Build the wrapper
const wrapperLines = [
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" style="display: contents">',
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
indent + ' <div data-impeccable-variant="original">',
originalIndented,
indent + ' </div>',
indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,
indent + '</div>',
indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
];
// Replace the original element with the wrapper
const newLines = [
...lines.slice(0, startLine),
...wrapperLines,
...lines.slice(endLine + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment)
const insertLine = startLine + 6; // 0-indexed in the new file
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
originalLineCount: originalLines.length,
}));
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function argVal(args, flag) {
const idx = args.indexOf(flag);
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
}
/**
* Build search query strings in priority order (most specific first).
* ID is most reliable, then specific class combos, then single classes, then raw query.
*/
function buildSearchQueries(elementId, classes, tag, query) {
const queries = [];
// 1. ID is the most specific
if (elementId) {
queries.push('id="' + elementId + '"');
}
// 2. Full class attribute match (for elements with distinctive multi-class combos)
if (classes) {
const classList = classes.split(',').map(c => c.trim()).filter(Boolean);
if (classList.length > 1) {
// Try the most distinctive class first (longest, most specific)
const sorted = [...classList].sort((a, b) => b.length - a.length);
queries.push('class="' + classList.join(' ') + '"'); // exact full match
queries.push(sorted[0]); // most distinctive single class
} else if (classList.length === 1) {
queries.push(classList[0]);
}
}
// 3. Tag + class combo (e.g., <section class="hero">)
if (tag && classes) {
const firstClass = classes.split(',')[0].trim();
queries.push('<' + tag + ' class="' + firstClass);
}
// 4. Raw fallback query
if (query) {
queries.push(query);
}
return queries;
}
function detectCommentSyntax(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.jsx' || ext === '.tsx') {
return { open: '{/*', close: '*/}' };
}
// HTML, Vue, Svelte, Astro all use HTML comments
return { open: '<!--', close: '-->' };
}
/**
* Search project files for the query string (class name, ID, etc.)
* Returns the first matching file path, or null.
*/
function findFileWithQuery(query, cwd) {
const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];
const seen = new Set();
for (const dir of searchDirs) {
const absDir = path.join(cwd, dir);
if (!fs.existsSync(absDir)) continue;
const result = searchDir(absDir, query, seen, 0);
if (result) return result;
}
return null;
}
function searchDir(dir, query, seen, depth) {
if (depth > 5) return null; // don't go too deep
const realDir = fs.realpathSync(dir);
if (seen.has(realDir)) return null;
seen.add(realDir);
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return null; }
// Check files first
for (const entry of entries) {
if (!entry.isFile()) continue;
const ext = path.extname(entry.name).toLowerCase();
if (!EXTENSIONS.includes(ext)) continue;
const filePath = path.join(dir, entry.name);
try {
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes(query)) return filePath;
} catch { /* skip unreadable files */ }
}
// Then recurse into directories
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'build') continue;
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
if (result) return result;
}
return null;
}
/**
* Find the element's start and end line in the file.
* The query is a class name, ID, or text snippet.
* We find the line containing the query, then find the matching closing tag.
*/
function findElement(lines, query) {
// Find the line containing the query
let startLine = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes(query)) {
// Make sure this looks like a tag opening, not a comment or string
const line = lines[i].trim();
if (line.startsWith('<!--') || line.startsWith('{/*') || line.startsWith('//')) continue;
// Skip lines inside data-impeccable-variant containers (already wrapped)
if (lines[i].includes('data-impeccable-variant')) continue;
startLine = i;
break;
}
}
if (startLine === -1) return null;
// Find the end of this element by counting open/close tags
const endLine = findClosingLine(lines, startLine);
return { startLine, endLine };
}
/**
* Starting from a line with an opening tag, find the line with the matching
* closing tag by counting tag nesting depth.
*/
function findClosingLine(lines, start) {
// Extract the tag name from the opening line
const openMatch = lines[start].match(/<(\w+)[\s>]/);
if (!openMatch) return start; // self-closing or text-only line
const tagName = openMatch[1];
let depth = 0;
for (let i = start; i < lines.length; i++) {
const line = lines[i];
// Count opening tags (not self-closing)
const opens = (line.match(new RegExp('<' + tagName + '[\\s>]', 'g')) || []).length;
const selfCloses = (line.match(new RegExp('<' + tagName + '[^>]*/>', 'g')) || []).length;
const closes = (line.match(new RegExp('</' + tagName + '\\s*>', 'g')) || []).length;
depth += opens - selfCloses - closes;
if (depth <= 0) return i;
}
// If we can't find the close, return a reasonable guess
return Math.min(start + 50, lines.length - 1);
}
+11 -7
View File
@@ -10,7 +10,7 @@ Launch interactive live variant mode: select elements in the browser, pick a des
1. Read `.impeccable.md` if it exists. Keep the design context in mind for variant generation.
2. Start the live variant server:
```bash
npx impeccable live &
node {{scripts_path}}/live-server.mjs &
```
3. Note the **port** and **token** printed to stdout.
@@ -53,7 +53,7 @@ Run a blocking poll loop. On each iteration, wait for a browser event and respon
```
LOOP:
Run: npx impeccable poll
Run: node {{scripts_path}}/live-poll.mjs
Read the JSON output. Dispatch based on the "type" field:
TYPE "generate":
@@ -85,7 +85,7 @@ The event contains: `{id, action, freeformPrompt, count, pageUrl, element}`.
Use the `wrap` helper to find the element and create the variant container:
```bash
npx impeccable wrap --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
node {{scripts_path}}/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
```
Pass the element's id (`event.element.id`), classes (`event.element.classes` joined with commas), and tag name. The command searches in priority order: ID match first, then class names, then tag+class combo. If `event.pageUrl` hints at the file (e.g., `/` is usually `index.html`), pass `--file PATH` to skip the search.
@@ -136,10 +136,14 @@ The first variant should NOT have `style="display: none"` (it should be visible
### Step 3: Signal completion
Include `--file` so the browser can fetch variants directly if the dev server lacks HMR:
```bash
npx impeccable poll --reply EVENT_ID done
node {{scripts_path}}/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH
```
The file path should be relative to the project root (e.g., `public/index.html`, `src/App.tsx`).
## Handle Accept
The event contains: `{id, variantId}`.
@@ -151,7 +155,7 @@ The user accepted a specific variant. For v1 (inspection mode):
4. Remove any scoped CSS blocks (between `impeccable-variants-css-start` and `impeccable-variants-css-end` markers).
5. Reply:
```bash
npx impeccable poll --reply SESSION_ID done
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
## Handle Discard
@@ -163,7 +167,7 @@ The event contains: `{id}`.
3. Remove any scoped CSS blocks for this session.
4. Reply:
```bash
npx impeccable poll --reply SESSION_ID done
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
## Cleanup (on exit)
@@ -174,7 +178,7 @@ When the loop ends:
2. **Remove any leftover variant wrappers** (search for `impeccable-variants-start` markers and clean up).
3. **Stop the server**:
```bash
npx impeccable live stop
node {{scripts_path}}/live-server.mjs stop
```
## Variant Generation Guidelines
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,123 @@
/**
* CLI client for the live variant mode poll/reply protocol.
*
* Usage:
* npx impeccable poll # Block until browser event, print JSON
* npx impeccable poll --timeout=60000 # Custom timeout (ms)
* npx impeccable poll --reply <id> done # Reply "done" to event <id>
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
const LIVE_PID_FILE = path.join(os.tmpdir(), 'impeccable-live.json');
function readServerInfo() {
try {
return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
} catch {
console.error('No running live server found. Start one with: npx impeccable live');
process.exit(1);
}
}
export async function pollCli() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: impeccable poll [options]
Wait for a browser event from the live variant server, or reply to one.
Modes:
poll Block until a browser event arrives, print JSON
poll --reply <id> done Reply "done" to event <id>
poll --reply <id> error "msg" Reply with an error message
Options:
--timeout=MS Poll timeout in milliseconds (default: 120000)
--help Show this help message`);
process.exit(0);
}
const info = readServerInfo();
const base = `http://localhost:${info.port}`;
// Reply mode: npx impeccable poll --reply <id> <status> [--file path] [message]
const replyIdx = args.indexOf('--reply');
if (replyIdx !== -1) {
const id = args[replyIdx + 1];
const status = args[replyIdx + 2] || 'done';
const fileIdx = args.indexOf('--file');
const filePath = fileIdx !== -1 && fileIdx + 1 < args.length ? args[fileIdx + 1] : undefined;
// Message is any remaining positional arg that isn't a flag
const message = args.find((a, i) => i > replyIdx + 2 && !a.startsWith('--') && i !== fileIdx + 1) || undefined;
if (!id) {
console.error('Usage: npx impeccable poll --reply <id> <status> [--file path] [message]');
process.exit(1);
}
try {
const res = await fetch(`${base}/poll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: info.token,
id,
type: status,
message,
file: filePath,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
console.error(`Reply failed (${res.status}):`, body.error || res.statusText);
process.exit(1);
}
// Success — silent exit (agent doesn't need output for replies)
} catch (err) {
if (err.cause?.code === 'ECONNREFUSED') {
console.error('Live server not running. Start one with: npx impeccable live');
} else {
console.error('Reply failed:', err.message);
}
process.exit(1);
}
return;
}
// Poll mode: block until browser event
const timeoutArg = args.find(a => a.startsWith('--timeout='));
const timeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 120000;
try {
const res = await fetch(`${base}/poll?token=${info.token}&timeout=${timeout}`);
if (res.status === 401) {
console.error('Authentication failed. The server token may have changed.');
console.error('Try restarting: npx impeccable live stop && npx impeccable live');
process.exit(1);
}
if (!res.ok) {
console.error(`Poll failed: ${res.status} ${res.statusText}`);
process.exit(1);
}
const event = await res.json();
// Print the event as JSON — the agent reads this from stdout
console.log(JSON.stringify(event));
} catch (err) {
if (err.cause?.code === 'ECONNREFUSED') {
console.error('Live server not running. Start one with: npx impeccable live');
} else {
console.error('Poll failed:', err.message);
}
process.exit(1);
}
}
@@ -0,0 +1,416 @@
#!/usr/bin/env node
/**
* Live variant mode server (self-contained, zero dependencies).
*
* Serves the browser script (/live.js), the detection overlay (/detect.js),
* uses Server-Sent Events (SSE) for serverbrowser push, and HTTP POST for
* browserserver events. Agent communicates via HTTP long-poll (/poll).
*
* Usage:
* node <scripts_path>/live-server.mjs # start
* node <scripts_path>/live-server.mjs stop # stop
* node <scripts_path>/live-server.mjs --help
*/
import http from 'node:http';
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import net from 'node:net';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LIVE_PID_FILE = path.join(os.tmpdir(), 'impeccable-live.json');
const DEFAULT_POLL_TIMEOUT = 120_000;
// ---------------------------------------------------------------------------
// Port detection
// ---------------------------------------------------------------------------
async function findOpenPort(start = 8400) {
return new Promise((resolve) => {
const srv = net.createServer();
srv.listen(start, '127.0.0.1', () => {
const port = srv.address().port;
srv.close(() => resolve(port));
});
srv.on('error', () => resolve(findOpenPort(start + 1)));
});
}
// ---------------------------------------------------------------------------
// Session state
// ---------------------------------------------------------------------------
const state = {
token: null,
port: null,
sseClients: new Set(), // SSE response objects (server→browser push)
pendingEvents: [], // browser events waiting for agent poll
pendingPolls: [], // agent poll callbacks waiting for browser events
exitTimer: null,
};
function enqueueEvent(event) {
if (state.pendingPolls.length > 0) {
state.pendingPolls.shift()(event);
} else {
state.pendingEvents.push(event);
}
}
/** Push a message to all connected SSE clients. */
function broadcast(msg) {
const data = 'data: ' + JSON.stringify(msg) + '\n\n';
for (const res of state.sseClients) {
try { res.write(data); } catch { /* client gone */ }
}
}
// ---------------------------------------------------------------------------
// Load scripts
// ---------------------------------------------------------------------------
function loadBrowserScripts() {
// Detection script: look relative to the skill scripts dir, then fall back
// to the npm package location (src/detect-antipatterns-browser.js)
const detectPaths = [
path.join(__dirname, '..', '..', '..', '..', 'src', 'detect-antipatterns-browser.js'),
path.join(process.cwd(), 'node_modules', 'impeccable', 'src', 'detect-antipatterns-browser.js'),
];
let detectScript = '';
for (const p of detectPaths) {
try { detectScript = fs.readFileSync(p, 'utf-8'); break; } catch { /* try next */ }
}
const livePath = path.join(__dirname, 'live-browser.js');
let liveScript = '';
try {
liveScript = fs.readFileSync(livePath, 'utf-8');
} catch {
process.stderr.write('Error: live-browser.js not found at ' + livePath + '\n');
process.exit(1);
}
return { detectScript, liveScript };
}
function hasProjectContext() {
try {
fs.accessSync(path.join(process.cwd(), '.impeccable.md'), fs.constants.R_OK);
return true;
} catch { return false; }
}
// ---------------------------------------------------------------------------
// Validation (inline — no external import needed for self-contained script)
// ---------------------------------------------------------------------------
const VISUAL_ACTIONS = [
'impeccable', 'bolder', 'quieter', 'distill', 'polish', 'typeset',
'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive',
];
function validateEvent(msg) {
if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message';
switch (msg.type) {
case 'generate':
if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id';
if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action';
if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8';
if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context';
return null;
case 'accept':
if (!msg.id) return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
return null;
case 'discard':
return msg.id ? null : 'discard: missing id';
case 'exit':
return null;
default:
return 'Unknown event type: ' + msg.type;
}
}
// ---------------------------------------------------------------------------
// HTTP request handler
// ---------------------------------------------------------------------------
function createRequestHandler({ detectScript, liveScriptWithToken }) {
return (req, res) => {
const url = new URL(req.url, `http://localhost:${state.port}`);
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
const p = url.pathname;
// --- Scripts ---
if (p === '/live.js') {
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.end(liveScriptWithToken);
return;
}
if (p === '/detect.js' || p === '/') {
if (!detectScript) { res.writeHead(404); res.end('Not available'); return; }
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.end(detectScript);
return;
}
// --- Health ---
if (p === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
status: 'ok', port: state.port, mode: 'variant',
hasProjectContext: hasProjectContext(),
connectedClients: state.sseClients.size,
}));
return;
}
// --- Source file (no-HMR fallback) ---
if (p === '/source') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
const filePath = url.searchParams.get('path');
if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
const absPath = path.resolve(process.cwd(), filePath);
if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; }
try {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(fs.readFileSync(absPath, 'utf-8'));
} catch { res.writeHead(404); res.end('File not found'); }
return;
}
// --- SSE: server→browser push (replaces WebSocket) ---
if (p === '/events' && req.method === 'GET') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
res.write('data: ' + JSON.stringify({
type: 'connected',
hasProjectContext: hasProjectContext(),
}) + '\n\n');
state.sseClients.add(res);
clearTimeout(state.exitTimer);
req.on('close', () => {
state.sseClients.delete(res);
if (state.sseClients.size === 0) {
clearTimeout(state.exitTimer);
state.exitTimer = setTimeout(() => {
if (state.sseClients.size === 0) enqueueEvent({ type: 'exit' });
}, 8000);
}
});
return;
}
// --- Browser→server events (replaces WebSocket messages) ---
if (p === '/events' && req.method === 'POST') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
let msg;
try { msg = JSON.parse(body); } catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON' }));
return;
}
if (msg.token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
const error = validateEvent(msg);
if (error) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error }));
return;
}
enqueueEvent(msg);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
return;
}
// --- Stop ---
if (p === '/stop') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('stopping');
shutdown();
return;
}
// --- Agent poll ---
if (p === '/poll' && req.method === 'GET') {
handlePollGet(req, res, url);
return;
}
if (p === '/poll' && req.method === 'POST') {
handlePollPost(req, res);
return;
}
res.writeHead(404); res.end('Not found');
};
}
// ---------------------------------------------------------------------------
// Agent poll endpoints (unchanged from WS version)
// ---------------------------------------------------------------------------
function handlePollGet(req, res, url) {
const token = url.searchParams.get('token');
if (token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10);
if (state.pendingEvents.length > 0) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(state.pendingEvents.shift()));
return;
}
const timer = setTimeout(() => {
const idx = state.pendingPolls.indexOf(resolve);
if (idx !== -1) state.pendingPolls.splice(idx, 1);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ type: 'timeout' }));
}, timeout);
function resolve(event) {
clearTimeout(timer);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(event));
}
state.pendingPolls.push(resolve);
req.on('close', () => {
clearTimeout(timer);
const idx = state.pendingPolls.indexOf(resolve);
if (idx !== -1) state.pendingPolls.splice(idx, 1);
});
}
function handlePollPost(req, res) {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
let msg;
try { msg = JSON.parse(body); } catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON' }));
return;
}
if (msg.token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
// Forward the reply to the browser via SSE
broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
let httpServer = null;
function shutdown() {
try { fs.unlinkSync(LIVE_PID_FILE); } catch {}
for (const res of state.sseClients) { try { res.end(); } catch {} }
state.sseClients.clear();
for (const resolve of state.pendingPolls) resolve({ type: 'exit' });
state.pendingPolls.length = 0;
if (httpServer) httpServer.close();
process.exit(0);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: node live-server.mjs [options]
Start the live variant mode server (zero dependencies).
Commands:
(default) Start the server
stop Stop a running server
Options:
--port=PORT Use a specific port (default: auto-detect starting at 8400)
--help Show this help
Endpoints:
/live.js Browser script (element picker + variant cycling)
/detect.js Detection overlay (backwards compatible)
/events SSE stream (serverbrowser) + POST (browserserver)
/poll Long-poll for agent CLI
/source Raw source file reader (no-HMR fallback)
/health Health check`);
process.exit(0);
}
if (args.includes('stop')) {
try {
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`);
if (res.ok) console.log(`Stopped live server on port ${info.port}.`);
} catch { console.log('No running live server found.'); }
process.exit(0);
}
// Check for existing session
try {
const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
try { process.kill(existing.pid, 0);
console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`);
console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop');
process.exit(1);
} catch { fs.unlinkSync(LIVE_PID_FILE); }
} catch {}
state.token = randomUUID();
const portArg = args.find(a => a.startsWith('--port='));
state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort();
const { detectScript, liveScript } = loadBrowserScripts();
const liveScriptWithToken =
`window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` +
`window.__IMPECCABLE_PORT__ = ${state.port};\n` +
liveScript;
httpServer = http.createServer(createRequestHandler({ detectScript, liveScriptWithToken }));
httpServer.listen(state.port, '127.0.0.1', () => {
fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token }));
const url = `http://localhost:${state.port}`;
console.log(`\nImpeccable live server running on ${url}`);
console.log(`Token: ${state.token}\n`);
console.log(`Inject: <script src="${url}/live.js"><\/script>`);
console.log(`Stop: node ${path.basename(fileURLToPath(import.meta.url))} stop`);
});
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
@@ -0,0 +1,298 @@
/**
* CLI helper: find an element in source and wrap it in a variant container.
*
* Usage:
* npx impeccable wrap --id SESSION_ID --count N --query "hero-combined-left" [--file path]
*
* Searches project files for the element matching the query (class name, ID, or
* text snippet), wraps it with the variant scaffolding, and prints the file path
* + line range where the agent should insert variant HTML.
*
* This replaces 3-4 agent tool calls (grep + read + edit) with a single CLI call.
*/
import fs from 'node:fs';
import path from 'node:path';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
export async function wrapCli() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: impeccable wrap [options]
Find an element in source and wrap it in a variant container.
Required:
--id ID Session ID for the variant wrapper
--count N Number of expected variants (1-8)
Element identification (at least one required):
--element-id ID HTML id attribute of the element
--classes A,B,C Comma-separated CSS class names
--tag TAG Tag name (div, section, etc.)
--query TEXT Fallback: raw text to search for
Optional:
--file PATH Source file to search in (skips auto-detection)
--help Show this help message
Output (JSON):
{ file, startLine, endLine, insertLine, commentSyntax }
The agent should insert variant HTML at insertLine.`);
process.exit(0);
}
const id = argVal(args, '--id');
const count = parseInt(argVal(args, '--count') || '3');
const elementId = argVal(args, '--element-id');
const classes = argVal(args, '--classes');
const tag = argVal(args, '--tag');
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!elementId && !classes && !query) {
console.error('Need at least one of: --element-id, --classes, --query');
process.exit(1);
}
// Build search queries in priority order (most specific first)
const queries = buildSearchQueries(elementId, classes, tag, query);
// Find the source file
let targetFile = filePath;
let matchedQuery = null;
if (!targetFile) {
for (const q of queries) {
targetFile = findFileWithQuery(q, process.cwd());
if (targetFile) { matchedQuery = q; break; }
}
if (!targetFile) {
console.error(JSON.stringify({ error: 'Could not find element in project files. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
} else {
matchedQuery = queries[0];
}
const content = fs.readFileSync(targetFile, 'utf-8');
const lines = content.split('\n');
// Find the element, trying each query in priority order
let match = null;
for (const q of queries) {
match = findElement(lines, q);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
const { startLine, endLine } = match;
const commentSyntax = detectCommentSyntax(targetFile);
const indent = lines[startLine].match(/^(\s*)/)[1];
// Extract the original element
const originalLines = lines.slice(startLine, endLine + 1);
const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
// Build the wrapper
const wrapperLines = [
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" style="display: contents">',
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
indent + ' <div data-impeccable-variant="original">',
originalIndented,
indent + ' </div>',
indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,
indent + '</div>',
indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
];
// Replace the original element with the wrapper
const newLines = [
...lines.slice(0, startLine),
...wrapperLines,
...lines.slice(endLine + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment)
const insertLine = startLine + 6; // 0-indexed in the new file
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
originalLineCount: originalLines.length,
}));
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function argVal(args, flag) {
const idx = args.indexOf(flag);
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
}
/**
* Build search query strings in priority order (most specific first).
* ID is most reliable, then specific class combos, then single classes, then raw query.
*/
function buildSearchQueries(elementId, classes, tag, query) {
const queries = [];
// 1. ID is the most specific
if (elementId) {
queries.push('id="' + elementId + '"');
}
// 2. Full class attribute match (for elements with distinctive multi-class combos)
if (classes) {
const classList = classes.split(',').map(c => c.trim()).filter(Boolean);
if (classList.length > 1) {
// Try the most distinctive class first (longest, most specific)
const sorted = [...classList].sort((a, b) => b.length - a.length);
queries.push('class="' + classList.join(' ') + '"'); // exact full match
queries.push(sorted[0]); // most distinctive single class
} else if (classList.length === 1) {
queries.push(classList[0]);
}
}
// 3. Tag + class combo (e.g., <section class="hero">)
if (tag && classes) {
const firstClass = classes.split(',')[0].trim();
queries.push('<' + tag + ' class="' + firstClass);
}
// 4. Raw fallback query
if (query) {
queries.push(query);
}
return queries;
}
function detectCommentSyntax(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.jsx' || ext === '.tsx') {
return { open: '{/*', close: '*/}' };
}
// HTML, Vue, Svelte, Astro all use HTML comments
return { open: '<!--', close: '-->' };
}
/**
* Search project files for the query string (class name, ID, etc.)
* Returns the first matching file path, or null.
*/
function findFileWithQuery(query, cwd) {
const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];
const seen = new Set();
for (const dir of searchDirs) {
const absDir = path.join(cwd, dir);
if (!fs.existsSync(absDir)) continue;
const result = searchDir(absDir, query, seen, 0);
if (result) return result;
}
return null;
}
function searchDir(dir, query, seen, depth) {
if (depth > 5) return null; // don't go too deep
const realDir = fs.realpathSync(dir);
if (seen.has(realDir)) return null;
seen.add(realDir);
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return null; }
// Check files first
for (const entry of entries) {
if (!entry.isFile()) continue;
const ext = path.extname(entry.name).toLowerCase();
if (!EXTENSIONS.includes(ext)) continue;
const filePath = path.join(dir, entry.name);
try {
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes(query)) return filePath;
} catch { /* skip unreadable files */ }
}
// Then recurse into directories
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'build') continue;
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
if (result) return result;
}
return null;
}
/**
* Find the element's start and end line in the file.
* The query is a class name, ID, or text snippet.
* We find the line containing the query, then find the matching closing tag.
*/
function findElement(lines, query) {
// Find the line containing the query
let startLine = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes(query)) {
// Make sure this looks like a tag opening, not a comment or string
const line = lines[i].trim();
if (line.startsWith('<!--') || line.startsWith('{/*') || line.startsWith('//')) continue;
// Skip lines inside data-impeccable-variant containers (already wrapped)
if (lines[i].includes('data-impeccable-variant')) continue;
startLine = i;
break;
}
}
if (startLine === -1) return null;
// Find the end of this element by counting open/close tags
const endLine = findClosingLine(lines, startLine);
return { startLine, endLine };
}
/**
* Starting from a line with an opening tag, find the line with the matching
* closing tag by counting tag nesting depth.
*/
function findClosingLine(lines, start) {
// Extract the tag name from the opening line
const openMatch = lines[start].match(/<(\w+)[\s>]/);
if (!openMatch) return start; // self-closing or text-only line
const tagName = openMatch[1];
let depth = 0;
for (let i = start; i < lines.length; i++) {
const line = lines[i];
// Count opening tags (not self-closing)
const opens = (line.match(new RegExp('<' + tagName + '[\\s>]', 'g')) || []).length;
const selfCloses = (line.match(new RegExp('<' + tagName + '[^>]*/>', 'g')) || []).length;
const closes = (line.match(new RegExp('</' + tagName + '\\s*>', 'g')) || []).length;
depth += opens - selfCloses - closes;
if (depth <= 0) return i;
}
// If we can't find the close, return a reasonable guess
return Math.min(start + 50, lines.length - 1);
}
+4 -4
View File
@@ -53,16 +53,16 @@ if (command === 'detect') {
const { detectCli } = await import('../src/detect-antipatterns.mjs');
await detectCli();
} else if (command === 'live') {
// Delegate to the self-contained skill script (also works via node scripts_path/live-server.mjs)
process.argv = [process.argv[0], process.argv[1], ...args.slice(1)];
const { liveCli } = await import('../src/detect-antipatterns.mjs');
await liveCli();
await import('../source/skills/impeccable/scripts/live-server.mjs');
} else if (command === 'poll') {
process.argv = [process.argv[0], process.argv[1], ...args.slice(1)];
const { pollCli } = await import('../src/live/poll.mjs');
const { pollCli } = await import('../source/skills/impeccable/scripts/live-poll.mjs');
await pollCli();
} else if (command === 'wrap') {
process.argv = [process.argv[0], process.argv[1], ...args.slice(1)];
const { wrapCli } = await import('../src/live/wrap.mjs');
const { wrapCli } = await import('../source/skills/impeccable/scripts/live-wrap.mjs');
await wrapCli();
} else if (command === 'skills') {
const { run } = await import('./commands/skills.mjs');
+1 -2
View File
@@ -57,8 +57,7 @@
},
"dependencies": {
"jsdom": "^29.0.0",
"marked": "^16.1.0",
"ws": "^8.20.0"
"marked": "^16.1.0"
},
"optionalDependencies": {
"puppeteer": "^24.39.1"
+7 -7
View File
@@ -10,7 +10,7 @@ Launch interactive live variant mode: select elements in the browser, pick a des
1. Read `.impeccable.md` if it exists. Keep the design context in mind for variant generation.
2. Start the live variant server:
```bash
npx impeccable live &
node {{scripts_path}}/live-server.mjs &
```
3. Note the **port** and **token** printed to stdout.
@@ -53,7 +53,7 @@ Run a blocking poll loop. On each iteration, wait for a browser event and respon
```
LOOP:
Run: npx impeccable poll
Run: node {{scripts_path}}/live-poll.mjs
Read the JSON output. Dispatch based on the "type" field:
TYPE "generate":
@@ -85,7 +85,7 @@ The event contains: `{id, action, freeformPrompt, count, pageUrl, element}`.
Use the `wrap` helper to find the element and create the variant container:
```bash
npx impeccable wrap --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
node {{scripts_path}}/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
```
Pass the element's id (`event.element.id`), classes (`event.element.classes` joined with commas), and tag name. The command searches in priority order: ID match first, then class names, then tag+class combo. If `event.pageUrl` hints at the file (e.g., `/` is usually `index.html`), pass `--file PATH` to skip the search.
@@ -139,7 +139,7 @@ The first variant should NOT have `style="display: none"` (it should be visible
Include `--file` so the browser can fetch variants directly if the dev server lacks HMR:
```bash
npx impeccable poll --reply EVENT_ID done --file RELATIVE_PATH
node {{scripts_path}}/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH
```
The file path should be relative to the project root (e.g., `public/index.html`, `src/App.tsx`).
@@ -155,7 +155,7 @@ The user accepted a specific variant. For v1 (inspection mode):
4. Remove any scoped CSS blocks (between `impeccable-variants-css-start` and `impeccable-variants-css-end` markers).
5. Reply:
```bash
npx impeccable poll --reply SESSION_ID done
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
## Handle Discard
@@ -167,7 +167,7 @@ The event contains: `{id}`.
3. Remove any scoped CSS blocks for this session.
4. Reply:
```bash
npx impeccable poll --reply SESSION_ID done
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
## Cleanup (on exit)
@@ -178,7 +178,7 @@ When the loop ends:
2. **Remove any leftover variant wrappers** (search for `impeccable-variants-start` markers and clean up).
3. **Stop the server**:
```bash
npx impeccable live stop
node {{scripts_path}}/live-server.mjs stop
```
## Variant Generation Guidelines
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,123 @@
/**
* CLI client for the live variant mode poll/reply protocol.
*
* Usage:
* npx impeccable poll # Block until browser event, print JSON
* npx impeccable poll --timeout=60000 # Custom timeout (ms)
* npx impeccable poll --reply <id> done # Reply "done" to event <id>
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
const LIVE_PID_FILE = path.join(os.tmpdir(), 'impeccable-live.json');
function readServerInfo() {
try {
return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
} catch {
console.error('No running live server found. Start one with: npx impeccable live');
process.exit(1);
}
}
export async function pollCli() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: impeccable poll [options]
Wait for a browser event from the live variant server, or reply to one.
Modes:
poll Block until a browser event arrives, print JSON
poll --reply <id> done Reply "done" to event <id>
poll --reply <id> error "msg" Reply with an error message
Options:
--timeout=MS Poll timeout in milliseconds (default: 120000)
--help Show this help message`);
process.exit(0);
}
const info = readServerInfo();
const base = `http://localhost:${info.port}`;
// Reply mode: npx impeccable poll --reply <id> <status> [--file path] [message]
const replyIdx = args.indexOf('--reply');
if (replyIdx !== -1) {
const id = args[replyIdx + 1];
const status = args[replyIdx + 2] || 'done';
const fileIdx = args.indexOf('--file');
const filePath = fileIdx !== -1 && fileIdx + 1 < args.length ? args[fileIdx + 1] : undefined;
// Message is any remaining positional arg that isn't a flag
const message = args.find((a, i) => i > replyIdx + 2 && !a.startsWith('--') && i !== fileIdx + 1) || undefined;
if (!id) {
console.error('Usage: npx impeccable poll --reply <id> <status> [--file path] [message]');
process.exit(1);
}
try {
const res = await fetch(`${base}/poll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: info.token,
id,
type: status,
message,
file: filePath,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
console.error(`Reply failed (${res.status}):`, body.error || res.statusText);
process.exit(1);
}
// Success — silent exit (agent doesn't need output for replies)
} catch (err) {
if (err.cause?.code === 'ECONNREFUSED') {
console.error('Live server not running. Start one with: npx impeccable live');
} else {
console.error('Reply failed:', err.message);
}
process.exit(1);
}
return;
}
// Poll mode: block until browser event
const timeoutArg = args.find(a => a.startsWith('--timeout='));
const timeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 120000;
try {
const res = await fetch(`${base}/poll?token=${info.token}&timeout=${timeout}`);
if (res.status === 401) {
console.error('Authentication failed. The server token may have changed.');
console.error('Try restarting: npx impeccable live stop && npx impeccable live');
process.exit(1);
}
if (!res.ok) {
console.error(`Poll failed: ${res.status} ${res.statusText}`);
process.exit(1);
}
const event = await res.json();
// Print the event as JSON — the agent reads this from stdout
console.log(JSON.stringify(event));
} catch (err) {
if (err.cause?.code === 'ECONNREFUSED') {
console.error('Live server not running. Start one with: npx impeccable live');
} else {
console.error('Poll failed:', err.message);
}
process.exit(1);
}
}
@@ -0,0 +1,416 @@
#!/usr/bin/env node
/**
* Live variant mode server (self-contained, zero dependencies).
*
* Serves the browser script (/live.js), the detection overlay (/detect.js),
* uses Server-Sent Events (SSE) for serverbrowser push, and HTTP POST for
* browserserver events. Agent communicates via HTTP long-poll (/poll).
*
* Usage:
* node <scripts_path>/live-server.mjs # start
* node <scripts_path>/live-server.mjs stop # stop
* node <scripts_path>/live-server.mjs --help
*/
import http from 'node:http';
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import net from 'node:net';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LIVE_PID_FILE = path.join(os.tmpdir(), 'impeccable-live.json');
const DEFAULT_POLL_TIMEOUT = 120_000;
// ---------------------------------------------------------------------------
// Port detection
// ---------------------------------------------------------------------------
async function findOpenPort(start = 8400) {
return new Promise((resolve) => {
const srv = net.createServer();
srv.listen(start, '127.0.0.1', () => {
const port = srv.address().port;
srv.close(() => resolve(port));
});
srv.on('error', () => resolve(findOpenPort(start + 1)));
});
}
// ---------------------------------------------------------------------------
// Session state
// ---------------------------------------------------------------------------
const state = {
token: null,
port: null,
sseClients: new Set(), // SSE response objects (server→browser push)
pendingEvents: [], // browser events waiting for agent poll
pendingPolls: [], // agent poll callbacks waiting for browser events
exitTimer: null,
};
function enqueueEvent(event) {
if (state.pendingPolls.length > 0) {
state.pendingPolls.shift()(event);
} else {
state.pendingEvents.push(event);
}
}
/** Push a message to all connected SSE clients. */
function broadcast(msg) {
const data = 'data: ' + JSON.stringify(msg) + '\n\n';
for (const res of state.sseClients) {
try { res.write(data); } catch { /* client gone */ }
}
}
// ---------------------------------------------------------------------------
// Load scripts
// ---------------------------------------------------------------------------
function loadBrowserScripts() {
// Detection script: look relative to the skill scripts dir, then fall back
// to the npm package location (src/detect-antipatterns-browser.js)
const detectPaths = [
path.join(__dirname, '..', '..', '..', '..', 'src', 'detect-antipatterns-browser.js'),
path.join(process.cwd(), 'node_modules', 'impeccable', 'src', 'detect-antipatterns-browser.js'),
];
let detectScript = '';
for (const p of detectPaths) {
try { detectScript = fs.readFileSync(p, 'utf-8'); break; } catch { /* try next */ }
}
const livePath = path.join(__dirname, 'live-browser.js');
let liveScript = '';
try {
liveScript = fs.readFileSync(livePath, 'utf-8');
} catch {
process.stderr.write('Error: live-browser.js not found at ' + livePath + '\n');
process.exit(1);
}
return { detectScript, liveScript };
}
function hasProjectContext() {
try {
fs.accessSync(path.join(process.cwd(), '.impeccable.md'), fs.constants.R_OK);
return true;
} catch { return false; }
}
// ---------------------------------------------------------------------------
// Validation (inline — no external import needed for self-contained script)
// ---------------------------------------------------------------------------
const VISUAL_ACTIONS = [
'impeccable', 'bolder', 'quieter', 'distill', 'polish', 'typeset',
'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive',
];
function validateEvent(msg) {
if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message';
switch (msg.type) {
case 'generate':
if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id';
if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action';
if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8';
if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context';
return null;
case 'accept':
if (!msg.id) return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
return null;
case 'discard':
return msg.id ? null : 'discard: missing id';
case 'exit':
return null;
default:
return 'Unknown event type: ' + msg.type;
}
}
// ---------------------------------------------------------------------------
// HTTP request handler
// ---------------------------------------------------------------------------
function createRequestHandler({ detectScript, liveScriptWithToken }) {
return (req, res) => {
const url = new URL(req.url, `http://localhost:${state.port}`);
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
const p = url.pathname;
// --- Scripts ---
if (p === '/live.js') {
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.end(liveScriptWithToken);
return;
}
if (p === '/detect.js' || p === '/') {
if (!detectScript) { res.writeHead(404); res.end('Not available'); return; }
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.end(detectScript);
return;
}
// --- Health ---
if (p === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
status: 'ok', port: state.port, mode: 'variant',
hasProjectContext: hasProjectContext(),
connectedClients: state.sseClients.size,
}));
return;
}
// --- Source file (no-HMR fallback) ---
if (p === '/source') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
const filePath = url.searchParams.get('path');
if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
const absPath = path.resolve(process.cwd(), filePath);
if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; }
try {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(fs.readFileSync(absPath, 'utf-8'));
} catch { res.writeHead(404); res.end('File not found'); }
return;
}
// --- SSE: server→browser push (replaces WebSocket) ---
if (p === '/events' && req.method === 'GET') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
res.write('data: ' + JSON.stringify({
type: 'connected',
hasProjectContext: hasProjectContext(),
}) + '\n\n');
state.sseClients.add(res);
clearTimeout(state.exitTimer);
req.on('close', () => {
state.sseClients.delete(res);
if (state.sseClients.size === 0) {
clearTimeout(state.exitTimer);
state.exitTimer = setTimeout(() => {
if (state.sseClients.size === 0) enqueueEvent({ type: 'exit' });
}, 8000);
}
});
return;
}
// --- Browser→server events (replaces WebSocket messages) ---
if (p === '/events' && req.method === 'POST') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
let msg;
try { msg = JSON.parse(body); } catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON' }));
return;
}
if (msg.token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
const error = validateEvent(msg);
if (error) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error }));
return;
}
enqueueEvent(msg);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
return;
}
// --- Stop ---
if (p === '/stop') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('stopping');
shutdown();
return;
}
// --- Agent poll ---
if (p === '/poll' && req.method === 'GET') {
handlePollGet(req, res, url);
return;
}
if (p === '/poll' && req.method === 'POST') {
handlePollPost(req, res);
return;
}
res.writeHead(404); res.end('Not found');
};
}
// ---------------------------------------------------------------------------
// Agent poll endpoints (unchanged from WS version)
// ---------------------------------------------------------------------------
function handlePollGet(req, res, url) {
const token = url.searchParams.get('token');
if (token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10);
if (state.pendingEvents.length > 0) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(state.pendingEvents.shift()));
return;
}
const timer = setTimeout(() => {
const idx = state.pendingPolls.indexOf(resolve);
if (idx !== -1) state.pendingPolls.splice(idx, 1);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ type: 'timeout' }));
}, timeout);
function resolve(event) {
clearTimeout(timer);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(event));
}
state.pendingPolls.push(resolve);
req.on('close', () => {
clearTimeout(timer);
const idx = state.pendingPolls.indexOf(resolve);
if (idx !== -1) state.pendingPolls.splice(idx, 1);
});
}
function handlePollPost(req, res) {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
let msg;
try { msg = JSON.parse(body); } catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON' }));
return;
}
if (msg.token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
// Forward the reply to the browser via SSE
broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
let httpServer = null;
function shutdown() {
try { fs.unlinkSync(LIVE_PID_FILE); } catch {}
for (const res of state.sseClients) { try { res.end(); } catch {} }
state.sseClients.clear();
for (const resolve of state.pendingPolls) resolve({ type: 'exit' });
state.pendingPolls.length = 0;
if (httpServer) httpServer.close();
process.exit(0);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: node live-server.mjs [options]
Start the live variant mode server (zero dependencies).
Commands:
(default) Start the server
stop Stop a running server
Options:
--port=PORT Use a specific port (default: auto-detect starting at 8400)
--help Show this help
Endpoints:
/live.js Browser script (element picker + variant cycling)
/detect.js Detection overlay (backwards compatible)
/events SSE stream (serverbrowser) + POST (browserserver)
/poll Long-poll for agent CLI
/source Raw source file reader (no-HMR fallback)
/health Health check`);
process.exit(0);
}
if (args.includes('stop')) {
try {
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`);
if (res.ok) console.log(`Stopped live server on port ${info.port}.`);
} catch { console.log('No running live server found.'); }
process.exit(0);
}
// Check for existing session
try {
const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
try { process.kill(existing.pid, 0);
console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`);
console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop');
process.exit(1);
} catch { fs.unlinkSync(LIVE_PID_FILE); }
} catch {}
state.token = randomUUID();
const portArg = args.find(a => a.startsWith('--port='));
state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort();
const { detectScript, liveScript } = loadBrowserScripts();
const liveScriptWithToken =
`window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` +
`window.__IMPECCABLE_PORT__ = ${state.port};\n` +
liveScript;
httpServer = http.createServer(createRequestHandler({ detectScript, liveScriptWithToken }));
httpServer.listen(state.port, '127.0.0.1', () => {
fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token }));
const url = `http://localhost:${state.port}`;
console.log(`\nImpeccable live server running on ${url}`);
console.log(`Token: ${state.token}\n`);
console.log(`Inject: <script src="${url}/live.js"><\/script>`);
console.log(`Stop: node ${path.basename(fileURLToPath(import.meta.url))} stop`);
});
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
@@ -0,0 +1,298 @@
/**
* CLI helper: find an element in source and wrap it in a variant container.
*
* Usage:
* npx impeccable wrap --id SESSION_ID --count N --query "hero-combined-left" [--file path]
*
* Searches project files for the element matching the query (class name, ID, or
* text snippet), wraps it with the variant scaffolding, and prints the file path
* + line range where the agent should insert variant HTML.
*
* This replaces 3-4 agent tool calls (grep + read + edit) with a single CLI call.
*/
import fs from 'node:fs';
import path from 'node:path';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
export async function wrapCli() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: impeccable wrap [options]
Find an element in source and wrap it in a variant container.
Required:
--id ID Session ID for the variant wrapper
--count N Number of expected variants (1-8)
Element identification (at least one required):
--element-id ID HTML id attribute of the element
--classes A,B,C Comma-separated CSS class names
--tag TAG Tag name (div, section, etc.)
--query TEXT Fallback: raw text to search for
Optional:
--file PATH Source file to search in (skips auto-detection)
--help Show this help message
Output (JSON):
{ file, startLine, endLine, insertLine, commentSyntax }
The agent should insert variant HTML at insertLine.`);
process.exit(0);
}
const id = argVal(args, '--id');
const count = parseInt(argVal(args, '--count') || '3');
const elementId = argVal(args, '--element-id');
const classes = argVal(args, '--classes');
const tag = argVal(args, '--tag');
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!elementId && !classes && !query) {
console.error('Need at least one of: --element-id, --classes, --query');
process.exit(1);
}
// Build search queries in priority order (most specific first)
const queries = buildSearchQueries(elementId, classes, tag, query);
// Find the source file
let targetFile = filePath;
let matchedQuery = null;
if (!targetFile) {
for (const q of queries) {
targetFile = findFileWithQuery(q, process.cwd());
if (targetFile) { matchedQuery = q; break; }
}
if (!targetFile) {
console.error(JSON.stringify({ error: 'Could not find element in project files. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
} else {
matchedQuery = queries[0];
}
const content = fs.readFileSync(targetFile, 'utf-8');
const lines = content.split('\n');
// Find the element, trying each query in priority order
let match = null;
for (const q of queries) {
match = findElement(lines, q);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
const { startLine, endLine } = match;
const commentSyntax = detectCommentSyntax(targetFile);
const indent = lines[startLine].match(/^(\s*)/)[1];
// Extract the original element
const originalLines = lines.slice(startLine, endLine + 1);
const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
// Build the wrapper
const wrapperLines = [
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" style="display: contents">',
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
indent + ' <div data-impeccable-variant="original">',
originalIndented,
indent + ' </div>',
indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,
indent + '</div>',
indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
];
// Replace the original element with the wrapper
const newLines = [
...lines.slice(0, startLine),
...wrapperLines,
...lines.slice(endLine + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment)
const insertLine = startLine + 6; // 0-indexed in the new file
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
originalLineCount: originalLines.length,
}));
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function argVal(args, flag) {
const idx = args.indexOf(flag);
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
}
/**
* Build search query strings in priority order (most specific first).
* ID is most reliable, then specific class combos, then single classes, then raw query.
*/
function buildSearchQueries(elementId, classes, tag, query) {
const queries = [];
// 1. ID is the most specific
if (elementId) {
queries.push('id="' + elementId + '"');
}
// 2. Full class attribute match (for elements with distinctive multi-class combos)
if (classes) {
const classList = classes.split(',').map(c => c.trim()).filter(Boolean);
if (classList.length > 1) {
// Try the most distinctive class first (longest, most specific)
const sorted = [...classList].sort((a, b) => b.length - a.length);
queries.push('class="' + classList.join(' ') + '"'); // exact full match
queries.push(sorted[0]); // most distinctive single class
} else if (classList.length === 1) {
queries.push(classList[0]);
}
}
// 3. Tag + class combo (e.g., <section class="hero">)
if (tag && classes) {
const firstClass = classes.split(',')[0].trim();
queries.push('<' + tag + ' class="' + firstClass);
}
// 4. Raw fallback query
if (query) {
queries.push(query);
}
return queries;
}
function detectCommentSyntax(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.jsx' || ext === '.tsx') {
return { open: '{/*', close: '*/}' };
}
// HTML, Vue, Svelte, Astro all use HTML comments
return { open: '<!--', close: '-->' };
}
/**
* Search project files for the query string (class name, ID, etc.)
* Returns the first matching file path, or null.
*/
function findFileWithQuery(query, cwd) {
const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];
const seen = new Set();
for (const dir of searchDirs) {
const absDir = path.join(cwd, dir);
if (!fs.existsSync(absDir)) continue;
const result = searchDir(absDir, query, seen, 0);
if (result) return result;
}
return null;
}
function searchDir(dir, query, seen, depth) {
if (depth > 5) return null; // don't go too deep
const realDir = fs.realpathSync(dir);
if (seen.has(realDir)) return null;
seen.add(realDir);
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return null; }
// Check files first
for (const entry of entries) {
if (!entry.isFile()) continue;
const ext = path.extname(entry.name).toLowerCase();
if (!EXTENSIONS.includes(ext)) continue;
const filePath = path.join(dir, entry.name);
try {
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes(query)) return filePath;
} catch { /* skip unreadable files */ }
}
// Then recurse into directories
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'build') continue;
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
if (result) return result;
}
return null;
}
/**
* Find the element's start and end line in the file.
* The query is a class name, ID, or text snippet.
* We find the line containing the query, then find the matching closing tag.
*/
function findElement(lines, query) {
// Find the line containing the query
let startLine = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes(query)) {
// Make sure this looks like a tag opening, not a comment or string
const line = lines[i].trim();
if (line.startsWith('<!--') || line.startsWith('{/*') || line.startsWith('//')) continue;
// Skip lines inside data-impeccable-variant containers (already wrapped)
if (lines[i].includes('data-impeccable-variant')) continue;
startLine = i;
break;
}
}
if (startLine === -1) return null;
// Find the end of this element by counting open/close tags
const endLine = findClosingLine(lines, startLine);
return { startLine, endLine };
}
/**
* Starting from a line with an opening tag, find the line with the matching
* closing tag by counting tag nesting depth.
*/
function findClosingLine(lines, start) {
// Extract the tag name from the opening line
const openMatch = lines[start].match(/<(\w+)[\s>]/);
if (!openMatch) return start; // self-closing or text-only line
const tagName = openMatch[1];
let depth = 0;
for (let i = start; i < lines.length; i++) {
const line = lines[i];
// Count opening tags (not self-closing)
const opens = (line.match(new RegExp('<' + tagName + '[\\s>]', 'g')) || []).length;
const selfCloses = (line.match(new RegExp('<' + tagName + '[^>]*/>', 'g')) || []).length;
const closes = (line.match(new RegExp('</' + tagName + '\\s*>', 'g')) || []).length;
depth += opens - selfCloses - closes;
if (depth <= 0) return i;
}
// If we can't find the close, return a reasonable guess
return Math.min(start + 50, lines.length - 1);
}
+25 -22
View File
@@ -71,7 +71,6 @@
// ---------------------------------------------------------------------------
let state = 'IDLE';
let ws = null;
let hoveredElement = null;
let selectedElement = null;
let currentSessionId = null;
@@ -866,28 +865,26 @@
}
// ---------------------------------------------------------------------------
// WebSocket
// SSE (server→browser) + fetch POST (browser→server)
// Zero-dependency replacement for WebSocket.
// ---------------------------------------------------------------------------
function connectWS() {
ws = new WebSocket('ws://localhost:' + PORT + '/ws');
ws.onopen = () => ws.send(JSON.stringify({ type: 'auth', token: TOKEN }));
ws.onmessage = (e) => {
let evtSource = null;
function connectSSE() {
evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN);
evtSource.onmessage = (e) => {
let msg; try { msg = JSON.parse(e.data); } catch { return; }
switch (msg.type) {
case 'auth_ok':
case 'connected':
hasProjectContext = !!msg.hasProjectContext;
if (!hasProjectContext) showToast('No .impeccable.md found. Variants will be brand-agnostic.', 6000);
console.log('[impeccable] Live mode connected.');
// Only go to PICKING if we're not already in a resumed session
if (state === 'IDLE') state = 'PICKING';
break;
case 'auth_fail':
console.error('[impeccable] Auth failed:', msg.reason);
break;
case 'done':
if (state === 'SAVING') {
// Accept/discard was processed. Show green confirmation, then dismiss.
state = 'CONFIRMED';
updateBarContent('confirmed');
setTimeout(() => {
@@ -903,8 +900,6 @@
}, 1800);
return;
}
// If variants haven't appeared in the DOM (no HMR), fetch the raw
// source file from the live server and inject variants into the DOM.
if (arrivedVariants === 0 && expectedVariants > 0 && msg.file) {
console.log('[impeccable] No HMR detected. Fetching variants from source file...');
injectVariantsFromSource(msg.file, currentSessionId);
@@ -921,12 +916,20 @@
break;
}
};
ws.onclose = () => { setTimeout(connectWS, 3000); };
ws.onerror = () => {};
evtSource.onerror = () => {
// EventSource auto-reconnects. Log once.
console.log('[impeccable] SSE connection lost. Reconnecting...');
};
}
function sendWS(msg) {
if (ws && ws.readyState === 1) ws.send(JSON.stringify(msg));
function sendEvent(msg) {
msg.token = TOKEN;
fetch('http://localhost:' + PORT + '/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(msg),
}).catch(err => console.error('[impeccable] Failed to send event:', err));
}
// ---------------------------------------------------------------------------
@@ -1017,7 +1020,7 @@
arrivedVariants = 0;
visibleVariant = 0;
sendWS({
sendEvent({
type: 'generate', id: currentSessionId,
action: selectedAction,
freeformPrompt: prompt || undefined,
@@ -1035,7 +1038,7 @@
function handleAccept() {
if (!currentSessionId || arrivedVariants === 0) return;
sendWS({ type: 'accept', id: currentSessionId, variantId: String(visibleVariant) });
sendEvent({ type: 'accept', id: currentSessionId, variantId: String(visibleVariant) });
markSessionHandled();
state = 'SAVING';
updateBarContent('saving');
@@ -1044,7 +1047,7 @@
function handleDiscard() {
if (!currentSessionId) return;
sendWS({ type: 'discard', id: currentSessionId });
sendEvent({ type: 'discard', id: currentSessionId });
markSessionHandled();
state = 'SAVING';
updateBarContent('saving');
@@ -1199,7 +1202,7 @@
document.addEventListener('mousemove', handleMouseMove, true);
document.addEventListener('click', handleClick, true);
document.addEventListener('keydown', handleKeyDown, true);
connectWS();
connectSSE();
// Check for an active session to resume (variant wrapper already in DOM after HMR)
if (!resumeSession()) {