diff --git a/Docs/Technical_Design_Document.md b/Docs/Technical_Design_Document.md index e06321b..a41fcf9 100644 --- a/Docs/Technical_Design_Document.md +++ b/Docs/Technical_Design_Document.md @@ -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. +## 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.1 Safety-First Design diff --git a/README.md b/README.md index 0b1aa9f..cf5301c 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,17 @@ SLOW_WARN_MS: 60000 5) Show inline status and store dedupe record 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 - **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. It’s silently skipped (no error UI). diff --git a/src/ai-repo-commander.user.js b/src/ai-repo-commander.user.js index db6076d..4f54af0 100644 --- a/src/ai-repo-commander.user.js +++ b/src/ai-repo-commander.user.js @@ -2,7 +2,7 @@ // @name AI Repo Commander // @namespace http://tampermonkey.net/ // @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 // @match https://chat.openai.com/* // @match https://chatgpt.com/* @@ -14,6 +14,9 @@ // @connect n8n.brrd.tech // @connect * // ==/UserScript== +/* global GM_notification */ +/* global GM_setClipboard */ +/* global GM_xmlhttpRequest */ (function () { 'use strict'; @@ -98,8 +101,7 @@ const raw = localStorage.getItem(STORAGE_KEYS.cfg); if (!raw) return structuredClone(DEFAULT_CONFIG); const saved = JSON.parse(raw); - const merged = { ...DEFAULT_CONFIG, ...saved, RUNTIME: { ...DEFAULT_CONFIG.RUNTIME, ...(saved.RUNTIME || {}) } }; - return merged; + return { ...DEFAULT_CONFIG, ...saved, RUNTIME: { ...DEFAULT_CONFIG.RUNTIME, ...(saved.RUNTIME || {}) } }; } catch { return structuredClone(DEFAULT_CONFIG); } @@ -192,17 +194,35 @@ _fallbackCopy(text, originalError = null) { 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'); ta.value = text; - ta.style.position = 'fixed'; - ta.style.opacity = '0'; - document.body.appendChild(ta); + ta.readOnly = true; + ta.style.cssText = 'width:100%;height:40vh;font:12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;'; + 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.select(); - const ok = document.execCommand('copy'); - 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)'); + this.warn('Clipboard API unavailable; showing manual copy UI', { error: originalError?.message }); } catch (e) { 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 - function _commandishText(el) { + function _commandLikeText(el) { // Mirror parser's detector: require header, action, and '@end@' const blocks = el.querySelectorAll('pre code, pre, code'); for (const b of blocks) { @@ -706,7 +726,7 @@ // Hash of the command (or element text) capped to 2000 chars function _hashCommand(el) { - const t = _commandishText(el); + const t = _commandLikeText(el); return _hash(t.slice(0, 2000)); } @@ -914,8 +934,9 @@ 'create_release': ['action', 'repo', 'tag_name', 'name'] }; - const FIELD_VALIDATORS = { - repo: (v) => /^[\w\-\.]+(\/[\w\-\.]+)?$/.test(v), + // noinspection JSUnusedGlobalSymbols + const FIELD_VALIDATORS = { + repo: (v) => /^[\w\-.]+(\/[\w\-.]+)?$/.test(v), path: (v) => !v.includes('..') && !v.startsWith('/') && !v.includes('\\'), action: (v) => Object.keys(REQUIRED_FIELDS).includes(v), url: (v) => !v || /^https?:\/\/[^/\s]+(?:\/|$)/i.test(v), @@ -1001,9 +1022,13 @@ } } + /** + * @param {Element} el + * @param {string|number} [suffix] + */ hasElement(el, suffix = '') { 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); if (result && CONFIG.DEBUG_LEVEL >= 4) { @@ -1017,9 +1042,13 @@ return result; } + /** + * @param {Element} el + * @param {string|number} [suffix] + */ markElement(el, suffix = '') { let fp = fingerprintElement(el); - if (suffix) fp += `#${suffix}`; + if (suffix !== '' && suffix != null) fp += `#${String(suffix)}`; this.session.add(fp); this.cache[fp] = Date.now(); this._save(); @@ -1028,7 +1057,7 @@ fingerprint: fp.slice(0, 60) + '...' }); - if (CONFIG.SHOW_EXECUTED_MARKER) { + if (CONFIG.SHOW_EXECUTED_MARKER && el instanceof HTMLElement) { try { el.style.borderLeft = '3px solid #10B981'; 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() { this.session.clear(); localStorage.removeItem(this.key); @@ -1059,7 +1074,8 @@ } // Global helpers (stable) - window.AI_REPO = { + // noinspection JSUnusedGlobalSymbols + window.AI_REPO = { clearHistory: () => { try { commandMonitor?.history?.resetAll?.(); } catch {} localStorage.removeItem(STORAGE_KEYS.history); // legacy @@ -1289,7 +1305,7 @@ 'form button[type="submit"]' ]; for (const s of selectors) { - const btn = document.querySelector(s); + const btn = scope.querySelector(s); if (!btn) continue; const style = window.getComputedStyle(btn); const disabled = btn.disabled || btn.getAttribute('aria-disabled') === 'true'; @@ -1369,37 +1385,52 @@ // Pad with blank lines before/after to preserve ``` fences visually. const payload2 = `\n${payload.replace(/\n?$/, '\n')}\n`; const escape = (s) => s.replace(/&/g,'&').replace(//g,'>'); - - const html = String(payload2) - .split('\n') - .map(line => line.length ? `

${escape(line)}

` : '


') - .join(''); - - el.innerHTML = html; + + el.innerHTML = String(payload2) + .split('\n') + .map(line => line.length ? `

${escape(line)}

` : '


') + .join(''); el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); RC_DEBUG?.info('✅ Paste method succeeded: ProseMirror'); return true; } - // Method 3: execCommand + // Method 3: Selection API insertion (non-deprecated) try { const sel = window.getSelection && window.getSelection(); - if (sel && sel.rangeCount === 0 && el instanceof HTMLElement) { - const r = document.createRange(); - r.selectNodeContents(el); r.collapse(false); sel.removeAllRanges(); sel.addRange(r); - RC_DEBUG?.verbose('Selection range set for execCommand'); - } - - const success = document.execCommand && document.execCommand('insertText', false, payload); - RC_DEBUG?.verbose('execCommand attempt', { success }); - - if (success) { - RC_DEBUG?.info('✅ Paste method succeeded: execCommand'); + if (el.isContentEditable || el.getAttribute('contenteditable') === 'true') { + // Ensure there's a caret; if not, place it at the end + if (sel && sel.rangeCount === 0) { + const r = document.createRange(); + r.selectNodeContents(el); r.collapse(false); sel.removeAllRanges(); sel.addRange(r); + RC_DEBUG?.verbose('Selection range set for contentEditable'); + } + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0); + range.deleteContents(); + 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; } } catch (e) { - RC_DEBUG?.verbose('execCommand failed', { error: String(e) }); + RC_DEBUG?.verbose('Selection API insertion failed', { error: String(e) }); } // Method 4: TEXTAREA/INPUT @@ -1594,8 +1625,7 @@ if (!CONFIG.ENABLE_API) { UIFeedback.appendStatus(sourceElement, 'EXECUTING', { action: command.action, details: 'Mocking...', key: renderKey, label }); - const res = await this.mockExecution(command, sourceElement, renderKey, label); - return res; + return await this.mockExecution(command, sourceElement, 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') { const body = this._extractGetFileBody(data); if (typeof body === 'string' && body.length) { - RESP_BUFFER.push({ label, content: body }); + new ResponseBuffer().push({ label, content: body }); } else { 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); if (files && files.length) { const listing = this._formatFilesListing(files); - RESP_BUFFER.push({ label, content: listing }); + new ResponseBuffer().push({ label, content: listing }); } else { const fallback = '```json\n' + JSON.stringify(data, null, 2) + '\n```'; - RESP_BUFFER.push({ label, content: fallback }); + new ResponseBuffer().push({ label, content: fallback }); GM_notification({ title: 'AI Repo Commander', text: 'list_files succeeded, but response had no obvious files array. Pasted raw JSON.', @@ -1764,7 +1794,7 @@ push(task) { this.q.push(task); this.onSizeChange?.(this.q.length); - if (!this.running) this._drain(); + if (!this.running) void this._drain(); } clear() { this.q.length = 0; @@ -1799,7 +1829,8 @@ } const execQueue = new ExecutionQueue(); - window.AI_REPO_QUEUE = { + // noinspection JSUnusedGlobalSymbols + window.AI_REPO_QUEUE = { clear: () => execQueue.clear(), size: () => execQueue.q.length, 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 ---------------------- let BRIDGE_KEY = null; @@ -2229,8 +2260,6 @@ let parsed; try { parsed = CommandParser.parseYAMLCommand(finalTxt); - const val = CommandParser.validateStructure(parsed); - if (!val.isValid) throw new Error(`Validation failed: ${val.errors.join(', ')}`); } catch (err) { UIFeedback.appendStatus(element, 'ERROR', { action: 'Command', @@ -2241,6 +2270,17 @@ this.attachRetryUI(element, subId); 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); const res = await ExecutionManager.executeCommand( @@ -2285,7 +2325,7 @@ cancelToken: { cancelled: false } }); this.updateState(messageId, COMMAND_STATES.PARSING); - this.processCommand(messageId); + void this.processCommand(messageId); } async debounceWithCancel(messageId) { @@ -2392,8 +2432,8 @@ // Highlight failed one try { const bar = element.querySelector('.ai-rc-rerun'); - const btns = [...bar.querySelectorAll('button')].filter(b => /Run again \[#\d+\]/.test(b.textContent || '')); - const b = btns[failedIdx]; + const buttons = [...bar.querySelectorAll('button')].filter(b => /Run again \[#\d+]/.test(b.textContent || '')); + const b = buttons[failedIdx]; if (b) b.style.outline = '2px solid #ef4444'; } catch {} } @@ -2467,8 +2507,13 @@ } if (!validation.isValid) { + this.updateState(messageId, COMMAND_STATES.ERROR); 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 @@ -2524,8 +2569,13 @@ const reParsed = CommandParser.parseYAMLCommand(stable); const reVal = CommandParser.validateStructure(reParsed); if (!reVal.isValid) { + this.updateState(messageId, COMMAND_STATES.ERROR); 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; } @@ -2647,7 +2697,7 @@ RC_DEBUG?.info('Retrying message now', { messageId }); commandMonitor.updateState(messageId, COMMAND_STATES.PARSING); - commandMonitor.processCommand(messageId); + void commandMonitor.processCommand(messageId); } catch (e) { RC_DEBUG?.error('Failed to retry message', { messageId, error: String(e) }); } @@ -2793,7 +2843,8 @@ path: . if (!commandMonitor) { commandMonitor = new CommandMonitor(); - window.AI_REPO_COMMANDER = { + // noinspection JSUnusedGlobalSymbols + window.AI_REPO_COMMANDER = { monitor: commandMonitor, config: CONFIG, test: TEST_COMMANDS,