diff --git a/src/ai-repo-commander.user.js b/src/ai-repo-commander.user.js index f30f1fc..8675732 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.0.1 -// @description Enable AI assistants to securely interact with git repositories via YAML commands +// @version 1.0.2 +// @description Enable AI assistants to securely interact with git repositories via YAML commands, with safety-first execution and clear inline feedback. // @author Your Name // @match https://chat.openai.com/* // @match https://claude.ai/* @@ -17,11 +17,11 @@ // Configuration - MUST be manually enabled for production const CONFIG = { - ENABLE_API: false, // Master kill switch - DEBUG_MODE: true, // Development logging - DEBOUNCE_DELAY: 5000, // 5-second bot typing protection - MAX_RETRIES: 2, // API retry attempts - VERSION: '1.0.1' + ENABLE_API: false, // Master kill switch + DEBUG_MODE: true, // Development logging + DEBOUNCE_DELAY: 5000, // 5-second bot typing protection + MAX_RETRIES: 2, // API retry attempts (2 retries => up to 3 total attempts) + VERSION: '1.0.2' }; // Platform-specific DOM selectors @@ -97,7 +97,7 @@ // Core Monitor Class class CommandMonitor { constructor() { - this.trackedMessages = new Map(); + this.trackedMessages = new Map(); // id -> { element, originalText, state, ... } this.observer = null; this.currentPlatform = null; this.initialize(); @@ -108,6 +108,9 @@ this.startObservation(); this.setupEmergencyStop(); this.log('AI Repo Commander initialized', CONFIG); + if (CONFIG.ENABLE_API) { + console.warn('[AI Repo Commander] API is enabled — you will be prompted for your bridge key on first command.'); + } } detectPlatform() { @@ -119,17 +122,16 @@ this.observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { mutation.addedNodes.forEach((node) => { - if (node.nodeType === 1) { // Element node + if (node.nodeType === 1) { // Element this.scanNode(node); } }); }); }); - const targetNode = document.body; - this.observer.observe(targetNode, { childList: true, subtree: true }); + this.observer.observe(document.body, { childList: true, subtree: true }); - // Initial scan of existing messages + // Initial scan this.scanExistingMessages(); } @@ -156,8 +158,12 @@ }); } + // Stable id: persist a data attribute to avoid duplicate processing on rescans getMessageId(element) { - return element.id || element.className + '-' + Array.from(element.children).length; + if (element.dataset && element.dataset.aiRcId) return element.dataset.aiRcId; + const id = element.id || `${element.className}-${element.childElementCount}-${Date.now()}-${Math.random().toString(36).slice(2,6)}`; + if (element.dataset) element.dataset.aiRcId = id; + return id; } extractText(element) { @@ -265,7 +271,7 @@ } } - // Command Parser Class (sturdier block extraction + simple content: |) + // Command Parser Class (sturdier block extraction + simple content: | handling) class CommandParser { static parseYAMLCommand(text) { try { @@ -273,10 +279,17 @@ if (!commandBlock) throw new Error('No valid command block found'); const parsed = this.parseKeyValuePairs(commandBlock); - // Set defaults + // Defaults parsed.url = parsed.url || 'https://n8n.brrd.tech/webhook/ai-gitea-bridge'; parsed.owner = parsed.owner || 'brrd'; + // Expand owner/repo shorthand here (not in validate) + if (parsed.repo && typeof parsed.repo === 'string' && parsed.repo.includes('/')) { + const [owner, repo] = parsed.repo.split('/', 2); + if (!parsed.owner) parsed.owner = owner; + parsed.repo = repo; + } + return parsed; } catch (error) { throw new Error(`YAML parsing failed: ${error.message}`); @@ -284,8 +297,8 @@ } static extractCommandBlock(text) { - // Finds the first ^%$bridge block up to the next line that contains only --- - const m = text.match(/^\^%\$bridge[ \t]*\n([\s\S]*?)\n---\s*$/m); + // Find ^%$bridge … up to a line with --- (allow trailing spaces) then newline OR end of string + const m = text.match(/^\^%\$bridge[ \t]*\n([\s\S]*?)\n---[ \t]*(?:\n|$)/m); return m ? m[1].trimEnd() : null; } @@ -295,13 +308,18 @@ let currentKey = null; let collecting = false; let buf = []; + const TOP_LEVEL_KEYS = ['action','repo','path','content','owner','url','commit_message','branch','ref']; for (const raw of lines) { const line = raw.replace(/\r$/, ''); if (collecting) { - // stop if we hit an unindented key:value (loose heuristic) - if (/^[A-Za-z_][\w\-]*\s*:/.test(line)) { + // Only stop if UNINDENTED and a KNOWN top-level key + const looksKey = /^[A-Za-z_][\w\-]*\s*:/.test(line); + const unindented = !/^[ \t]/.test(line); + const isTopKey = looksKey && unindented && + TOP_LEVEL_KEYS.some(k => line.startsWith(k + ':')); + if (isTopKey) { result[currentKey] = buf.join('\n').trimEnd(); collecting = false; buf = []; @@ -343,13 +361,6 @@ static validateStructure(parsed) { const errors = []; - // Expand owner/repo shorthand if present - if (parsed.repo && parsed.repo.includes('/')) { - const [owner, repo] = parsed.repo.split('/', 2); - parsed.owner = parsed.owner || owner; - parsed.repo = repo; - } - // Required fields per action const action = parsed.action; if (!action) { @@ -369,7 +380,7 @@ } } - // Field format validators + // Field format validators (includes path traversal guard) for (const [field, value] of Object.entries(parsed)) { const validator = FIELD_VALIDATORS[field]; if (validator && !validator(value)) { @@ -377,11 +388,6 @@ } } - // Minimal traversal guard (remove if you truly don't want it) - if (parsed.path && (parsed.path.startsWith('/') || parsed.path.includes('..') || parsed.path.includes('\\'))) { - errors.push('Invalid path: no leading "/" or ".." or "\\" allowed.'); - } - return { isValid: errors.length === 0, errors }; } } @@ -413,10 +419,11 @@ return await this.makeAPICall(command); } catch (error) { if (attempt < CONFIG.MAX_RETRIES) { - await this.delay(1000 * (attempt + 1)); // simple backoff: 1s, 2s + await this.delay(1000 * (attempt + 1)); // simple backoff: 1s, 2s, ... return this.makeAPICallWithRetry(command, attempt + 1); } - throw new Error(`${error.message} (after ${attempt + 1} attempts)`); + const totalAttempts = attempt + 1; // attempts actually made + throw new Error(`${error.message} (failed after ${totalAttempts} attempts; max ${CONFIG.MAX_RETRIES + 1})`); } } @@ -464,7 +471,12 @@ } static handleSuccess(response, command, sourceElement, isMock = false) { - const responseData = JSON.parse(response.responseText || '{}'); + let responseData; + try { + responseData = JSON.parse(response.responseText || '{}'); + } catch { + responseData = { message: 'Operation completed (no JSON body)' }; + } const templateType = isMock ? 'MOCK' : 'SUCCESS'; UIFeedback.appendStatus(sourceElement, templateType, { @@ -522,11 +534,11 @@ static colorCodeStatus(type) { const colors = { - 'SUCCESS': '#10B981', // Green - 'ERROR': '#EF4444', // Red + 'SUCCESS': '#10B981', // Green + 'ERROR': '#EF4444', // Red 'VALIDATION_ERROR': '#F59E0B', // Yellow - 'EXECUTING': '#3B82F6', // Blue - 'MOCK': '#8B5CF6' // Purple + 'EXECUTING': '#3B82F6', // Blue + 'MOCK': '#8B5CF6' // Purple }; return colors[type] || '#6B7280'; // Default gray }