Update src/ai-repo-commander.user.js
Security improvements - Bridge key prompting, @connect directive, basic path validation Better UX - Appending status instead of replacing (much cleaner!) Streaming detection - Restarts debounce if content changes Auto-commit messages - Nice touch for file operations Owner/repo normalization - Handles owner/repo format gracefully
This commit is contained in:
parent
945c7eca57
commit
d53162e021
|
|
@ -1,7 +1,7 @@
|
|||
// ==UserScript==
|
||||
// @name AI Repo Commander
|
||||
// @namespace http://tampermonkey.net/
|
||||
// @version 1.0.0
|
||||
// @version 1.0.1
|
||||
// @description Enable AI assistants to securely interact with git repositories via YAML commands
|
||||
// @author Your Name
|
||||
// @match https://chat.openai.com/*
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
// @match https://gemini.google.com/*
|
||||
// @grant GM_xmlhttpRequest
|
||||
// @grant GM_notification
|
||||
// @connect n8n.brrd.tech
|
||||
// ==/UserScript==
|
||||
|
||||
(function() {
|
||||
|
|
@ -20,7 +21,7 @@
|
|||
DEBUG_MODE: true, // Development logging
|
||||
DEBOUNCE_DELAY: 5000, // 5-second bot typing protection
|
||||
MAX_RETRIES: 2, // API retry attempts
|
||||
VERSION: '1.0.0'
|
||||
VERSION: '1.0.1'
|
||||
};
|
||||
|
||||
// Platform-specific DOM selectors
|
||||
|
|
@ -52,9 +53,12 @@
|
|||
'list_files': ['action', 'repo', 'path']
|
||||
};
|
||||
|
||||
// Enhanced field validators (minimal safety)
|
||||
const FIELD_VALIDATORS = {
|
||||
'repo': (value) => /^[\w\-\.]+$/.test(value),
|
||||
'path': (value) => !value.includes('..') && !value.startsWith('/'),
|
||||
// allow "owner/repo" or just "repo"
|
||||
'repo': (value) => /^[\w\-\.]+(\/[\w\-\.]+)?$/.test(value),
|
||||
// basic traversal guard: no absolute paths, no backslashes, no ".."
|
||||
'path': (value) => !value.includes('..') && !value.startsWith('/') && !value.includes('\\'),
|
||||
'action': (value) => Object.keys(REQUIRED_FIELDS).includes(value),
|
||||
'owner': (value) => !value || /^[\w\-]+$/.test(value),
|
||||
'url': (value) => !value || /^https?:\/\/.+\..+/.test(value)
|
||||
|
|
@ -80,6 +84,16 @@
|
|||
ERROR: 'error'
|
||||
};
|
||||
|
||||
// Ephemeral secret (memory only; prompted when API enabled)
|
||||
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:');
|
||||
if (!BRIDGE_KEY) throw new Error('Bridge key required when API is enabled.');
|
||||
}
|
||||
return BRIDGE_KEY;
|
||||
}
|
||||
|
||||
// Core Monitor Class
|
||||
class CommandMonitor {
|
||||
constructor() {
|
||||
|
|
@ -102,7 +116,6 @@
|
|||
}
|
||||
|
||||
startObservation() {
|
||||
// Observe for new messages
|
||||
this.observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
|
|
@ -113,12 +126,8 @@
|
|||
});
|
||||
});
|
||||
|
||||
// Start observing
|
||||
const targetNode = document.body;
|
||||
this.observer.observe(targetNode, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
this.observer.observe(targetNode, { childList: true, subtree: true });
|
||||
|
||||
// Initial scan of existing messages
|
||||
this.scanExistingMessages();
|
||||
|
|
@ -136,17 +145,11 @@
|
|||
|
||||
scanMessages() {
|
||||
const messages = document.querySelectorAll(this.currentPlatform.messages);
|
||||
|
||||
messages.forEach((messageElement) => {
|
||||
const messageId = this.getMessageId(messageElement);
|
||||
|
||||
// Skip if already tracking this message
|
||||
if (this.trackedMessages.has(messageId)) {
|
||||
return;
|
||||
}
|
||||
if (this.trackedMessages.has(messageId)) return;
|
||||
|
||||
const text = this.extractText(messageElement);
|
||||
|
||||
if (text && text.includes('^%$bridge')) {
|
||||
this.trackMessage(messageElement, text, messageId);
|
||||
}
|
||||
|
|
@ -164,14 +167,12 @@
|
|||
|
||||
trackMessage(element, text, messageId) {
|
||||
this.log('New command detected:', { messageId, text: text.substring(0, 100) });
|
||||
|
||||
this.trackedMessages.set(messageId, {
|
||||
element,
|
||||
originalText: text,
|
||||
state: COMMAND_STATES.DETECTED,
|
||||
startTime: Date.now()
|
||||
});
|
||||
|
||||
this.updateState(messageId, COMMAND_STATES.PARSING);
|
||||
this.processCommand(messageId);
|
||||
}
|
||||
|
|
@ -201,24 +202,35 @@
|
|||
throw new Error(`Validation failed: ${validation.errors.join(', ')}`);
|
||||
}
|
||||
|
||||
// Step 3: Debounce (wait for bot to finish typing)
|
||||
// Step 3: Debounce with content change detection (handles streaming edits)
|
||||
this.updateState(messageId, COMMAND_STATES.DEBOUNCING);
|
||||
const initialText = message.originalText;
|
||||
await this.debounce();
|
||||
const currentText = this.extractText(message.element);
|
||||
if (currentText && currentText !== initialText) {
|
||||
this.log('Content changed during debounce; restarting debounce.');
|
||||
await this.debounce();
|
||||
}
|
||||
|
||||
// Step 4: Execute command
|
||||
// Step 4: Optional commit message synthesis
|
||||
if ((parsedCommand.action === 'update_file' || parsedCommand.action === 'create_file') &&
|
||||
!parsedCommand.commit_message) {
|
||||
parsedCommand.commit_message = `AI Repo Commander: ${parsedCommand.path} (${new Date().toISOString()})`;
|
||||
}
|
||||
|
||||
// Step 5: Execute command
|
||||
this.updateState(messageId, COMMAND_STATES.EXECUTING);
|
||||
const result = await ExecutionManager.executeCommand(parsedCommand, message.element);
|
||||
await ExecutionManager.executeCommand(parsedCommand, message.element);
|
||||
|
||||
// Step 5: Complete
|
||||
// Step 6: Complete
|
||||
this.updateState(messageId, COMMAND_STATES.COMPLETE);
|
||||
|
||||
} catch (error) {
|
||||
this.log(`Command processing error: ${error.message}`);
|
||||
this.updateState(messageId, COMMAND_STATES.ERROR);
|
||||
|
||||
const message = this.trackedMessages.get(messageId);
|
||||
if (message) {
|
||||
UIFeedback.replaceWithStatus(message.element, 'ERROR', {
|
||||
UIFeedback.appendStatus(message.element, 'ERROR', {
|
||||
action: 'Command',
|
||||
details: error.message
|
||||
});
|
||||
|
|
@ -232,9 +244,7 @@
|
|||
|
||||
stopAllProcessing() {
|
||||
this.trackedMessages.clear();
|
||||
if (this.observer) {
|
||||
this.observer.disconnect();
|
||||
}
|
||||
if (this.observer) this.observer.disconnect();
|
||||
}
|
||||
|
||||
setupEmergencyStop() {
|
||||
|
|
@ -251,23 +261,16 @@
|
|||
}
|
||||
|
||||
log(...args) {
|
||||
if (CONFIG.DEBUG_MODE) {
|
||||
console.log('[AI Repo Commander]', ...args);
|
||||
}
|
||||
if (CONFIG.DEBUG_MODE) console.log('[AI Repo Commander]', ...args);
|
||||
}
|
||||
}
|
||||
|
||||
// Command Parser Class
|
||||
// Command Parser Class (sturdier block extraction + simple content: |)
|
||||
class CommandParser {
|
||||
static parseYAMLCommand(text) {
|
||||
try {
|
||||
// Extract command block between ^%$bridge and ---
|
||||
const commandBlock = this.extractCommandBlock(text);
|
||||
if (!commandBlock) {
|
||||
throw new Error('No valid command block found');
|
||||
}
|
||||
|
||||
// Parse YAML-like syntax
|
||||
if (!commandBlock) throw new Error('No valid command block found');
|
||||
const parsed = this.parseKeyValuePairs(commandBlock);
|
||||
|
||||
// Set defaults
|
||||
|
|
@ -281,68 +284,57 @@
|
|||
}
|
||||
|
||||
static extractCommandBlock(text) {
|
||||
const startMarker = '^%$bridge';
|
||||
const endMarker = '---';
|
||||
|
||||
const startIndex = text.indexOf(startMarker);
|
||||
if (startIndex === -1) return null;
|
||||
|
||||
const endIndex = text.indexOf(endMarker, startIndex + startMarker.length);
|
||||
if (endIndex === -1) return null;
|
||||
|
||||
return text.substring(startIndex + startMarker.length, endIndex).trim();
|
||||
// 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);
|
||||
return m ? m[1].trimEnd() : null;
|
||||
}
|
||||
|
||||
static parseKeyValuePairs(block) {
|
||||
const lines = block.split('\n');
|
||||
const result = {};
|
||||
let currentKey = null;
|
||||
let multiLineContent = null;
|
||||
let collecting = false;
|
||||
let buf = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
for (const raw of lines) {
|
||||
const line = raw.replace(/\r$/, '');
|
||||
|
||||
if (!trimmed) continue;
|
||||
|
||||
// Check for multi-line content marker
|
||||
if (trimmed === '|' && currentKey === 'content') {
|
||||
multiLineContent = [];
|
||||
if (collecting) {
|
||||
// stop if we hit an unindented key:value (loose heuristic)
|
||||
if (/^[A-Za-z_][\w\-]*\s*:/.test(line)) {
|
||||
result[currentKey] = buf.join('\n').trimEnd();
|
||||
collecting = false;
|
||||
buf = [];
|
||||
// fall through to parse this line as a new key
|
||||
} else {
|
||||
buf.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If we're collecting multi-line content
|
||||
if (multiLineContent !== null) {
|
||||
if (trimmed === '---') break; // End of command
|
||||
multiLineContent.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse key: value pairs
|
||||
const colonIndex = trimmed.indexOf(':');
|
||||
if (colonIndex !== -1) {
|
||||
const key = trimmed.substring(0, colonIndex).trim();
|
||||
let value = trimmed.substring(colonIndex + 1).trim();
|
||||
const idx = line.indexOf(':');
|
||||
if (idx !== -1) {
|
||||
const key = line.slice(0, idx).trim();
|
||||
let value = line.slice(idx + 1).trim();
|
||||
|
||||
// Handle empty values that might be multi-line
|
||||
if (value === '' || value === '|') {
|
||||
currentKey = key;
|
||||
if (value === '|') {
|
||||
multiLineContent = [];
|
||||
}
|
||||
currentKey = key;
|
||||
collecting = true;
|
||||
buf = [];
|
||||
} else if (value === '') {
|
||||
currentKey = key;
|
||||
result[key] = '';
|
||||
} else {
|
||||
result[key] = value;
|
||||
currentKey = null;
|
||||
}
|
||||
} else if (currentKey && result[currentKey] === '') {
|
||||
// Continue with previous key (simple multi-line)
|
||||
result[currentKey] += '\n' + trimmed;
|
||||
result[currentKey] += (result[currentKey] ? '\n' : '') + line.trimEnd();
|
||||
}
|
||||
}
|
||||
|
||||
// Join multi-line content
|
||||
if (multiLineContent !== null && currentKey) {
|
||||
result[currentKey] = multiLineContent.join('\n').trim();
|
||||
if (collecting && currentKey) {
|
||||
result[currentKey] = buf.join('\n').trimEnd();
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
@ -351,7 +343,14 @@
|
|||
static validateStructure(parsed) {
|
||||
const errors = [];
|
||||
|
||||
// Check required fields
|
||||
// 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) {
|
||||
errors.push('Missing required field: action');
|
||||
|
|
@ -370,7 +369,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Validate field formats
|
||||
// Field format validators
|
||||
for (const [field, value] of Object.entries(parsed)) {
|
||||
const validator = FIELD_VALIDATORS[field];
|
||||
if (validator && !validator(value)) {
|
||||
|
|
@ -378,29 +377,28 @@
|
|||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
// 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 };
|
||||
}
|
||||
}
|
||||
|
||||
// Execution Manager Class
|
||||
// Execution Manager (append status, clearer retry errors, ephemeral key)
|
||||
class ExecutionManager {
|
||||
static async executeCommand(command, sourceElement) {
|
||||
try {
|
||||
// Pre-execution safety checks
|
||||
if (!CONFIG.ENABLE_API) {
|
||||
return this.mockExecution(command, sourceElement);
|
||||
}
|
||||
|
||||
// Show executing status
|
||||
UIFeedback.replaceWithStatus(sourceElement, 'EXECUTING', {
|
||||
UIFeedback.appendStatus(sourceElement, 'EXECUTING', {
|
||||
action: command.action,
|
||||
details: 'Making API request...'
|
||||
});
|
||||
|
||||
// API call with retry logic
|
||||
const response = await this.makeAPICallWithRetry(command);
|
||||
return this.handleSuccess(response, command, sourceElement);
|
||||
|
||||
|
|
@ -411,23 +409,26 @@
|
|||
|
||||
static async makeAPICallWithRetry(command, attempt = 0) {
|
||||
try {
|
||||
requireBridgeKeyIfNeeded();
|
||||
return await this.makeAPICall(command);
|
||||
} catch (error) {
|
||||
if (attempt < CONFIG.MAX_RETRIES) {
|
||||
await this.delay(1000 * (attempt + 1));
|
||||
await this.delay(1000 * (attempt + 1)); // simple backoff: 1s, 2s
|
||||
return this.makeAPICallWithRetry(command, attempt + 1);
|
||||
}
|
||||
throw error;
|
||||
throw new Error(`${error.message} (after ${attempt + 1} attempts)`);
|
||||
}
|
||||
}
|
||||
|
||||
static makeAPICall(command) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const bridgeKey = requireBridgeKeyIfNeeded();
|
||||
|
||||
GM_xmlhttpRequest({
|
||||
method: 'POST',
|
||||
url: command.url,
|
||||
headers: {
|
||||
'X-Bridge-Key': 'mango-rocket-82',
|
||||
'X-Bridge-Key': bridgeKey,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
data: JSON.stringify(command),
|
||||
|
|
@ -451,45 +452,36 @@
|
|||
|
||||
static async mockExecution(command, sourceElement) {
|
||||
await this.delay(1000); // Simulate API delay
|
||||
|
||||
const mockResponse = {
|
||||
status: 200,
|
||||
responseText: JSON.stringify({
|
||||
success: true,
|
||||
message: `Mock execution completed for ${command.action}`,
|
||||
data: { command: command.action, repo: command.repo }
|
||||
data: { command: command.action, repo: command.repo, path: command.path }
|
||||
})
|
||||
};
|
||||
|
||||
return this.handleSuccess(mockResponse, command, sourceElement, true);
|
||||
}
|
||||
|
||||
static handleSuccess(response, command, sourceElement, isMock = false) {
|
||||
const responseData = JSON.parse(response.responseText);
|
||||
const responseData = JSON.parse(response.responseText || '{}');
|
||||
const templateType = isMock ? 'MOCK' : 'SUCCESS';
|
||||
|
||||
UIFeedback.replaceWithStatus(sourceElement, templateType, {
|
||||
UIFeedback.appendStatus(sourceElement, templateType, {
|
||||
action: command.action,
|
||||
details: responseData.message || 'Operation completed successfully'
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: responseData,
|
||||
isMock
|
||||
};
|
||||
return { success: true, data: responseData, isMock };
|
||||
}
|
||||
|
||||
static handleError(error, command, sourceElement) {
|
||||
UIFeedback.replaceWithStatus(sourceElement, 'ERROR', {
|
||||
action: command.action,
|
||||
UIFeedback.appendStatus(sourceElement, 'ERROR', {
|
||||
action: command.action || 'Command',
|
||||
details: error.message
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
static delay(ms) {
|
||||
|
|
@ -497,43 +489,37 @@
|
|||
}
|
||||
}
|
||||
|
||||
// UI Feedback System
|
||||
// UI Feedback System (APPENDS instead of replacing)
|
||||
class UIFeedback {
|
||||
static replaceWithStatus(sourceElement, templateType, data) {
|
||||
static appendStatus(sourceElement, templateType, data) {
|
||||
const statusElement = this.createStatusElement(templateType, data);
|
||||
this.replaceElement(sourceElement, statusElement);
|
||||
const existingStatus = sourceElement.querySelector('.ai-repo-commander-status');
|
||||
if (existingStatus) existingStatus.remove();
|
||||
sourceElement.appendChild(statusElement);
|
||||
}
|
||||
|
||||
static createStatusElement(templateType, data) {
|
||||
const template = STATUS_TEMPLATES[templateType];
|
||||
const message = template
|
||||
.replace('{action}', data.action)
|
||||
.replace('{details}', data.details);
|
||||
const template = STATUS_TEMPLATES[templateType] || STATUS_TEMPLATES.ERROR;
|
||||
const message = template.replace('{action}', data.action).replace('{details}', data.details);
|
||||
|
||||
const statusElement = document.createElement('div');
|
||||
statusElement.className = 'ai-repo-commander-status';
|
||||
statusElement.textContent = message;
|
||||
statusElement.style.cssText = `
|
||||
padding: 8px 12px;
|
||||
margin: 5px 0;
|
||||
margin: 10px 0;
|
||||
border-radius: 4px;
|
||||
border-left: 4px solid ${this.colorCodeStatus(templateType)};
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
`;
|
||||
|
||||
return statusElement;
|
||||
}
|
||||
|
||||
static replaceElement(oldElement, newElement) {
|
||||
if (oldElement && oldElement.parentNode) {
|
||||
oldElement.parentNode.replaceChild(newElement, oldElement);
|
||||
}
|
||||
}
|
||||
|
||||
static colorCodeStatus(type) {
|
||||
const colors = {
|
||||
'SUCCESS': '#10B981', // Green
|
||||
|
|
@ -546,7 +532,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Test command generator
|
||||
// Test command generator (unchanged)
|
||||
const TEST_COMMANDS = {
|
||||
validUpdate: `^%$bridge
|
||||
action: update_file
|
||||
|
|
@ -555,18 +541,19 @@ path: TEST.md
|
|||
content: |
|
||||
Test content
|
||||
Multiple lines
|
||||
---`,
|
||||
|
||||
---
|
||||
`,
|
||||
invalidCommand: `^%$bridge
|
||||
action: update_file
|
||||
repo: test-repo
|
||||
---`,
|
||||
|
||||
---
|
||||
`,
|
||||
getFile: `^%$bridge
|
||||
action: get_file
|
||||
repo: test-repo
|
||||
path: README.md
|
||||
---`
|
||||
---
|
||||
`
|
||||
};
|
||||
|
||||
// Initialize the system
|
||||
|
|
@ -575,7 +562,6 @@ path: README.md
|
|||
function initializeRepoCommander() {
|
||||
if (!commandMonitor) {
|
||||
commandMonitor = new CommandMonitor();
|
||||
|
||||
// Expose for debugging
|
||||
window.AI_REPO_COMMANDER = {
|
||||
monitor: commandMonitor,
|
||||
|
|
@ -583,18 +569,15 @@ path: README.md
|
|||
test: TEST_COMMANDS,
|
||||
version: CONFIG.VERSION
|
||||
};
|
||||
|
||||
console.log('AI Repo Commander fully initialized');
|
||||
console.log('API Enabled:', CONFIG.ENABLE_API);
|
||||
console.log('Test commands available in window.AI_REPO_COMMANDER.test');
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for DOM to be ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initializeRepoCommander);
|
||||
} else {
|
||||
initializeRepoCommander();
|
||||
}
|
||||
|
||||
})();
|
||||
Loading…
Reference in New Issue