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

1. Fixed YAML Parsing Edge Cases
Multi-line content with colons now works correctly

Uses TOP_LEVEL_KEYS to only stop collection on known keys

Much more robust for real-world content

2. Better Command Block Extraction
More tolerant --- matching (allows trailing spaces)

Won't break on content that happens to contain ---

3. Cleaner Owner/Repo Handling
Moved normalization to parser (side-effect free validation)

More predictable behavior

4. Improved Error Messaging
Clear retry attempt counts: "failed after 2 attempts; max 3"

Better user feedback

5. Robust JSON Parsing
Handles malformed API responses gracefully

Won't crash on non-JSON responses

6. Better Message ID Generation
Added timestamp and random salt to prevent collisions

Uses data attributes to persist IDs

7. User Experience
Bridge key warning on initialization

Cleaner separation of concerns
This commit is contained in:
rob 2025-10-06 20:04:23 +00:00
parent d53162e021
commit ebd72ed0c9
1 changed files with 51 additions and 39 deletions

View File

@ -1,8 +1,8 @@
// ==UserScript== // ==UserScript==
// @name AI Repo Commander // @name AI Repo Commander
// @namespace http://tampermonkey.net/ // @namespace http://tampermonkey.net/
// @version 1.0.1 // @version 1.0.2
// @description Enable AI assistants to securely interact with git repositories via YAML commands // @description Enable AI assistants to securely interact with git repositories via YAML commands, with safety-first execution and clear inline feedback.
// @author Your Name // @author Your Name
// @match https://chat.openai.com/* // @match https://chat.openai.com/*
// @match https://claude.ai/* // @match https://claude.ai/*
@ -20,8 +20,8 @@
ENABLE_API: false, // Master kill switch ENABLE_API: false, // Master kill switch
DEBUG_MODE: true, // Development logging DEBUG_MODE: true, // Development logging
DEBOUNCE_DELAY: 5000, // 5-second bot typing protection DEBOUNCE_DELAY: 5000, // 5-second bot typing protection
MAX_RETRIES: 2, // API retry attempts MAX_RETRIES: 2, // API retry attempts (2 retries => up to 3 total attempts)
VERSION: '1.0.1' VERSION: '1.0.2'
}; };
// Platform-specific DOM selectors // Platform-specific DOM selectors
@ -97,7 +97,7 @@
// Core Monitor Class // Core Monitor Class
class CommandMonitor { class CommandMonitor {
constructor() { constructor() {
this.trackedMessages = new Map(); this.trackedMessages = new Map(); // id -> { element, originalText, state, ... }
this.observer = null; this.observer = null;
this.currentPlatform = null; this.currentPlatform = null;
this.initialize(); this.initialize();
@ -108,6 +108,9 @@
this.startObservation(); this.startObservation();
this.setupEmergencyStop(); this.setupEmergencyStop();
this.log('AI Repo Commander initialized', CONFIG); 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() { detectPlatform() {
@ -119,17 +122,16 @@
this.observer = new MutationObserver((mutations) => { this.observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => { mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => { mutation.addedNodes.forEach((node) => {
if (node.nodeType === 1) { // Element node if (node.nodeType === 1) { // Element
this.scanNode(node); this.scanNode(node);
} }
}); });
}); });
}); });
const targetNode = document.body; this.observer.observe(document.body, { childList: true, subtree: true });
this.observer.observe(targetNode, { childList: true, subtree: true });
// Initial scan of existing messages // Initial scan
this.scanExistingMessages(); this.scanExistingMessages();
} }
@ -156,8 +158,12 @@
}); });
} }
// Stable id: persist a data attribute to avoid duplicate processing on rescans
getMessageId(element) { 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) { 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 { class CommandParser {
static parseYAMLCommand(text) { static parseYAMLCommand(text) {
try { try {
@ -273,10 +279,17 @@
if (!commandBlock) throw new Error('No valid command block found'); if (!commandBlock) throw new Error('No valid command block found');
const parsed = this.parseKeyValuePairs(commandBlock); const parsed = this.parseKeyValuePairs(commandBlock);
// Set defaults // Defaults
parsed.url = parsed.url || 'https://n8n.brrd.tech/webhook/ai-gitea-bridge'; parsed.url = parsed.url || 'https://n8n.brrd.tech/webhook/ai-gitea-bridge';
parsed.owner = parsed.owner || 'brrd'; 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; return parsed;
} catch (error) { } catch (error) {
throw new Error(`YAML parsing failed: ${error.message}`); throw new Error(`YAML parsing failed: ${error.message}`);
@ -284,8 +297,8 @@
} }
static extractCommandBlock(text) { static extractCommandBlock(text) {
// Finds the first ^%$bridge block up to the next line that contains only --- // 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---\s*$/m); const m = text.match(/^\^%\$bridge[ \t]*\n([\s\S]*?)\n---[ \t]*(?:\n|$)/m);
return m ? m[1].trimEnd() : null; return m ? m[1].trimEnd() : null;
} }
@ -295,13 +308,18 @@
let currentKey = null; let currentKey = null;
let collecting = false; let collecting = false;
let buf = []; let buf = [];
const TOP_LEVEL_KEYS = ['action','repo','path','content','owner','url','commit_message','branch','ref'];
for (const raw of lines) { for (const raw of lines) {
const line = raw.replace(/\r$/, ''); const line = raw.replace(/\r$/, '');
if (collecting) { if (collecting) {
// stop if we hit an unindented key:value (loose heuristic) // Only stop if UNINDENTED and a KNOWN top-level key
if (/^[A-Za-z_][\w\-]*\s*:/.test(line)) { 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(); result[currentKey] = buf.join('\n').trimEnd();
collecting = false; collecting = false;
buf = []; buf = [];
@ -343,13 +361,6 @@
static validateStructure(parsed) { static validateStructure(parsed) {
const errors = []; 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 // Required fields per action
const action = parsed.action; const action = parsed.action;
if (!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)) { for (const [field, value] of Object.entries(parsed)) {
const validator = FIELD_VALIDATORS[field]; const validator = FIELD_VALIDATORS[field];
if (validator && !validator(value)) { 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 }; return { isValid: errors.length === 0, errors };
} }
} }
@ -413,10 +419,11 @@
return await this.makeAPICall(command); return await this.makeAPICall(command);
} catch (error) { } catch (error) {
if (attempt < CONFIG.MAX_RETRIES) { 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); 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) { 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'; const templateType = isMock ? 'MOCK' : 'SUCCESS';
UIFeedback.appendStatus(sourceElement, templateType, { UIFeedback.appendStatus(sourceElement, templateType, {