Update src/ai-repo-commander.user.js

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.
This commit is contained in:
rob 2025-10-07 07:27:37 +00:00
parent 36951c5b16
commit e75a06a751
1 changed files with 177 additions and 77 deletions

View File

@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const html = String(text).split('\n').map(line => line.length ? `<p>${escape(line)}</p>` : '<p><br></p>').join('');
const html = String(payload).split('\n').map(line => line.length ? `<p>${escape(line)}</p>` : '<p><br></p>').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);