Last active
April 13, 2026 10:34
-
-
Save replete/a0da6720189cbd1dfc063e0f67b28168 to your computer and use it in GitHub Desktop.
repomix-style HTML GUI for generating prompts for LLM web UI sessions
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env node | |
| /* | |
| repogui v0.2 | |
| https://gist.github.com/replete/a0da6720189cbd1dfc063e0f67b28168 | |
| 1. save into a $PATH location (e.g. add ~/bin) for global terminal activation | |
| 2. chmod +x ~/bin/repogui | |
| 3. execute 'repogui' in a terminal | |
| 4. open http://127.0.0.1:6969 in browser | |
| 5. drag files from vscode (even most remote ssh/devcontainers) into the window to easily build a repomix-style code aggregate xml for use in an LLM web UI | |
| */ | |
| const http = require('node:http'); | |
| const fs = require('node:fs'); | |
| const path = require('node:path'); | |
| const os = require('node:os'); | |
| const { execFileSync } = require('node:child_process'); | |
| const PORT = 6969; | |
| const BINARY_EXT = new Set([ | |
| '.png','.jpg','.jpeg','.gif','.bmp','.ico','.webp','.avif', | |
| '.woff','.woff2','.ttf','.otf','.eot', | |
| '.zip','.tar','.gz','.bz2','.7z','.rar', | |
| '.pdf','.doc','.docx','.xls','.xlsx', | |
| '.mp3','.mp4','.avi','.mov','.wav', | |
| '.exe','.dll','.so','.dylib','.o','.a', | |
| '.sqlite','.db','.DS_Store', | |
| ]); | |
| const SKIP_DIRS = new Set(['node_modules', 'vendor', '__pycache__', '.git', 'dist', 'build']); | |
| const SETS_FILE = path.join(os.homedir(), '.repogui.json'); | |
| function readSets() { | |
| try { return JSON.parse(fs.readFileSync(SETS_FILE, 'utf-8')); } catch { return {}; } | |
| } | |
| function writeSets(sets) { | |
| fs.writeFileSync(SETS_FILE, JSON.stringify(sets, null, 2)); | |
| } | |
| function readFileSSH(host, filePath) { | |
| try { | |
| if (BINARY_EXT.has(path.extname(filePath).toLowerCase())) return []; | |
| const content = execFileSync('ssh', [host, 'cat', filePath], { encoding: 'utf-8', timeout: 10000 }); | |
| if (content.slice(0, 1000).includes('\0')) return []; | |
| return [{ path: filePath, content, size: Buffer.byteLength(content) }]; | |
| } catch { return []; } | |
| } | |
| function readFile(filePath) { | |
| try { | |
| if (BINARY_EXT.has(path.extname(filePath).toLowerCase())) return []; | |
| const stat = fs.statSync(filePath); | |
| if (stat.isDirectory()) return readDir(filePath); | |
| if (stat.size > 5 * 1024 * 1024) return []; | |
| const content = fs.readFileSync(filePath, 'utf-8'); | |
| if (content.slice(0, 1000).includes('\0')) return []; | |
| return [{ path: filePath, content, size: stat.size }]; | |
| } catch { return []; } | |
| } | |
| function readDir(dirPath) { | |
| const results = []; | |
| try { | |
| for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) { | |
| if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name)) continue; | |
| const full = path.join(dirPath, entry.name); | |
| if (entry.isDirectory()) results.push(...readDir(full)); | |
| else results.push(...readFile(full)); | |
| } | |
| } catch {} | |
| return results; | |
| } | |
| const HTML = `<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>repogui</title> | |
| <script src="https://cdn.tailwindcss.com"><\/script> | |
| </head> | |
| <body class="bg-gray-950 text-gray-200 min-h-screen p-6"> | |
| <div class="max-w-3xl mx-auto"> | |
| <h1 class="text-lg font-semibold text-white mb-4">repogui</h1> | |
| <div id="mainCard" class="border border-gray-800 rounded-lg overflow-hidden"> | |
| <div class="flex items-center gap-2 px-3 py-2 bg-gray-900 border-b border-gray-800"> | |
| <label class="text-gray-400 shrink-0 text-xs">strip prefix</label> | |
| <input id="pathPrefix" class="flex-1 bg-gray-800 text-gray-300 font-mono text-xs px-2 py-0.5 rounded focus:outline-none focus:ring-1 focus:ring-indigo-500 placeholder-gray-600" placeholder="${os.homedir()}/project/"> | |
| </div> | |
| <ul id="fileList" class="divide-y divide-gray-900"></ul> | |
| <div id="emptyHint" class="flex items-center justify-center h-32 text-gray-600 text-sm select-none"> | |
| drop files or folders from VS Code / Finder | |
| </div> | |
| <div class="flex flex-wrap items-center gap-2 px-3 py-2 bg-gray-900 border-t border-gray-800"> | |
| <span class="text-gray-400 text-xs shrink-0">sets</span> | |
| <input id="setName" class="bg-gray-800 border border-gray-600 text-gray-200 px-2 py-0.5 rounded text-xs focus:outline-none focus:border-indigo-500 w-32 placeholder-gray-500" placeholder="name…"> | |
| <button id="saveSetBtn" class="px-2 py-0.5 bg-gray-700 border border-gray-600 text-gray-200 rounded text-xs hover:bg-gray-600">save</button> | |
| <div class="w-px h-3 bg-gray-600 shrink-0"></div> | |
| <select id="setsSelect" class="bg-gray-800 border border-gray-600 text-gray-200 px-2 py-0.5 rounded text-xs focus:outline-none focus:border-indigo-500"></select> | |
| <button id="loadSetBtn" class="px-2 py-0.5 bg-gray-700 border border-gray-600 text-gray-200 rounded text-xs hover:bg-gray-600">load</button> | |
| <button id="deleteSetBtn" class="px-2 py-0.5 bg-gray-700 border border-gray-600 text-red-400 rounded text-xs hover:bg-gray-600 hover:text-red-300">×</button> | |
| </div> | |
| <div class="flex items-center gap-3 px-3 py-2 bg-gray-900 border-t border-gray-800"> | |
| <span id="fileCount" class="text-xs text-gray-400"></span> | |
| <span id="totalSize" class="text-xs text-gray-400"></span> | |
| <span id="tokenCount" class="text-sm font-semibold text-indigo-400 mr-auto"></span> | |
| <button id="updateBtn" disabled class="px-3 py-1 bg-gray-700 border border-gray-600 text-gray-200 rounded text-xs font-medium disabled:opacity-30 disabled:cursor-not-allowed hover:bg-gray-600">update</button> | |
| <button id="clearBtn" class="hidden px-3 py-1 bg-gray-700 border border-gray-600 text-red-400 rounded text-xs font-medium hover:bg-gray-600 hover:text-red-300">clear</button> | |
| <button id="copyBtn" disabled class="px-3 py-1 bg-indigo-600 text-white rounded text-xs font-medium disabled:opacity-30 disabled:cursor-not-allowed hover:bg-indigo-500">copy XML</button> | |
| </div> | |
| </div> | |
| <textarea id="output" class="hidden mt-4 w-full bg-gray-900 border border-gray-800 p-4 rounded text-xs text-gray-400 font-mono resize-y focus:outline-none focus:border-indigo-500" rows="16" readonly></textarea> | |
| <pre id="dropLog" class="hidden mt-4 bg-yellow-950 border border-yellow-800 p-4 rounded text-xs text-yellow-300 overflow-auto max-h-64 whitespace-pre-wrap break-all"></pre> | |
| </div> | |
| <div id="dragOverlay" class="fixed inset-0 pointer-events-none opacity-0 transition-opacity duration-150 flex items-center justify-center z-50"> | |
| <div class="absolute inset-2 border-2 border-dashed border-indigo-500 rounded-xl"></div> | |
| <div class="absolute inset-0 bg-gray-950 opacity-60"></div> | |
| <span class="relative text-indigo-400 text-base font-medium tracking-wide">drop files or folders</span> | |
| </div> | |
| <div id="toast" class="fixed bottom-6 left-1/2 -translate-x-1/2 bg-emerald-600 text-white px-6 py-2 rounded-lg text-sm opacity-0 transition-opacity pointer-events-none">Copied!</div> | |
| <script> | |
| const files = new Map(); | |
| const $ = id => document.getElementById(id); | |
| const DEBUG = location.search.includes('debug') || location.hash.includes('debug'); | |
| function esc(s) { return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); } | |
| function fmtSize(b) { return b < 1024 ? b+' B' : b < 1048576 ? (b/1024).toFixed(1)+' KB' : (b/1048576).toFixed(1)+' MB'; } | |
| function fmtTok(n) { return n < 1000 ? n+'' : (n/1000).toFixed(1)+'k'; } | |
| function clean(p) { const px = $('pathPrefix').value.trim(); return px && p.startsWith(px) ? p.slice(px.length) : p; } | |
| function updateUI() { | |
| const list = $('fileList'); | |
| list.innerHTML = ''; | |
| const sorted = [...files.keys()].sort(); | |
| sorted.forEach(p => { | |
| const f = files.get(p); | |
| const li = document.createElement('li'); | |
| li.className = 'flex items-center gap-2 px-3 py-2 text-xs font-mono hover:bg-gray-900 transition-colors'; | |
| li.innerHTML = '<span class="truncate flex-1 text-gray-300" title="'+esc(p)+'">'+esc(clean(p))+'</span>' | |
| + '<span class="text-gray-500 shrink-0">'+fmtSize(f.size)+'</span>' | |
| + '<button class="text-gray-500 hover:text-red-400 pl-2 leading-none text-sm">×</button>'; | |
| li.querySelector('button').onclick = () => { files.delete(p); updateUI(); }; | |
| list.appendChild(li); | |
| }); | |
| const has = files.size > 0; | |
| $('emptyHint').className = has ? 'hidden' : 'flex items-center justify-center h-32 text-gray-700 text-sm select-none'; | |
| const xml = genXml(); | |
| $('fileCount').textContent = has ? files.size + ' file' + (files.size === 1 ? '' : 's') : ''; | |
| $('totalSize').textContent = has ? fmtSize([...files.values()].reduce((s,f) => s+f.size, 0)) : ''; | |
| $('tokenCount').textContent = has ? '~' + fmtTok(Math.ceil(xml.length / 3.5)) + ' tokens' : ''; | |
| $('copyBtn').disabled = !has; | |
| $('updateBtn').disabled = ![...files.values()].some(f => f.reloadable); | |
| $('clearBtn').className = has ? 'px-3 py-1 bg-gray-700 border border-gray-600 text-red-400 rounded text-xs font-medium hover:bg-gray-600 hover:text-red-300' : 'hidden'; | |
| const out = $('output'); | |
| if (has) { out.classList.remove('hidden'); out.value = xml; } else { out.classList.add('hidden'); out.value = ''; } | |
| } | |
| function genXml() { | |
| const sorted = [...files.keys()].sort(); | |
| const tree = sorted.map(p => clean(p)).join('\\n'); | |
| let x = \`This file is a merged representation of a subset of the codebase, containing specifically included files, combined into a single document. | |
| <file_summary> | |
| <purpose> | |
| This file contains a packed representation of the repository's contents. | |
| It is designed to be easily consumable by AI systems for analysis, code review, | |
| or other automated processes. | |
| </purpose> | |
| <file_format> | |
| The content is organized as follows: | |
| 1. This summary section | |
| 2. Directory structure | |
| 3. Multiple file entries, each consisting of: | |
| - File path as an attribute | |
| - Full contents of the file | |
| </file_format> | |
| <usage_guidelines> | |
| - This file should be treated as read-only. Any changes should be made to the original repository files, not this packed version. | |
| - When processing this file, use the file path to distinguish between different files in the repository. | |
| </usage_guidelines> | |
| </file_summary> | |
| <directory_structure> | |
| \${tree} | |
| </directory_structure> | |
| <files> | |
| \`; | |
| sorted.forEach(p => { x += '\\n<file path="'+esc(clean(p))+'">\\n'+files.get(p).content+'\\n</file>\\n'; }); | |
| return x + '\\n</files>\\n'; | |
| } | |
| function autoPrefix() { | |
| if ($('pathPrefix').value || files.size < 2) return; | |
| const paths = [...files.keys()]; | |
| if (!paths[0].includes('/')) return; | |
| const parts = paths[0].split('/'); | |
| let px = ''; | |
| for (let i = 0; i < parts.length - 1; i++) { | |
| const c = px + parts[i] + '/'; | |
| if (paths.every(p => p.startsWith(c))) px = c; else break; | |
| } | |
| if (px) $('pathPrefix').value = px; | |
| } | |
| async function addPaths(paths, host) { | |
| try { | |
| const payload = { paths }; if (host) payload.host = host; | |
| const r = await fetch('/read', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(payload) }); | |
| const results = await r.json(); | |
| for (const f of results) files.set(f.path, { content: f.content, size: f.size, host: host || null, reloadable: true }); | |
| autoPrefix(); | |
| updateUI(); | |
| } catch (e) { console.error('read failed', e); } | |
| } | |
| function extractPaths(dt) { | |
| // Check for vscode-remote:// URIs first (devcontainers, SSH remote) | |
| const uriList = dt.getData('application/vnd.code.uri-list') || dt.getData('text/uri-list') || ''; | |
| const remoteUris = uriList.split(/[\\r\\n]+/).map(s => s.trim()).filter(s => s.startsWith('vscode-remote://')); | |
| if (remoteUris.length) { | |
| // Parse: vscode-remote://ssh-remote%2Bhostname/path | |
| const parsed = remoteUris.map(u => { try { return new URL(u); } catch { return null; } }).filter(Boolean); | |
| if (parsed.length) { | |
| // hostname is like "ssh-remote%2Bhostname" | |
| const host = decodeURIComponent(parsed[0].hostname).replace(/^ssh-remote\\+/, ''); | |
| const paths = parsed.map(u => decodeURIComponent(u.pathname)); | |
| return { paths, host }; | |
| } | |
| } | |
| // VS Code local: 'codefiles' has JSON array of absolute paths | |
| const codefiles = dt.getData('codefiles'); | |
| if (codefiles) { | |
| try { const arr = JSON.parse(codefiles); if (arr.length) return { paths: arr }; } catch {} | |
| } | |
| // text/plain: newline-separated absolute paths | |
| const plain = dt.getData('text/plain'); | |
| if (plain) { | |
| const paths = plain.split(/[\\r\\n]+/).map(s => s.trim()).filter(s => s.startsWith('/')); | |
| if (paths.length) return { paths }; | |
| } | |
| // Fallback: file:// URIs | |
| const fileUris = uriList.split(/[\\r\\n]+/).map(s => s.trim()).filter(s => s.startsWith('file://')).map(s => decodeURIComponent(new URL(s).pathname)); | |
| if (fileUris.length) return { paths: fileUris }; | |
| return { paths: [] }; | |
| } | |
| function readEntry(entry, base) { | |
| return new Promise(resolve => { | |
| if (entry.isFile) { | |
| entry.file(f => { | |
| const p = base ? base+'/'+f.name : f.name; | |
| resolve([new File([f], p, {type: f.type})]); | |
| }, () => resolve([])); | |
| } else if (entry.isDirectory) { | |
| const reader = entry.createReader(), entries = []; | |
| const batch = () => reader.readEntries(async b => { | |
| if (!b.length) { const r = await Promise.all(entries.map(e => readEntry(e, base ? base+'/'+entry.name : entry.name))); resolve(r.flat()); } | |
| else { entries.push(...b); batch(); } | |
| }, () => resolve([])); | |
| batch(); | |
| } else resolve([]); | |
| }); | |
| } | |
| async function addFileObjects(list) { | |
| for (const f of list) { | |
| if (!f.size) continue; | |
| try { | |
| const t = await f.text(); | |
| if (t.slice(0,1000).includes('\\0')) continue; | |
| files.set(f.name, { content: t, size: f.size, host: null, reloadable: false }); | |
| } catch {} | |
| } | |
| autoPrefix(); | |
| updateUI(); | |
| } | |
| async function updateFiles() { | |
| const byHost = new Map(); | |
| for (const [p, f] of files) { | |
| if (!f.reloadable) continue; | |
| const k = f.host || ''; | |
| if (!byHost.has(k)) byHost.set(k, { host: f.host, paths: [] }); | |
| byHost.get(k).paths.push(p); | |
| } | |
| for (const { host, paths } of byHost.values()) { | |
| await addPaths(paths, host || undefined); | |
| } | |
| } | |
| let sets = {}; | |
| async function fetchSets() { | |
| try { const r = await fetch('/sets'); sets = await r.json(); renderSets(); } catch {} | |
| } | |
| function renderSets() { | |
| const sel = $('setsSelect'); | |
| const cur = sel.value; | |
| sel.innerHTML = '<option value="">— saved sets —</option>'; | |
| for (const name of Object.keys(sets).sort()) { | |
| const o = document.createElement('option'); | |
| o.value = name; o.textContent = name; | |
| sel.appendChild(o); | |
| } | |
| if (cur) sel.value = cur; | |
| } | |
| async function saveCurrentSet() { | |
| const name = $('setName').value.trim(); | |
| if (!name || !files.size) return; | |
| const entries = [...files.entries()].map(([p, f]) => ({ path: p, host: f.host || null })); | |
| await fetch('/sets', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ name, entries, prefix: $('pathPrefix').value }) }); | |
| await fetchSets(); | |
| $('setsSelect').value = name; | |
| $('setName').value = ''; | |
| } | |
| async function loadSelectedSet() { | |
| const name = $('setsSelect').value; | |
| if (!name || !sets[name]) return; | |
| const set = sets[name]; | |
| files.clear(); | |
| if (set.prefix) $('pathPrefix').value = set.prefix; | |
| const byHost = new Map(); | |
| for (const e of set.entries) { | |
| const k = e.host || ''; | |
| if (!byHost.has(k)) byHost.set(k, { host: e.host, paths: [] }); | |
| byHost.get(k).paths.push(e.path); | |
| } | |
| for (const { host, paths } of byHost.values()) { | |
| await addPaths(paths, host || undefined); | |
| } | |
| } | |
| async function deleteSelectedSet() { | |
| const name = $('setsSelect').value; | |
| if (!name) return; | |
| if (!confirm('Delete set "' + name + '"?')) return; | |
| await fetch('/sets/' + encodeURIComponent(name), { method: 'DELETE' }); | |
| await fetchSets(); | |
| } | |
| function logDrop(label, data) { | |
| if (!DEBUG) return; | |
| const el = $('dropLog'); | |
| el.classList.remove('hidden'); | |
| el.textContent += '[' + label + '] ' + data + '\\n'; | |
| } | |
| document.addEventListener('dragover', e => { e.preventDefault(); $('dragOverlay').classList.replace('opacity-0','opacity-100'); }); | |
| document.addEventListener('dragleave', e => { if (!e.relatedTarget) $('dragOverlay').classList.replace('opacity-100','opacity-0'); }); | |
| document.addEventListener('drop', async e => { | |
| e.preventDefault(); | |
| $('dragOverlay').classList.replace('opacity-100','opacity-0'); | |
| const dt = e.dataTransfer; | |
| logDrop('types', JSON.stringify(dt.types)); | |
| for (const type of dt.types) { | |
| try { logDrop('getData('+type+')', dt.getData(type)); } catch(err) { logDrop('getData('+type+')', 'ERROR: '+err); } | |
| } | |
| if (dt.items) { | |
| for (let i = 0; i < dt.items.length; i++) { | |
| const item = dt.items[i]; | |
| logDrop('item['+i+']', 'kind='+item.kind+' type='+item.type); | |
| if (item.kind === 'file') { | |
| const f = item.getAsFile(); | |
| logDrop('item['+i+'].file', f ? 'name='+f.name+' size='+f.size+' type='+f.type : 'null'); | |
| const entry = item.webkitGetAsEntry?.(); | |
| logDrop('item['+i+'].entry', entry ? 'name='+entry.name+' isFile='+entry.isFile+' isDir='+entry.isDirectory : 'null'); | |
| } | |
| } | |
| } | |
| if (dt.files?.length) { | |
| for (let i = 0; i < dt.files.length; i++) { | |
| logDrop('files['+i+']', 'name='+dt.files[i].name+' size='+dt.files[i].size); | |
| } | |
| } | |
| logDrop('---', ''); | |
| const extracted = extractPaths(e.dataTransfer); | |
| if (extracted.paths.length) { logDrop('action', (extracted.host ? 'SSH:'+extracted.host+' ' : '') + JSON.stringify(extracted.paths)); await addPaths(extracted.paths, extracted.host); return; } | |
| const items = e.dataTransfer.items; | |
| if (items?.length) { | |
| const proms = []; | |
| for (const item of items) { | |
| if (item.kind !== 'file') continue; | |
| const entry = item.webkitGetAsEntry?.(); | |
| if (entry) proms.push(readEntry(entry, '')); | |
| else { const f = item.getAsFile(); if (f) proms.push(Promise.resolve([f])); } | |
| } | |
| if (proms.length) { await addFileObjects((await Promise.all(proms)).flat()); return; } | |
| } | |
| if (e.dataTransfer.files.length) await addFileObjects([...e.dataTransfer.files]); | |
| }); | |
| $('pathPrefix').addEventListener('input', updateUI); | |
| $('copyBtn').addEventListener('click', async () => { | |
| try { await navigator.clipboard.writeText(genXml()); } catch { | |
| const t = document.createElement('textarea'); t.value = genXml(); t.style.cssText='position:fixed;left:-9999px'; | |
| document.body.appendChild(t); t.select(); document.execCommand('copy'); document.body.removeChild(t); | |
| } | |
| $('toast').classList.add('opacity-100'); setTimeout(() => $('toast').classList.remove('opacity-100'), 1500); | |
| }); | |
| $('clearBtn').addEventListener('click', () => { files.clear(); $('pathPrefix').value=''; updateUI(); }); | |
| $('updateBtn').addEventListener('click', updateFiles); | |
| $('saveSetBtn').addEventListener('click', saveCurrentSet); | |
| $('loadSetBtn').addEventListener('click', loadSelectedSet); | |
| $('deleteSetBtn').addEventListener('click', deleteSelectedSet); | |
| fetchSets(); | |
| <\/script> | |
| </body> | |
| </html>`; | |
| const server = http.createServer((req, res) => { | |
| res.setHeader('Access-Control-Allow-Origin', '*'); | |
| res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, DELETE'); | |
| res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); | |
| if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } | |
| if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) { | |
| res.writeHead(200, { 'Content-Type': 'text/html' }); | |
| res.end(HTML); | |
| return; | |
| } | |
| if (req.method === 'POST' && req.url === '/read') { | |
| let body = ''; | |
| req.on('data', c => body += c); | |
| req.on('end', () => { | |
| try { | |
| const { paths, host } = JSON.parse(body); | |
| const results = host | |
| ? paths.flatMap(p => readFileSSH(host, p)) | |
| : paths.flatMap(p => readFile(p)); | |
| res.writeHead(200, { 'Content-Type': 'application/json' }); | |
| res.end(JSON.stringify(results)); | |
| } catch (e) { | |
| res.writeHead(400, { 'Content-Type': 'application/json' }); | |
| res.end(JSON.stringify({ error: e.message })); | |
| } | |
| }); | |
| return; | |
| } | |
| if (req.method === 'GET' && req.url === '/sets') { | |
| res.writeHead(200, { 'Content-Type': 'application/json' }); | |
| res.end(JSON.stringify(readSets())); | |
| return; | |
| } | |
| if (req.method === 'POST' && req.url === '/sets') { | |
| let body = ''; | |
| req.on('data', c => body += c); | |
| req.on('end', () => { | |
| try { | |
| const { name, entries, prefix } = JSON.parse(body); | |
| const s = readSets(); | |
| s[name] = { entries, prefix: prefix || '' }; | |
| writeSets(s); | |
| res.writeHead(200, { 'Content-Type': 'application/json' }); | |
| res.end(JSON.stringify({ ok: true })); | |
| } catch (e) { | |
| res.writeHead(400, { 'Content-Type': 'application/json' }); | |
| res.end(JSON.stringify({ error: e.message })); | |
| } | |
| }); | |
| return; | |
| } | |
| if (req.method === 'DELETE' && req.url.startsWith('/sets/')) { | |
| const name = decodeURIComponent(req.url.slice(6)); | |
| const s = readSets(); | |
| delete s[name]; | |
| writeSets(s); | |
| res.writeHead(200, { 'Content-Type': 'application/json' }); | |
| res.end(JSON.stringify({ ok: true })); | |
| return; | |
| } | |
| res.writeHead(404); res.end('Not found'); | |
| }); | |
| server.listen(PORT, '127.0.0.1', () => { | |
| const url = `http://127.0.0.1:${PORT}`; | |
| // OSC 8 hyperlink escape sequence — works in most modern terminals | |
| const link = `\x1b]8;;${url}\x07${url}\x1b]8;;\x07`; | |
| console.log(`repogui → ${link}`); | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment