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:
parent
d53162e021
commit
ebd72ed0c9
|
|
@ -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/*
|
||||||
|
|
@ -17,11 +17,11 @@
|
||||||
|
|
||||||
// Configuration - MUST be manually enabled for production
|
// Configuration - MUST be manually enabled for production
|
||||||
const CONFIG = {
|
const CONFIG = {
|
||||||
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, {
|
||||||
|
|
@ -522,11 +534,11 @@
|
||||||
|
|
||||||
static colorCodeStatus(type) {
|
static colorCodeStatus(type) {
|
||||||
const colors = {
|
const colors = {
|
||||||
'SUCCESS': '#10B981', // Green
|
'SUCCESS': '#10B981', // Green
|
||||||
'ERROR': '#EF4444', // Red
|
'ERROR': '#EF4444', // Red
|
||||||
'VALIDATION_ERROR': '#F59E0B', // Yellow
|
'VALIDATION_ERROR': '#F59E0B', // Yellow
|
||||||
'EXECUTING': '#3B82F6', // Blue
|
'EXECUTING': '#3B82F6', // Blue
|
||||||
'MOCK': '#8B5CF6' // Purple
|
'MOCK': '#8B5CF6' // Purple
|
||||||
};
|
};
|
||||||
return colors[type] || '#6B7280'; // Default gray
|
return colors[type] || '#6B7280'; // Default gray
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue