From e75a06a751916b71230be163cf18db46c1aafbdd Mon Sep 17 00:00:00 2001 From: rob Date: Tue, 7 Oct 2025 07:27:37 +0000 Subject: [PATCH] Update src/ai-repo-commander.user.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleSuccess() now handles list_files by extracting a files array from typical n8n payloads and pasting a neat text code block. If it can’t find a files array, it pastes the raw JSON so you can see what came back. Added auto-submit after paste (AUTO_SUBMIT: true). It: tries clicking a visible Send button (button[data-testid="send-button"] or aria-label*="Send"), falls back to synthesizing Enter key events. Added APPEND_TRAILING_NEWLINE: true (helps some editors pick up a final input change reliably). Removed the unused scanExistingMessages() and updated a comment in the parser to match actual behavior. --- src/ai-repo-commander.user.js | 254 +++++++++++++++++++++++----------- 1 file changed, 177 insertions(+), 77 deletions(-) diff --git a/src/ai-repo-commander.user.js b/src/ai-repo-commander.user.js index 9df4a81..6e22153 100644 --- a/src/ai-repo-commander.user.js +++ b/src/ai-repo-commander.user.js @@ -1,8 +1,8 @@ // ==UserScript== // @name AI Repo Commander // @namespace http://tampermonkey.net/ -// @version 1.2.1 -// @description Execute ^%$bridge YAML commands from AI assistants in code blocks with persistent dedupe and robust paste +// @version 1.3.0 +// @description Execute ^%$bridge YAML commands from AI assistants in code blocks with persistent dedupe, robust paste, and optional auto-submit // @author Your Name // @match https://chat.openai.com/* // @match https://chatgpt.com/* @@ -23,7 +23,7 @@ DEBUG_MODE: true, // Console logs DEBOUNCE_DELAY: 5000, // Bot typing protection MAX_RETRIES: 2, // Retry attempts (=> up to MAX_RETRIES+1 total tries) - VERSION: '1.2.1', + VERSION: '1.3.0', PROCESS_EXISTING: false, // If false, only process messages added after init (but see initial delayed scan) ASSISTANT_ONLY: true, // Process assistant messages by default (your core use case) @@ -33,7 +33,13 @@ // Housekeeping CLEANUP_AFTER_MS: 30000, // Drop COMPLETE/ERROR entries after 30s - CLEANUP_INTERVAL_MS: 60000 // Sweep cadence + CLEANUP_INTERVAL_MS: 60000, // Sweep cadence + + // Paste + submit behavior + APPEND_TRAILING_NEWLINE: true, // Add '\n' after pasted text + AUTO_SUBMIT: true, // Try to submit after pasting content + POST_PASTE_DELAY_MS: 250, // Small delay before submit to let editors settle + SUBMIT_MODE: 'button_first', // 'button_first' | 'enter_only' | 'smart' }; // ---------------------- Platform selectors ---------------------- @@ -55,9 +61,7 @@ }; const FIELD_VALIDATORS = { - // allow "owner/repo" or just "repo" repo: (v) => /^[\w\-\.]+(\/[\w\-\.]+)?$/.test(v), - // minimal traversal guard: no absolute paths, no backslashes, no ".." path: (v) => !v.includes('..') && !v.startsWith('/') && !v.includes('\\'), action: (v) => Object.keys(REQUIRED_FIELDS).includes(v), owner: (v) => !v || /^[\w\-]+$/.test(v), @@ -151,7 +155,7 @@ } } - // ---------------------- Paste helper ---------------------- + // ---------------------- Paste + Submit helpers ---------------------- function getVisibleInputCandidate() { const candidates = [ '.ProseMirror#prompt-textarea', @@ -172,6 +176,58 @@ return null; } + function findSendButton() { + const selectors = [ + 'button[data-testid="send-button"]', + 'button[aria-label*="Send"]', + 'button[aria-label*="send"]', + 'button[aria-label*="Submit"]', + 'button[aria-label*="submit"]', + 'form button[type="submit"]' + ]; + for (const s of selectors) { + const btn = document.querySelector(s); + if (!btn) continue; + const style = window.getComputedStyle(btn); + const disabled = btn.disabled || btn.getAttribute('aria-disabled') === 'true'; + if (style.display === 'none' || style.visibility === 'hidden') continue; + if (btn.offsetParent === null && style.position !== 'fixed') continue; + if (!disabled) return btn; + } + return null; + } + + function pressEnterOn(el) { + const events = ['keydown','keypress','keyup']; + for (const type of events) { + const ok = el.dispatchEvent(new KeyboardEvent(type, { + key: 'Enter', + code: 'Enter', + keyCode: 13, + which: 13, + bubbles: true, + cancelable: true + })); + if (!ok) return false; + } + return true; + } + + async function submitComposer() { + try { + const btn = findSendButton(); + if (CONFIG.SUBMIT_MODE !== 'enter_only' && btn) { + btn.click(); + return true; + } + const el = getVisibleInputCandidate(); + if (!el) return false; + return pressEnterOn(el); + } catch { + return false; + } + } + function pasteToComposer(text) { try { const el = getVisibleInputCandidate(); @@ -180,17 +236,19 @@ return false; } + const payload = CONFIG.APPEND_TRAILING_NEWLINE ? (text.endsWith('\n') ? text : text + '\n') : text; + el.focus(); - // 1) Try a real-ish paste event with DataTransfer + // 1) ClipboardEvent paste try { const dt = new DataTransfer(); - dt.setData('text/plain', text); + dt.setData('text/plain', payload); const pasteEvt = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }); if (el.dispatchEvent(pasteEvt) && !pasteEvt.defaultPrevented) return true; } catch (_) { /* continue */ } - // 2) Try execCommand insertText (still widely supported) + // 2) execCommand insertText try { const sel = window.getSelection && window.getSelection(); if (sel && sel.rangeCount === 0 && el instanceof HTMLElement) { @@ -200,14 +258,14 @@ sel.removeAllRanges(); sel.addRange(r); } - if (document.execCommand && document.execCommand('insertText', false, text)) return true; + if (document.execCommand && document.execCommand('insertText', false, payload)) return true; } catch (_) { /* continue */ } - // 3) ProseMirror: inject paragraph HTML + notify input/change + // 3) ProseMirror innerHTML const isPM = el.classList && el.classList.contains('ProseMirror'); if (isPM) { const escape = (s) => s.replace(/&/g,'&').replace(//g,'>'); - const html = String(text).split('\n').map(line => line.length ? `

${escape(line)}

` : '


').join(''); + const html = String(payload).split('\n').map(line => line.length ? `

${escape(line)}

` : '


').join(''); el.innerHTML = html; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); @@ -216,20 +274,20 @@ // 4) contenteditable/textarea fallback if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') { - el.value = text; + el.value = payload; el.dispatchEvent(new Event('input', { bubbles: true })); return true; } if (el.isContentEditable || el.getAttribute('contenteditable') === 'true') { - el.textContent = text; + el.textContent = payload; el.dispatchEvent(new Event('input', { bubbles: true })); return true; } - // 5) Clipboard fallback for manual paste + // 5) Clipboard fallback try { if (typeof GM_setClipboard === 'function') { - GM_setClipboard(text, { type: 'text', mimetype: 'text/plain' }); + GM_setClipboard(payload, { type: 'text', mimetype: 'text/plain' }); GM_notification({ title: 'AI Repo Commander', text: 'Content copied to clipboard — press Ctrl/Cmd+V to paste.', timeout: 5000 }); } } catch (_) {} @@ -241,6 +299,18 @@ } } + async function pasteAndMaybeSubmit(text) { + const pasted = pasteToComposer(text); + if (!pasted) return false; + if (!CONFIG.AUTO_SUBMIT) return true; + await ExecutionManager.delay(CONFIG.POST_PASTE_DELAY_MS); + const ok = await submitComposer(); + if (!ok) { + GM_notification({ title: 'AI Repo Commander', text: 'Pasted content, but auto-submit did not trigger.', timeout: 4000 }); + } + return true; + } + // ---------------------- Parser ---------------------- class CommandParser { static parseYAMLCommand(codeBlockText) { @@ -262,14 +332,17 @@ } static extractCommandBlock(text) { - // We are passed the *code block text*. Accept ^%$bridge until explicit '---' or end-of-code-block (EOF). - // More flexible: allow ^%$bridge anywhere on its own line (with optional leading spaces). - const start = text.search(/(^|\n)\s*\^%\$bridge\b/m); - if (start === -1) return null; - - const after = text.slice(start); - const m = after.match(/^\s*\^%\$bridge[ \t]*\n([\s\S]*?)(?:\n---[ \t]*(?:\n|$)|$)/m); - return m ? m[1].trimEnd() : null; + // Find ^%$bridge anywhere in the code block, prefer explicit '---' terminator + const patterns = [ + /^\s*\^%\$bridge[ \t]*\n([\s\S]*?)\n---[ \t]*(?:\n|$)/m, + /^\s*\^%\$bridge[ \t]*\n([\s\S]*?)(?=\n\s*$|\n---|\n```|$)/m, + /^\s*\^%\$bridge[ \t]*\n([\s\S]*)/m + ]; + for (const pattern of patterns) { + const match = text.match(pattern); + if (match && match[1]?.trim()) return match[1].trimEnd(); + } + return null; } static parseKeyValuePairs(block) { @@ -319,12 +392,9 @@ const errors = []; const action = parsed.action; if (!action) { errors.push('Missing required field: action'); return { isValid:false, errors }; } - const req = REQUIRED_FIELDS[action]; if (!req) { errors.push(`Unknown action: ${action}`); return { isValid:false, errors }; } - for (const f of req) if (!parsed[f] && parsed[f] !== '') errors.push(`Missing required field: ${f}`); - for (const [field, value] of Object.entries(parsed)) { const validator = FIELD_VALIDATORS[field]; if (validator && !validator(value)) errors.push(`Invalid format for field: ${field}`); @@ -337,7 +407,6 @@ class ExecutionManager { static async executeCommand(command, sourceElement) { try { - // synthesize commit_message for file writes if ((command.action === 'update_file' || command.action === 'create_file') && !command.commit_message) { command.commit_message = `AI Repo Commander: ${command.path} (${new Date().toISOString()})`; } @@ -387,7 +456,7 @@ } static async mockExecution(command, sourceElement) { - await this.delay(1000); // consistency + await this.delay(1000); const mock = { status: 200, responseText: JSON.stringify({ @@ -399,7 +468,50 @@ return this.handleSuccess(mock, command, sourceElement, true); } - static handleSuccess(response, command, sourceElement, isMock = false) { + static _extractGetFileBody(payload) { + const item = Array.isArray(payload) ? payload[0] : payload; + return ( + item?.result?.content?.data ?? + item?.content?.data ?? + payload?.result?.content?.data ?? + null + ); + } + + static _extractFilesArray(payload) { + const obj = Array.isArray(payload) ? payload[0] : payload; + // Common shapes: { result: { files: [...] } } or { files: [...] } + let files = obj?.result?.files ?? obj?.files ?? null; + if (!files) { + // Try to sniff: look for any array under result that looks like files + const res = obj?.result; + if (res) { + for (const [k, v] of Object.entries(res)) { + if (Array.isArray(v) && v.length && (k.toLowerCase().includes('file') || typeof v[0] === 'string' || v[0]?.path || v[0]?.name)) { + files = v; break; + } + } + } + } + return Array.isArray(files) ? files : null; + } + + static _formatFilesListing(files) { + // Accept strings or objects; prefer .path, else join directory/name + const pickPath = (f) => { + if (typeof f === 'string') return f; + if (typeof f?.path === 'string') return f.path; + if (f?.dir && f?.name) return `${f.dir.replace(/\/+$/,'')}/${f.name}`; + if (f?.name) return f.name; + try { return JSON.stringify(f); } catch { return String(f); } + }; + + const lines = files.map(pickPath).filter(Boolean).sort(); + // Friendly text block (fits most chat UIs) + return '```text\n' + lines.join('\n') + '\n```'; + } + + static async handleSuccess(response, command, sourceElement, isMock = false) { let data; try { data = JSON.parse(response.responseText || '{}'); } catch { data = { message: 'Operation completed (no JSON body)' }; } @@ -409,28 +521,29 @@ details: data.message || 'Operation completed successfully' }); - // If this was a get_file, try to paste the returned content into the composer + // Auto-paste handlers if (command.action === 'get_file') { - const payload = data; - const item = Array.isArray(payload) ? payload[0] : payload; - - // Try common shapes (n8n variations) - const body = - item?.result?.content?.data ?? - item?.content?.data ?? - payload?.result?.content?.data ?? - null; - + const body = this._extractGetFileBody(data); if (typeof body === 'string' && body.length) { - const pasted = pasteToComposer(body); - if (!pasted) { - GM_notification({ title: 'AI Repo Commander', text: 'Fetched file (get_file), but could not paste into input.', timeout: 4000 }); - } + await pasteAndMaybeSubmit(body); } else { - console.log('[AI Repo Commander] get_file: no content.data in response', data); - GM_notification({ title: 'AI Repo Commander', text: 'get_file succeeded, but response had no content.data', timeout: 4000 }); + GM_notification({ title: 'AI Repo Commander', text: 'get_file succeeded, but no content to paste.', timeout: 4000 }); } } + + if (command.action === 'list_files') { + const files = this._extractFilesArray(data); + if (files && files.length) { + const listing = this._formatFilesListing(files); + await pasteAndMaybeSubmit(listing); + } else { + // Fallback: paste the whole payload as JSON for visibility + const fallback = '```json\n' + JSON.stringify(data, null, 2) + '\n```'; + await pasteAndMaybeSubmit(fallback); + GM_notification({ title: 'AI Repo Commander', text: 'list_files succeeded, but response had no obvious files array. Pasted raw JSON.', timeout: 5000 }); + } + } + return { success: true, data, isMock }; } @@ -446,7 +559,7 @@ } // ---------------------- Bridge Key ---------------------- - let BRIDGE_KEY = null; // (Declared near top for clarity) + let BRIDGE_KEY = null; function requireBridgeKeyIfNeeded() { if (CONFIG.ENABLE_API && !BRIDGE_KEY) { BRIDGE_KEY = prompt('[AI Repo Commander] Enter your bridge key for this session:'); @@ -497,7 +610,6 @@ this.scanMessages(); }, 2000); - // Optional deeper scan if explicitly enabled if (CONFIG.PROCESS_EXISTING) { setTimeout(() => { this.log('Deep scan of existing messages (PROCESS_EXISTING=true)'); @@ -506,43 +618,26 @@ } } - scanNode(node) { - if (node.querySelector) this.scanMessages(); - } - - scanExistingMessages() { setTimeout(() => this.scanMessages(), 1000); } + scanNode() { this.scanMessages(); } isAssistantMessage(el) { - if (!CONFIG.ASSISTANT_ONLY) return true; // Process all messages - + if (!CONFIG.ASSISTANT_ONLY) return true; const host = location.hostname; - - // OpenAI/ChatGPT: reliable role attribute if (/chat\.openai\.com|chatgpt\.com/.test(host)) { return !!el.closest?.('[data-message-author-role="assistant"]'); } - - // Claude: prefer explicit role if present; else allow (Claude DOM varies) if (/claude\.ai/.test(host)) { const isUser = !!el.closest?.('[data-message-author-role="user"]'); return !isUser; } - - // Gemini: DOM varies; allow by default (can refine later) - if (/gemini\.google\.com/.test(host)) { - return true; - } - - // Unknown platforms: allow + if (/gemini\.google\.com/.test(host)) return true; return true; } - // Require: command must be in a code block (pre/code) findCommandInCodeBlock(el) { const blocks = el.querySelectorAll('pre code, pre, code'); for (const b of blocks) { const txt = (b.textContent || '').trim(); - // Flexible: allow ^%$bridge to appear anywhere on its own line if (/(^|\n)\s*\^%\$bridge\b/m.test(txt)) { return { blockElement: b, text: txt }; } @@ -573,7 +668,6 @@ const cmdText = hit.text; - // Persistent dedupe: skip if we've already executed this exact block recently if (this.history.has(cmdText)) { this.log('Skipping already-executed command (persistent history)'); return; @@ -617,17 +711,14 @@ const before = message.originalText; await this.debounce(); - // Re-extract after debounce in case the AI finished streaming or edited const after = this.reextractCommandText(message.element); - // If command disappeared, abort gracefully if (!after) { this.log('Command removed during debounce - aborting'); this.updateState(messageId, COMMAND_STATES.ERROR); return; } - // If changed, re-debounce once and re-parse if (after !== before) { this.log('Command changed during debounce - updating and re-debouncing once'); message.originalText = after; @@ -648,9 +739,7 @@ this.updateState(messageId, COMMAND_STATES.EXECUTING); await ExecutionManager.executeCommand(parsed, message.element); - // Mark the FINAL executed version in history this.history.mark(message.originalText); - this.updateState(messageId, COMMAND_STATES.COMPLETE); } catch (error) { @@ -716,6 +805,16 @@ repo: test-repo path: README.md --- \`\`\` +`, + listFiles: +`\ +\`\`\`yaml +^%$bridge +action: list_files +repo: test-repo +path: . +--- +\`\`\` ` }; @@ -729,7 +828,8 @@ path: README.md config: CONFIG, test: TEST_COMMANDS, version: CONFIG.VERSION, - history: commandMonitor.history + history: commandMonitor.history, + submitComposer // expose for quick testing }; console.log('AI Repo Commander fully initialized'); console.log('API Enabled:', CONFIG.ENABLE_API);