@bobsworkshop/cli
Advanced tools
| import { | ||
| callLocalModel, | ||
| getConfig, | ||
| loadLocalSuggestions, | ||
| markSuggestionStatus, | ||
| readFileContent | ||
| } from "./chunk-CI36GGK2.js"; | ||
| // src/commands/analyse-auto.ts | ||
| import chalk from "chalk"; | ||
| import inquirer from "inquirer"; | ||
| import * as fs from "fs"; | ||
| import * as path from "path"; | ||
| import * as readline from "readline"; | ||
| var RED = chalk.hex("#EF5350"); | ||
| var GREEN = chalk.hex("#66BB6A"); | ||
| var AMBER = chalk.hex("#FFAB00"); | ||
| var BLUE = chalk.hex("#42A5F5"); | ||
| var GRAY = chalk.gray; | ||
| var BORDER = chalk.hex("#455A64"); | ||
| async function runAutoFix(options) { | ||
| const config = getConfig(); | ||
| if (config.provider !== "local" || !config.localEndpoint) { | ||
| console.log(""); | ||
| console.log(chalk.red(" \u274C Auto-fix requires a local model.")); | ||
| console.log(GRAY(" Run `bob config set provider local`")); | ||
| console.log(GRAY(" Run `bob config set localEndpoint http://127.0.0.1:11434/api/chat`")); | ||
| console.log(""); | ||
| return; | ||
| } | ||
| const confidenceGate = options.confidence || 90; | ||
| const priorityGate = options.priority || "critical"; | ||
| const categories = options.category ? [options.category] : ["bugs", "features", "improvements", "upgrades"]; | ||
| const isAutoMode = config.autoMode || false; | ||
| console.log(""); | ||
| console.log(chalk.bold.cyan(" \u26A1 MiniBob Auto-Fix Mode")); | ||
| console.log(GRAY(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500")); | ||
| console.log(GRAY(` Confidence gate: ${confidenceGate}%`)); | ||
| console.log(GRAY(` Priority gate: ${priorityGate}+`)); | ||
| console.log(GRAY(` Categories: ${categories.join(", ")}`)); | ||
| console.log(GRAY(` Auto mode: ${isAutoMode ? "ON (no approval prompts)" : "OFF (approval required)"}`)); | ||
| console.log(GRAY(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500")); | ||
| console.log(""); | ||
| let allSuggestions = []; | ||
| for (const cat of categories) { | ||
| allSuggestions.push(...loadLocalSuggestions(cat)); | ||
| } | ||
| const priorityOrder = ["critical", "high", "medium", "low"]; | ||
| const gateIndex = priorityOrder.indexOf(priorityGate.toLowerCase()); | ||
| if (gateIndex >= 0) { | ||
| allSuggestions = allSuggestions.filter((s) => { | ||
| const idx = priorityOrder.indexOf(s.priority?.toLowerCase()); | ||
| return idx >= 0 && idx <= gateIndex; | ||
| }); | ||
| } | ||
| if (allSuggestions.length === 0) { | ||
| console.log(chalk.green(" \u2705 No suggestions match your gates. Project is clean!")); | ||
| console.log(""); | ||
| return; | ||
| } | ||
| console.log(GRAY(` Found ${allSuggestions.length} suggestions matching criteria.`)); | ||
| console.log(""); | ||
| console.log(AMBER(" \u{1F9E0} Phase 1: Triage \u2014 Bob is evaluating suggestions...")); | ||
| console.log(""); | ||
| const triageResults = await performTriage(allSuggestions, confidenceGate, config.localEndpoint); | ||
| if (!triageResults) return; | ||
| const autoApprove = triageResults.filter((r) => r.action === "work" && r.confidence >= confidenceGate); | ||
| const needsReview = triageResults.filter((r) => r.action === "review" || r.action === "work" && r.confidence < confidenceGate && r.confidence >= confidenceGate - 15); | ||
| const dismissed = triageResults.filter((r) => r.action === "dismiss" || r.confidence < confidenceGate - 15); | ||
| console.log(BORDER(" \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557")); | ||
| console.log(BORDER(" \u2551") + AMBER(" \u25C6 TRIAGE COMPLETE")); | ||
| console.log(BORDER(" \u2560\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2563")); | ||
| console.log(BORDER(" \u2551") + GREEN(` \u2705 Auto-approve: ${autoApprove.length} items (confidence \u2265 ${confidenceGate}%)`)); | ||
| if (needsReview.length > 0) { | ||
| console.log(BORDER(" \u2551") + AMBER(` \u{1F914} Needs review: ${needsReview.length} items`)); | ||
| } | ||
| console.log(BORDER(" \u2551") + GRAY(` \u23F8\uFE0F Dismissed: ${dismissed.length} items`)); | ||
| console.log(BORDER(" \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D")); | ||
| console.log(""); | ||
| if (autoApprove.length > 0) { | ||
| console.log(GREEN(" \u2705 APPROVE (auto-fix these):")); | ||
| for (let i = 0; i < autoApprove.length; i++) { | ||
| const item = autoApprove[i]; | ||
| console.log(GRAY(` ${i + 1}. ${item.suggestion.filePath} \u2014 ${item.suggestion.title || item.suggestion.description?.slice(0, 40) || "No title"} (${item.confidence}%)`)); | ||
| } | ||
| console.log(""); | ||
| } | ||
| if (needsReview.length > 0) { | ||
| console.log(AMBER(" \u{1F914} REVIEW (Bob wants your input):")); | ||
| for (let i = 0; i < needsReview.length; i++) { | ||
| const item = needsReview[i]; | ||
| console.log(GRAY(` ${i + 1}. ${item.suggestion.filePath} \u2014 ${item.reason} (${item.confidence}%)`)); | ||
| } | ||
| console.log(""); | ||
| } | ||
| for (const item of dismissed) { | ||
| const suggestionIndex = parseInt(item.suggestion.id?.split("_").pop() || "0"); | ||
| const category = detectCategory(item.suggestion); | ||
| markSuggestionStatus(item.suggestion.filePath, suggestionIndex, category, "dismissed", { | ||
| confidence: item.confidence, | ||
| reason: item.reason, | ||
| implementedBy: "bob-triage" | ||
| }); | ||
| } | ||
| let workQueue = []; | ||
| if (isAutoMode) { | ||
| workQueue = autoApprove.map((r) => ({ suggestion: r.suggestion, confidence: r.confidence, reason: r.reason, status: "pending" })); | ||
| console.log(GRAY(" [Auto mode] Proceeding without approval prompt.")); | ||
| } else { | ||
| const { choice } = await inquirer.prompt([{ | ||
| type: "select", | ||
| name: "choice", | ||
| message: AMBER("How would you like to proceed?"), | ||
| choices: [ | ||
| { name: GREEN(` \u2705 Auto-fix approved items only (${autoApprove.length} items)`), value: "approved_only" }, | ||
| { name: GREEN(` \u2705 Auto-fix ALL including review items (${autoApprove.length + needsReview.length} items)`), value: "all" }, | ||
| { name: BLUE(" \u{1F5E3}\uFE0F Talk to Bob about these suggestions"), value: "talk" }, | ||
| { name: GRAY(" \u2190 Cancel"), value: "cancel" } | ||
| ] | ||
| }]); | ||
| if (choice === "cancel") { | ||
| console.log(GRAY(" Cancelled.")); | ||
| return; | ||
| } | ||
| if (choice === "talk") { | ||
| const updatedQueue = await talkToBobAboutSuggestions(autoApprove, needsReview, dismissed, config.localEndpoint); | ||
| if (updatedQueue.length === 0) { | ||
| console.log(GRAY(" No items to implement.")); | ||
| return; | ||
| } | ||
| workQueue = updatedQueue; | ||
| } else if (choice === "approved_only") { | ||
| workQueue = autoApprove.map((r) => ({ suggestion: r.suggestion, confidence: r.confidence, reason: r.reason, status: "pending" })); | ||
| } else { | ||
| workQueue = [...autoApprove, ...needsReview].map((r) => ({ suggestion: r.suggestion, confidence: r.confidence, reason: r.reason, status: "pending" })); | ||
| } | ||
| } | ||
| if (workQueue.length === 0) { | ||
| console.log(chalk.yellow(" \u26A0\uFE0F Nothing to implement.")); | ||
| return; | ||
| } | ||
| console.log(""); | ||
| console.log(AMBER(" \u{1F527} Phase 3: MiniBob Implementing...")); | ||
| console.log(GRAY(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500")); | ||
| console.log(GRAY(" \u{1F4AC} /skip <file> to skip. /done to stop early.")); | ||
| console.log(GRAY(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500")); | ||
| console.log(""); | ||
| await executeWithChat(workQueue, config); | ||
| const fixed = workQueue.filter((t) => t.status === "done"); | ||
| const failed = workQueue.filter((t) => t.status === "failed"); | ||
| const skipped = workQueue.filter((t) => t.status === "skipped"); | ||
| console.log(""); | ||
| console.log(BORDER(" \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557")); | ||
| console.log(BORDER(" \u2551") + AMBER(" \u25C6 MINIBOB AUTO-FIX REPORT")); | ||
| console.log(BORDER(" \u2560\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2563")); | ||
| console.log(BORDER(" \u2551") + GREEN(` \u2705 Fixed: ${fixed.length} items`)); | ||
| console.log(BORDER(" \u2551") + GRAY(` \u23F8\uFE0F Held: ${dismissed.length + skipped.length} items`)); | ||
| if (failed.length > 0) { | ||
| console.log(BORDER(" \u2551") + RED(` \u274C Failed: ${failed.length} items`)); | ||
| } | ||
| console.log(BORDER(" \u2560\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2563")); | ||
| if (fixed.length > 0) { | ||
| console.log(BORDER(" \u2551") + GRAY(" Fixed files:")); | ||
| for (const item of fixed) { | ||
| console.log(BORDER(" \u2551") + GREEN(` \u2705 ${item.suggestion.filePath}`)); | ||
| } | ||
| } | ||
| if (failed.length > 0) { | ||
| console.log(BORDER(" \u2551") + GRAY(" Failed:")); | ||
| for (const item of failed) { | ||
| console.log(BORDER(" \u2551") + RED(` \u274C ${item.suggestion.filePath}`)); | ||
| } | ||
| } | ||
| if (skipped.length > 0) { | ||
| console.log(BORDER(" \u2551") + GRAY(" Skipped:")); | ||
| for (const item of skipped) { | ||
| console.log(BORDER(" \u2551") + GRAY(` \u23F8\uFE0F ${item.suggestion.filePath}`)); | ||
| } | ||
| } | ||
| console.log(BORDER(" \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D")); | ||
| console.log(""); | ||
| console.log(GRAY(" \u{1F4E6} All original files backed up to .bob-backups/")); | ||
| console.log(GRAY(' Run `bob push "MiniBob auto-fix batch"` to commit changes.')); | ||
| console.log(""); | ||
| } | ||
| async function performTriage(suggestions, confidenceGate, endpoint) { | ||
| const triagePrompt = `You are the Lead QA Engineer triaging code suggestions for auto-implementation by MiniBob (a junior engineer). | ||
| For each suggestion, decide: WORK, REVIEW, or DISMISS. | ||
| DECISION CRITERIA: | ||
| - WORK: The fix is clear, specific, well-defined, and you are CONFIDENT it will not break anything. MiniBob can implement it without supervision. | ||
| - REVIEW: The fix is good but has side effects, touches shared logic, or behavioral changes that need human approval first. | ||
| - DISMISS: The suggestion is vague, risky, poorly defined, or the effort/risk outweighs the benefit. | ||
| CONFIDENCE SCORING \u2014 Your confidence represents: | ||
| "How certain am I that this fix will NOT break anything AND will ACTUALLY contribute positively to the project?" | ||
| - 95-100%: Fix is 1-5 lines, explicit instructions, zero side effects, purely additive improvement | ||
| - 85-94%: Clear fix, well-scoped, touches isolated logic, minimal risk | ||
| - 75-84%: Good fix but touches shared modules or has minor behavioral implications | ||
| - <75%: Requires judgment, structural changes, or has unpredictable side effects | ||
| SUGGESTIONS: | ||
| ${suggestions.map((s, i) => `[${i}] ${s.priority?.toUpperCase()} | ${s.filePath} | ${s.title || "No title"} | ${s.description || "No description"} | Implementation: ${s.implementation || "None provided"}`).join("\n")} | ||
| Respond with ONLY a JSON array: | ||
| [{"index": 0, "action": "work"|"review"|"dismiss", "confidence": 0-100, "reason": "brief reason for this confidence level"}]`; | ||
| try { | ||
| const messages = [ | ||
| { role: "system", content: "You are a senior engineering lead. Respond with ONLY a valid JSON array." }, | ||
| { role: "user", content: triagePrompt } | ||
| ]; | ||
| const response = await callLocalModel(endpoint, messages); | ||
| const jsonMatch = response.match(/\[[\s\S]*\]/); | ||
| if (!jsonMatch) { | ||
| console.log(chalk.red(" \u274C Triage failed: Could not parse.")); | ||
| return null; | ||
| } | ||
| const parsed = JSON.parse(jsonMatch[0]); | ||
| return parsed.map((d) => ({ | ||
| action: d.action === "work" ? "work" : d.action === "review" ? "review" : "dismiss", | ||
| confidence: d.confidence || 0, | ||
| reason: d.reason || "", | ||
| suggestion: suggestions[d.index] | ||
| })).filter((r) => r.suggestion); | ||
| } catch (error) { | ||
| console.log(chalk.red(` \u274C Triage failed: ${error.message}`)); | ||
| return null; | ||
| } | ||
| } | ||
| async function talkToBobAboutSuggestions(approved, review, dismissed, endpoint) { | ||
| console.log(""); | ||
| console.log(BLUE(" \u{1F5E3}\uFE0F Chat with Bob about the suggestions")); | ||
| console.log(GRAY(" Commands: skip <file>, add <file>, /done")); | ||
| console.log(GRAY(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500")); | ||
| console.log(""); | ||
| const history = [ | ||
| { role: "system", content: `You are Bob, helping decide which suggestions to implement. Be concise. | ||
| APPROVED: ${approved.map((r) => `${r.suggestion.filePath}: ${r.suggestion.title || r.suggestion.description}`).join("\n")} | ||
| REVIEW: ${review.map((r) => `${r.suggestion.filePath}: ${r.reason}`).join("\n")} | ||
| DISMISSED: ${dismissed.map((r) => `${r.suggestion.filePath}: ${r.reason}`).join("\n")}` } | ||
| ]; | ||
| let finalApproved = [...approved]; | ||
| const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); | ||
| return new Promise((resolve) => { | ||
| const prompt = () => { | ||
| rl.question(chalk.green(" You: "), async (input) => { | ||
| const trimmed = input.trim(); | ||
| if (!trimmed) { | ||
| prompt(); | ||
| return; | ||
| } | ||
| if (trimmed === "/done") { | ||
| rl.close(); | ||
| resolve(finalApproved.map((r) => ({ suggestion: r.suggestion, confidence: r.confidence, reason: r.reason, status: "pending" }))); | ||
| return; | ||
| } | ||
| if (trimmed.toLowerCase().startsWith("skip ") || trimmed.toLowerCase().startsWith("remove ")) { | ||
| const target = trimmed.slice(trimmed.indexOf(" ") + 1).trim().toLowerCase(); | ||
| const before = finalApproved.length; | ||
| finalApproved = finalApproved.filter((r) => !r.suggestion.filePath.toLowerCase().includes(target)); | ||
| console.log(before > finalApproved.length ? chalk.yellow(` \u23F8\uFE0F Removed ${before - finalApproved.length} item(s)`) : GRAY(` No match for "${target}"`)); | ||
| prompt(); | ||
| return; | ||
| } | ||
| if (trimmed.toLowerCase().startsWith("add ")) { | ||
| const target = trimmed.slice(4).trim().toLowerCase(); | ||
| const toAdd = [...review, ...dismissed].filter((r) => r.suggestion.filePath.toLowerCase().includes(target)); | ||
| if (toAdd.length > 0) { | ||
| finalApproved.push(...toAdd); | ||
| console.log(chalk.green(` \u2705 Added ${toAdd.length} item(s)`)); | ||
| } else { | ||
| console.log(GRAY(` No match for "${target}"`)); | ||
| } | ||
| prompt(); | ||
| return; | ||
| } | ||
| history.push({ role: "user", content: trimmed }); | ||
| try { | ||
| const response = await callLocalModel(endpoint, history); | ||
| history.push({ role: "assistant", content: response }); | ||
| console.log(chalk.bold.cyan(" \u{1F916} Bob: ") + response.split("\n")[0]); | ||
| if (response.split("\n").length > 1) { | ||
| response.split("\n").slice(1).forEach((l) => console.log(` ${l}`)); | ||
| } | ||
| console.log(""); | ||
| } catch { | ||
| } | ||
| prompt(); | ||
| }); | ||
| }; | ||
| prompt(); | ||
| }); | ||
| } | ||
| async function executeWithChat(workQueue, config) { | ||
| renderTodoList(workQueue); | ||
| let userMessages = []; | ||
| let chatActive = true; | ||
| const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); | ||
| const inputPromise = new Promise((resolve) => { | ||
| const askForInput = () => { | ||
| if (!chatActive) { | ||
| resolve(); | ||
| return; | ||
| } | ||
| rl.question(chalk.gray(" \u{1F4AC} "), (input) => { | ||
| const trimmed = input.trim(); | ||
| if (trimmed === "/done") { | ||
| for (const task of workQueue) { | ||
| if (task.status === "pending") task.status = "skipped"; | ||
| } | ||
| chatActive = false; | ||
| resolve(); | ||
| return; | ||
| } | ||
| if (trimmed.startsWith("/skip ")) { | ||
| const target = trimmed.slice(6).trim().toLowerCase(); | ||
| for (const task of workQueue) { | ||
| if (task.status === "pending" && task.suggestion.filePath.toLowerCase().includes(target)) { | ||
| task.status = "skipped"; | ||
| console.log(chalk.yellow(` \u23F8\uFE0F Skipping: ${task.suggestion.filePath}`)); | ||
| } | ||
| } | ||
| } else if (trimmed) { | ||
| userMessages.push(trimmed); | ||
| } | ||
| if (chatActive) askForInput(); | ||
| else resolve(); | ||
| }); | ||
| }; | ||
| askForInput(); | ||
| }); | ||
| for (let i = 0; i < workQueue.length; i++) { | ||
| const task = workQueue[i]; | ||
| if (task.status === "skipped") continue; | ||
| task.status = "working"; | ||
| renderTodoList(workQueue); | ||
| if (userMessages.length > 0) { | ||
| const userMsg = userMessages.shift(); | ||
| try { | ||
| const bobResponse = await callLocalModel(config.localEndpoint, [ | ||
| { role: "system", content: `You are Bob supervising MiniBob. Respond in 1-2 sentences. Current task: ${task.suggestion.filePath}` }, | ||
| { role: "user", content: userMsg } | ||
| ]); | ||
| console.log(chalk.bold.cyan(` \u{1F916} Bob: `) + bobResponse.split("\n")[0]); | ||
| console.log(""); | ||
| } catch { | ||
| } | ||
| } | ||
| const success = await implementTask(task, config.localEndpoint); | ||
| task.status = success ? "done" : "failed"; | ||
| if (success) { | ||
| const suggestionIndex = parseInt(task.suggestion.id?.split("_").pop() || "0"); | ||
| const category = detectCategory(task.suggestion); | ||
| markSuggestionStatus(task.suggestion.filePath, suggestionIndex, category, "implemented", { | ||
| confidence: task.confidence, | ||
| reason: task.reason, | ||
| implementedBy: "minibob-auto" | ||
| }); | ||
| } | ||
| renderTodoList(workQueue); | ||
| } | ||
| chatActive = false; | ||
| rl.close(); | ||
| await Promise.race([inputPromise, new Promise((resolve) => setTimeout(resolve, 100))]); | ||
| } | ||
| async function implementTask(task, endpoint) { | ||
| const suggestion = task.suggestion; | ||
| const fileContent = readFileContent(suggestion.filePath); | ||
| if (!fileContent) return false; | ||
| const prompt = `You are MiniBob \u2014 a junior engineer making SURGICAL code fixes under strict supervision. | ||
| CURRENT FILE: ${suggestion.filePath} | ||
| ${fileContent} | ||
| CHANGE TO IMPLEMENT: | ||
| Title: ${suggestion.title || "Fix"} | ||
| Description: ${suggestion.description} | ||
| Implementation Instructions: ${suggestion.implementation || "Apply the fix described above."} | ||
| RULES (CRITICAL \u2014 VIOLATION = REJECTED): | ||
| - Return ONLY valid source code. No markdown, no code fences, no \`\`\`, no explanation text. | ||
| - Start the FIRST line with: // File: ${suggestion.filePath} | ||
| - PRESERVE ALL existing imports exactly as they are. Do NOT add, remove, or reorder imports. | ||
| - PRESERVE ALL existing exports exactly as they are. Do NOT rename exported functions or classes. | ||
| - PRESERVE the existing code structure, indentation, patterns, and naming conventions. | ||
| - Make the MINIMUM change necessary to implement the fix. Touch NOTHING else. | ||
| - Do NOT refactor, reorganize, or "improve" unrelated code. | ||
| - Do NOT add comments explaining what you changed. | ||
| - Do NOT wrap the response in markdown code blocks. | ||
| - The output must be valid TypeScript/JavaScript that compiles without errors. | ||
| - If you are unsure about a change, return the file UNCHANGED rather than risk breaking it. | ||
| Return the complete file content now:`; | ||
| try { | ||
| const messages = [ | ||
| { role: "system", content: "You are MiniBob, a junior engineer making SURGICAL fixes. Return ONLY valid source code. NO markdown. NO code fences. NO explanation. Start with // File: comment. Make the ABSOLUTE MINIMUM change needed. Do NOT restructure, refactor, or touch ANYTHING beyond the specific fix. If unsure, return the file unchanged." }, | ||
| { role: "user", content: prompt } | ||
| ]; | ||
| const response = await callLocalModel(endpoint, messages); | ||
| const lines = response.split("\n"); | ||
| const firstLine = lines[0].trim(); | ||
| let newContent; | ||
| if (firstLine.match(/^\/\/\s*(File:)?\s*/)) { | ||
| newContent = lines.slice(1).join("\n").trim(); | ||
| } else { | ||
| newContent = response.trim(); | ||
| } | ||
| if (newContent.includes("```") || newContent.includes("## ") || newContent.startsWith("Here") || newContent.startsWith("I have") || newContent.startsWith("Sure")) { | ||
| console.log(chalk.yellow(` \u26A0\uFE0F MiniBob returned explanation instead of code. Skipping ${suggestion.filePath}.`)); | ||
| return false; | ||
| } | ||
| if (newContent.length < fileContent.length * 0.5) { | ||
| console.log(chalk.yellow(` \u26A0\uFE0F MiniBob's output is ${Math.round(newContent.length / fileContent.length * 100)}% of original size. Rejecting to prevent data loss.`)); | ||
| return false; | ||
| } | ||
| const originalExports = fileContent.match(/export\s+(function|class|const|interface|type|async\s+function)\s+\w+/g) || []; | ||
| for (const exp of originalExports) { | ||
| const exportName = exp.split(/\s+/).pop(); | ||
| if (!newContent.includes(exportName)) { | ||
| console.log(chalk.yellow(` \u26A0\uFE0F MiniBob removed export "${exportName}". Rejecting change to ${suggestion.filePath}.`)); | ||
| return false; | ||
| } | ||
| } | ||
| const originalImportCount = (fileContent.match(/^import\s+/gm) || []).length; | ||
| const newImportCount = (newContent.match(/^import\s+/gm) || []).length; | ||
| if (Math.abs(originalImportCount - newImportCount) > 2) { | ||
| console.log(chalk.yellow(` \u26A0\uFE0F MiniBob changed import count from ${originalImportCount} to ${newImportCount}. Rejecting.`)); | ||
| return false; | ||
| } | ||
| const absolutePath = path.join(process.cwd(), suggestion.filePath); | ||
| const backupDir = path.join(process.cwd(), ".bob-backups"); | ||
| if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir, { recursive: true }); | ||
| if (fs.existsSync(absolutePath)) { | ||
| const timestamp = Date.now(); | ||
| const backupName = suggestion.filePath.replace(/[\/\\]/g, "_") + `.${timestamp}.bak`; | ||
| fs.copyFileSync(absolutePath, path.join(backupDir, backupName)); | ||
| } | ||
| const dir = path.dirname(absolutePath); | ||
| if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); | ||
| fs.writeFileSync(absolutePath, newContent, "utf-8"); | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
| function detectCategory(suggestion) { | ||
| const cwd = process.cwd(); | ||
| const projectName = path.basename(cwd); | ||
| const homeDir = process.env.HOME || process.env.USERPROFILE || ""; | ||
| const analysisPath = path.join(homeDir, ".bob", "projects", projectName, "analysis", "results", "analysis.json"); | ||
| if (!fs.existsSync(analysisPath)) return "bugs"; | ||
| const allResults = JSON.parse(fs.readFileSync(analysisPath, "utf-8")); | ||
| const fileResults = allResults[suggestion.filePath]; | ||
| if (!fileResults) return "bugs"; | ||
| for (const cat of ["bugs", "features", "improvements", "upgrades"]) { | ||
| const items = fileResults[cat] || []; | ||
| for (const item of items) { | ||
| if (item.title === suggestion.title && item.description === suggestion.description) return cat; | ||
| } | ||
| } | ||
| return "bugs"; | ||
| } | ||
| var lastTodoLines = 0; | ||
| function renderTodoList(queue) { | ||
| const lines = []; | ||
| lines.push(""); | ||
| lines.push(AMBER(" \u{1F4CB} MiniBob Work Queue")); | ||
| lines.push(GRAY(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500")); | ||
| for (let i = 0; i < queue.length; i++) { | ||
| const task = queue[i]; | ||
| const label = task.suggestion.title || task.suggestion.description?.slice(0, 40) || "No title"; | ||
| let icon; | ||
| let color; | ||
| switch (task.status) { | ||
| case "done": | ||
| icon = "\u2611"; | ||
| color = GREEN; | ||
| break; | ||
| case "working": | ||
| icon = "\u23F3"; | ||
| color = AMBER; | ||
| break; | ||
| case "failed": | ||
| icon = "\u2717"; | ||
| color = RED; | ||
| break; | ||
| case "skipped": | ||
| icon = "\u23F8\uFE0F"; | ||
| color = GRAY; | ||
| break; | ||
| default: | ||
| icon = "\u2610"; | ||
| color = GRAY; | ||
| } | ||
| lines.push(color(` ${icon} [${i + 1}/${queue.length}] ${task.suggestion.filePath}`)); | ||
| lines.push(color(` ${label} (${task.confidence}%)`)); | ||
| } | ||
| const completed = queue.filter((t) => t.status === "done" || t.status === "failed" || t.status === "skipped").length; | ||
| const total = queue.length; | ||
| const percent = total > 0 ? completed / total : 0; | ||
| const barLen = 30; | ||
| const filled = Math.round(percent * barLen); | ||
| let barColor; | ||
| if (percent < 0.25) barColor = chalk.red; | ||
| else if (percent < 0.5) barColor = chalk.hex("#FF8C00"); | ||
| else if (percent < 0.75) barColor = chalk.yellow; | ||
| else barColor = chalk.green; | ||
| lines.push(""); | ||
| lines.push(` [${barColor("\u2588".repeat(filled))}${GRAY("\u2591".repeat(barLen - filled))}] ${completed}/${total} ${barColor(Math.round(percent * 100) + "%")}`); | ||
| lines.push(""); | ||
| if (lastTodoLines > 0) { | ||
| process.stdout.write(`\x1B[${lastTodoLines}A`); | ||
| for (let i = 0; i < lastTodoLines; i++) { | ||
| process.stdout.write("\x1B[2K\n"); | ||
| } | ||
| process.stdout.write(`\x1B[${lastTodoLines}A`); | ||
| } | ||
| for (const line of lines) { | ||
| process.stdout.write(line + "\n"); | ||
| } | ||
| lastTodoLines = lines.length; | ||
| } | ||
| export { | ||
| runAutoFix | ||
| }; |
| import { | ||
| loadLocalSuggestions, | ||
| showInteractiveResults | ||
| } from "./chunk-CI36GGK2.js"; | ||
| export { | ||
| loadLocalSuggestions, | ||
| showInteractiveResults | ||
| }; |
Sorry, the diff of this file is too big to display
| // src/commands/analyse-results.ts | ||
| import chalk3 from "chalk"; | ||
| import inquirer from "inquirer"; | ||
| import * as fs4 from "fs"; | ||
| import * as path4 from "path"; | ||
| // src/core/api-client.ts | ||
| import axios2 from "axios"; | ||
| // src/core/config-store.ts | ||
| import Conf from "conf"; | ||
| // src/types/config.ts | ||
| var DEFAULT_CONFIG = { | ||
| tier: "local", | ||
| loggedIn: false, | ||
| email: null, | ||
| uid: null, | ||
| authToken: null, | ||
| refreshToken: null, | ||
| provider: null, | ||
| providerKey: null, | ||
| localEndpoint: null, | ||
| personalizationMode: false, | ||
| consultantMode: false, | ||
| autoMode: false, | ||
| idrp: false, | ||
| idrpFilter: "free", | ||
| activeProject: null, | ||
| conversationId: null, | ||
| activePersona: null, | ||
| hasSeenWelcome: false | ||
| }; | ||
| // src/core/config-store.ts | ||
| var store = new Conf({ | ||
| projectName: "bob-cli", | ||
| defaults: DEFAULT_CONFIG | ||
| }); | ||
| function getConfig() { | ||
| return { | ||
| tier: store.get("tier"), | ||
| loggedIn: store.get("loggedIn"), | ||
| email: store.get("email"), | ||
| uid: store.get("uid"), | ||
| authToken: store.get("authToken"), | ||
| refreshToken: store.get("refreshToken"), | ||
| provider: store.get("provider"), | ||
| providerKey: store.get("providerKey"), | ||
| localEndpoint: store.get("localEndpoint"), | ||
| personalizationMode: store.get("personalizationMode"), | ||
| consultantMode: store.get("consultantMode"), | ||
| idrp: store.get("idrp"), | ||
| idrpFilter: store.get("idrpFilter"), | ||
| activeProject: store.get("activeProject"), | ||
| conversationId: store.get("conversationId"), | ||
| activePersona: store.get("activePersona"), | ||
| hasSeenWelcome: store.get("hasSeenWelcome"), | ||
| autoMode: store.get("autoMode") | ||
| }; | ||
| } | ||
| function setConfigValue(key, value) { | ||
| store.set(key, value); | ||
| } | ||
| function getConfigPath() { | ||
| return store.path; | ||
| } | ||
| // src/commands/login.ts | ||
| import chalk from "chalk"; | ||
| import http from "http"; | ||
| import open from "open"; | ||
| import axios from "axios"; | ||
| import { URL } from "url"; | ||
| import * as readline from "readline"; | ||
| var CLI_AUTH_URL = "https://bobs-workshop.web.app/cli-auth"; | ||
| var CALLBACK_PORT = 9876; | ||
| var FIREBASE_API_KEY = "AIzaSyB-hUZEonRIzbExVDwuneJaDjJZBvHdIps"; | ||
| function registerLoginCommand(program) { | ||
| program.command("login").description("Authenticate with Bob's Workshop via browser").action(async () => { | ||
| console.log(""); | ||
| console.log(chalk.bold.cyan(" \u{1F510} Bob CLI \u2014 Login")); | ||
| console.log(chalk.gray(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500")); | ||
| console.log(""); | ||
| console.log(chalk.yellow(" \u26A0\uFE0F Important:")); | ||
| console.log(chalk.gray(" \u2022 Local conversations (Tier 1) will NOT sync to the platform.")); | ||
| console.log(chalk.gray(" \u2022 Only NEW conversations created after login will save to Firebase.")); | ||
| console.log(chalk.gray(" \u2022 Your local history stays in ~/.bob/projects/ (backup via `bob backup`).")); | ||
| console.log(chalk.gray(" \u2022 Logging in upgrades you to Tier 3 (Platform) with full features.")); | ||
| console.log(""); | ||
| const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); | ||
| const answer = await new Promise((resolve3) => { | ||
| rl.question(chalk.cyan(" Continue with login? (y/n): "), resolve3); | ||
| }); | ||
| rl.close(); | ||
| if (answer.toLowerCase() !== "y" && answer.toLowerCase() !== "yes") { | ||
| console.log(""); | ||
| console.log(chalk.gray(" Login cancelled.")); | ||
| console.log(""); | ||
| return; | ||
| } | ||
| console.log(""); | ||
| console.log(chalk.gray(" Opening browser for authentication...")); | ||
| console.log(""); | ||
| try { | ||
| const result = await startAuthFlow(); | ||
| if (result) { | ||
| const exchangeResult = await exchangeCustomToken(result.token); | ||
| setConfigValue("authToken", exchangeResult.idToken); | ||
| setConfigValue("refreshToken", exchangeResult.refreshToken); | ||
| setConfigValue("email", result.email); | ||
| setConfigValue("uid", result.uid); | ||
| setConfigValue("loggedIn", true); | ||
| setConfigValue("tier", "platform"); | ||
| console.log(""); | ||
| console.log(chalk.green(` \u2705 Logged in as ${result.email}`)); | ||
| console.log(chalk.gray(" Tier: Platform (Tier 3)")); | ||
| console.log(chalk.gray(" All platform features are now available.")); | ||
| console.log(""); | ||
| } | ||
| } catch (error) { | ||
| console.log(chalk.red(` \u274C Login failed: ${error.message}`)); | ||
| console.log(""); | ||
| } | ||
| }); | ||
| program.command("logout").description("Sign out and clear stored credentials").action(() => { | ||
| setConfigValue("authToken", null); | ||
| setConfigValue("refreshToken", null); | ||
| setConfigValue("email", null); | ||
| setConfigValue("uid", null); | ||
| setConfigValue("loggedIn", false); | ||
| setConfigValue("tier", "local"); | ||
| console.log(""); | ||
| console.log(chalk.gray(" \u{1F44B} Logged out. Switched to Tier 1 (local-first).")); | ||
| console.log(""); | ||
| }); | ||
| } | ||
| async function exchangeCustomToken(customToken) { | ||
| const url = `https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=${FIREBASE_API_KEY}`; | ||
| const response = await axios.post(url, { | ||
| token: customToken, | ||
| returnSecureToken: true | ||
| }); | ||
| if (!response.data?.idToken || !response.data?.refreshToken) { | ||
| throw new Error("Token exchange failed \u2014 no ID token returned."); | ||
| } | ||
| return { | ||
| idToken: response.data.idToken, | ||
| refreshToken: response.data.refreshToken | ||
| }; | ||
| } | ||
| async function refreshAuthToken(refreshToken) { | ||
| const url = `https://securetoken.googleapis.com/v1/token?key=${FIREBASE_API_KEY}`; | ||
| const response = await axios.post(url, { | ||
| grant_type: "refresh_token", | ||
| refresh_token: refreshToken | ||
| }); | ||
| if (!response.data?.id_token) { | ||
| throw new Error("Token refresh failed."); | ||
| } | ||
| setConfigValue("authToken", response.data.id_token); | ||
| return response.data.id_token; | ||
| } | ||
| function startAuthFlow() { | ||
| return new Promise((resolve3, reject) => { | ||
| const timeout = setTimeout(() => { | ||
| server.close(); | ||
| reject(new Error("Login timed out after 120 seconds. Please try again.")); | ||
| }, 12e4); | ||
| const server = http.createServer((req, res) => { | ||
| if (!req.url?.startsWith("/callback")) { | ||
| res.writeHead(404); | ||
| res.end("Not found"); | ||
| return; | ||
| } | ||
| try { | ||
| const url = new URL(req.url, `http://localhost:${CALLBACK_PORT}`); | ||
| const token = url.searchParams.get("token"); | ||
| const email = url.searchParams.get("email"); | ||
| const uid = url.searchParams.get("uid"); | ||
| if (!token || !email || !uid) { | ||
| res.writeHead(400); | ||
| res.end("Missing parameters"); | ||
| reject(new Error("Invalid callback \u2014 missing token, email, or uid.")); | ||
| return; | ||
| } | ||
| res.writeHead(200, { "Content-Type": "text/html" }); | ||
| res.end(` | ||
| <html> | ||
| <body style="background: #0a0a0a; color: white; font-family: system-ui; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0;"> | ||
| <div style="text-align: center;"> | ||
| <h1>\u2705 Authenticated!</h1> | ||
| <p style="color: #888;">You can close this tab and return to your terminal.</p> | ||
| </div> | ||
| </body> | ||
| </html> | ||
| `); | ||
| clearTimeout(timeout); | ||
| server.close(); | ||
| resolve3({ token, email, uid }); | ||
| } catch (e) { | ||
| res.writeHead(500); | ||
| res.end("Error"); | ||
| reject(e); | ||
| } | ||
| }); | ||
| server.listen(CALLBACK_PORT, () => { | ||
| console.log(chalk.gray(` \u{1F310} Waiting for authentication (port ${CALLBACK_PORT})...`)); | ||
| console.log(chalk.gray(" If your browser doesn't open, visit:")); | ||
| console.log(chalk.cyan(` ${CLI_AUTH_URL}`)); | ||
| console.log(""); | ||
| open(CLI_AUTH_URL).catch(() => { | ||
| }); | ||
| }); | ||
| server.on("error", (err) => { | ||
| clearTimeout(timeout); | ||
| if (err.code === "EADDRINUSE") { | ||
| reject(new Error("Port 9876 is already in use. Close other instances and try again.")); | ||
| } else { | ||
| reject(err); | ||
| } | ||
| }); | ||
| }); | ||
| } | ||
| // src/core/api-client.ts | ||
| var FUNCTIONS_BASE = "https://us-central1-seedlingapp.cloudfunctions.net"; | ||
| async function callCloudFunction(functionName, data) { | ||
| const config = getConfig(); | ||
| if (!config.authToken) { | ||
| throw new Error("Not authenticated. Run `bob login` first."); | ||
| } | ||
| try { | ||
| const response = await axios2.post( | ||
| `${FUNCTIONS_BASE}/${functionName}`, | ||
| { data }, | ||
| { | ||
| headers: { | ||
| "Content-Type": "application/json", | ||
| "Authorization": `Bearer ${config.authToken}` | ||
| }, | ||
| timeout: 18e4 | ||
| } | ||
| ); | ||
| return response.data?.result || response.data; | ||
| } catch (error) { | ||
| const status = error.response?.status; | ||
| if (status === 401 && config.refreshToken) { | ||
| try { | ||
| const newToken = await refreshAuthToken(config.refreshToken); | ||
| const retryResponse = await axios2.post( | ||
| `${FUNCTIONS_BASE}/${functionName}`, | ||
| { data }, | ||
| { | ||
| headers: { | ||
| "Content-Type": "application/json", | ||
| "Authorization": `Bearer ${newToken}` | ||
| }, | ||
| timeout: 18e4 | ||
| } | ||
| ); | ||
| return retryResponse.data?.result || retryResponse.data; | ||
| } catch (refreshError) { | ||
| setConfigValue("loggedIn", false); | ||
| throw new Error("Session expired. Run `bob login` again."); | ||
| } | ||
| } | ||
| if (status === 404) { | ||
| throw new Error(`Function "${functionName}" not found. Is it deployed?`); | ||
| } | ||
| if (status === 403) { | ||
| throw new Error("Permission denied. You may not have access to this feature."); | ||
| } | ||
| if (status === 500) { | ||
| const serverMsg = error.response?.data?.error?.message || error.response?.data?.error || "Internal server error"; | ||
| throw new Error(`Server error: ${serverMsg}`); | ||
| } | ||
| if (status === 429) { | ||
| throw new Error("Rate limited. Please wait a moment and try again."); | ||
| } | ||
| const errorMsg = error.response?.data?.error?.message || error.message || `Request failed with status ${status}`; | ||
| throw new Error(errorMsg); | ||
| } | ||
| } | ||
| function isAuthenticated() { | ||
| const config = getConfig(); | ||
| return !!(config.loggedIn && config.authToken); | ||
| } | ||
| // src/ai/providers/local.ts | ||
| import axios3 from "axios"; | ||
| async function callLocalModel(endpoint, messages) { | ||
| try { | ||
| const response = await axios3.post( | ||
| endpoint, | ||
| { | ||
| model: "bob-local-dna:latest", | ||
| messages, | ||
| stream: false | ||
| }, | ||
| { | ||
| headers: { "Content-Type": "application/json" }, | ||
| timeout: 18e4 | ||
| } | ||
| ); | ||
| if (response.data?.message?.content) { | ||
| return { | ||
| text: response.data.message.content, | ||
| evalCount: response.data.eval_count || void 0, | ||
| promptEvalCount: response.data.prompt_eval_count || void 0, | ||
| evalDurationMs: response.data.eval_duration ? Math.round(response.data.eval_duration / 1e6) : void 0, | ||
| totalDurationMs: response.data.total_duration ? Math.round(response.data.total_duration / 1e6) : void 0 | ||
| }; | ||
| } | ||
| const choice = response.data?.choices?.[0]; | ||
| if (choice?.message?.content) { | ||
| return { | ||
| text: choice.message.content, | ||
| evalCount: response.data.usage?.completion_tokens || void 0, | ||
| promptEvalCount: response.data.usage?.prompt_tokens || void 0 | ||
| }; | ||
| } | ||
| if (typeof response.data?.response === "string") { | ||
| return { text: response.data.response }; | ||
| } | ||
| return { text: "No response received from local model." }; | ||
| } catch (error) { | ||
| if (error.code === "ECONNREFUSED") { | ||
| throw new Error("Cannot connect to local model. Is Ollama running? Check your endpoint: " + endpoint); | ||
| } | ||
| throw new Error("Local model error: " + (error.response?.status ? `Status ${error.response.status}` : error.message)); | ||
| } | ||
| } | ||
| // src/core/context-builder.ts | ||
| import * as fs from "fs"; | ||
| import * as path from "path"; | ||
| var IGNORE_DIRS = ["node_modules", ".git", "dist", "build", ".dart_tool", ".idea", ".gradle", ".pub-cache", ".bob"]; | ||
| var MAX_DEPTH = 3; | ||
| function buildLocalContext(rootDir) { | ||
| const tree = getDirectoryTree(rootDir, 0); | ||
| return `Working Directory: ${rootDir} | ||
| File Tree: | ||
| ${tree}`; | ||
| } | ||
| function getDirectoryTree(dir, depth) { | ||
| if (depth >= MAX_DEPTH) return ""; | ||
| let result = ""; | ||
| try { | ||
| const entries = fs.readdirSync(dir, { withFileTypes: true }); | ||
| for (const entry of entries) { | ||
| if (IGNORE_DIRS.includes(entry.name)) continue; | ||
| if (entry.name.startsWith(".") && depth === 0) continue; | ||
| const indent = " ".repeat(depth); | ||
| if (entry.isDirectory()) { | ||
| result += `${indent}${entry.name}/ | ||
| `; | ||
| result += getDirectoryTree(path.join(dir, entry.name), depth + 1); | ||
| } else { | ||
| result += `${indent}${entry.name} | ||
| `; | ||
| } | ||
| } | ||
| } catch (e) { | ||
| } | ||
| return result; | ||
| } | ||
| function readFileContent(filePath) { | ||
| try { | ||
| return fs.readFileSync(path.resolve(filePath), "utf-8"); | ||
| } catch (e) { | ||
| return null; | ||
| } | ||
| } | ||
| // src/core/file-writer.ts | ||
| import * as fs2 from "fs"; | ||
| import * as path2 from "path"; | ||
| import * as readline2 from "readline"; | ||
| import chalk2 from "chalk"; | ||
| var SUCCESS = chalk2.hex("#66BB6A"); | ||
| var INFO = chalk2.hex("#26C6DA"); | ||
| var WARNING = chalk2.hex("#FFC107"); | ||
| var ERROR = chalk2.hex("#EF5350"); | ||
| var MUTED = chalk2.hex("#78909C"); | ||
| var BRAND_SECONDARY = chalk2.hex("#FFAB00"); | ||
| var BORDER = chalk2.hex("#455A64"); | ||
| function extractAllProposedFiles(response) { | ||
| const proposals = []; | ||
| const codeBlockRegex = /```[\w]*\n([\s\S]*?)```/g; | ||
| let match; | ||
| while ((match = codeBlockRegex.exec(response)) !== null) { | ||
| const codeContent = match[1].trim(); | ||
| const lines = codeContent.split("\n"); | ||
| if (lines.length === 0) continue; | ||
| const firstLine = lines[0].trim(); | ||
| let filePathMatch = firstLine.match(/^\/\/\s*File:\s*(.+)$/); | ||
| if (!filePathMatch) filePathMatch = firstLine.match(/^\/\/\s*([\w\-\.\/\\]+\.\w+)\s*$/); | ||
| if (!filePathMatch) filePathMatch = firstLine.match(/^#\s*File:\s*(.+)$/); | ||
| if (!filePathMatch) filePathMatch = firstLine.match(/^#\s*([\w\-\.\/\\]+\.\w+)\s*$/); | ||
| if (!filePathMatch) filePathMatch = firstLine.match(/^\*\s*\[FILE:\s*(.+?)\]/); | ||
| if (!filePathMatch) continue; | ||
| const filePath = filePathMatch[1].trim(); | ||
| if (!filePath.includes("/") && !filePath.includes("\\")) continue; | ||
| if (!filePath.includes(".")) continue; | ||
| const fileContent = lines.slice(1).join("\n").trim(); | ||
| const isLocal = isLocalProjectFile(filePath); | ||
| const absolutePath = path2.join(process.cwd(), filePath); | ||
| const isNew = !fs2.existsSync(absolutePath); | ||
| proposals.push({ filePath, content: fileContent, isNew, isLocal }); | ||
| } | ||
| return proposals; | ||
| } | ||
| function extractProposedFile(response) { | ||
| const all = extractAllProposedFiles(response); | ||
| return all.length > 0 ? all[0] : null; | ||
| } | ||
| function stripCodeBlockFromResponse(response) { | ||
| let stripped = response.replace(/```[\w]*\n\s*(?:\/\/\s*(?:File:)?\s*[\w\-\.\/\\]+\.\w+|#\s*(?:File:)?\s*[\w\-\.\/\\]+\.\w+|\*\s*\[FILE:)[^\n]*\n[\s\S]*?```/g, "").trim(); | ||
| stripped = stripped.replace(/```capability_invocation\s*[\s\S]*?```/g, "").trim(); | ||
| return stripped; | ||
| } | ||
| function isLocalProjectFile(filePath) { | ||
| const cwd = process.cwd(); | ||
| const externalPatterns = ["functions/", "lib/", "android/", "ios/", "macos/", "windows/", "web/"]; | ||
| for (const pattern of externalPatterns) { | ||
| if (filePath.startsWith(pattern)) { | ||
| const localPath = path2.join(cwd, pattern.replace("/", "")); | ||
| if (!fs2.existsSync(localPath)) return false; | ||
| } | ||
| } | ||
| const resolved = path2.resolve(cwd, filePath); | ||
| if (!resolved.startsWith(cwd)) return false; | ||
| return true; | ||
| } | ||
| async function processAllProposedFiles(response, autoApprove = false, existingRl) { | ||
| const proposals = extractAllProposedFiles(response); | ||
| const idrpProposals = extractIDRPFileProposals(response); | ||
| const allProposals = [...proposals, ...idrpProposals]; | ||
| if (allProposals.length === 0) return; | ||
| for (const proposed of allProposals) { | ||
| if (proposed.isLocal) { | ||
| await proposeAndWriteFile(proposed, autoApprove, existingRl); | ||
| } else { | ||
| displayExternalFile(proposed); | ||
| } | ||
| } | ||
| } | ||
| function displayExternalFile(proposed) { | ||
| const totalLines = proposed.content.split("\n").length; | ||
| console.log(""); | ||
| console.log(WARNING(` \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557`)); | ||
| console.log(WARNING(` \u2551`) + BRAND_SECONDARY(` \u{1F4CB} EXTERNAL: ${proposed.filePath}`)); | ||
| console.log(WARNING(` \u2551`) + MUTED(` This file belongs to another project.`)); | ||
| console.log(WARNING(` \u2560\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2563`)); | ||
| const previewLines = proposed.content.split("\n").slice(0, 6); | ||
| for (const line of previewLines) { | ||
| console.log(WARNING(` \u2551`) + MUTED(` ${line}`)); | ||
| } | ||
| if (totalLines > 6) { | ||
| console.log(WARNING(` \u2551`) + MUTED(` ... (${totalLines - 6} more lines)`)); | ||
| } | ||
| console.log(WARNING(` \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D`)); | ||
| console.log(MUTED(` Copy this file manually to your project at: ${proposed.filePath}`)); | ||
| console.log(""); | ||
| } | ||
| async function proposeAndWriteFile(proposed, autoApprove = false, existingRl) { | ||
| if (!proposed.isLocal) { | ||
| displayExternalFile(proposed); | ||
| return false; | ||
| } | ||
| const absolutePath = path2.join(process.cwd(), proposed.filePath); | ||
| const action = proposed.isNew ? "CREATE" : "UPDATE"; | ||
| const icon = proposed.isNew ? "\u{1F4C4}" : "\u270F\uFE0F"; | ||
| const accentColor = proposed.isNew ? SUCCESS : BRAND_SECONDARY; | ||
| const totalLines = proposed.content.split("\n").length; | ||
| if (!autoApprove) { | ||
| console.log(""); | ||
| console.log(BORDER(` \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557`)); | ||
| console.log(BORDER(` \u2551`) + accentColor(` ${icon} ${action}: `) + chalk2.white(`${proposed.filePath}`) + MUTED(` (${totalLines} lines)`)); | ||
| console.log(BORDER(` \u2560\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2563`)); | ||
| const previewLines = proposed.content.split("\n").slice(0, 6); | ||
| for (const line of previewLines) { | ||
| console.log(BORDER(` \u2551`) + MUTED(` ${line}`)); | ||
| } | ||
| if (totalLines > 6) { | ||
| console.log(BORDER(` \u2551`) + MUTED(` ... (${totalLines - 6} more lines)`)); | ||
| } | ||
| console.log(BORDER(` \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D`)); | ||
| console.log(""); | ||
| const promptText = INFO(` \u{1F4BE} ${action === "CREATE" ? "Write this file" : "Apply changes"}? `) + MUTED(`(y/n/path): `); | ||
| let answer; | ||
| if (existingRl) { | ||
| existingRl.pause(); | ||
| process.stdout.write(promptText); | ||
| const buf = Buffer.alloc(1024); | ||
| const bytesRead = fs2.readSync(0, buf, 0, 1024, null); | ||
| answer = buf.toString("utf-8", 0, bytesRead).replace(/\r?\n/, "").trim(); | ||
| existingRl.resume(); | ||
| } else { | ||
| const rl = readline2.createInterface({ input: process.stdin, output: process.stdout }); | ||
| answer = await new Promise((resolve3) => { | ||
| rl.question(promptText, (ans) => { | ||
| rl.close(); | ||
| resolve3(ans); | ||
| }); | ||
| }); | ||
| } | ||
| const trimmed = answer.trim().toLowerCase(); | ||
| if (trimmed === "n" || trimmed === "no") { | ||
| console.log(MUTED(" \u23ED\uFE0F Skipped.")); | ||
| return false; | ||
| } | ||
| if (trimmed !== "y" && trimmed !== "yes" && trimmed.length > 0) { | ||
| const customPath = path2.join(process.cwd(), trimmed); | ||
| return writeFile(customPath, proposed.content, proposed.filePath, proposed.isNew); | ||
| } | ||
| } | ||
| return writeFile(absolutePath, proposed.content, proposed.filePath, proposed.isNew); | ||
| } | ||
| function writeFile(targetPath, content, originalFilePath, isNew) { | ||
| try { | ||
| const dir = path2.dirname(targetPath); | ||
| if (!fs2.existsSync(dir)) fs2.mkdirSync(dir, { recursive: true }); | ||
| if (!isNew && fs2.existsSync(targetPath)) { | ||
| const backupDir = path2.join(process.cwd(), ".bob-backups"); | ||
| if (!fs2.existsSync(backupDir)) fs2.mkdirSync(backupDir, { recursive: true }); | ||
| const timestamp = Date.now(); | ||
| const backupName = originalFilePath.replace(/[\/\\]/g, "_") + `.${timestamp}.bak`; | ||
| fs2.copyFileSync(targetPath, path2.join(backupDir, backupName)); | ||
| } | ||
| fs2.writeFileSync(targetPath, content, "utf-8"); | ||
| const relativePath = path2.relative(process.cwd(), targetPath); | ||
| console.log(SUCCESS(` \u2705 Written: ${relativePath}`)); | ||
| if (!isNew) { | ||
| console.log(MUTED(` \u{1F4E6} Backup saved to .bob-backups/`)); | ||
| } | ||
| console.log(""); | ||
| return true; | ||
| } catch (error) { | ||
| console.log(ERROR(` \u274C Write failed: ${error.message}`)); | ||
| return false; | ||
| } | ||
| } | ||
| function extractIDRPFileProposals(response) { | ||
| const proposals = []; | ||
| const invocationRegex = /```capability_invocation\s*([\s\S]*?)```/g; | ||
| let match; | ||
| while ((match = invocationRegex.exec(response)) !== null) { | ||
| try { | ||
| const invocation = JSON.parse(match[1].trim()); | ||
| if (invocation.action !== "invoke") continue; | ||
| if (!["workspace_create_file", "workspace_update_file"].includes(invocation.capabilityId)) continue; | ||
| const filePath = invocation.params?.filePath; | ||
| const content = invocation.params?.content || invocation.params?.newContent; | ||
| if (!filePath || !content) continue; | ||
| const absolutePath = path2.join(process.cwd(), filePath); | ||
| const isNew = !fs2.existsSync(absolutePath); | ||
| const isLocal = isLocalProjectFile(filePath); | ||
| proposals.push({ filePath, content, isNew, isLocal }); | ||
| } catch { | ||
| } | ||
| } | ||
| return proposals; | ||
| } | ||
| // src/core/analysis-tracker.ts | ||
| import * as fs3 from "fs"; | ||
| import * as path3 from "path"; | ||
| var BOB_DIR = path3.join(process.env.HOME || process.env.USERPROFILE || "", ".bob"); | ||
| function getResultsDir() { | ||
| const projectName = path3.basename(process.cwd()); | ||
| return path3.join(BOB_DIR, "projects", projectName, "analysis", "results"); | ||
| } | ||
| function getAnalysisPath() { | ||
| return path3.join(getResultsDir(), "analysis.json"); | ||
| } | ||
| function getStatusLogPath() { | ||
| return path3.join(getResultsDir(), "status-log.json"); | ||
| } | ||
| function markSuggestionStatus(filePath, suggestionIndex, category, status, metadata) { | ||
| const analysisPath = getAnalysisPath(); | ||
| const logPath = getStatusLogPath(); | ||
| if (!fs3.existsSync(analysisPath)) return; | ||
| const allResults = JSON.parse(fs3.readFileSync(analysisPath, "utf-8")); | ||
| if (allResults[filePath] && allResults[filePath][category]) { | ||
| const items = allResults[filePath][category]; | ||
| if (items[suggestionIndex]) { | ||
| items[suggestionIndex].status = status; | ||
| items[suggestionIndex].statusUpdatedAt = (/* @__PURE__ */ new Date()).toISOString(); | ||
| } | ||
| } | ||
| fs3.writeFileSync(analysisPath, JSON.stringify(allResults, null, 2)); | ||
| let log = []; | ||
| if (fs3.existsSync(logPath)) { | ||
| try { | ||
| log = JSON.parse(fs3.readFileSync(logPath, "utf-8")); | ||
| } catch { | ||
| log = []; | ||
| } | ||
| } | ||
| log.push({ | ||
| timestamp: (/* @__PURE__ */ new Date()).toISOString(), | ||
| filePath, | ||
| category, | ||
| suggestionIndex, | ||
| action: status, | ||
| confidence: metadata?.confidence || null, | ||
| reason: metadata?.reason || null, | ||
| implementedBy: metadata?.implementedBy || "minibob", | ||
| previousStatus: "pending" | ||
| }); | ||
| fs3.writeFileSync(logPath, JSON.stringify(log, null, 2)); | ||
| } | ||
| function markSuggestionById(id, category, status, metadata) { | ||
| const analysisPath = getAnalysisPath(); | ||
| if (!fs3.existsSync(analysisPath)) return; | ||
| const allResults = JSON.parse(fs3.readFileSync(analysisPath, "utf-8")); | ||
| for (const [filePath, fileResults] of Object.entries(allResults)) { | ||
| const items = fileResults[category]; | ||
| if (!items) continue; | ||
| for (let i = 0; i < items.length; i++) { | ||
| const itemId = `${filePath.replace(/[\/\\]/g, "_")}_${i}`; | ||
| if (itemId === id) { | ||
| markSuggestionStatus(filePath, i, category, status, metadata); | ||
| return; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| // src/commands/analyse-results.ts | ||
| var BRAND_PRIMARY = chalk3.hex("#E66F24"); | ||
| var BRAND_SECONDARY2 = chalk3.hex("#FFAB00"); | ||
| var SUCCESS2 = chalk3.hex("#66BB6A"); | ||
| var INFO2 = chalk3.hex("#26C6DA"); | ||
| var WARNING2 = chalk3.hex("#FFC107"); | ||
| var ERROR2 = chalk3.hex("#EF5350"); | ||
| var MUTED2 = chalk3.hex("#78909C"); | ||
| var BORDER2 = chalk3.hex("#455A64"); | ||
| var MODE_CONSULTANT = chalk3.hex("#AB47BC"); | ||
| var PRIORITY_COLORS = { | ||
| "critical": chalk3.bgHex("#B71C1C").white, | ||
| "high": chalk3.hex("#FF6D00"), | ||
| "medium": chalk3.hex("#FFA726"), | ||
| "low": chalk3.hex("#66BB6A") | ||
| }; | ||
| var CATEGORY_COLORS = { | ||
| "bugs": ERROR2, | ||
| "features": MODE_CONSULTANT, | ||
| "improvements": INFO2, | ||
| "upgrades": SUCCESS2 | ||
| }; | ||
| var CATEGORY_ICONS = { | ||
| "bugs": "\u{1F534}", | ||
| "features": "\u{1F7E3}", | ||
| "improvements": "\u{1F535}", | ||
| "upgrades": "\u{1F7E2}" | ||
| }; | ||
| async function showInteractiveResults(config, category, sort, search) { | ||
| let allSuggestions = []; | ||
| if (config.tier === "platform" && config.provider !== "local" && config.loggedIn && config.conversationId) { | ||
| try { | ||
| const result = await callCloudFunction("getCLIAnalysisResults", { | ||
| conversationId: config.conversationId, | ||
| category, | ||
| sort: sort || "priority", | ||
| search: search || null | ||
| }); | ||
| allSuggestions = result?.suggestions || []; | ||
| } catch (error) { | ||
| console.log(ERROR2(` \u274C ${error.message}`)); | ||
| return; | ||
| } | ||
| } else { | ||
| allSuggestions = loadLocalSuggestions(category); | ||
| } | ||
| if (search) { | ||
| const query = search.toLowerCase(); | ||
| allSuggestions = allSuggestions.filter( | ||
| (s) => (s.description || "").toLowerCase().includes(query) || (s.title || "").toLowerCase().includes(query) || (s.filePath || "").toLowerCase().includes(query) | ||
| ); | ||
| } | ||
| sortSuggestions(allSuggestions, sort || "priority"); | ||
| if (allSuggestions.length === 0) { | ||
| console.log(""); | ||
| console.log(SUCCESS2(" \u2705 No items found. Clean!")); | ||
| console.log(""); | ||
| return; | ||
| } | ||
| const color = CATEGORY_COLORS[category] || MUTED2; | ||
| const icon = CATEGORY_ICONS[category] || "\u25C6"; | ||
| let running = true; | ||
| let displaySuggestions = [...allSuggestions]; | ||
| let currentSort = sort || "priority"; | ||
| while (running) { | ||
| console.log(""); | ||
| console.log(color(` ${icon} ${category.toUpperCase()} (${displaySuggestions.length} items) \u2502 Sort: ${currentSort}`)); | ||
| console.log(MUTED2(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500")); | ||
| console.log(""); | ||
| const choices = []; | ||
| choices.push({ | ||
| name: INFO2(" \u{1F500} Toggle sort"), | ||
| value: "__sort__", | ||
| short: "Sort" | ||
| }); | ||
| choices.push(new inquirer.Separator(MUTED2(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"))); | ||
| for (let idx = 0; idx < displaySuggestions.length; idx++) { | ||
| const item = displaySuggestions[idx]; | ||
| const pColor = PRIORITY_COLORS[item.priority?.toLowerCase()] || MUTED2; | ||
| const priorityLabel = pColor((item.priority || "MEDIUM").toUpperCase().padEnd(9)); | ||
| const fileName = (item.filePath || "unknown").split("/").pop() || "unknown"; | ||
| const title = (item.title || item.description || "No description").slice(0, 40); | ||
| const displayName = ` ${priorityLabel} ${INFO2(fileName.padEnd(20))} ${chalk3.white(title)}`; | ||
| choices.push({ | ||
| name: displayName, | ||
| value: idx, | ||
| short: item.title || item.description?.slice(0, 30) || "Item", | ||
| description: `${item.priority} ${item.filePath} ${item.title} ${item.description}` | ||
| }); | ||
| } | ||
| choices.push(new inquirer.Separator(MUTED2(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"))); | ||
| choices.push({ | ||
| name: MUTED2(" \u2190 Quit"), | ||
| value: "__quit__", | ||
| short: "Quit" | ||
| }); | ||
| const { selected } = await inquirer.prompt([ | ||
| { | ||
| type: "search", | ||
| name: "selected", | ||
| message: color(`Search ${category} (type to filter):`), | ||
| source: (input) => { | ||
| if (!input) return choices; | ||
| const query = input.toLowerCase(); | ||
| const filtered = choices.filter((c) => { | ||
| if (c.type === "separator") return true; | ||
| if (c.value === "__sort__" || c.value === "__quit__") return true; | ||
| const searchable = c.description?.toLowerCase() || ""; | ||
| return searchable.includes(query); | ||
| }); | ||
| return filtered; | ||
| }, | ||
| pageSize: 12 | ||
| } | ||
| ]); | ||
| if (selected === "__quit__") { | ||
| running = false; | ||
| break; | ||
| } | ||
| if (selected === "__sort__") { | ||
| currentSort = currentSort === "priority" ? "file" : "priority"; | ||
| sortSuggestions(displaySuggestions, currentSort); | ||
| console.log(INFO2(` Sort changed to: ${currentSort}`)); | ||
| continue; | ||
| } | ||
| if (typeof selected === "number") { | ||
| const item = displaySuggestions[selected]; | ||
| const action = await showExpandedView(item, category); | ||
| if (action === "implement") { | ||
| await handleImplement(item, config, category); | ||
| displaySuggestions.splice(selected, 1); | ||
| const originalIdx = allSuggestions.findIndex((s) => s.id === item.id); | ||
| if (originalIdx !== -1) allSuggestions.splice(originalIdx, 1); | ||
| } else if (action === "dismiss") { | ||
| if (item.id) { | ||
| markSuggestionById(item.id, category, "dismissed", { | ||
| reason: "User dismissed from CLI", | ||
| implementedBy: "user" | ||
| }); | ||
| } | ||
| displaySuggestions.splice(selected, 1); | ||
| const originalIdx = allSuggestions.findIndex((s) => s.id === item.id); | ||
| if (originalIdx !== -1) allSuggestions.splice(originalIdx, 1); | ||
| console.log(MUTED2(" \u23ED\uFE0F Dismissed and logged.")); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| async function showExpandedView(item, category) { | ||
| const color = CATEGORY_COLORS[category] || MUTED2; | ||
| const pColor = PRIORITY_COLORS[item.priority?.toLowerCase()] || MUTED2; | ||
| const icon = CATEGORY_ICONS[category] || "\u25C6"; | ||
| console.log(""); | ||
| console.log(BORDER2(" \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557")); | ||
| console.log(BORDER2(" \u2551") + ` ${icon} ` + pColor(`${(item.priority || "MEDIUM").toUpperCase()} ${category.toUpperCase().slice(0, -1)}`)); | ||
| console.log(BORDER2(" \u2560\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2563")); | ||
| console.log(BORDER2(" \u2551") + MUTED2(" File: ") + INFO2(item.filePath || "unknown")); | ||
| console.log(BORDER2(" \u2551") + MUTED2(" Priority: ") + pColor((item.priority || "medium").toUpperCase())); | ||
| console.log(BORDER2(" \u2551")); | ||
| console.log(BORDER2(" \u2551") + MUTED2(" Title:")); | ||
| console.log(BORDER2(" \u2551") + chalk3.white.bold(` ${item.title || "No title"}`)); | ||
| console.log(BORDER2(" \u2551")); | ||
| console.log(BORDER2(" \u2551") + MUTED2(" Description:")); | ||
| const descLines = wrapText(item.description || "No description", 54); | ||
| for (const line of descLines) { | ||
| console.log(BORDER2(" \u2551") + chalk3.white(` ${line}`)); | ||
| } | ||
| if (item.implementation) { | ||
| console.log(BORDER2(" \u2551")); | ||
| console.log(BORDER2(" \u2551") + MUTED2(" Implementation:")); | ||
| const implLines = wrapText(item.implementation, 54); | ||
| for (const line of implLines) { | ||
| console.log(BORDER2(" \u2551") + chalk3.white(` ${line}`)); | ||
| } | ||
| } | ||
| console.log(BORDER2(" \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D")); | ||
| console.log(""); | ||
| const { action } = await inquirer.prompt([ | ||
| { | ||
| type: "select", | ||
| name: "action", | ||
| message: BRAND_SECONDARY2("What do you want to do?"), | ||
| choices: [ | ||
| { name: SUCCESS2(" \u{1F527} Implement this fix"), value: "implement" }, | ||
| { name: ERROR2(" \u{1F5D1}\uFE0F Dismiss"), value: "dismiss" }, | ||
| { name: MUTED2(" \u2190 Back to list"), value: "back" } | ||
| ] | ||
| } | ||
| ]); | ||
| return action; | ||
| } | ||
| async function handleImplement(item, config, category) { | ||
| console.log(""); | ||
| console.log(INFO2(" \u{1F527} Implementing fix...")); | ||
| console.log(""); | ||
| if (config.provider === "local" && config.localEndpoint) { | ||
| const fileContent = readFileContent(item.filePath); | ||
| if (!fileContent) { | ||
| console.log(ERROR2(` \u274C Could not read file: ${item.filePath}`)); | ||
| return; | ||
| } | ||
| const prompt = `You are MiniBob \u2014 a junior engineer making SURGICAL code fixes under strict supervision. | ||
| CURRENT FILE: ${item.filePath} | ||
| ${fileContent} | ||
| CHANGE TO IMPLEMENT: | ||
| Title: ${item.title} | ||
| Description: ${item.description} | ||
| Implementation Instructions: ${item.implementation || "Apply the fix described above."} | ||
| RULES (CRITICAL \u2014 VIOLATION = REJECTED): | ||
| - Return ONLY valid source code. No markdown, no code fences, no \`\`\`, no explanation text. | ||
| - Start the FIRST line with: // File: ${item.filePath} | ||
| - PRESERVE ALL existing imports exactly as they are. Do NOT add, remove, or reorder imports. | ||
| - PRESERVE ALL existing exports exactly as they are. Do NOT rename exported functions or classes. | ||
| - PRESERVE the existing code structure, indentation, patterns, and naming conventions. | ||
| - Make the MINIMUM change necessary to implement the fix. Touch NOTHING else. | ||
| - Do NOT refactor, reorganize, or "improve" unrelated code. | ||
| - Do NOT add comments explaining what you changed. | ||
| - Do NOT wrap the response in markdown code blocks. | ||
| - The output must be valid TypeScript/JavaScript that compiles without errors. | ||
| - If you are unsure about a change, return the file UNCHANGED rather than risk breaking it. | ||
| Return the complete file content now:`; | ||
| try { | ||
| const messages = [ | ||
| { role: "system", content: "You are MiniBob, a junior engineer making SURGICAL fixes. Return ONLY valid source code. NO markdown. NO code fences. NO explanation. Start with // File: comment. Make the ABSOLUTE MINIMUM change needed. Do NOT restructure, refactor, or touch ANYTHING beyond the specific fix. If unsure, return the file unchanged." }, | ||
| { role: "user", content: prompt } | ||
| ]; | ||
| const localResult = await callLocalModel(config.localEndpoint, messages); | ||
| const response = typeof localResult === "object" && localResult.text ? localResult.text : localResult; | ||
| const lines = response.split("\n"); | ||
| const firstLine = lines[0].trim(); | ||
| let newContent; | ||
| if (firstLine.match(/^\/\/\s*(File:)?\s*/)) { | ||
| newContent = lines.slice(1).join("\n").trim(); | ||
| } else { | ||
| newContent = response.trim(); | ||
| } | ||
| if (newContent.includes("```") || newContent.includes("## ") || newContent.startsWith("Here") || newContent.startsWith("I have") || newContent.startsWith("Sure")) { | ||
| console.log(WARNING2(" \u26A0\uFE0F MiniBob returned explanation instead of code. Fix rejected.")); | ||
| return; | ||
| } | ||
| if (newContent.length < fileContent.length * 0.5) { | ||
| console.log(WARNING2(` \u26A0\uFE0F MiniBob's output is ${Math.round(newContent.length / fileContent.length * 100)}% of original size. Rejecting.`)); | ||
| return; | ||
| } | ||
| const originalExports = fileContent.match(/export\s+(function|class|const|interface|type|async\s+function)\s+\w+/g) || []; | ||
| for (const exp of originalExports) { | ||
| const exportName = exp.split(/\s+/).pop(); | ||
| if (!newContent.includes(exportName)) { | ||
| console.log(WARNING2(` \u26A0\uFE0F MiniBob removed export "${exportName}". Rejecting.`)); | ||
| return; | ||
| } | ||
| } | ||
| await proposeAndWriteFile({ | ||
| filePath: item.filePath, | ||
| content: newContent, | ||
| isNew: false, | ||
| isLocal: true | ||
| }); | ||
| if (item.id) { | ||
| markSuggestionById(item.id, category, "implemented", { | ||
| reason: "User approved implementation from CLI", | ||
| implementedBy: "minibob" | ||
| }); | ||
| } | ||
| } catch (error) { | ||
| console.log(ERROR2(` \u274C Implementation failed: ${error.message}`)); | ||
| } | ||
| } else if (config.loggedIn && config.conversationId) { | ||
| try { | ||
| const result = await callCloudFunction("implementSuggestion", { | ||
| conversationId: config.conversationId, | ||
| filePath: item.filePath, | ||
| suggestionId: item.id || "unknown", | ||
| category, | ||
| jobId: `cli_impl_${Date.now()}` | ||
| }); | ||
| if (result?.success) { | ||
| console.log(SUCCESS2(` \u2705 ${result.message}`)); | ||
| if (item.id) { | ||
| markSuggestionById(item.id, category, "implemented", { | ||
| reason: "Platform implementation", | ||
| implementedBy: "platform" | ||
| }); | ||
| } | ||
| } else { | ||
| console.log(ERROR2(" \u274C Implementation failed on platform.")); | ||
| } | ||
| } catch (error) { | ||
| console.log(ERROR2(` \u274C ${error.message}`)); | ||
| } | ||
| } else { | ||
| console.log(ERROR2(" \u274C No provider configured for implementation.")); | ||
| } | ||
| console.log(""); | ||
| } | ||
| function sortSuggestions(suggestions, method) { | ||
| if (method === "file") { | ||
| suggestions.sort((a, b) => (a.filePath || "").localeCompare(b.filePath || "")); | ||
| } else { | ||
| const priorityMap = { "critical": 0, "high": 1, "medium": 2, "low": 3 }; | ||
| suggestions.sort((a, b) => { | ||
| const pA = priorityMap[a.priority?.toLowerCase()] ?? 99; | ||
| const pB = priorityMap[b.priority?.toLowerCase()] ?? 99; | ||
| return pA - pB; | ||
| }); | ||
| } | ||
| } | ||
| function loadLocalSuggestions(category) { | ||
| const cwd = process.cwd(); | ||
| const projectName = path4.basename(cwd); | ||
| const homeDir = process.env.HOME || process.env.USERPROFILE || ""; | ||
| const analysisPath = path4.join(homeDir, ".bob", "projects", projectName, "analysis", "results", "analysis.json"); | ||
| if (!fs4.existsSync(analysisPath)) return []; | ||
| const allResults = JSON.parse(fs4.readFileSync(analysisPath, "utf-8")); | ||
| const suggestions = []; | ||
| for (const [filePath, fileResults] of Object.entries(allResults)) { | ||
| const items = fileResults[category] || []; | ||
| items.forEach((item, idx) => { | ||
| if (!item.status || item.status === "pending") { | ||
| suggestions.push({ | ||
| ...item, | ||
| filePath, | ||
| id: `${filePath.replace(/[\/\\]/g, "_")}_${idx}` | ||
| }); | ||
| } | ||
| }); | ||
| } | ||
| return suggestions; | ||
| } | ||
| function wrapText(text, maxWidth) { | ||
| const words = text.split(" "); | ||
| const lines = []; | ||
| let currentLine = ""; | ||
| for (const word of words) { | ||
| if (currentLine.length + word.length + 1 > maxWidth) { | ||
| lines.push(currentLine); | ||
| currentLine = word; | ||
| } else { | ||
| currentLine += (currentLine ? " " : "") + word; | ||
| } | ||
| } | ||
| if (currentLine) lines.push(currentLine); | ||
| return lines; | ||
| } | ||
| export { | ||
| getConfig, | ||
| setConfigValue, | ||
| getConfigPath, | ||
| registerLoginCommand, | ||
| callCloudFunction, | ||
| isAuthenticated, | ||
| callLocalModel, | ||
| buildLocalContext, | ||
| readFileContent, | ||
| extractAllProposedFiles, | ||
| extractProposedFile, | ||
| stripCodeBlockFromResponse, | ||
| processAllProposedFiles, | ||
| proposeAndWriteFile, | ||
| markSuggestionStatus, | ||
| showInteractiveResults, | ||
| loadLocalSuggestions | ||
| }; |
+1
-1
| { | ||
| "name": "@bobsworkshop/cli", | ||
| "version": "0.2.0", | ||
| "version": "0.5.1", | ||
| "description": "Bob's CLI — AI coding assistant and Forge orchestrator", | ||
@@ -5,0 +5,0 @@ "type": "module", |
+103
-54
@@ -8,4 +8,10 @@ // File: README.md | ||
| [](https://www.npmjs.com/package/@bobsworkshop/cli) | ||
| [](https://nodejs.org) | ||
| [](https://opensource.org/licenses/MIT) | ||
| **Bob's CLI** is a locally-installed developer tool that provides a senior-level AI engineering partner directly inside your native terminal. Stay in your development environment. Never switch to a browser. Ship faster. | ||
|  | ||
| [Installation](#installation) · [Quick Start](#quick-start) · [Features](#features) · [Docs](https://seedling-io.gitbook.io/bob-cli/) | ||
@@ -27,12 +33,12 @@ | ||
| |---------|-----------|-------------|---------|--------| | ||
| | Local file awareness | ✅ | ✅ | ✅ | ✅ | | ||
| | Zero-cost local model | ✅ | ✅ | ❌ | ❌ | | ||
| | Behavioral profiling | ✅ | ❌ | ❌ | ❌ | | ||
| | Personalization Mode | ✅ | ❌ | ❌ | ❌ | | ||
| | Conversation persistence | ✅ | ✅ | ❌ | Partial | | ||
| | Deep Dives & Forks | ✅ | ❌ | ❌ | ❌ | | ||
| | Remote execution (SovereignLink) | ✅ | Partial | ❌ | ❌ | | ||
| | Cross-surface sync (CLI ↔ Web) | ✅ | ✅ | ❌ | ❌ | | ||
| | Autonomous code repair | ✅ | ✅ | ❌ | ✅ | | ||
| | Source code stays on-device | ✅ | ✅ | ❌ | ✅ | | ||
| | Local file awareness | ✅ | ✅ | ✅ | ✅ | | ||
| | Zero-cost local model | ✅ | ✅ | ❌ | ❌ | | ||
| | Behavioral profiling | ✅ | ❌ | ❌ | ❌ | | ||
| | Personalization Mode | ✅ | ❌ | ❌ | ❌ | | ||
| | Conversation persistence | ✅ | ✅ | ❌ | Partial | | ||
| | Deep Dives & Forks | ✅ | ❌ | ❌ | ❌ | | ||
| | Remote execution (SovereignLink) | ✅ | Partial | ❌ | ❌ | | ||
| | Cross-surface sync (CLI ↔ Web) | ✅ | ✅ | ❌ | ❌ | | ||
| | Autonomous code repair | ✅ | ✅ | ❌ | ✅ | | ||
| | Source code stays on-device | ✅ | ✅ | ❌ | ✅ | | ||
@@ -43,11 +49,17 @@ --- | ||
| pnpm add -g @bobsworkshop/cli | ||
| ```bash | ||
| pnpm add -g @bobsworkshop/cli | ||
| ``` | ||
| Or with npm: | ||
| npm install -g @bobsworkshop/cli | ||
| ```bash | ||
| npm install -g @bobsworkshop/cli | ||
| ``` | ||
| Verify: | ||
| bob whoami | ||
| ```bash | ||
| bob whoami | ||
| ``` | ||
@@ -57,6 +69,6 @@ **Requirements:** | ||
| - Any terminal (VS Code, Android Studio, Windows Terminal, iTerm, PowerShell) | ||
| - For local AI: Ollama with a downloaded model | ||
| - For platform features: A Bob's Workshop account | ||
| - For local AI: [Ollama](https://ollama.com) with a downloaded model | ||
| - For platform features: A [Bob's Workshop](https://bobs-workshop.web.app) account | ||
| > Full setup guide: https://seedling-io.gitbook.io/bob-cli/ | ||
| > 📖 Full setup guide: https://seedling-io.gitbook.io/bob-cli/ | ||
@@ -69,12 +81,14 @@ --- | ||
| bob config set provider local | ||
| bob config set localEndpoint http://127.0.0.1:11434/api/chat | ||
| bob chat "hello, what can you help me with?" | ||
| ```bash | ||
| bob chat "hello, what can you help me with?" | ||
| ``` | ||
| No internet. No API keys. No cost. Your code never leaves your machine. | ||
| Bob auto-detects Ollama running on your machine. No configuration needed. No internet. No API keys. No cost. Your code never leaves your machine. | ||
| ### Platform (Subscribers) | ||
| bob login | ||
| bob chat "help me refactor this service" | ||
| ```bash | ||
| bob login | ||
| bob chat "help me refactor this service" | ||
| ``` | ||
@@ -85,2 +99,10 @@ Sync to web. Access Claude, Gemini, deep dives, forks, and personalization. | ||
| ## First Run Experience | ||
| When you first install Bob's CLI, you're greeted with a branded welcome screen: | ||
|  | ||
| --- | ||
| ## Features | ||
@@ -104,2 +126,43 @@ | ||
| ## Code Analysis | ||
| Bob performs production-grade QA reviews across your entire codebase — identifying bugs, features, improvements, and upgrades with actionable implementation instructions: | ||
|  | ||
| ```bash | ||
| bob analyse # Run full code review | ||
| bob analyse --results # View dashboard | ||
| bob analyse --auto # Auto-fix with safety constraints | ||
| ``` | ||
| --- | ||
| ## Commands | ||
|  | ||
| ``` | ||
| bob chat "question" # AI coding partner | ||
| bob consult "question" # Strategic advice | ||
| bob index # Index codebase | ||
| bob analyse # Code review | ||
| bob analyse --auto # Auto-fix | ||
| bob autonomy # Full autonomous repair | ||
| bob profile --cloud # Generate DNA profile | ||
| bob profile # View dashboard | ||
| bob deepdive # Sandboxed exploration | ||
| bob fork "topic" # Branch conversation | ||
| bob serve # Start SovereignLink | ||
| bob remote chat "msg" # Remote execution | ||
| bob push "message" # Git push | ||
| bob login # Authenticate | ||
| bob byok set google <key> # Add BYOK key | ||
| bob whoami # Status | ||
| ``` | ||
| > 📖 Full command reference: https://seedling-io.gitbook.io/bob-cli/bobs-cli-product-wiki-and-user-guide/command-reference | ||
| --- | ||
| ## Personalization Mode | ||
@@ -113,35 +176,19 @@ | ||
| bob profile --cloud | ||
| bob chat --personalized "what should I focus on?" | ||
| ```bash | ||
| bob profile --cloud | ||
| bob chat --personalized "what should I focus on?" | ||
| ``` | ||
| --- | ||
| ## Commands | ||
| bob chat "question" # AI coding partner | ||
| bob consult "question" # Strategic advice | ||
| bob index # Index codebase | ||
| bob analyse # Code review | ||
| bob analyse --auto # Auto-fix | ||
| bob autonomy # Full autonomous repair | ||
| bob profile --cloud # Generate DNA profile | ||
| bob profile # View dashboard | ||
| bob deepdive # Sandboxed exploration | ||
| bob fork "topic" # Branch conversation | ||
| bob serve # Start SovereignLink | ||
| bob remote chat "msg" # Remote execution | ||
| bob push "message" # Git push | ||
| bob login # Authenticate | ||
| bob byok set google <key> # Add BYOK key | ||
| bob whoami # Status | ||
| --- | ||
| ## Architecture | ||
| Tier 1 — Local (Free) Tier 3 — Platform (Subscription) | ||
| - Your model (Ollama) - Claude / Gemini | ||
| - Files on your machine - Conversations sync to web | ||
| - Local profiling - Cloud profiling + Frank Engine | ||
| - Zero cost - Deep dives, forks, remote exec | ||
| ``` | ||
| Tier 1 — Local (Free) Tier 3 — Platform (Subscription) | ||
| ───────────────────────── ───────────────────────────────── | ||
| ▸ Your model (Ollama) ▸ Claude / Gemini | ||
| ▸ Files on your machine ▸ Conversations sync to web | ||
| ▸ Local profiling ▸ Cloud profiling + Frank Engine | ||
| ▸ Zero cost ▸ Deep dives, forks, remote exec | ||
| ``` | ||
@@ -154,5 +201,5 @@ Same commands. Scale without changing tools. | ||
| - Full Docs: https://seedling-io.gitbook.io/bob-cli/ | ||
| - Web App: https://bobs-workshop.web.app | ||
| - npm: https://www.npmjs.com/package/@bobsworkshop/cli | ||
| - 📖 Full Docs: https://seedling-io.gitbook.io/bob-cli/ | ||
| - 🌐 Web App: https://bobs-workshop.web.app | ||
| - 📦 npm: https://www.npmjs.com/package/@bobsworkshop/cli | ||
@@ -169,2 +216,4 @@ --- | ||
| </div> | ||
| </div> | ||
Sorry, the diff of this file is too big to display
Network access
Supply chain riskThis module accesses the network.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
714913
103.75%10
66.67%14804
105.44%210
31.25%59
96.67%4
100%