fixed all warnings

This commit is contained in:
rob 2025-10-14 14:53:18 -03:00
parent d6f48cdeac
commit 0da540b575
3 changed files with 141 additions and 71 deletions

View File

@ -4,6 +4,14 @@
A browser userscript that enables AI assistants to securely interact with git repositories via YAML-style commands, with comprehensive safety measures and real-time feedback. A browser userscript that enables AI assistants to securely interact with git repositories via YAML-style commands, with comprehensive safety measures and real-time feedback.
## 1.1 Diagrams (PlantUML)
- Architecture Overview: Docs/diagrams/architecture-overview.puml
- Command Execution Sequence: Docs/diagrams/sequence-command-execution.puml
- Command Processing State Machine: Docs/diagrams/state-machine.puml
How to view: open the .puml files with any PlantUML viewer (IDE plugin or web renderer) to generate images.
## 2. Core Architecture ## 2. Core Architecture
### 2.1 Safety-First Design ### 2.1 Safety-First Design

View File

@ -74,6 +74,17 @@ SLOW_WARN_MS: 60000
5) Show inline status and store dedupe record 5) Show inline status and store dedupe record
6) Expose a **Run Again** button for intentional re-execution 6) Expose a **Run Again** button for intentional re-execution
## Diagrams (PlantUML)
The following PlantUML diagrams provide a high-level overview of the codebase:
- Architecture Overview: Docs/diagrams/architecture-overview.puml
- Command Execution Sequence: Docs/diagrams/sequence-command-execution.puml
- Command Processing State Machine: Docs/diagrams/state-machine.puml
How to view:
- Use any PlantUML renderer (IntelliJ/VSCode plugin, plantuml.com/plantuml, or local PlantUML jar)
- Copy the contents of a .puml file into your renderer to generate the diagram image
## New in v1.6.2 ## New in v1.6.2
- **Resume-Safe Guard:** On resume, treat like a cold start and mark visible commands as processed, preventing accidental re-execution. - **Resume-Safe Guard:** On resume, treat like a cold start and mark visible commands as processed, preventing accidental re-execution.
- **Example Field Support:** Add `example: true` to any command block to make it inert. Its silently skipped (no error UI). - **Example Field Support:** Add `example: true` to any command block to make it inert. Its silently skipped (no error UI).

View File

@ -2,7 +2,7 @@
// @name AI Repo Commander // @name AI Repo Commander
// @namespace http://tampermonkey.net/ // @namespace http://tampermonkey.net/
// @version 1.6.2 // @version 1.6.2
// @description Execute @bridge@ YAML commands from AI assistants (safe & robust): complete-block detection, streaming-settle, persistent dedupe, paste+autosubmit, debug console with Tools/Settings, draggable/collapsible panel, multi-command queue // @description Execute @bridge@ YAML commands from AI assistants (safe & robust): complete-block detection, streaming-settle, persistent dedupe, paste + auto-submit, debug console with Tools/Settings, draggable/collapsible panel, multi-command queue
// @author Your Name // @author Your Name
// @match https://chat.openai.com/* // @match https://chat.openai.com/*
// @match https://chatgpt.com/* // @match https://chatgpt.com/*
@ -14,6 +14,9 @@
// @connect n8n.brrd.tech // @connect n8n.brrd.tech
// @connect * // @connect *
// ==/UserScript== // ==/UserScript==
/* global GM_notification */
/* global GM_setClipboard */
/* global GM_xmlhttpRequest */
(function () { (function () {
'use strict'; 'use strict';
@ -98,8 +101,7 @@
const raw = localStorage.getItem(STORAGE_KEYS.cfg); const raw = localStorage.getItem(STORAGE_KEYS.cfg);
if (!raw) return structuredClone(DEFAULT_CONFIG); if (!raw) return structuredClone(DEFAULT_CONFIG);
const saved = JSON.parse(raw); const saved = JSON.parse(raw);
const merged = { ...DEFAULT_CONFIG, ...saved, RUNTIME: { ...DEFAULT_CONFIG.RUNTIME, ...(saved.RUNTIME || {}) } }; return { ...DEFAULT_CONFIG, ...saved, RUNTIME: { ...DEFAULT_CONFIG.RUNTIME, ...(saved.RUNTIME || {}) } };
return merged;
} catch { } catch {
return structuredClone(DEFAULT_CONFIG); return structuredClone(DEFAULT_CONFIG);
} }
@ -192,17 +194,35 @@
_fallbackCopy(text, originalError = null) { _fallbackCopy(text, originalError = null) {
try { try {
// Show a minimal manual copy UI (no deprecated execCommand)
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;z-index:999999;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;';
const panel = document.createElement('div');
panel.style.cssText = 'background:#1f2937;color:#e5e7eb;padding:12px 12px 8px;border-radius:8px;width:min(760px,90vw);max-height:70vh;display:flex;flex-direction:column;gap:8px;box-shadow:0 10px 30px rgba(0,0,0,0.5)';
const title = document.createElement('div');
title.textContent = 'Copy to clipboard';
title.style.cssText = 'font:600 14px system-ui,sans-serif;';
const hint = document.createElement('div');
hint.textContent = 'Press Ctrl+C (Windows/Linux) or ⌘+C (macOS) to copy the selected text.';
hint.style.cssText = 'font:12px system-ui,sans-serif;opacity:0.85;';
const ta = document.createElement('textarea'); const ta = document.createElement('textarea');
ta.value = text; ta.value = text;
ta.style.position = 'fixed'; ta.readOnly = true;
ta.style.opacity = '0'; ta.style.cssText = 'width:100%;height:40vh;font:12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;';
document.body.appendChild(ta); const row = document.createElement('div');
row.style.cssText = 'display:flex;gap:8px;justify-content:flex-end;';
const close = document.createElement('button');
close.textContent = 'Close';
close.style.cssText = 'padding:6px 10px;background:#374151;color:#e5e7eb;border:1px solid #4b5563;border-radius:6px;cursor:pointer;';
close.onclick = () => overlay.remove();
panel.append(title, hint, ta, row);
row.append(close);
overlay.append(panel);
document.body.appendChild(overlay);
// Focus and select
ta.focus(); ta.focus();
ta.select(); ta.select();
const ok = document.execCommand('copy'); this.warn('Clipboard API unavailable; showing manual copy UI', { error: originalError?.message });
document.body.removeChild(ta);
if (ok) this.info(`Copied last ${text.split('\n').length} lines to clipboard (fallback)`);
else this.warn('Clipboard copy failed (fallback)');
} catch (e) { } catch (e) {
this.warn('Clipboard copy failed', { error: originalError?.message || e.message }); this.warn('Clipboard copy failed', { error: originalError?.message || e.message });
} }
@ -691,7 +711,7 @@
} }
// Extract the *command block* if present; else fall back to element text // Extract the *command block* if present; else fall back to element text
function _commandishText(el) { function _commandLikeText(el) {
// Mirror parser's detector: require header, action, and '@end@' // Mirror parser's detector: require header, action, and '@end@'
const blocks = el.querySelectorAll('pre code, pre, code'); const blocks = el.querySelectorAll('pre code, pre, code');
for (const b of blocks) { for (const b of blocks) {
@ -706,7 +726,7 @@
// Hash of the command (or element text) capped to 2000 chars // Hash of the command (or element text) capped to 2000 chars
function _hashCommand(el) { function _hashCommand(el) {
const t = _commandishText(el); const t = _commandLikeText(el);
return _hash(t.slice(0, 2000)); return _hash(t.slice(0, 2000));
} }
@ -914,8 +934,9 @@
'create_release': ['action', 'repo', 'tag_name', 'name'] 'create_release': ['action', 'repo', 'tag_name', 'name']
}; };
const FIELD_VALIDATORS = { // noinspection JSUnusedGlobalSymbols
repo: (v) => /^[\w\-\.]+(\/[\w\-\.]+)?$/.test(v), const FIELD_VALIDATORS = {
repo: (v) => /^[\w\-.]+(\/[\w\-.]+)?$/.test(v),
path: (v) => !v.includes('..') && !v.startsWith('/') && !v.includes('\\'), path: (v) => !v.includes('..') && !v.startsWith('/') && !v.includes('\\'),
action: (v) => Object.keys(REQUIRED_FIELDS).includes(v), action: (v) => Object.keys(REQUIRED_FIELDS).includes(v),
url: (v) => !v || /^https?:\/\/[^/\s]+(?:\/|$)/i.test(v), url: (v) => !v || /^https?:\/\/[^/\s]+(?:\/|$)/i.test(v),
@ -1001,9 +1022,13 @@
} }
} }
/**
* @param {Element} el
* @param {string|number} [suffix]
*/
hasElement(el, suffix = '') { hasElement(el, suffix = '') {
let fp = fingerprintElement(el); let fp = fingerprintElement(el);
if (suffix) fp += `#${suffix}`; if (suffix !== '' && suffix != null) fp += `#${String(suffix)}`;
const result = this.session.has(fp) || (fp in this.cache); const result = this.session.has(fp) || (fp in this.cache);
if (result && CONFIG.DEBUG_LEVEL >= 4) { if (result && CONFIG.DEBUG_LEVEL >= 4) {
@ -1017,9 +1042,13 @@
return result; return result;
} }
/**
* @param {Element} el
* @param {string|number} [suffix]
*/
markElement(el, suffix = '') { markElement(el, suffix = '') {
let fp = fingerprintElement(el); let fp = fingerprintElement(el);
if (suffix) fp += `#${suffix}`; if (suffix !== '' && suffix != null) fp += `#${String(suffix)}`;
this.session.add(fp); this.session.add(fp);
this.cache[fp] = Date.now(); this.cache[fp] = Date.now();
this._save(); this._save();
@ -1028,7 +1057,7 @@
fingerprint: fp.slice(0, 60) + '...' fingerprint: fp.slice(0, 60) + '...'
}); });
if (CONFIG.SHOW_EXECUTED_MARKER) { if (CONFIG.SHOW_EXECUTED_MARKER && el instanceof HTMLElement) {
try { try {
el.style.borderLeft = '3px solid #10B981'; el.style.borderLeft = '3px solid #10B981';
el.title = 'Command executed — use "Run again" to re-run'; el.title = 'Command executed — use "Run again" to re-run';
@ -1036,20 +1065,6 @@
} }
} }
unmarkElement(el, suffix = '') {
let fp = fingerprintElement(el);
if (suffix) fp += `#${suffix}`;
this.session.delete(fp);
if (fp in this.cache) {
delete this.cache[fp];
this._save();
}
RC_DEBUG?.verbose('Unmarked element', {
fingerprint: fp.slice(0, 60) + '...'
});
}
resetAll() { resetAll() {
this.session.clear(); this.session.clear();
localStorage.removeItem(this.key); localStorage.removeItem(this.key);
@ -1059,7 +1074,8 @@
} }
// Global helpers (stable) // Global helpers (stable)
window.AI_REPO = { // noinspection JSUnusedGlobalSymbols
window.AI_REPO = {
clearHistory: () => { clearHistory: () => {
try { commandMonitor?.history?.resetAll?.(); } catch {} try { commandMonitor?.history?.resetAll?.(); } catch {}
localStorage.removeItem(STORAGE_KEYS.history); // legacy localStorage.removeItem(STORAGE_KEYS.history); // legacy
@ -1289,7 +1305,7 @@
'form button[type="submit"]' 'form button[type="submit"]'
]; ];
for (const s of selectors) { for (const s of selectors) {
const btn = document.querySelector(s); const btn = scope.querySelector(s);
if (!btn) continue; if (!btn) continue;
const style = window.getComputedStyle(btn); const style = window.getComputedStyle(btn);
const disabled = btn.disabled || btn.getAttribute('aria-disabled') === 'true'; const disabled = btn.disabled || btn.getAttribute('aria-disabled') === 'true';
@ -1369,37 +1385,52 @@
// Pad with blank lines before/after to preserve ``` fences visually. // Pad with blank lines before/after to preserve ``` fences visually.
const payload2 = `\n${payload.replace(/\n?$/, '\n')}\n`; const payload2 = `\n${payload.replace(/\n?$/, '\n')}\n`;
const escape = (s) => s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); const escape = (s) => s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const html = String(payload2) el.innerHTML = String(payload2)
.split('\n') .split('\n')
.map(line => line.length ? `<p>${escape(line)}</p>` : '<p><br></p>') .map(line => line.length ? `<p>${escape(line)}</p>` : '<p><br></p>')
.join(''); .join('');
el.innerHTML = html;
el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true }));
RC_DEBUG?.info('✅ Paste method succeeded: ProseMirror'); RC_DEBUG?.info('✅ Paste method succeeded: ProseMirror');
return true; return true;
} }
// Method 3: execCommand // Method 3: Selection API insertion (non-deprecated)
try { try {
const sel = window.getSelection && window.getSelection(); const sel = window.getSelection && window.getSelection();
if (sel && sel.rangeCount === 0 && el instanceof HTMLElement) { if (el.isContentEditable || el.getAttribute('contenteditable') === 'true') {
const r = document.createRange(); // Ensure there's a caret; if not, place it at the end
r.selectNodeContents(el); r.collapse(false); sel.removeAllRanges(); sel.addRange(r); if (sel && sel.rangeCount === 0) {
RC_DEBUG?.verbose('Selection range set for execCommand'); const r = document.createRange();
} r.selectNodeContents(el); r.collapse(false); sel.removeAllRanges(); sel.addRange(r);
RC_DEBUG?.verbose('Selection range set for contentEditable');
const success = document.execCommand && document.execCommand('insertText', false, payload); }
RC_DEBUG?.verbose('execCommand attempt', { success }); if (sel && sel.rangeCount > 0) {
const range = sel.getRangeAt(0);
if (success) { range.deleteContents();
RC_DEBUG?.info('✅ Paste method succeeded: execCommand'); const node = document.createTextNode(payload);
range.insertNode(node);
// Move caret after inserted node
range.setStartAfter(node);
range.setEndAfter(node);
sel.removeAllRanges(); sel.addRange(range);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
RC_DEBUG?.info('✅ Paste method succeeded: Selection API (contentEditable)');
return true;
}
} else if (typeof el.setRangeText === 'function') {
// For inputs/text-areas supporting setRangeText
const start = el.selectionStart ?? 0;
const end = el.selectionEnd ?? start;
el.setRangeText(payload, start, end, 'end');
el.dispatchEvent(new Event('input', { bubbles: true }));
RC_DEBUG?.info('✅ Paste method succeeded: setRangeText');
return true; return true;
} }
} catch (e) { } catch (e) {
RC_DEBUG?.verbose('execCommand failed', { error: String(e) }); RC_DEBUG?.verbose('Selection API insertion failed', { error: String(e) });
} }
// Method 4: TEXTAREA/INPUT // Method 4: TEXTAREA/INPUT
@ -1594,8 +1625,7 @@
if (!CONFIG.ENABLE_API) { if (!CONFIG.ENABLE_API) {
UIFeedback.appendStatus(sourceElement, 'EXECUTING', { action: command.action, details: 'Mocking...', key: renderKey, label }); UIFeedback.appendStatus(sourceElement, 'EXECUTING', { action: command.action, details: 'Mocking...', key: renderKey, label });
const res = await this.mockExecution(command, sourceElement, renderKey, label); return await this.mockExecution(command, sourceElement, renderKey, label);
return res;
} }
UIFeedback.appendStatus(sourceElement, 'EXECUTING', { action: command.action, details: 'Making API request...', key: renderKey, label }); UIFeedback.appendStatus(sourceElement, 'EXECUTING', { action: command.action, details: 'Making API request...', key: renderKey, label });
@ -1713,7 +1743,7 @@
if (command.action === 'get_file') { if (command.action === 'get_file') {
const body = this._extractGetFileBody(data); const body = this._extractGetFileBody(data);
if (typeof body === 'string' && body.length) { if (typeof body === 'string' && body.length) {
RESP_BUFFER.push({ label, content: body }); new ResponseBuffer().push({ label, content: body });
} else { } else {
GM_notification({ title: 'AI Repo Commander', text: 'get_file succeeded, but no content to paste.', timeout: 4000 }); GM_notification({ title: 'AI Repo Commander', text: 'get_file succeeded, but no content to paste.', timeout: 4000 });
} }
@ -1723,10 +1753,10 @@
const files = this._extractFilesArray(data); const files = this._extractFilesArray(data);
if (files && files.length) { if (files && files.length) {
const listing = this._formatFilesListing(files); const listing = this._formatFilesListing(files);
RESP_BUFFER.push({ label, content: listing }); new ResponseBuffer().push({ label, content: listing });
} else { } else {
const fallback = '```json\n' + JSON.stringify(data, null, 2) + '\n```'; const fallback = '```json\n' + JSON.stringify(data, null, 2) + '\n```';
RESP_BUFFER.push({ label, content: fallback }); new ResponseBuffer().push({ label, content: fallback });
GM_notification({ GM_notification({
title: 'AI Repo Commander', title: 'AI Repo Commander',
text: 'list_files succeeded, but response had no obvious files array. Pasted raw JSON.', text: 'list_files succeeded, but response had no obvious files array. Pasted raw JSON.',
@ -1764,7 +1794,7 @@
push(task) { push(task) {
this.q.push(task); this.q.push(task);
this.onSizeChange?.(this.q.length); this.onSizeChange?.(this.q.length);
if (!this.running) this._drain(); if (!this.running) void this._drain();
} }
clear() { clear() {
this.q.length = 0; this.q.length = 0;
@ -1799,7 +1829,8 @@
} }
const execQueue = new ExecutionQueue(); const execQueue = new ExecutionQueue();
window.AI_REPO_QUEUE = { // noinspection JSUnusedGlobalSymbols
window.AI_REPO_QUEUE = {
clear: () => execQueue.clear(), clear: () => execQueue.clear(),
size: () => execQueue.q.length, size: () => execQueue.q.length,
cancelOne: (cb) => execQueue.cancelOne(cb), cancelOne: (cb) => execQueue.cancelOne(cb),
@ -1910,8 +1941,8 @@
} }
const RESP_BUFFER = new ResponseBuffer();
window.AI_REPO_RESPONSES = RESP_BUFFER; // optional debug handle window.AI_REPO_RESPONSES = new ResponseBuffer(); // optional debug handle
// ---------------------- Bridge Key ---------------------- // ---------------------- Bridge Key ----------------------
let BRIDGE_KEY = null; let BRIDGE_KEY = null;
@ -2229,8 +2260,6 @@
let parsed; let parsed;
try { try {
parsed = CommandParser.parseYAMLCommand(finalTxt); parsed = CommandParser.parseYAMLCommand(finalTxt);
const val = CommandParser.validateStructure(parsed);
if (!val.isValid) throw new Error(`Validation failed: ${val.errors.join(', ')}`);
} catch (err) { } catch (err) {
UIFeedback.appendStatus(element, 'ERROR', { UIFeedback.appendStatus(element, 'ERROR', {
action: 'Command', action: 'Command',
@ -2241,6 +2270,17 @@
this.attachRetryUI(element, subId); this.attachRetryUI(element, subId);
return; return;
} }
const val = CommandParser.validateStructure(parsed);
if (!val.isValid) {
UIFeedback.appendStatus(element, 'ERROR', {
action: 'Command',
details: `Validation failed: ${val.errors.join(', ')}`,
key: subId,
label: `[${idx+1}] parse`
});
this.attachRetryUI(element, subId);
return;
}
this.updateState(subId, COMMAND_STATES.EXECUTING); this.updateState(subId, COMMAND_STATES.EXECUTING);
const res = await ExecutionManager.executeCommand( const res = await ExecutionManager.executeCommand(
@ -2285,7 +2325,7 @@
cancelToken: { cancelled: false } cancelToken: { cancelled: false }
}); });
this.updateState(messageId, COMMAND_STATES.PARSING); this.updateState(messageId, COMMAND_STATES.PARSING);
this.processCommand(messageId); void this.processCommand(messageId);
} }
async debounceWithCancel(messageId) { async debounceWithCancel(messageId) {
@ -2392,8 +2432,8 @@
// Highlight failed one // Highlight failed one
try { try {
const bar = element.querySelector('.ai-rc-rerun'); const bar = element.querySelector('.ai-rc-rerun');
const btns = [...bar.querySelectorAll('button')].filter(b => /Run again \[#\d+\]/.test(b.textContent || '')); const buttons = [...bar.querySelectorAll('button')].filter(b => /Run again \[#\d+]/.test(b.textContent || ''));
const b = btns[failedIdx]; const b = buttons[failedIdx];
if (b) b.style.outline = '2px solid #ef4444'; if (b) b.style.outline = '2px solid #ef4444';
} catch {} } catch {}
} }
@ -2467,8 +2507,13 @@
} }
if (!validation.isValid) { if (!validation.isValid) {
this.updateState(messageId, COMMAND_STATES.ERROR);
this.attachRetryUI(message.element, messageId); this.attachRetryUI(message.element, messageId);
throw new Error(`Validation failed: ${validation.errors.join(', ')}`); UIFeedback.appendStatus(message.element, 'ERROR', {
action: 'Command',
details: `Validation failed: ${validation.errors.join(', ')}`
});
return;
} }
// 3) Debounce // 3) Debounce
@ -2524,8 +2569,13 @@
const reParsed = CommandParser.parseYAMLCommand(stable); const reParsed = CommandParser.parseYAMLCommand(stable);
const reVal = CommandParser.validateStructure(reParsed); const reVal = CommandParser.validateStructure(reParsed);
if (!reVal.isValid) { if (!reVal.isValid) {
this.updateState(messageId, COMMAND_STATES.ERROR);
this.attachRetryUI(message.element, messageId); this.attachRetryUI(message.element, messageId);
throw new Error(`Final validation failed: ${reVal.errors.join(', ')}`); UIFeedback.appendStatus(message.element, 'ERROR', {
action: 'Command',
details: `Final validation failed: ${reVal.errors.join(', ')}`
});
return;
} }
parsed = reParsed; parsed = reParsed;
} }
@ -2647,7 +2697,7 @@
RC_DEBUG?.info('Retrying message now', { messageId }); RC_DEBUG?.info('Retrying message now', { messageId });
commandMonitor.updateState(messageId, COMMAND_STATES.PARSING); commandMonitor.updateState(messageId, COMMAND_STATES.PARSING);
commandMonitor.processCommand(messageId); void commandMonitor.processCommand(messageId);
} catch (e) { } catch (e) {
RC_DEBUG?.error('Failed to retry message', { messageId, error: String(e) }); RC_DEBUG?.error('Failed to retry message', { messageId, error: String(e) });
} }
@ -2793,7 +2843,8 @@ path: .
if (!commandMonitor) { if (!commandMonitor) {
commandMonitor = new CommandMonitor(); commandMonitor = new CommandMonitor();
window.AI_REPO_COMMANDER = { // noinspection JSUnusedGlobalSymbols
window.AI_REPO_COMMANDER = {
monitor: commandMonitor, monitor: commandMonitor,
config: CONFIG, config: CONFIG,
test: TEST_COMMANDS, test: TEST_COMMANDS,