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);