diff --git a/.agents/skills/impeccable/reference/live.md b/.agents/skills/impeccable/reference/live.md index a6a449cbe..f3b785fc7 100644 --- a/.agents/skills/impeccable/reference/live.md +++ b/.agents/skills/impeccable/reference/live.md @@ -15,34 +15,48 @@ Launch interactive live variant mode: select elements in the browser, pick a des ## Inject the Browser Script -Find the project's main HTML entry point. This varies by framework: +The `live-inject.mjs` script handles insertion deterministically. It reads `config.json` from its own directory (one-time per-project setup). -| Framework | Typical file | -|-----------|-------------| -| Plain HTML | `index.html` | -| Vite / React | `index.html` (project root) | -| Next.js (App Router) | `app/layout.tsx` (add a ` - +```bash +node {{scripts_path}}/live-inject.mjs --check ``` -**JSX / TSX (React, Next.js):** -```jsx -{/* impeccable-live-start */} - -{/* impeccable-live-end */} +If the output says `{"ok": true, ...}`, skip to Step 2. + +If the output says `{"ok": false, "error": "config_missing", "path": "..."}`, you need to create the config **once**. Look at the project structure and package.json to determine: + +| Framework | `file` | `insertBefore` | `commentSyntax` | +|-----------|--------|----------------|-----------------| +| Plain HTML | `index.html` | `` | `html` | +| Vite / React | `index.html` | `` | `html` | +| Next.js (App Router) | `app/layout.tsx` | `` | `jsx` | +| Next.js (Pages) | `pages/_document.tsx` | `` | `jsx` | +| Nuxt | `app.vue` | `` | `html` | +| Svelte / SvelteKit | `src/app.html` | `` | `html` | +| Astro | the root layout `.astro` file | `` | `html` | +| Static site with a non-root HTML file | e.g. `public/index.html` | `` | `html` | + +Write the config to the path reported by `--check`. Example for this project: + +```json +{ + "file": "public/index.html", + "insertBefore": "", + "commentSyntax": "html" +} ``` -Place it before the closing `` or at the end of the layout component. Save the file. The dev server will reload and the element picker will activate. +Use `insertAfter` instead of `insertBefore` if the anchor should be matched **after** a specific line (e.g. just after the main app script). + +### Step 2: Insert the live tag + +```bash +node {{scripts_path}}/live-inject.mjs --port PORT +``` + +Use the `port` from the live-server startup output. The script writes the tag idempotently: if a stale tag is present, it's replaced with one pointing at the new port. Save is automatic. If browser automation tools are available, also navigate to the page so the user can see it. @@ -177,7 +191,11 @@ If the poll is still running as a background task, kill it and proceed directly When the loop ends: -1. **Remove the injected script tag** from the source file. Delete everything between `` and `` (inclusive). Use the appropriate comment syntax for the framework. +1. **Remove the injected script tag**: + ```bash + node {{scripts_path}}/live-inject.mjs --remove + ``` + (The config.json stays so future `live-inject.mjs --port PORT` calls are instant.) 2. **Remove any leftover variant wrappers** (search for `impeccable-variants-start` markers and clean up). 3. **Remove any leftover carbonize blocks** (search for `impeccable-carbonize-start` markers and clean up). 4. **Stop the server**: diff --git a/.agents/skills/impeccable/scripts/live-inject.mjs b/.agents/skills/impeccable/scripts/live-inject.mjs new file mode 100644 index 000000000..d61c17925 --- /dev/null +++ b/.agents/skills/impeccable/scripts/live-inject.mjs @@ -0,0 +1,174 @@ +/** + * CLI helper: insert/remove the live variant mode script tag in the project's + * main HTML entry point. + * + * On first live run, the agent generates `config.json` in this script's + * directory with the project's insertion target (framework-specific). On + * every subsequent run, this script handles insert/remove deterministically + * with zero LLM involvement. + * + * Usage: + * node live-inject.mjs --port PORT # Insert the live script tag + * node live-inject.mjs --remove # Remove the live script tag + * node live-inject.mjs --check # Check whether config.json exists + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const CONFIG_PATH = path.join(__dirname, 'config.json'); +const MARKER_OPEN_TEXT = 'impeccable-live-start'; +const MARKER_CLOSE_TEXT = 'impeccable-live-end'; + +export async function injectCli() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + console.log(`Usage: node live-inject.mjs [options] + +Insert or remove the live mode script tag in the project's HTML entry point. +Reads configuration from config.json (in this same directory). + +Modes: + --port PORT Insert script tag pointing at http://localhost:PORT/live.js + --remove Remove the script tag (if present) + --check Print whether config.json exists and its content + +Output (JSON): + { ok, file, inserted|removed, config? }`); + process.exit(0); + } + + if (args.includes('--check')) { + if (!fs.existsSync(CONFIG_PATH)) { + console.log(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH })); + process.exit(0); + } + try { + const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); + console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH })); + } catch (err) { + console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message })); + } + return; + } + + // Load config + if (!fs.existsSync(CONFIG_PATH)) { + console.error(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH })); + process.exit(1); + } + const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); + validateConfig(config); + + const absFile = path.resolve(process.cwd(), config.file); + if (!fs.existsSync(absFile)) { + console.error(JSON.stringify({ ok: false, error: 'file_not_found', file: config.file })); + process.exit(1); + } + + const content = fs.readFileSync(absFile, 'utf-8'); + + if (args.includes('--remove')) { + const updated = removeTag(content, config.commentSyntax); + if (updated === content) { + console.log(JSON.stringify({ ok: true, file: config.file, removed: false, note: 'no tag present' })); + return; + } + fs.writeFileSync(absFile, updated, 'utf-8'); + console.log(JSON.stringify({ ok: true, file: config.file, removed: true })); + return; + } + + // Insert mode — need --port + const portIdx = args.indexOf('--port'); + const port = portIdx !== -1 ? parseInt(args[portIdx + 1], 10) : NaN; + if (!Number.isFinite(port)) { + console.error(JSON.stringify({ ok: false, error: 'missing_port' })); + process.exit(1); + } + + // Already inserted? Replace to refresh the port. + const withoutOld = removeTag(content, config.commentSyntax); + const updated = insertTag(withoutOld, config, port); + if (updated === withoutOld) { + console.error(JSON.stringify({ ok: false, error: 'insertion_point_not_found', anchor: config.insertBefore })); + process.exit(1); + } + fs.writeFileSync(absFile, updated, 'utf-8'); + console.log(JSON.stringify({ ok: true, file: config.file, inserted: true, port })); +} + +// --------------------------------------------------------------------------- +// Core operations +// --------------------------------------------------------------------------- + +function validateConfig(cfg) { + if (!cfg || typeof cfg !== 'object') throw new Error('config.json must be an object'); + if (typeof cfg.file !== 'string') throw new Error('config.file (string) required'); + if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') { + throw new Error('config.insertBefore or config.insertAfter (string) required'); + } + if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') { + throw new Error("config.commentSyntax must be 'html' or 'jsx'"); + } +} + +function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : ''; } + +function buildTagBlock(syntax, port) { + const open = commentOpen(syntax); + const close = commentClose(syntax); + return ( + open + ' ' + MARKER_OPEN_TEXT + ' ' + close + '\n' + + '\n' + + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' + ); +} + +function insertTag(content, config, port) { + const block = buildTagBlock(config.commentSyntax, port); + if (config.insertBefore) { + const idx = content.indexOf(config.insertBefore); + if (idx === -1) return content; + return content.slice(0, idx) + block + content.slice(idx); + } + // insertAfter + const idx = content.indexOf(config.insertAfter); + if (idx === -1) return content; + const after = idx + config.insertAfter.length; + // Preserve a single trailing newline if the anchor didn't end with one + const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n'; + return prefix + block + content.slice(prefix.length); +} + +/** + * Remove the live script block. Matches either HTML or JSX comment markers + * regardless of config (so stale tags from a wrong config can still be cleaned). + */ +function removeTag(content, _syntax) { + // Two patterns: HTML comment markers or JSX comment markers, with any content between. + const patterns = [ + /\n?[\s\S]*?\n?/, + /\n?\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}\n?/, + ]; + for (const pat of patterns) { + const next = content.replace(pat, '\n'); + if (next !== content) return next; + } + return content; +} + +// --------------------------------------------------------------------------- +// Auto-execute +// --------------------------------------------------------------------------- + +const _running = process.argv[1]; +if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs/')) { + injectCli(); +} + +export { insertTag, removeTag, validateConfig, buildTagBlock }; diff --git a/.claude/skills/impeccable/reference/live.md b/.claude/skills/impeccable/reference/live.md index a6a449cbe..f3b785fc7 100644 --- a/.claude/skills/impeccable/reference/live.md +++ b/.claude/skills/impeccable/reference/live.md @@ -15,34 +15,48 @@ Launch interactive live variant mode: select elements in the browser, pick a des ## Inject the Browser Script -Find the project's main HTML entry point. This varies by framework: +The `live-inject.mjs` script handles insertion deterministically. It reads `config.json` from its own directory (one-time per-project setup). -| Framework | Typical file | -|-----------|-------------| -| Plain HTML | `index.html` | -| Vite / React | `index.html` (project root) | -| Next.js (App Router) | `app/layout.tsx` (add a ` - +```bash +node {{scripts_path}}/live-inject.mjs --check ``` -**JSX / TSX (React, Next.js):** -```jsx -{/* impeccable-live-start */} - -{/* impeccable-live-end */} +If the output says `{"ok": true, ...}`, skip to Step 2. + +If the output says `{"ok": false, "error": "config_missing", "path": "..."}`, you need to create the config **once**. Look at the project structure and package.json to determine: + +| Framework | `file` | `insertBefore` | `commentSyntax` | +|-----------|--------|----------------|-----------------| +| Plain HTML | `index.html` | `` | `html` | +| Vite / React | `index.html` | `` | `html` | +| Next.js (App Router) | `app/layout.tsx` | `` | `jsx` | +| Next.js (Pages) | `pages/_document.tsx` | `` | `jsx` | +| Nuxt | `app.vue` | `` | `html` | +| Svelte / SvelteKit | `src/app.html` | `` | `html` | +| Astro | the root layout `.astro` file | `` | `html` | +| Static site with a non-root HTML file | e.g. `public/index.html` | `` | `html` | + +Write the config to the path reported by `--check`. Example for this project: + +```json +{ + "file": "public/index.html", + "insertBefore": "", + "commentSyntax": "html" +} ``` -Place it before the closing `` or at the end of the layout component. Save the file. The dev server will reload and the element picker will activate. +Use `insertAfter` instead of `insertBefore` if the anchor should be matched **after** a specific line (e.g. just after the main app script). + +### Step 2: Insert the live tag + +```bash +node {{scripts_path}}/live-inject.mjs --port PORT +``` + +Use the `port` from the live-server startup output. The script writes the tag idempotently: if a stale tag is present, it's replaced with one pointing at the new port. Save is automatic. If browser automation tools are available, also navigate to the page so the user can see it. @@ -177,7 +191,11 @@ If the poll is still running as a background task, kill it and proceed directly When the loop ends: -1. **Remove the injected script tag** from the source file. Delete everything between `` and `` (inclusive). Use the appropriate comment syntax for the framework. +1. **Remove the injected script tag**: + ```bash + node {{scripts_path}}/live-inject.mjs --remove + ``` + (The config.json stays so future `live-inject.mjs --port PORT` calls are instant.) 2. **Remove any leftover variant wrappers** (search for `impeccable-variants-start` markers and clean up). 3. **Remove any leftover carbonize blocks** (search for `impeccable-carbonize-start` markers and clean up). 4. **Stop the server**: diff --git a/.claude/skills/impeccable/scripts/live-inject.mjs b/.claude/skills/impeccable/scripts/live-inject.mjs new file mode 100644 index 000000000..d61c17925 --- /dev/null +++ b/.claude/skills/impeccable/scripts/live-inject.mjs @@ -0,0 +1,174 @@ +/** + * CLI helper: insert/remove the live variant mode script tag in the project's + * main HTML entry point. + * + * On first live run, the agent generates `config.json` in this script's + * directory with the project's insertion target (framework-specific). On + * every subsequent run, this script handles insert/remove deterministically + * with zero LLM involvement. + * + * Usage: + * node live-inject.mjs --port PORT # Insert the live script tag + * node live-inject.mjs --remove # Remove the live script tag + * node live-inject.mjs --check # Check whether config.json exists + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const CONFIG_PATH = path.join(__dirname, 'config.json'); +const MARKER_OPEN_TEXT = 'impeccable-live-start'; +const MARKER_CLOSE_TEXT = 'impeccable-live-end'; + +export async function injectCli() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + console.log(`Usage: node live-inject.mjs [options] + +Insert or remove the live mode script tag in the project's HTML entry point. +Reads configuration from config.json (in this same directory). + +Modes: + --port PORT Insert script tag pointing at http://localhost:PORT/live.js + --remove Remove the script tag (if present) + --check Print whether config.json exists and its content + +Output (JSON): + { ok, file, inserted|removed, config? }`); + process.exit(0); + } + + if (args.includes('--check')) { + if (!fs.existsSync(CONFIG_PATH)) { + console.log(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH })); + process.exit(0); + } + try { + const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); + console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH })); + } catch (err) { + console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message })); + } + return; + } + + // Load config + if (!fs.existsSync(CONFIG_PATH)) { + console.error(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH })); + process.exit(1); + } + const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); + validateConfig(config); + + const absFile = path.resolve(process.cwd(), config.file); + if (!fs.existsSync(absFile)) { + console.error(JSON.stringify({ ok: false, error: 'file_not_found', file: config.file })); + process.exit(1); + } + + const content = fs.readFileSync(absFile, 'utf-8'); + + if (args.includes('--remove')) { + const updated = removeTag(content, config.commentSyntax); + if (updated === content) { + console.log(JSON.stringify({ ok: true, file: config.file, removed: false, note: 'no tag present' })); + return; + } + fs.writeFileSync(absFile, updated, 'utf-8'); + console.log(JSON.stringify({ ok: true, file: config.file, removed: true })); + return; + } + + // Insert mode — need --port + const portIdx = args.indexOf('--port'); + const port = portIdx !== -1 ? parseInt(args[portIdx + 1], 10) : NaN; + if (!Number.isFinite(port)) { + console.error(JSON.stringify({ ok: false, error: 'missing_port' })); + process.exit(1); + } + + // Already inserted? Replace to refresh the port. + const withoutOld = removeTag(content, config.commentSyntax); + const updated = insertTag(withoutOld, config, port); + if (updated === withoutOld) { + console.error(JSON.stringify({ ok: false, error: 'insertion_point_not_found', anchor: config.insertBefore })); + process.exit(1); + } + fs.writeFileSync(absFile, updated, 'utf-8'); + console.log(JSON.stringify({ ok: true, file: config.file, inserted: true, port })); +} + +// --------------------------------------------------------------------------- +// Core operations +// --------------------------------------------------------------------------- + +function validateConfig(cfg) { + if (!cfg || typeof cfg !== 'object') throw new Error('config.json must be an object'); + if (typeof cfg.file !== 'string') throw new Error('config.file (string) required'); + if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') { + throw new Error('config.insertBefore or config.insertAfter (string) required'); + } + if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') { + throw new Error("config.commentSyntax must be 'html' or 'jsx'"); + } +} + +function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : ''; } + +function buildTagBlock(syntax, port) { + const open = commentOpen(syntax); + const close = commentClose(syntax); + return ( + open + ' ' + MARKER_OPEN_TEXT + ' ' + close + '\n' + + '\n' + + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' + ); +} + +function insertTag(content, config, port) { + const block = buildTagBlock(config.commentSyntax, port); + if (config.insertBefore) { + const idx = content.indexOf(config.insertBefore); + if (idx === -1) return content; + return content.slice(0, idx) + block + content.slice(idx); + } + // insertAfter + const idx = content.indexOf(config.insertAfter); + if (idx === -1) return content; + const after = idx + config.insertAfter.length; + // Preserve a single trailing newline if the anchor didn't end with one + const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n'; + return prefix + block + content.slice(prefix.length); +} + +/** + * Remove the live script block. Matches either HTML or JSX comment markers + * regardless of config (so stale tags from a wrong config can still be cleaned). + */ +function removeTag(content, _syntax) { + // Two patterns: HTML comment markers or JSX comment markers, with any content between. + const patterns = [ + /\n?[\s\S]*?\n?/, + /\n?\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}\n?/, + ]; + for (const pat of patterns) { + const next = content.replace(pat, '\n'); + if (next !== content) return next; + } + return content; +} + +// --------------------------------------------------------------------------- +// Auto-execute +// --------------------------------------------------------------------------- + +const _running = process.argv[1]; +if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs/')) { + injectCli(); +} + +export { insertTag, removeTag, validateConfig, buildTagBlock }; diff --git a/.cursor/skills/impeccable/reference/live.md b/.cursor/skills/impeccable/reference/live.md index a6a449cbe..f3b785fc7 100644 --- a/.cursor/skills/impeccable/reference/live.md +++ b/.cursor/skills/impeccable/reference/live.md @@ -15,34 +15,48 @@ Launch interactive live variant mode: select elements in the browser, pick a des ## Inject the Browser Script -Find the project's main HTML entry point. This varies by framework: +The `live-inject.mjs` script handles insertion deterministically. It reads `config.json` from its own directory (one-time per-project setup). -| Framework | Typical file | -|-----------|-------------| -| Plain HTML | `index.html` | -| Vite / React | `index.html` (project root) | -| Next.js (App Router) | `app/layout.tsx` (add a ` - +```bash +node {{scripts_path}}/live-inject.mjs --check ``` -**JSX / TSX (React, Next.js):** -```jsx -{/* impeccable-live-start */} - -{/* impeccable-live-end */} +If the output says `{"ok": true, ...}`, skip to Step 2. + +If the output says `{"ok": false, "error": "config_missing", "path": "..."}`, you need to create the config **once**. Look at the project structure and package.json to determine: + +| Framework | `file` | `insertBefore` | `commentSyntax` | +|-----------|--------|----------------|-----------------| +| Plain HTML | `index.html` | `` | `html` | +| Vite / React | `index.html` | `` | `html` | +| Next.js (App Router) | `app/layout.tsx` | `` | `jsx` | +| Next.js (Pages) | `pages/_document.tsx` | `` | `jsx` | +| Nuxt | `app.vue` | `` | `html` | +| Svelte / SvelteKit | `src/app.html` | `` | `html` | +| Astro | the root layout `.astro` file | `` | `html` | +| Static site with a non-root HTML file | e.g. `public/index.html` | `` | `html` | + +Write the config to the path reported by `--check`. Example for this project: + +```json +{ + "file": "public/index.html", + "insertBefore": "", + "commentSyntax": "html" +} ``` -Place it before the closing `` or at the end of the layout component. Save the file. The dev server will reload and the element picker will activate. +Use `insertAfter` instead of `insertBefore` if the anchor should be matched **after** a specific line (e.g. just after the main app script). + +### Step 2: Insert the live tag + +```bash +node {{scripts_path}}/live-inject.mjs --port PORT +``` + +Use the `port` from the live-server startup output. The script writes the tag idempotently: if a stale tag is present, it's replaced with one pointing at the new port. Save is automatic. If browser automation tools are available, also navigate to the page so the user can see it. @@ -177,7 +191,11 @@ If the poll is still running as a background task, kill it and proceed directly When the loop ends: -1. **Remove the injected script tag** from the source file. Delete everything between `` and `` (inclusive). Use the appropriate comment syntax for the framework. +1. **Remove the injected script tag**: + ```bash + node {{scripts_path}}/live-inject.mjs --remove + ``` + (The config.json stays so future `live-inject.mjs --port PORT` calls are instant.) 2. **Remove any leftover variant wrappers** (search for `impeccable-variants-start` markers and clean up). 3. **Remove any leftover carbonize blocks** (search for `impeccable-carbonize-start` markers and clean up). 4. **Stop the server**: diff --git a/.cursor/skills/impeccable/scripts/live-inject.mjs b/.cursor/skills/impeccable/scripts/live-inject.mjs new file mode 100644 index 000000000..d61c17925 --- /dev/null +++ b/.cursor/skills/impeccable/scripts/live-inject.mjs @@ -0,0 +1,174 @@ +/** + * CLI helper: insert/remove the live variant mode script tag in the project's + * main HTML entry point. + * + * On first live run, the agent generates `config.json` in this script's + * directory with the project's insertion target (framework-specific). On + * every subsequent run, this script handles insert/remove deterministically + * with zero LLM involvement. + * + * Usage: + * node live-inject.mjs --port PORT # Insert the live script tag + * node live-inject.mjs --remove # Remove the live script tag + * node live-inject.mjs --check # Check whether config.json exists + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const CONFIG_PATH = path.join(__dirname, 'config.json'); +const MARKER_OPEN_TEXT = 'impeccable-live-start'; +const MARKER_CLOSE_TEXT = 'impeccable-live-end'; + +export async function injectCli() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + console.log(`Usage: node live-inject.mjs [options] + +Insert or remove the live mode script tag in the project's HTML entry point. +Reads configuration from config.json (in this same directory). + +Modes: + --port PORT Insert script tag pointing at http://localhost:PORT/live.js + --remove Remove the script tag (if present) + --check Print whether config.json exists and its content + +Output (JSON): + { ok, file, inserted|removed, config? }`); + process.exit(0); + } + + if (args.includes('--check')) { + if (!fs.existsSync(CONFIG_PATH)) { + console.log(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH })); + process.exit(0); + } + try { + const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); + console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH })); + } catch (err) { + console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message })); + } + return; + } + + // Load config + if (!fs.existsSync(CONFIG_PATH)) { + console.error(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH })); + process.exit(1); + } + const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); + validateConfig(config); + + const absFile = path.resolve(process.cwd(), config.file); + if (!fs.existsSync(absFile)) { + console.error(JSON.stringify({ ok: false, error: 'file_not_found', file: config.file })); + process.exit(1); + } + + const content = fs.readFileSync(absFile, 'utf-8'); + + if (args.includes('--remove')) { + const updated = removeTag(content, config.commentSyntax); + if (updated === content) { + console.log(JSON.stringify({ ok: true, file: config.file, removed: false, note: 'no tag present' })); + return; + } + fs.writeFileSync(absFile, updated, 'utf-8'); + console.log(JSON.stringify({ ok: true, file: config.file, removed: true })); + return; + } + + // Insert mode — need --port + const portIdx = args.indexOf('--port'); + const port = portIdx !== -1 ? parseInt(args[portIdx + 1], 10) : NaN; + if (!Number.isFinite(port)) { + console.error(JSON.stringify({ ok: false, error: 'missing_port' })); + process.exit(1); + } + + // Already inserted? Replace to refresh the port. + const withoutOld = removeTag(content, config.commentSyntax); + const updated = insertTag(withoutOld, config, port); + if (updated === withoutOld) { + console.error(JSON.stringify({ ok: false, error: 'insertion_point_not_found', anchor: config.insertBefore })); + process.exit(1); + } + fs.writeFileSync(absFile, updated, 'utf-8'); + console.log(JSON.stringify({ ok: true, file: config.file, inserted: true, port })); +} + +// --------------------------------------------------------------------------- +// Core operations +// --------------------------------------------------------------------------- + +function validateConfig(cfg) { + if (!cfg || typeof cfg !== 'object') throw new Error('config.json must be an object'); + if (typeof cfg.file !== 'string') throw new Error('config.file (string) required'); + if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') { + throw new Error('config.insertBefore or config.insertAfter (string) required'); + } + if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') { + throw new Error("config.commentSyntax must be 'html' or 'jsx'"); + } +} + +function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : ''; } + +function buildTagBlock(syntax, port) { + const open = commentOpen(syntax); + const close = commentClose(syntax); + return ( + open + ' ' + MARKER_OPEN_TEXT + ' ' + close + '\n' + + '\n' + + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' + ); +} + +function insertTag(content, config, port) { + const block = buildTagBlock(config.commentSyntax, port); + if (config.insertBefore) { + const idx = content.indexOf(config.insertBefore); + if (idx === -1) return content; + return content.slice(0, idx) + block + content.slice(idx); + } + // insertAfter + const idx = content.indexOf(config.insertAfter); + if (idx === -1) return content; + const after = idx + config.insertAfter.length; + // Preserve a single trailing newline if the anchor didn't end with one + const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n'; + return prefix + block + content.slice(prefix.length); +} + +/** + * Remove the live script block. Matches either HTML or JSX comment markers + * regardless of config (so stale tags from a wrong config can still be cleaned). + */ +function removeTag(content, _syntax) { + // Two patterns: HTML comment markers or JSX comment markers, with any content between. + const patterns = [ + /\n?[\s\S]*?\n?/, + /\n?\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}\n?/, + ]; + for (const pat of patterns) { + const next = content.replace(pat, '\n'); + if (next !== content) return next; + } + return content; +} + +// --------------------------------------------------------------------------- +// Auto-execute +// --------------------------------------------------------------------------- + +const _running = process.argv[1]; +if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs/')) { + injectCli(); +} + +export { insertTag, removeTag, validateConfig, buildTagBlock }; diff --git a/.gemini/skills/impeccable/reference/live.md b/.gemini/skills/impeccable/reference/live.md index a6a449cbe..f3b785fc7 100644 --- a/.gemini/skills/impeccable/reference/live.md +++ b/.gemini/skills/impeccable/reference/live.md @@ -15,34 +15,48 @@ Launch interactive live variant mode: select elements in the browser, pick a des ## Inject the Browser Script -Find the project's main HTML entry point. This varies by framework: +The `live-inject.mjs` script handles insertion deterministically. It reads `config.json` from its own directory (one-time per-project setup). -| Framework | Typical file | -|-----------|-------------| -| Plain HTML | `index.html` | -| Vite / React | `index.html` (project root) | -| Next.js (App Router) | `app/layout.tsx` (add a ` - +```bash +node {{scripts_path}}/live-inject.mjs --check ``` -**JSX / TSX (React, Next.js):** -```jsx -{/* impeccable-live-start */} - -{/* impeccable-live-end */} +If the output says `{"ok": true, ...}`, skip to Step 2. + +If the output says `{"ok": false, "error": "config_missing", "path": "..."}`, you need to create the config **once**. Look at the project structure and package.json to determine: + +| Framework | `file` | `insertBefore` | `commentSyntax` | +|-----------|--------|----------------|-----------------| +| Plain HTML | `index.html` | `` | `html` | +| Vite / React | `index.html` | `` | `html` | +| Next.js (App Router) | `app/layout.tsx` | `` | `jsx` | +| Next.js (Pages) | `pages/_document.tsx` | `` | `jsx` | +| Nuxt | `app.vue` | `` | `html` | +| Svelte / SvelteKit | `src/app.html` | `` | `html` | +| Astro | the root layout `.astro` file | `` | `html` | +| Static site with a non-root HTML file | e.g. `public/index.html` | `` | `html` | + +Write the config to the path reported by `--check`. Example for this project: + +```json +{ + "file": "public/index.html", + "insertBefore": "", + "commentSyntax": "html" +} ``` -Place it before the closing `` or at the end of the layout component. Save the file. The dev server will reload and the element picker will activate. +Use `insertAfter` instead of `insertBefore` if the anchor should be matched **after** a specific line (e.g. just after the main app script). + +### Step 2: Insert the live tag + +```bash +node {{scripts_path}}/live-inject.mjs --port PORT +``` + +Use the `port` from the live-server startup output. The script writes the tag idempotently: if a stale tag is present, it's replaced with one pointing at the new port. Save is automatic. If browser automation tools are available, also navigate to the page so the user can see it. @@ -177,7 +191,11 @@ If the poll is still running as a background task, kill it and proceed directly When the loop ends: -1. **Remove the injected script tag** from the source file. Delete everything between `` and `` (inclusive). Use the appropriate comment syntax for the framework. +1. **Remove the injected script tag**: + ```bash + node {{scripts_path}}/live-inject.mjs --remove + ``` + (The config.json stays so future `live-inject.mjs --port PORT` calls are instant.) 2. **Remove any leftover variant wrappers** (search for `impeccable-variants-start` markers and clean up). 3. **Remove any leftover carbonize blocks** (search for `impeccable-carbonize-start` markers and clean up). 4. **Stop the server**: diff --git a/.gemini/skills/impeccable/scripts/live-inject.mjs b/.gemini/skills/impeccable/scripts/live-inject.mjs new file mode 100644 index 000000000..d61c17925 --- /dev/null +++ b/.gemini/skills/impeccable/scripts/live-inject.mjs @@ -0,0 +1,174 @@ +/** + * CLI helper: insert/remove the live variant mode script tag in the project's + * main HTML entry point. + * + * On first live run, the agent generates `config.json` in this script's + * directory with the project's insertion target (framework-specific). On + * every subsequent run, this script handles insert/remove deterministically + * with zero LLM involvement. + * + * Usage: + * node live-inject.mjs --port PORT # Insert the live script tag + * node live-inject.mjs --remove # Remove the live script tag + * node live-inject.mjs --check # Check whether config.json exists + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const CONFIG_PATH = path.join(__dirname, 'config.json'); +const MARKER_OPEN_TEXT = 'impeccable-live-start'; +const MARKER_CLOSE_TEXT = 'impeccable-live-end'; + +export async function injectCli() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + console.log(`Usage: node live-inject.mjs [options] + +Insert or remove the live mode script tag in the project's HTML entry point. +Reads configuration from config.json (in this same directory). + +Modes: + --port PORT Insert script tag pointing at http://localhost:PORT/live.js + --remove Remove the script tag (if present) + --check Print whether config.json exists and its content + +Output (JSON): + { ok, file, inserted|removed, config? }`); + process.exit(0); + } + + if (args.includes('--check')) { + if (!fs.existsSync(CONFIG_PATH)) { + console.log(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH })); + process.exit(0); + } + try { + const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); + console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH })); + } catch (err) { + console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message })); + } + return; + } + + // Load config + if (!fs.existsSync(CONFIG_PATH)) { + console.error(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH })); + process.exit(1); + } + const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); + validateConfig(config); + + const absFile = path.resolve(process.cwd(), config.file); + if (!fs.existsSync(absFile)) { + console.error(JSON.stringify({ ok: false, error: 'file_not_found', file: config.file })); + process.exit(1); + } + + const content = fs.readFileSync(absFile, 'utf-8'); + + if (args.includes('--remove')) { + const updated = removeTag(content, config.commentSyntax); + if (updated === content) { + console.log(JSON.stringify({ ok: true, file: config.file, removed: false, note: 'no tag present' })); + return; + } + fs.writeFileSync(absFile, updated, 'utf-8'); + console.log(JSON.stringify({ ok: true, file: config.file, removed: true })); + return; + } + + // Insert mode — need --port + const portIdx = args.indexOf('--port'); + const port = portIdx !== -1 ? parseInt(args[portIdx + 1], 10) : NaN; + if (!Number.isFinite(port)) { + console.error(JSON.stringify({ ok: false, error: 'missing_port' })); + process.exit(1); + } + + // Already inserted? Replace to refresh the port. + const withoutOld = removeTag(content, config.commentSyntax); + const updated = insertTag(withoutOld, config, port); + if (updated === withoutOld) { + console.error(JSON.stringify({ ok: false, error: 'insertion_point_not_found', anchor: config.insertBefore })); + process.exit(1); + } + fs.writeFileSync(absFile, updated, 'utf-8'); + console.log(JSON.stringify({ ok: true, file: config.file, inserted: true, port })); +} + +// --------------------------------------------------------------------------- +// Core operations +// --------------------------------------------------------------------------- + +function validateConfig(cfg) { + if (!cfg || typeof cfg !== 'object') throw new Error('config.json must be an object'); + if (typeof cfg.file !== 'string') throw new Error('config.file (string) required'); + if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') { + throw new Error('config.insertBefore or config.insertAfter (string) required'); + } + if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') { + throw new Error("config.commentSyntax must be 'html' or 'jsx'"); + } +} + +function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : ''; } + +function buildTagBlock(syntax, port) { + const open = commentOpen(syntax); + const close = commentClose(syntax); + return ( + open + ' ' + MARKER_OPEN_TEXT + ' ' + close + '\n' + + '\n' + + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' + ); +} + +function insertTag(content, config, port) { + const block = buildTagBlock(config.commentSyntax, port); + if (config.insertBefore) { + const idx = content.indexOf(config.insertBefore); + if (idx === -1) return content; + return content.slice(0, idx) + block + content.slice(idx); + } + // insertAfter + const idx = content.indexOf(config.insertAfter); + if (idx === -1) return content; + const after = idx + config.insertAfter.length; + // Preserve a single trailing newline if the anchor didn't end with one + const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n'; + return prefix + block + content.slice(prefix.length); +} + +/** + * Remove the live script block. Matches either HTML or JSX comment markers + * regardless of config (so stale tags from a wrong config can still be cleaned). + */ +function removeTag(content, _syntax) { + // Two patterns: HTML comment markers or JSX comment markers, with any content between. + const patterns = [ + /\n?[\s\S]*?\n?/, + /\n?\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}\n?/, + ]; + for (const pat of patterns) { + const next = content.replace(pat, '\n'); + if (next !== content) return next; + } + return content; +} + +// --------------------------------------------------------------------------- +// Auto-execute +// --------------------------------------------------------------------------- + +const _running = process.argv[1]; +if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs/')) { + injectCli(); +} + +export { insertTag, removeTag, validateConfig, buildTagBlock }; diff --git a/.github/skills/impeccable/reference/live.md b/.github/skills/impeccable/reference/live.md index a6a449cbe..f3b785fc7 100644 --- a/.github/skills/impeccable/reference/live.md +++ b/.github/skills/impeccable/reference/live.md @@ -15,34 +15,48 @@ Launch interactive live variant mode: select elements in the browser, pick a des ## Inject the Browser Script -Find the project's main HTML entry point. This varies by framework: +The `live-inject.mjs` script handles insertion deterministically. It reads `config.json` from its own directory (one-time per-project setup). -| Framework | Typical file | -|-----------|-------------| -| Plain HTML | `index.html` | -| Vite / React | `index.html` (project root) | -| Next.js (App Router) | `app/layout.tsx` (add a ` - +```bash +node {{scripts_path}}/live-inject.mjs --check ``` -**JSX / TSX (React, Next.js):** -```jsx -{/* impeccable-live-start */} - -{/* impeccable-live-end */} +If the output says `{"ok": true, ...}`, skip to Step 2. + +If the output says `{"ok": false, "error": "config_missing", "path": "..."}`, you need to create the config **once**. Look at the project structure and package.json to determine: + +| Framework | `file` | `insertBefore` | `commentSyntax` | +|-----------|--------|----------------|-----------------| +| Plain HTML | `index.html` | `` | `html` | +| Vite / React | `index.html` | `` | `html` | +| Next.js (App Router) | `app/layout.tsx` | `` | `jsx` | +| Next.js (Pages) | `pages/_document.tsx` | `` | `jsx` | +| Nuxt | `app.vue` | `` | `html` | +| Svelte / SvelteKit | `src/app.html` | `` | `html` | +| Astro | the root layout `.astro` file | `` | `html` | +| Static site with a non-root HTML file | e.g. `public/index.html` | `` | `html` | + +Write the config to the path reported by `--check`. Example for this project: + +```json +{ + "file": "public/index.html", + "insertBefore": "", + "commentSyntax": "html" +} ``` -Place it before the closing `` or at the end of the layout component. Save the file. The dev server will reload and the element picker will activate. +Use `insertAfter` instead of `insertBefore` if the anchor should be matched **after** a specific line (e.g. just after the main app script). + +### Step 2: Insert the live tag + +```bash +node {{scripts_path}}/live-inject.mjs --port PORT +``` + +Use the `port` from the live-server startup output. The script writes the tag idempotently: if a stale tag is present, it's replaced with one pointing at the new port. Save is automatic. If browser automation tools are available, also navigate to the page so the user can see it. @@ -177,7 +191,11 @@ If the poll is still running as a background task, kill it and proceed directly When the loop ends: -1. **Remove the injected script tag** from the source file. Delete everything between `` and `` (inclusive). Use the appropriate comment syntax for the framework. +1. **Remove the injected script tag**: + ```bash + node {{scripts_path}}/live-inject.mjs --remove + ``` + (The config.json stays so future `live-inject.mjs --port PORT` calls are instant.) 2. **Remove any leftover variant wrappers** (search for `impeccable-variants-start` markers and clean up). 3. **Remove any leftover carbonize blocks** (search for `impeccable-carbonize-start` markers and clean up). 4. **Stop the server**: diff --git a/.github/skills/impeccable/scripts/live-inject.mjs b/.github/skills/impeccable/scripts/live-inject.mjs new file mode 100644 index 000000000..d61c17925 --- /dev/null +++ b/.github/skills/impeccable/scripts/live-inject.mjs @@ -0,0 +1,174 @@ +/** + * CLI helper: insert/remove the live variant mode script tag in the project's + * main HTML entry point. + * + * On first live run, the agent generates `config.json` in this script's + * directory with the project's insertion target (framework-specific). On + * every subsequent run, this script handles insert/remove deterministically + * with zero LLM involvement. + * + * Usage: + * node live-inject.mjs --port PORT # Insert the live script tag + * node live-inject.mjs --remove # Remove the live script tag + * node live-inject.mjs --check # Check whether config.json exists + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const CONFIG_PATH = path.join(__dirname, 'config.json'); +const MARKER_OPEN_TEXT = 'impeccable-live-start'; +const MARKER_CLOSE_TEXT = 'impeccable-live-end'; + +export async function injectCli() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + console.log(`Usage: node live-inject.mjs [options] + +Insert or remove the live mode script tag in the project's HTML entry point. +Reads configuration from config.json (in this same directory). + +Modes: + --port PORT Insert script tag pointing at http://localhost:PORT/live.js + --remove Remove the script tag (if present) + --check Print whether config.json exists and its content + +Output (JSON): + { ok, file, inserted|removed, config? }`); + process.exit(0); + } + + if (args.includes('--check')) { + if (!fs.existsSync(CONFIG_PATH)) { + console.log(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH })); + process.exit(0); + } + try { + const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); + console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH })); + } catch (err) { + console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message })); + } + return; + } + + // Load config + if (!fs.existsSync(CONFIG_PATH)) { + console.error(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH })); + process.exit(1); + } + const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); + validateConfig(config); + + const absFile = path.resolve(process.cwd(), config.file); + if (!fs.existsSync(absFile)) { + console.error(JSON.stringify({ ok: false, error: 'file_not_found', file: config.file })); + process.exit(1); + } + + const content = fs.readFileSync(absFile, 'utf-8'); + + if (args.includes('--remove')) { + const updated = removeTag(content, config.commentSyntax); + if (updated === content) { + console.log(JSON.stringify({ ok: true, file: config.file, removed: false, note: 'no tag present' })); + return; + } + fs.writeFileSync(absFile, updated, 'utf-8'); + console.log(JSON.stringify({ ok: true, file: config.file, removed: true })); + return; + } + + // Insert mode — need --port + const portIdx = args.indexOf('--port'); + const port = portIdx !== -1 ? parseInt(args[portIdx + 1], 10) : NaN; + if (!Number.isFinite(port)) { + console.error(JSON.stringify({ ok: false, error: 'missing_port' })); + process.exit(1); + } + + // Already inserted? Replace to refresh the port. + const withoutOld = removeTag(content, config.commentSyntax); + const updated = insertTag(withoutOld, config, port); + if (updated === withoutOld) { + console.error(JSON.stringify({ ok: false, error: 'insertion_point_not_found', anchor: config.insertBefore })); + process.exit(1); + } + fs.writeFileSync(absFile, updated, 'utf-8'); + console.log(JSON.stringify({ ok: true, file: config.file, inserted: true, port })); +} + +// --------------------------------------------------------------------------- +// Core operations +// --------------------------------------------------------------------------- + +function validateConfig(cfg) { + if (!cfg || typeof cfg !== 'object') throw new Error('config.json must be an object'); + if (typeof cfg.file !== 'string') throw new Error('config.file (string) required'); + if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') { + throw new Error('config.insertBefore or config.insertAfter (string) required'); + } + if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') { + throw new Error("config.commentSyntax must be 'html' or 'jsx'"); + } +} + +function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : ''; } + +function buildTagBlock(syntax, port) { + const open = commentOpen(syntax); + const close = commentClose(syntax); + return ( + open + ' ' + MARKER_OPEN_TEXT + ' ' + close + '\n' + + '\n' + + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' + ); +} + +function insertTag(content, config, port) { + const block = buildTagBlock(config.commentSyntax, port); + if (config.insertBefore) { + const idx = content.indexOf(config.insertBefore); + if (idx === -1) return content; + return content.slice(0, idx) + block + content.slice(idx); + } + // insertAfter + const idx = content.indexOf(config.insertAfter); + if (idx === -1) return content; + const after = idx + config.insertAfter.length; + // Preserve a single trailing newline if the anchor didn't end with one + const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n'; + return prefix + block + content.slice(prefix.length); +} + +/** + * Remove the live script block. Matches either HTML or JSX comment markers + * regardless of config (so stale tags from a wrong config can still be cleaned). + */ +function removeTag(content, _syntax) { + // Two patterns: HTML comment markers or JSX comment markers, with any content between. + const patterns = [ + /\n?[\s\S]*?\n?/, + /\n?\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}\n?/, + ]; + for (const pat of patterns) { + const next = content.replace(pat, '\n'); + if (next !== content) return next; + } + return content; +} + +// --------------------------------------------------------------------------- +// Auto-execute +// --------------------------------------------------------------------------- + +const _running = process.argv[1]; +if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs/')) { + injectCli(); +} + +export { insertTag, removeTag, validateConfig, buildTagBlock }; diff --git a/.gitignore b/.gitignore index 1923ca3ca..631650cc6 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,10 @@ Thumbs.db # Live mode session file (created by live-server.mjs, cleaned up on stop) .impeccable-live.json +# Per-project live mode injection config (generated once per project by the +# skill; wiped on skill update, regenerated on next live run) +**/skills/impeccable/scripts/config.json + # Extension build artifacts extension/detector/ diff --git a/.kiro/skills/impeccable/reference/live.md b/.kiro/skills/impeccable/reference/live.md index a6a449cbe..f3b785fc7 100644 --- a/.kiro/skills/impeccable/reference/live.md +++ b/.kiro/skills/impeccable/reference/live.md @@ -15,34 +15,48 @@ Launch interactive live variant mode: select elements in the browser, pick a des ## Inject the Browser Script -Find the project's main HTML entry point. This varies by framework: +The `live-inject.mjs` script handles insertion deterministically. It reads `config.json` from its own directory (one-time per-project setup). -| Framework | Typical file | -|-----------|-------------| -| Plain HTML | `index.html` | -| Vite / React | `index.html` (project root) | -| Next.js (App Router) | `app/layout.tsx` (add a ` - +```bash +node {{scripts_path}}/live-inject.mjs --check ``` -**JSX / TSX (React, Next.js):** -```jsx -{/* impeccable-live-start */} - -{/* impeccable-live-end */} +If the output says `{"ok": true, ...}`, skip to Step 2. + +If the output says `{"ok": false, "error": "config_missing", "path": "..."}`, you need to create the config **once**. Look at the project structure and package.json to determine: + +| Framework | `file` | `insertBefore` | `commentSyntax` | +|-----------|--------|----------------|-----------------| +| Plain HTML | `index.html` | `` | `html` | +| Vite / React | `index.html` | `` | `html` | +| Next.js (App Router) | `app/layout.tsx` | `` | `jsx` | +| Next.js (Pages) | `pages/_document.tsx` | `` | `jsx` | +| Nuxt | `app.vue` | `` | `html` | +| Svelte / SvelteKit | `src/app.html` | `` | `html` | +| Astro | the root layout `.astro` file | `` | `html` | +| Static site with a non-root HTML file | e.g. `public/index.html` | `` | `html` | + +Write the config to the path reported by `--check`. Example for this project: + +```json +{ + "file": "public/index.html", + "insertBefore": "", + "commentSyntax": "html" +} ``` -Place it before the closing `` or at the end of the layout component. Save the file. The dev server will reload and the element picker will activate. +Use `insertAfter` instead of `insertBefore` if the anchor should be matched **after** a specific line (e.g. just after the main app script). + +### Step 2: Insert the live tag + +```bash +node {{scripts_path}}/live-inject.mjs --port PORT +``` + +Use the `port` from the live-server startup output. The script writes the tag idempotently: if a stale tag is present, it's replaced with one pointing at the new port. Save is automatic. If browser automation tools are available, also navigate to the page so the user can see it. @@ -177,7 +191,11 @@ If the poll is still running as a background task, kill it and proceed directly When the loop ends: -1. **Remove the injected script tag** from the source file. Delete everything between `` and `` (inclusive). Use the appropriate comment syntax for the framework. +1. **Remove the injected script tag**: + ```bash + node {{scripts_path}}/live-inject.mjs --remove + ``` + (The config.json stays so future `live-inject.mjs --port PORT` calls are instant.) 2. **Remove any leftover variant wrappers** (search for `impeccable-variants-start` markers and clean up). 3. **Remove any leftover carbonize blocks** (search for `impeccable-carbonize-start` markers and clean up). 4. **Stop the server**: diff --git a/.kiro/skills/impeccable/scripts/live-inject.mjs b/.kiro/skills/impeccable/scripts/live-inject.mjs new file mode 100644 index 000000000..d61c17925 --- /dev/null +++ b/.kiro/skills/impeccable/scripts/live-inject.mjs @@ -0,0 +1,174 @@ +/** + * CLI helper: insert/remove the live variant mode script tag in the project's + * main HTML entry point. + * + * On first live run, the agent generates `config.json` in this script's + * directory with the project's insertion target (framework-specific). On + * every subsequent run, this script handles insert/remove deterministically + * with zero LLM involvement. + * + * Usage: + * node live-inject.mjs --port PORT # Insert the live script tag + * node live-inject.mjs --remove # Remove the live script tag + * node live-inject.mjs --check # Check whether config.json exists + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const CONFIG_PATH = path.join(__dirname, 'config.json'); +const MARKER_OPEN_TEXT = 'impeccable-live-start'; +const MARKER_CLOSE_TEXT = 'impeccable-live-end'; + +export async function injectCli() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + console.log(`Usage: node live-inject.mjs [options] + +Insert or remove the live mode script tag in the project's HTML entry point. +Reads configuration from config.json (in this same directory). + +Modes: + --port PORT Insert script tag pointing at http://localhost:PORT/live.js + --remove Remove the script tag (if present) + --check Print whether config.json exists and its content + +Output (JSON): + { ok, file, inserted|removed, config? }`); + process.exit(0); + } + + if (args.includes('--check')) { + if (!fs.existsSync(CONFIG_PATH)) { + console.log(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH })); + process.exit(0); + } + try { + const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); + console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH })); + } catch (err) { + console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message })); + } + return; + } + + // Load config + if (!fs.existsSync(CONFIG_PATH)) { + console.error(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH })); + process.exit(1); + } + const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); + validateConfig(config); + + const absFile = path.resolve(process.cwd(), config.file); + if (!fs.existsSync(absFile)) { + console.error(JSON.stringify({ ok: false, error: 'file_not_found', file: config.file })); + process.exit(1); + } + + const content = fs.readFileSync(absFile, 'utf-8'); + + if (args.includes('--remove')) { + const updated = removeTag(content, config.commentSyntax); + if (updated === content) { + console.log(JSON.stringify({ ok: true, file: config.file, removed: false, note: 'no tag present' })); + return; + } + fs.writeFileSync(absFile, updated, 'utf-8'); + console.log(JSON.stringify({ ok: true, file: config.file, removed: true })); + return; + } + + // Insert mode — need --port + const portIdx = args.indexOf('--port'); + const port = portIdx !== -1 ? parseInt(args[portIdx + 1], 10) : NaN; + if (!Number.isFinite(port)) { + console.error(JSON.stringify({ ok: false, error: 'missing_port' })); + process.exit(1); + } + + // Already inserted? Replace to refresh the port. + const withoutOld = removeTag(content, config.commentSyntax); + const updated = insertTag(withoutOld, config, port); + if (updated === withoutOld) { + console.error(JSON.stringify({ ok: false, error: 'insertion_point_not_found', anchor: config.insertBefore })); + process.exit(1); + } + fs.writeFileSync(absFile, updated, 'utf-8'); + console.log(JSON.stringify({ ok: true, file: config.file, inserted: true, port })); +} + +// --------------------------------------------------------------------------- +// Core operations +// --------------------------------------------------------------------------- + +function validateConfig(cfg) { + if (!cfg || typeof cfg !== 'object') throw new Error('config.json must be an object'); + if (typeof cfg.file !== 'string') throw new Error('config.file (string) required'); + if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') { + throw new Error('config.insertBefore or config.insertAfter (string) required'); + } + if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') { + throw new Error("config.commentSyntax must be 'html' or 'jsx'"); + } +} + +function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : ''; } + +function buildTagBlock(syntax, port) { + const open = commentOpen(syntax); + const close = commentClose(syntax); + return ( + open + ' ' + MARKER_OPEN_TEXT + ' ' + close + '\n' + + '\n' + + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' + ); +} + +function insertTag(content, config, port) { + const block = buildTagBlock(config.commentSyntax, port); + if (config.insertBefore) { + const idx = content.indexOf(config.insertBefore); + if (idx === -1) return content; + return content.slice(0, idx) + block + content.slice(idx); + } + // insertAfter + const idx = content.indexOf(config.insertAfter); + if (idx === -1) return content; + const after = idx + config.insertAfter.length; + // Preserve a single trailing newline if the anchor didn't end with one + const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n'; + return prefix + block + content.slice(prefix.length); +} + +/** + * Remove the live script block. Matches either HTML or JSX comment markers + * regardless of config (so stale tags from a wrong config can still be cleaned). + */ +function removeTag(content, _syntax) { + // Two patterns: HTML comment markers or JSX comment markers, with any content between. + const patterns = [ + /\n?[\s\S]*?\n?/, + /\n?\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}\n?/, + ]; + for (const pat of patterns) { + const next = content.replace(pat, '\n'); + if (next !== content) return next; + } + return content; +} + +// --------------------------------------------------------------------------- +// Auto-execute +// --------------------------------------------------------------------------- + +const _running = process.argv[1]; +if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs/')) { + injectCli(); +} + +export { insertTag, removeTag, validateConfig, buildTagBlock }; diff --git a/.opencode/skills/impeccable/reference/live.md b/.opencode/skills/impeccable/reference/live.md index a6a449cbe..f3b785fc7 100644 --- a/.opencode/skills/impeccable/reference/live.md +++ b/.opencode/skills/impeccable/reference/live.md @@ -15,34 +15,48 @@ Launch interactive live variant mode: select elements in the browser, pick a des ## Inject the Browser Script -Find the project's main HTML entry point. This varies by framework: +The `live-inject.mjs` script handles insertion deterministically. It reads `config.json` from its own directory (one-time per-project setup). -| Framework | Typical file | -|-----------|-------------| -| Plain HTML | `index.html` | -| Vite / React | `index.html` (project root) | -| Next.js (App Router) | `app/layout.tsx` (add a ` - +```bash +node {{scripts_path}}/live-inject.mjs --check ``` -**JSX / TSX (React, Next.js):** -```jsx -{/* impeccable-live-start */} - -{/* impeccable-live-end */} +If the output says `{"ok": true, ...}`, skip to Step 2. + +If the output says `{"ok": false, "error": "config_missing", "path": "..."}`, you need to create the config **once**. Look at the project structure and package.json to determine: + +| Framework | `file` | `insertBefore` | `commentSyntax` | +|-----------|--------|----------------|-----------------| +| Plain HTML | `index.html` | `` | `html` | +| Vite / React | `index.html` | `` | `html` | +| Next.js (App Router) | `app/layout.tsx` | `` | `jsx` | +| Next.js (Pages) | `pages/_document.tsx` | `` | `jsx` | +| Nuxt | `app.vue` | `` | `html` | +| Svelte / SvelteKit | `src/app.html` | `` | `html` | +| Astro | the root layout `.astro` file | `` | `html` | +| Static site with a non-root HTML file | e.g. `public/index.html` | `` | `html` | + +Write the config to the path reported by `--check`. Example for this project: + +```json +{ + "file": "public/index.html", + "insertBefore": "", + "commentSyntax": "html" +} ``` -Place it before the closing `` or at the end of the layout component. Save the file. The dev server will reload and the element picker will activate. +Use `insertAfter` instead of `insertBefore` if the anchor should be matched **after** a specific line (e.g. just after the main app script). + +### Step 2: Insert the live tag + +```bash +node {{scripts_path}}/live-inject.mjs --port PORT +``` + +Use the `port` from the live-server startup output. The script writes the tag idempotently: if a stale tag is present, it's replaced with one pointing at the new port. Save is automatic. If browser automation tools are available, also navigate to the page so the user can see it. @@ -177,7 +191,11 @@ If the poll is still running as a background task, kill it and proceed directly When the loop ends: -1. **Remove the injected script tag** from the source file. Delete everything between `` and `` (inclusive). Use the appropriate comment syntax for the framework. +1. **Remove the injected script tag**: + ```bash + node {{scripts_path}}/live-inject.mjs --remove + ``` + (The config.json stays so future `live-inject.mjs --port PORT` calls are instant.) 2. **Remove any leftover variant wrappers** (search for `impeccable-variants-start` markers and clean up). 3. **Remove any leftover carbonize blocks** (search for `impeccable-carbonize-start` markers and clean up). 4. **Stop the server**: diff --git a/.opencode/skills/impeccable/scripts/live-inject.mjs b/.opencode/skills/impeccable/scripts/live-inject.mjs new file mode 100644 index 000000000..d61c17925 --- /dev/null +++ b/.opencode/skills/impeccable/scripts/live-inject.mjs @@ -0,0 +1,174 @@ +/** + * CLI helper: insert/remove the live variant mode script tag in the project's + * main HTML entry point. + * + * On first live run, the agent generates `config.json` in this script's + * directory with the project's insertion target (framework-specific). On + * every subsequent run, this script handles insert/remove deterministically + * with zero LLM involvement. + * + * Usage: + * node live-inject.mjs --port PORT # Insert the live script tag + * node live-inject.mjs --remove # Remove the live script tag + * node live-inject.mjs --check # Check whether config.json exists + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const CONFIG_PATH = path.join(__dirname, 'config.json'); +const MARKER_OPEN_TEXT = 'impeccable-live-start'; +const MARKER_CLOSE_TEXT = 'impeccable-live-end'; + +export async function injectCli() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + console.log(`Usage: node live-inject.mjs [options] + +Insert or remove the live mode script tag in the project's HTML entry point. +Reads configuration from config.json (in this same directory). + +Modes: + --port PORT Insert script tag pointing at http://localhost:PORT/live.js + --remove Remove the script tag (if present) + --check Print whether config.json exists and its content + +Output (JSON): + { ok, file, inserted|removed, config? }`); + process.exit(0); + } + + if (args.includes('--check')) { + if (!fs.existsSync(CONFIG_PATH)) { + console.log(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH })); + process.exit(0); + } + try { + const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); + console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH })); + } catch (err) { + console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message })); + } + return; + } + + // Load config + if (!fs.existsSync(CONFIG_PATH)) { + console.error(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH })); + process.exit(1); + } + const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); + validateConfig(config); + + const absFile = path.resolve(process.cwd(), config.file); + if (!fs.existsSync(absFile)) { + console.error(JSON.stringify({ ok: false, error: 'file_not_found', file: config.file })); + process.exit(1); + } + + const content = fs.readFileSync(absFile, 'utf-8'); + + if (args.includes('--remove')) { + const updated = removeTag(content, config.commentSyntax); + if (updated === content) { + console.log(JSON.stringify({ ok: true, file: config.file, removed: false, note: 'no tag present' })); + return; + } + fs.writeFileSync(absFile, updated, 'utf-8'); + console.log(JSON.stringify({ ok: true, file: config.file, removed: true })); + return; + } + + // Insert mode — need --port + const portIdx = args.indexOf('--port'); + const port = portIdx !== -1 ? parseInt(args[portIdx + 1], 10) : NaN; + if (!Number.isFinite(port)) { + console.error(JSON.stringify({ ok: false, error: 'missing_port' })); + process.exit(1); + } + + // Already inserted? Replace to refresh the port. + const withoutOld = removeTag(content, config.commentSyntax); + const updated = insertTag(withoutOld, config, port); + if (updated === withoutOld) { + console.error(JSON.stringify({ ok: false, error: 'insertion_point_not_found', anchor: config.insertBefore })); + process.exit(1); + } + fs.writeFileSync(absFile, updated, 'utf-8'); + console.log(JSON.stringify({ ok: true, file: config.file, inserted: true, port })); +} + +// --------------------------------------------------------------------------- +// Core operations +// --------------------------------------------------------------------------- + +function validateConfig(cfg) { + if (!cfg || typeof cfg !== 'object') throw new Error('config.json must be an object'); + if (typeof cfg.file !== 'string') throw new Error('config.file (string) required'); + if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') { + throw new Error('config.insertBefore or config.insertAfter (string) required'); + } + if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') { + throw new Error("config.commentSyntax must be 'html' or 'jsx'"); + } +} + +function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : ''; } + +function buildTagBlock(syntax, port) { + const open = commentOpen(syntax); + const close = commentClose(syntax); + return ( + open + ' ' + MARKER_OPEN_TEXT + ' ' + close + '\n' + + '\n' + + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' + ); +} + +function insertTag(content, config, port) { + const block = buildTagBlock(config.commentSyntax, port); + if (config.insertBefore) { + const idx = content.indexOf(config.insertBefore); + if (idx === -1) return content; + return content.slice(0, idx) + block + content.slice(idx); + } + // insertAfter + const idx = content.indexOf(config.insertAfter); + if (idx === -1) return content; + const after = idx + config.insertAfter.length; + // Preserve a single trailing newline if the anchor didn't end with one + const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n'; + return prefix + block + content.slice(prefix.length); +} + +/** + * Remove the live script block. Matches either HTML or JSX comment markers + * regardless of config (so stale tags from a wrong config can still be cleaned). + */ +function removeTag(content, _syntax) { + // Two patterns: HTML comment markers or JSX comment markers, with any content between. + const patterns = [ + /\n?[\s\S]*?\n?/, + /\n?\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}\n?/, + ]; + for (const pat of patterns) { + const next = content.replace(pat, '\n'); + if (next !== content) return next; + } + return content; +} + +// --------------------------------------------------------------------------- +// Auto-execute +// --------------------------------------------------------------------------- + +const _running = process.argv[1]; +if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs/')) { + injectCli(); +} + +export { insertTag, removeTag, validateConfig, buildTagBlock }; diff --git a/.pi/skills/impeccable/reference/live.md b/.pi/skills/impeccable/reference/live.md index a6a449cbe..f3b785fc7 100644 --- a/.pi/skills/impeccable/reference/live.md +++ b/.pi/skills/impeccable/reference/live.md @@ -15,34 +15,48 @@ Launch interactive live variant mode: select elements in the browser, pick a des ## Inject the Browser Script -Find the project's main HTML entry point. This varies by framework: +The `live-inject.mjs` script handles insertion deterministically. It reads `config.json` from its own directory (one-time per-project setup). -| Framework | Typical file | -|-----------|-------------| -| Plain HTML | `index.html` | -| Vite / React | `index.html` (project root) | -| Next.js (App Router) | `app/layout.tsx` (add a ` - +```bash +node {{scripts_path}}/live-inject.mjs --check ``` -**JSX / TSX (React, Next.js):** -```jsx -{/* impeccable-live-start */} - -{/* impeccable-live-end */} +If the output says `{"ok": true, ...}`, skip to Step 2. + +If the output says `{"ok": false, "error": "config_missing", "path": "..."}`, you need to create the config **once**. Look at the project structure and package.json to determine: + +| Framework | `file` | `insertBefore` | `commentSyntax` | +|-----------|--------|----------------|-----------------| +| Plain HTML | `index.html` | `` | `html` | +| Vite / React | `index.html` | `` | `html` | +| Next.js (App Router) | `app/layout.tsx` | `` | `jsx` | +| Next.js (Pages) | `pages/_document.tsx` | `` | `jsx` | +| Nuxt | `app.vue` | `` | `html` | +| Svelte / SvelteKit | `src/app.html` | `` | `html` | +| Astro | the root layout `.astro` file | `` | `html` | +| Static site with a non-root HTML file | e.g. `public/index.html` | `` | `html` | + +Write the config to the path reported by `--check`. Example for this project: + +```json +{ + "file": "public/index.html", + "insertBefore": "", + "commentSyntax": "html" +} ``` -Place it before the closing `` or at the end of the layout component. Save the file. The dev server will reload and the element picker will activate. +Use `insertAfter` instead of `insertBefore` if the anchor should be matched **after** a specific line (e.g. just after the main app script). + +### Step 2: Insert the live tag + +```bash +node {{scripts_path}}/live-inject.mjs --port PORT +``` + +Use the `port` from the live-server startup output. The script writes the tag idempotently: if a stale tag is present, it's replaced with one pointing at the new port. Save is automatic. If browser automation tools are available, also navigate to the page so the user can see it. @@ -177,7 +191,11 @@ If the poll is still running as a background task, kill it and proceed directly When the loop ends: -1. **Remove the injected script tag** from the source file. Delete everything between `` and `` (inclusive). Use the appropriate comment syntax for the framework. +1. **Remove the injected script tag**: + ```bash + node {{scripts_path}}/live-inject.mjs --remove + ``` + (The config.json stays so future `live-inject.mjs --port PORT` calls are instant.) 2. **Remove any leftover variant wrappers** (search for `impeccable-variants-start` markers and clean up). 3. **Remove any leftover carbonize blocks** (search for `impeccable-carbonize-start` markers and clean up). 4. **Stop the server**: diff --git a/.pi/skills/impeccable/scripts/live-inject.mjs b/.pi/skills/impeccable/scripts/live-inject.mjs new file mode 100644 index 000000000..d61c17925 --- /dev/null +++ b/.pi/skills/impeccable/scripts/live-inject.mjs @@ -0,0 +1,174 @@ +/** + * CLI helper: insert/remove the live variant mode script tag in the project's + * main HTML entry point. + * + * On first live run, the agent generates `config.json` in this script's + * directory with the project's insertion target (framework-specific). On + * every subsequent run, this script handles insert/remove deterministically + * with zero LLM involvement. + * + * Usage: + * node live-inject.mjs --port PORT # Insert the live script tag + * node live-inject.mjs --remove # Remove the live script tag + * node live-inject.mjs --check # Check whether config.json exists + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const CONFIG_PATH = path.join(__dirname, 'config.json'); +const MARKER_OPEN_TEXT = 'impeccable-live-start'; +const MARKER_CLOSE_TEXT = 'impeccable-live-end'; + +export async function injectCli() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + console.log(`Usage: node live-inject.mjs [options] + +Insert or remove the live mode script tag in the project's HTML entry point. +Reads configuration from config.json (in this same directory). + +Modes: + --port PORT Insert script tag pointing at http://localhost:PORT/live.js + --remove Remove the script tag (if present) + --check Print whether config.json exists and its content + +Output (JSON): + { ok, file, inserted|removed, config? }`); + process.exit(0); + } + + if (args.includes('--check')) { + if (!fs.existsSync(CONFIG_PATH)) { + console.log(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH })); + process.exit(0); + } + try { + const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); + console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH })); + } catch (err) { + console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message })); + } + return; + } + + // Load config + if (!fs.existsSync(CONFIG_PATH)) { + console.error(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH })); + process.exit(1); + } + const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); + validateConfig(config); + + const absFile = path.resolve(process.cwd(), config.file); + if (!fs.existsSync(absFile)) { + console.error(JSON.stringify({ ok: false, error: 'file_not_found', file: config.file })); + process.exit(1); + } + + const content = fs.readFileSync(absFile, 'utf-8'); + + if (args.includes('--remove')) { + const updated = removeTag(content, config.commentSyntax); + if (updated === content) { + console.log(JSON.stringify({ ok: true, file: config.file, removed: false, note: 'no tag present' })); + return; + } + fs.writeFileSync(absFile, updated, 'utf-8'); + console.log(JSON.stringify({ ok: true, file: config.file, removed: true })); + return; + } + + // Insert mode — need --port + const portIdx = args.indexOf('--port'); + const port = portIdx !== -1 ? parseInt(args[portIdx + 1], 10) : NaN; + if (!Number.isFinite(port)) { + console.error(JSON.stringify({ ok: false, error: 'missing_port' })); + process.exit(1); + } + + // Already inserted? Replace to refresh the port. + const withoutOld = removeTag(content, config.commentSyntax); + const updated = insertTag(withoutOld, config, port); + if (updated === withoutOld) { + console.error(JSON.stringify({ ok: false, error: 'insertion_point_not_found', anchor: config.insertBefore })); + process.exit(1); + } + fs.writeFileSync(absFile, updated, 'utf-8'); + console.log(JSON.stringify({ ok: true, file: config.file, inserted: true, port })); +} + +// --------------------------------------------------------------------------- +// Core operations +// --------------------------------------------------------------------------- + +function validateConfig(cfg) { + if (!cfg || typeof cfg !== 'object') throw new Error('config.json must be an object'); + if (typeof cfg.file !== 'string') throw new Error('config.file (string) required'); + if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') { + throw new Error('config.insertBefore or config.insertAfter (string) required'); + } + if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') { + throw new Error("config.commentSyntax must be 'html' or 'jsx'"); + } +} + +function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : ''; } + +function buildTagBlock(syntax, port) { + const open = commentOpen(syntax); + const close = commentClose(syntax); + return ( + open + ' ' + MARKER_OPEN_TEXT + ' ' + close + '\n' + + '\n' + + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' + ); +} + +function insertTag(content, config, port) { + const block = buildTagBlock(config.commentSyntax, port); + if (config.insertBefore) { + const idx = content.indexOf(config.insertBefore); + if (idx === -1) return content; + return content.slice(0, idx) + block + content.slice(idx); + } + // insertAfter + const idx = content.indexOf(config.insertAfter); + if (idx === -1) return content; + const after = idx + config.insertAfter.length; + // Preserve a single trailing newline if the anchor didn't end with one + const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n'; + return prefix + block + content.slice(prefix.length); +} + +/** + * Remove the live script block. Matches either HTML or JSX comment markers + * regardless of config (so stale tags from a wrong config can still be cleaned). + */ +function removeTag(content, _syntax) { + // Two patterns: HTML comment markers or JSX comment markers, with any content between. + const patterns = [ + /\n?[\s\S]*?\n?/, + /\n?\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}\n?/, + ]; + for (const pat of patterns) { + const next = content.replace(pat, '\n'); + if (next !== content) return next; + } + return content; +} + +// --------------------------------------------------------------------------- +// Auto-execute +// --------------------------------------------------------------------------- + +const _running = process.argv[1]; +if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs/')) { + injectCli(); +} + +export { insertTag, removeTag, validateConfig, buildTagBlock }; diff --git a/.rovodev/skills/impeccable/reference/live.md b/.rovodev/skills/impeccable/reference/live.md index a6a449cbe..f3b785fc7 100644 --- a/.rovodev/skills/impeccable/reference/live.md +++ b/.rovodev/skills/impeccable/reference/live.md @@ -15,34 +15,48 @@ Launch interactive live variant mode: select elements in the browser, pick a des ## Inject the Browser Script -Find the project's main HTML entry point. This varies by framework: +The `live-inject.mjs` script handles insertion deterministically. It reads `config.json` from its own directory (one-time per-project setup). -| Framework | Typical file | -|-----------|-------------| -| Plain HTML | `index.html` | -| Vite / React | `index.html` (project root) | -| Next.js (App Router) | `app/layout.tsx` (add a ` - +```bash +node {{scripts_path}}/live-inject.mjs --check ``` -**JSX / TSX (React, Next.js):** -```jsx -{/* impeccable-live-start */} - -{/* impeccable-live-end */} +If the output says `{"ok": true, ...}`, skip to Step 2. + +If the output says `{"ok": false, "error": "config_missing", "path": "..."}`, you need to create the config **once**. Look at the project structure and package.json to determine: + +| Framework | `file` | `insertBefore` | `commentSyntax` | +|-----------|--------|----------------|-----------------| +| Plain HTML | `index.html` | `` | `html` | +| Vite / React | `index.html` | `` | `html` | +| Next.js (App Router) | `app/layout.tsx` | `` | `jsx` | +| Next.js (Pages) | `pages/_document.tsx` | `` | `jsx` | +| Nuxt | `app.vue` | `` | `html` | +| Svelte / SvelteKit | `src/app.html` | `` | `html` | +| Astro | the root layout `.astro` file | `` | `html` | +| Static site with a non-root HTML file | e.g. `public/index.html` | `` | `html` | + +Write the config to the path reported by `--check`. Example for this project: + +```json +{ + "file": "public/index.html", + "insertBefore": "", + "commentSyntax": "html" +} ``` -Place it before the closing `` or at the end of the layout component. Save the file. The dev server will reload and the element picker will activate. +Use `insertAfter` instead of `insertBefore` if the anchor should be matched **after** a specific line (e.g. just after the main app script). + +### Step 2: Insert the live tag + +```bash +node {{scripts_path}}/live-inject.mjs --port PORT +``` + +Use the `port` from the live-server startup output. The script writes the tag idempotently: if a stale tag is present, it's replaced with one pointing at the new port. Save is automatic. If browser automation tools are available, also navigate to the page so the user can see it. @@ -177,7 +191,11 @@ If the poll is still running as a background task, kill it and proceed directly When the loop ends: -1. **Remove the injected script tag** from the source file. Delete everything between `` and `` (inclusive). Use the appropriate comment syntax for the framework. +1. **Remove the injected script tag**: + ```bash + node {{scripts_path}}/live-inject.mjs --remove + ``` + (The config.json stays so future `live-inject.mjs --port PORT` calls are instant.) 2. **Remove any leftover variant wrappers** (search for `impeccable-variants-start` markers and clean up). 3. **Remove any leftover carbonize blocks** (search for `impeccable-carbonize-start` markers and clean up). 4. **Stop the server**: diff --git a/.rovodev/skills/impeccable/scripts/live-inject.mjs b/.rovodev/skills/impeccable/scripts/live-inject.mjs new file mode 100644 index 000000000..d61c17925 --- /dev/null +++ b/.rovodev/skills/impeccable/scripts/live-inject.mjs @@ -0,0 +1,174 @@ +/** + * CLI helper: insert/remove the live variant mode script tag in the project's + * main HTML entry point. + * + * On first live run, the agent generates `config.json` in this script's + * directory with the project's insertion target (framework-specific). On + * every subsequent run, this script handles insert/remove deterministically + * with zero LLM involvement. + * + * Usage: + * node live-inject.mjs --port PORT # Insert the live script tag + * node live-inject.mjs --remove # Remove the live script tag + * node live-inject.mjs --check # Check whether config.json exists + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const CONFIG_PATH = path.join(__dirname, 'config.json'); +const MARKER_OPEN_TEXT = 'impeccable-live-start'; +const MARKER_CLOSE_TEXT = 'impeccable-live-end'; + +export async function injectCli() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + console.log(`Usage: node live-inject.mjs [options] + +Insert or remove the live mode script tag in the project's HTML entry point. +Reads configuration from config.json (in this same directory). + +Modes: + --port PORT Insert script tag pointing at http://localhost:PORT/live.js + --remove Remove the script tag (if present) + --check Print whether config.json exists and its content + +Output (JSON): + { ok, file, inserted|removed, config? }`); + process.exit(0); + } + + if (args.includes('--check')) { + if (!fs.existsSync(CONFIG_PATH)) { + console.log(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH })); + process.exit(0); + } + try { + const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); + console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH })); + } catch (err) { + console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message })); + } + return; + } + + // Load config + if (!fs.existsSync(CONFIG_PATH)) { + console.error(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH })); + process.exit(1); + } + const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); + validateConfig(config); + + const absFile = path.resolve(process.cwd(), config.file); + if (!fs.existsSync(absFile)) { + console.error(JSON.stringify({ ok: false, error: 'file_not_found', file: config.file })); + process.exit(1); + } + + const content = fs.readFileSync(absFile, 'utf-8'); + + if (args.includes('--remove')) { + const updated = removeTag(content, config.commentSyntax); + if (updated === content) { + console.log(JSON.stringify({ ok: true, file: config.file, removed: false, note: 'no tag present' })); + return; + } + fs.writeFileSync(absFile, updated, 'utf-8'); + console.log(JSON.stringify({ ok: true, file: config.file, removed: true })); + return; + } + + // Insert mode — need --port + const portIdx = args.indexOf('--port'); + const port = portIdx !== -1 ? parseInt(args[portIdx + 1], 10) : NaN; + if (!Number.isFinite(port)) { + console.error(JSON.stringify({ ok: false, error: 'missing_port' })); + process.exit(1); + } + + // Already inserted? Replace to refresh the port. + const withoutOld = removeTag(content, config.commentSyntax); + const updated = insertTag(withoutOld, config, port); + if (updated === withoutOld) { + console.error(JSON.stringify({ ok: false, error: 'insertion_point_not_found', anchor: config.insertBefore })); + process.exit(1); + } + fs.writeFileSync(absFile, updated, 'utf-8'); + console.log(JSON.stringify({ ok: true, file: config.file, inserted: true, port })); +} + +// --------------------------------------------------------------------------- +// Core operations +// --------------------------------------------------------------------------- + +function validateConfig(cfg) { + if (!cfg || typeof cfg !== 'object') throw new Error('config.json must be an object'); + if (typeof cfg.file !== 'string') throw new Error('config.file (string) required'); + if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') { + throw new Error('config.insertBefore or config.insertAfter (string) required'); + } + if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') { + throw new Error("config.commentSyntax must be 'html' or 'jsx'"); + } +} + +function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : ''; } + +function buildTagBlock(syntax, port) { + const open = commentOpen(syntax); + const close = commentClose(syntax); + return ( + open + ' ' + MARKER_OPEN_TEXT + ' ' + close + '\n' + + '\n' + + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' + ); +} + +function insertTag(content, config, port) { + const block = buildTagBlock(config.commentSyntax, port); + if (config.insertBefore) { + const idx = content.indexOf(config.insertBefore); + if (idx === -1) return content; + return content.slice(0, idx) + block + content.slice(idx); + } + // insertAfter + const idx = content.indexOf(config.insertAfter); + if (idx === -1) return content; + const after = idx + config.insertAfter.length; + // Preserve a single trailing newline if the anchor didn't end with one + const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n'; + return prefix + block + content.slice(prefix.length); +} + +/** + * Remove the live script block. Matches either HTML or JSX comment markers + * regardless of config (so stale tags from a wrong config can still be cleaned). + */ +function removeTag(content, _syntax) { + // Two patterns: HTML comment markers or JSX comment markers, with any content between. + const patterns = [ + /\n?[\s\S]*?\n?/, + /\n?\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}\n?/, + ]; + for (const pat of patterns) { + const next = content.replace(pat, '\n'); + if (next !== content) return next; + } + return content; +} + +// --------------------------------------------------------------------------- +// Auto-execute +// --------------------------------------------------------------------------- + +const _running = process.argv[1]; +if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs/')) { + injectCli(); +} + +export { insertTag, removeTag, validateConfig, buildTagBlock }; diff --git a/.trae-cn/skills/impeccable/reference/live.md b/.trae-cn/skills/impeccable/reference/live.md index a6a449cbe..f3b785fc7 100644 --- a/.trae-cn/skills/impeccable/reference/live.md +++ b/.trae-cn/skills/impeccable/reference/live.md @@ -15,34 +15,48 @@ Launch interactive live variant mode: select elements in the browser, pick a des ## Inject the Browser Script -Find the project's main HTML entry point. This varies by framework: +The `live-inject.mjs` script handles insertion deterministically. It reads `config.json` from its own directory (one-time per-project setup). -| Framework | Typical file | -|-----------|-------------| -| Plain HTML | `index.html` | -| Vite / React | `index.html` (project root) | -| Next.js (App Router) | `app/layout.tsx` (add a ` - +```bash +node {{scripts_path}}/live-inject.mjs --check ``` -**JSX / TSX (React, Next.js):** -```jsx -{/* impeccable-live-start */} - -{/* impeccable-live-end */} +If the output says `{"ok": true, ...}`, skip to Step 2. + +If the output says `{"ok": false, "error": "config_missing", "path": "..."}`, you need to create the config **once**. Look at the project structure and package.json to determine: + +| Framework | `file` | `insertBefore` | `commentSyntax` | +|-----------|--------|----------------|-----------------| +| Plain HTML | `index.html` | `` | `html` | +| Vite / React | `index.html` | `` | `html` | +| Next.js (App Router) | `app/layout.tsx` | `` | `jsx` | +| Next.js (Pages) | `pages/_document.tsx` | `` | `jsx` | +| Nuxt | `app.vue` | `` | `html` | +| Svelte / SvelteKit | `src/app.html` | `` | `html` | +| Astro | the root layout `.astro` file | `` | `html` | +| Static site with a non-root HTML file | e.g. `public/index.html` | `` | `html` | + +Write the config to the path reported by `--check`. Example for this project: + +```json +{ + "file": "public/index.html", + "insertBefore": "", + "commentSyntax": "html" +} ``` -Place it before the closing `` or at the end of the layout component. Save the file. The dev server will reload and the element picker will activate. +Use `insertAfter` instead of `insertBefore` if the anchor should be matched **after** a specific line (e.g. just after the main app script). + +### Step 2: Insert the live tag + +```bash +node {{scripts_path}}/live-inject.mjs --port PORT +``` + +Use the `port` from the live-server startup output. The script writes the tag idempotently: if a stale tag is present, it's replaced with one pointing at the new port. Save is automatic. If browser automation tools are available, also navigate to the page so the user can see it. @@ -177,7 +191,11 @@ If the poll is still running as a background task, kill it and proceed directly When the loop ends: -1. **Remove the injected script tag** from the source file. Delete everything between `` and `` (inclusive). Use the appropriate comment syntax for the framework. +1. **Remove the injected script tag**: + ```bash + node {{scripts_path}}/live-inject.mjs --remove + ``` + (The config.json stays so future `live-inject.mjs --port PORT` calls are instant.) 2. **Remove any leftover variant wrappers** (search for `impeccable-variants-start` markers and clean up). 3. **Remove any leftover carbonize blocks** (search for `impeccable-carbonize-start` markers and clean up). 4. **Stop the server**: diff --git a/.trae-cn/skills/impeccable/scripts/live-inject.mjs b/.trae-cn/skills/impeccable/scripts/live-inject.mjs new file mode 100644 index 000000000..d61c17925 --- /dev/null +++ b/.trae-cn/skills/impeccable/scripts/live-inject.mjs @@ -0,0 +1,174 @@ +/** + * CLI helper: insert/remove the live variant mode script tag in the project's + * main HTML entry point. + * + * On first live run, the agent generates `config.json` in this script's + * directory with the project's insertion target (framework-specific). On + * every subsequent run, this script handles insert/remove deterministically + * with zero LLM involvement. + * + * Usage: + * node live-inject.mjs --port PORT # Insert the live script tag + * node live-inject.mjs --remove # Remove the live script tag + * node live-inject.mjs --check # Check whether config.json exists + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const CONFIG_PATH = path.join(__dirname, 'config.json'); +const MARKER_OPEN_TEXT = 'impeccable-live-start'; +const MARKER_CLOSE_TEXT = 'impeccable-live-end'; + +export async function injectCli() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + console.log(`Usage: node live-inject.mjs [options] + +Insert or remove the live mode script tag in the project's HTML entry point. +Reads configuration from config.json (in this same directory). + +Modes: + --port PORT Insert script tag pointing at http://localhost:PORT/live.js + --remove Remove the script tag (if present) + --check Print whether config.json exists and its content + +Output (JSON): + { ok, file, inserted|removed, config? }`); + process.exit(0); + } + + if (args.includes('--check')) { + if (!fs.existsSync(CONFIG_PATH)) { + console.log(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH })); + process.exit(0); + } + try { + const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); + console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH })); + } catch (err) { + console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message })); + } + return; + } + + // Load config + if (!fs.existsSync(CONFIG_PATH)) { + console.error(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH })); + process.exit(1); + } + const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); + validateConfig(config); + + const absFile = path.resolve(process.cwd(), config.file); + if (!fs.existsSync(absFile)) { + console.error(JSON.stringify({ ok: false, error: 'file_not_found', file: config.file })); + process.exit(1); + } + + const content = fs.readFileSync(absFile, 'utf-8'); + + if (args.includes('--remove')) { + const updated = removeTag(content, config.commentSyntax); + if (updated === content) { + console.log(JSON.stringify({ ok: true, file: config.file, removed: false, note: 'no tag present' })); + return; + } + fs.writeFileSync(absFile, updated, 'utf-8'); + console.log(JSON.stringify({ ok: true, file: config.file, removed: true })); + return; + } + + // Insert mode — need --port + const portIdx = args.indexOf('--port'); + const port = portIdx !== -1 ? parseInt(args[portIdx + 1], 10) : NaN; + if (!Number.isFinite(port)) { + console.error(JSON.stringify({ ok: false, error: 'missing_port' })); + process.exit(1); + } + + // Already inserted? Replace to refresh the port. + const withoutOld = removeTag(content, config.commentSyntax); + const updated = insertTag(withoutOld, config, port); + if (updated === withoutOld) { + console.error(JSON.stringify({ ok: false, error: 'insertion_point_not_found', anchor: config.insertBefore })); + process.exit(1); + } + fs.writeFileSync(absFile, updated, 'utf-8'); + console.log(JSON.stringify({ ok: true, file: config.file, inserted: true, port })); +} + +// --------------------------------------------------------------------------- +// Core operations +// --------------------------------------------------------------------------- + +function validateConfig(cfg) { + if (!cfg || typeof cfg !== 'object') throw new Error('config.json must be an object'); + if (typeof cfg.file !== 'string') throw new Error('config.file (string) required'); + if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') { + throw new Error('config.insertBefore or config.insertAfter (string) required'); + } + if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') { + throw new Error("config.commentSyntax must be 'html' or 'jsx'"); + } +} + +function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : ''; } + +function buildTagBlock(syntax, port) { + const open = commentOpen(syntax); + const close = commentClose(syntax); + return ( + open + ' ' + MARKER_OPEN_TEXT + ' ' + close + '\n' + + '\n' + + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' + ); +} + +function insertTag(content, config, port) { + const block = buildTagBlock(config.commentSyntax, port); + if (config.insertBefore) { + const idx = content.indexOf(config.insertBefore); + if (idx === -1) return content; + return content.slice(0, idx) + block + content.slice(idx); + } + // insertAfter + const idx = content.indexOf(config.insertAfter); + if (idx === -1) return content; + const after = idx + config.insertAfter.length; + // Preserve a single trailing newline if the anchor didn't end with one + const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n'; + return prefix + block + content.slice(prefix.length); +} + +/** + * Remove the live script block. Matches either HTML or JSX comment markers + * regardless of config (so stale tags from a wrong config can still be cleaned). + */ +function removeTag(content, _syntax) { + // Two patterns: HTML comment markers or JSX comment markers, with any content between. + const patterns = [ + /\n?[\s\S]*?\n?/, + /\n?\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}\n?/, + ]; + for (const pat of patterns) { + const next = content.replace(pat, '\n'); + if (next !== content) return next; + } + return content; +} + +// --------------------------------------------------------------------------- +// Auto-execute +// --------------------------------------------------------------------------- + +const _running = process.argv[1]; +if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs/')) { + injectCli(); +} + +export { insertTag, removeTag, validateConfig, buildTagBlock }; diff --git a/.trae/skills/impeccable/reference/live.md b/.trae/skills/impeccable/reference/live.md index a6a449cbe..f3b785fc7 100644 --- a/.trae/skills/impeccable/reference/live.md +++ b/.trae/skills/impeccable/reference/live.md @@ -15,34 +15,48 @@ Launch interactive live variant mode: select elements in the browser, pick a des ## Inject the Browser Script -Find the project's main HTML entry point. This varies by framework: +The `live-inject.mjs` script handles insertion deterministically. It reads `config.json` from its own directory (one-time per-project setup). -| Framework | Typical file | -|-----------|-------------| -| Plain HTML | `index.html` | -| Vite / React | `index.html` (project root) | -| Next.js (App Router) | `app/layout.tsx` (add a ` - +```bash +node {{scripts_path}}/live-inject.mjs --check ``` -**JSX / TSX (React, Next.js):** -```jsx -{/* impeccable-live-start */} - -{/* impeccable-live-end */} +If the output says `{"ok": true, ...}`, skip to Step 2. + +If the output says `{"ok": false, "error": "config_missing", "path": "..."}`, you need to create the config **once**. Look at the project structure and package.json to determine: + +| Framework | `file` | `insertBefore` | `commentSyntax` | +|-----------|--------|----------------|-----------------| +| Plain HTML | `index.html` | `` | `html` | +| Vite / React | `index.html` | `` | `html` | +| Next.js (App Router) | `app/layout.tsx` | `` | `jsx` | +| Next.js (Pages) | `pages/_document.tsx` | `` | `jsx` | +| Nuxt | `app.vue` | `` | `html` | +| Svelte / SvelteKit | `src/app.html` | `` | `html` | +| Astro | the root layout `.astro` file | `` | `html` | +| Static site with a non-root HTML file | e.g. `public/index.html` | `` | `html` | + +Write the config to the path reported by `--check`. Example for this project: + +```json +{ + "file": "public/index.html", + "insertBefore": "", + "commentSyntax": "html" +} ``` -Place it before the closing `` or at the end of the layout component. Save the file. The dev server will reload and the element picker will activate. +Use `insertAfter` instead of `insertBefore` if the anchor should be matched **after** a specific line (e.g. just after the main app script). + +### Step 2: Insert the live tag + +```bash +node {{scripts_path}}/live-inject.mjs --port PORT +``` + +Use the `port` from the live-server startup output. The script writes the tag idempotently: if a stale tag is present, it's replaced with one pointing at the new port. Save is automatic. If browser automation tools are available, also navigate to the page so the user can see it. @@ -177,7 +191,11 @@ If the poll is still running as a background task, kill it and proceed directly When the loop ends: -1. **Remove the injected script tag** from the source file. Delete everything between `` and `` (inclusive). Use the appropriate comment syntax for the framework. +1. **Remove the injected script tag**: + ```bash + node {{scripts_path}}/live-inject.mjs --remove + ``` + (The config.json stays so future `live-inject.mjs --port PORT` calls are instant.) 2. **Remove any leftover variant wrappers** (search for `impeccable-variants-start` markers and clean up). 3. **Remove any leftover carbonize blocks** (search for `impeccable-carbonize-start` markers and clean up). 4. **Stop the server**: diff --git a/.trae/skills/impeccable/scripts/live-inject.mjs b/.trae/skills/impeccable/scripts/live-inject.mjs new file mode 100644 index 000000000..d61c17925 --- /dev/null +++ b/.trae/skills/impeccable/scripts/live-inject.mjs @@ -0,0 +1,174 @@ +/** + * CLI helper: insert/remove the live variant mode script tag in the project's + * main HTML entry point. + * + * On first live run, the agent generates `config.json` in this script's + * directory with the project's insertion target (framework-specific). On + * every subsequent run, this script handles insert/remove deterministically + * with zero LLM involvement. + * + * Usage: + * node live-inject.mjs --port PORT # Insert the live script tag + * node live-inject.mjs --remove # Remove the live script tag + * node live-inject.mjs --check # Check whether config.json exists + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const CONFIG_PATH = path.join(__dirname, 'config.json'); +const MARKER_OPEN_TEXT = 'impeccable-live-start'; +const MARKER_CLOSE_TEXT = 'impeccable-live-end'; + +export async function injectCli() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + console.log(`Usage: node live-inject.mjs [options] + +Insert or remove the live mode script tag in the project's HTML entry point. +Reads configuration from config.json (in this same directory). + +Modes: + --port PORT Insert script tag pointing at http://localhost:PORT/live.js + --remove Remove the script tag (if present) + --check Print whether config.json exists and its content + +Output (JSON): + { ok, file, inserted|removed, config? }`); + process.exit(0); + } + + if (args.includes('--check')) { + if (!fs.existsSync(CONFIG_PATH)) { + console.log(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH })); + process.exit(0); + } + try { + const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); + console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH })); + } catch (err) { + console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message })); + } + return; + } + + // Load config + if (!fs.existsSync(CONFIG_PATH)) { + console.error(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH })); + process.exit(1); + } + const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); + validateConfig(config); + + const absFile = path.resolve(process.cwd(), config.file); + if (!fs.existsSync(absFile)) { + console.error(JSON.stringify({ ok: false, error: 'file_not_found', file: config.file })); + process.exit(1); + } + + const content = fs.readFileSync(absFile, 'utf-8'); + + if (args.includes('--remove')) { + const updated = removeTag(content, config.commentSyntax); + if (updated === content) { + console.log(JSON.stringify({ ok: true, file: config.file, removed: false, note: 'no tag present' })); + return; + } + fs.writeFileSync(absFile, updated, 'utf-8'); + console.log(JSON.stringify({ ok: true, file: config.file, removed: true })); + return; + } + + // Insert mode — need --port + const portIdx = args.indexOf('--port'); + const port = portIdx !== -1 ? parseInt(args[portIdx + 1], 10) : NaN; + if (!Number.isFinite(port)) { + console.error(JSON.stringify({ ok: false, error: 'missing_port' })); + process.exit(1); + } + + // Already inserted? Replace to refresh the port. + const withoutOld = removeTag(content, config.commentSyntax); + const updated = insertTag(withoutOld, config, port); + if (updated === withoutOld) { + console.error(JSON.stringify({ ok: false, error: 'insertion_point_not_found', anchor: config.insertBefore })); + process.exit(1); + } + fs.writeFileSync(absFile, updated, 'utf-8'); + console.log(JSON.stringify({ ok: true, file: config.file, inserted: true, port })); +} + +// --------------------------------------------------------------------------- +// Core operations +// --------------------------------------------------------------------------- + +function validateConfig(cfg) { + if (!cfg || typeof cfg !== 'object') throw new Error('config.json must be an object'); + if (typeof cfg.file !== 'string') throw new Error('config.file (string) required'); + if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') { + throw new Error('config.insertBefore or config.insertAfter (string) required'); + } + if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') { + throw new Error("config.commentSyntax must be 'html' or 'jsx'"); + } +} + +function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : ''; } + +function buildTagBlock(syntax, port) { + const open = commentOpen(syntax); + const close = commentClose(syntax); + return ( + open + ' ' + MARKER_OPEN_TEXT + ' ' + close + '\n' + + '\n' + + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' + ); +} + +function insertTag(content, config, port) { + const block = buildTagBlock(config.commentSyntax, port); + if (config.insertBefore) { + const idx = content.indexOf(config.insertBefore); + if (idx === -1) return content; + return content.slice(0, idx) + block + content.slice(idx); + } + // insertAfter + const idx = content.indexOf(config.insertAfter); + if (idx === -1) return content; + const after = idx + config.insertAfter.length; + // Preserve a single trailing newline if the anchor didn't end with one + const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n'; + return prefix + block + content.slice(prefix.length); +} + +/** + * Remove the live script block. Matches either HTML or JSX comment markers + * regardless of config (so stale tags from a wrong config can still be cleaned). + */ +function removeTag(content, _syntax) { + // Two patterns: HTML comment markers or JSX comment markers, with any content between. + const patterns = [ + /\n?[\s\S]*?\n?/, + /\n?\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}\n?/, + ]; + for (const pat of patterns) { + const next = content.replace(pat, '\n'); + if (next !== content) return next; + } + return content; +} + +// --------------------------------------------------------------------------- +// Auto-execute +// --------------------------------------------------------------------------- + +const _running = process.argv[1]; +if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs/')) { + injectCli(); +} + +export { insertTag, removeTag, validateConfig, buildTagBlock }; diff --git a/public/index.html b/public/index.html index b8ee0f9c6..68bac36f0 100644 --- a/public/index.html +++ b/public/index.html @@ -756,6 +756,5 @@ - diff --git a/source/skills/impeccable/reference/live.md b/source/skills/impeccable/reference/live.md index a6a449cbe..f3b785fc7 100644 --- a/source/skills/impeccable/reference/live.md +++ b/source/skills/impeccable/reference/live.md @@ -15,34 +15,48 @@ Launch interactive live variant mode: select elements in the browser, pick a des ## Inject the Browser Script -Find the project's main HTML entry point. This varies by framework: +The `live-inject.mjs` script handles insertion deterministically. It reads `config.json` from its own directory (one-time per-project setup). -| Framework | Typical file | -|-----------|-------------| -| Plain HTML | `index.html` | -| Vite / React | `index.html` (project root) | -| Next.js (App Router) | `app/layout.tsx` (add a ` - +```bash +node {{scripts_path}}/live-inject.mjs --check ``` -**JSX / TSX (React, Next.js):** -```jsx -{/* impeccable-live-start */} - -{/* impeccable-live-end */} +If the output says `{"ok": true, ...}`, skip to Step 2. + +If the output says `{"ok": false, "error": "config_missing", "path": "..."}`, you need to create the config **once**. Look at the project structure and package.json to determine: + +| Framework | `file` | `insertBefore` | `commentSyntax` | +|-----------|--------|----------------|-----------------| +| Plain HTML | `index.html` | `` | `html` | +| Vite / React | `index.html` | `` | `html` | +| Next.js (App Router) | `app/layout.tsx` | `` | `jsx` | +| Next.js (Pages) | `pages/_document.tsx` | `` | `jsx` | +| Nuxt | `app.vue` | `` | `html` | +| Svelte / SvelteKit | `src/app.html` | `` | `html` | +| Astro | the root layout `.astro` file | `` | `html` | +| Static site with a non-root HTML file | e.g. `public/index.html` | `` | `html` | + +Write the config to the path reported by `--check`. Example for this project: + +```json +{ + "file": "public/index.html", + "insertBefore": "", + "commentSyntax": "html" +} ``` -Place it before the closing `` or at the end of the layout component. Save the file. The dev server will reload and the element picker will activate. +Use `insertAfter` instead of `insertBefore` if the anchor should be matched **after** a specific line (e.g. just after the main app script). + +### Step 2: Insert the live tag + +```bash +node {{scripts_path}}/live-inject.mjs --port PORT +``` + +Use the `port` from the live-server startup output. The script writes the tag idempotently: if a stale tag is present, it's replaced with one pointing at the new port. Save is automatic. If browser automation tools are available, also navigate to the page so the user can see it. @@ -177,7 +191,11 @@ If the poll is still running as a background task, kill it and proceed directly When the loop ends: -1. **Remove the injected script tag** from the source file. Delete everything between `` and `` (inclusive). Use the appropriate comment syntax for the framework. +1. **Remove the injected script tag**: + ```bash + node {{scripts_path}}/live-inject.mjs --remove + ``` + (The config.json stays so future `live-inject.mjs --port PORT` calls are instant.) 2. **Remove any leftover variant wrappers** (search for `impeccable-variants-start` markers and clean up). 3. **Remove any leftover carbonize blocks** (search for `impeccable-carbonize-start` markers and clean up). 4. **Stop the server**: diff --git a/source/skills/impeccable/scripts/live-inject.mjs b/source/skills/impeccable/scripts/live-inject.mjs new file mode 100644 index 000000000..d61c17925 --- /dev/null +++ b/source/skills/impeccable/scripts/live-inject.mjs @@ -0,0 +1,174 @@ +/** + * CLI helper: insert/remove the live variant mode script tag in the project's + * main HTML entry point. + * + * On first live run, the agent generates `config.json` in this script's + * directory with the project's insertion target (framework-specific). On + * every subsequent run, this script handles insert/remove deterministically + * with zero LLM involvement. + * + * Usage: + * node live-inject.mjs --port PORT # Insert the live script tag + * node live-inject.mjs --remove # Remove the live script tag + * node live-inject.mjs --check # Check whether config.json exists + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const CONFIG_PATH = path.join(__dirname, 'config.json'); +const MARKER_OPEN_TEXT = 'impeccable-live-start'; +const MARKER_CLOSE_TEXT = 'impeccable-live-end'; + +export async function injectCli() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + console.log(`Usage: node live-inject.mjs [options] + +Insert or remove the live mode script tag in the project's HTML entry point. +Reads configuration from config.json (in this same directory). + +Modes: + --port PORT Insert script tag pointing at http://localhost:PORT/live.js + --remove Remove the script tag (if present) + --check Print whether config.json exists and its content + +Output (JSON): + { ok, file, inserted|removed, config? }`); + process.exit(0); + } + + if (args.includes('--check')) { + if (!fs.existsSync(CONFIG_PATH)) { + console.log(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH })); + process.exit(0); + } + try { + const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); + console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH })); + } catch (err) { + console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message })); + } + return; + } + + // Load config + if (!fs.existsSync(CONFIG_PATH)) { + console.error(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH })); + process.exit(1); + } + const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); + validateConfig(config); + + const absFile = path.resolve(process.cwd(), config.file); + if (!fs.existsSync(absFile)) { + console.error(JSON.stringify({ ok: false, error: 'file_not_found', file: config.file })); + process.exit(1); + } + + const content = fs.readFileSync(absFile, 'utf-8'); + + if (args.includes('--remove')) { + const updated = removeTag(content, config.commentSyntax); + if (updated === content) { + console.log(JSON.stringify({ ok: true, file: config.file, removed: false, note: 'no tag present' })); + return; + } + fs.writeFileSync(absFile, updated, 'utf-8'); + console.log(JSON.stringify({ ok: true, file: config.file, removed: true })); + return; + } + + // Insert mode — need --port + const portIdx = args.indexOf('--port'); + const port = portIdx !== -1 ? parseInt(args[portIdx + 1], 10) : NaN; + if (!Number.isFinite(port)) { + console.error(JSON.stringify({ ok: false, error: 'missing_port' })); + process.exit(1); + } + + // Already inserted? Replace to refresh the port. + const withoutOld = removeTag(content, config.commentSyntax); + const updated = insertTag(withoutOld, config, port); + if (updated === withoutOld) { + console.error(JSON.stringify({ ok: false, error: 'insertion_point_not_found', anchor: config.insertBefore })); + process.exit(1); + } + fs.writeFileSync(absFile, updated, 'utf-8'); + console.log(JSON.stringify({ ok: true, file: config.file, inserted: true, port })); +} + +// --------------------------------------------------------------------------- +// Core operations +// --------------------------------------------------------------------------- + +function validateConfig(cfg) { + if (!cfg || typeof cfg !== 'object') throw new Error('config.json must be an object'); + if (typeof cfg.file !== 'string') throw new Error('config.file (string) required'); + if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') { + throw new Error('config.insertBefore or config.insertAfter (string) required'); + } + if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') { + throw new Error("config.commentSyntax must be 'html' or 'jsx'"); + } +} + +function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : ''; } + +function buildTagBlock(syntax, port) { + const open = commentOpen(syntax); + const close = commentClose(syntax); + return ( + open + ' ' + MARKER_OPEN_TEXT + ' ' + close + '\n' + + '\n' + + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' + ); +} + +function insertTag(content, config, port) { + const block = buildTagBlock(config.commentSyntax, port); + if (config.insertBefore) { + const idx = content.indexOf(config.insertBefore); + if (idx === -1) return content; + return content.slice(0, idx) + block + content.slice(idx); + } + // insertAfter + const idx = content.indexOf(config.insertAfter); + if (idx === -1) return content; + const after = idx + config.insertAfter.length; + // Preserve a single trailing newline if the anchor didn't end with one + const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n'; + return prefix + block + content.slice(prefix.length); +} + +/** + * Remove the live script block. Matches either HTML or JSX comment markers + * regardless of config (so stale tags from a wrong config can still be cleaned). + */ +function removeTag(content, _syntax) { + // Two patterns: HTML comment markers or JSX comment markers, with any content between. + const patterns = [ + /\n?[\s\S]*?\n?/, + /\n?\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}\n?/, + ]; + for (const pat of patterns) { + const next = content.replace(pat, '\n'); + if (next !== content) return next; + } + return content; +} + +// --------------------------------------------------------------------------- +// Auto-execute +// --------------------------------------------------------------------------- + +const _running = process.argv[1]; +if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs/')) { + injectCli(); +} + +export { insertTag, removeTag, validateConfig, buildTagBlock };