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==
// @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/*
@ -20,8 +20,8 @@
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'
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, {