From 0d32231de89f8bf5e69d025d46669ce45fa91140 Mon Sep 17 00:00:00 2001 From: Murali Varadarajan Date: Thu, 22 Jan 2026 15:32:03 -0800 Subject: [PATCH] Add OpenCode support and fix attribution bugs --- packages/chrome/package.json | 2 +- .../chrome/src/content/analytics-overlay.ts | 98 +++++- packages/chrome/src/content/content.css | 6 +- packages/chrome/src/content/content.ts | 13 +- packages/chrome/src/content/github-dom.ts | 92 +++++- packages/chrome/src/lib/mock-analytics.ts | 17 ++ packages/cli/package.json | 2 +- packages/cli/src/blame.ts | 4 +- packages/cli/src/capture.ts | 288 +++++++++++++++--- packages/cli/src/index.ts | 20 +- packages/cli/src/lib/hooks.ts | 151 ++++++++- packages/cli/src/lib/types.ts | 3 +- packages/cli/src/process.ts | 4 +- 13 files changed, 614 insertions(+), 86 deletions(-) diff --git a/packages/chrome/package.json b/packages/chrome/package.json index 98126cc..c49ef4f 100644 --- a/packages/chrome/package.json +++ b/packages/chrome/package.json @@ -1,6 +1,6 @@ { "name": "@agentblame/chrome", - "version": "0.2.7", + "version": "0.2.8", "description": "Agent Blame Chrome Extension - See AI attribution on GitHub PRs", "private": true, "scripts": { diff --git a/packages/chrome/src/content/analytics-overlay.ts b/packages/chrome/src/content/analytics-overlay.ts index 89efa9e..d9f169f 100644 --- a/packages/chrome/src/content/analytics-overlay.ts +++ b/packages/chrome/src/content/analytics-overlay.ts @@ -10,21 +10,22 @@ import { type AnalyticsData, type AnalyticsHistoryEntry, getAnalytics, + clearAnalyticsCache, } from "../lib/mock-analytics"; const PAGE_CONTAINER_ID = "agentblame-page-container"; const ORIGINAL_CONTENT_ATTR = "data-agentblame-hidden"; -// Tool color palette - High contrast colors that work in light/dark themes +// Tool color palette - Soft, pleasant colors that work in light/dark themes const TOOL_COLOR_PALETTE = [ - "#0969da", // Blue - "#cf222e", // Red - "#1a7f37", // Green - "#8250df", // Purple - "#bf8700", // Gold/Yellow - "#0550ae", // Dark Blue - "#bf3989", // Magenta - "#1b7c83", // Teal + "#5B8DEE", // Soft blue (Cursor) + "#E07B53", // Soft coral/orange (Claude Code) + "#4DBBAA", // Soft teal (OpenCode) + "#9B7ED9", // Soft purple + "#6ABF69", // Soft green + "#E5A84B", // Soft amber + "#D97BA2", // Soft rose + "#5AADCF", // Soft cyan ]; /** @@ -34,6 +35,7 @@ function formatProviderName(provider: string): string { const names: Record = { cursor: "Cursor", claudeCode: "Claude Code", + opencode: "OpenCode", copilot: "Copilot", windsurf: "Windsurf", aider: "Aider", @@ -325,7 +327,7 @@ function handlePeriodChange(period: PeriodOption): void { } /** - * Attach event listeners to period dropdown + * Attach event listeners to period dropdown and refresh button */ function attachPeriodListeners(): void { const dropdown = document.getElementById("agentblame-period-select"); @@ -335,6 +337,38 @@ function attachPeriodListeners(): void { handlePeriodChange(select.value as PeriodOption); }); } + + const refreshBtn = document.getElementById("agentblame-refresh-btn"); + if (refreshBtn) { + refreshBtn.addEventListener("click", handleRefresh); + } +} + +/** + * Handle refresh button click - clear cache and refetch data + */ +async function handleRefresh(): Promise { + if (!currentOwner || !currentRepo) return; + + const container = document.getElementById(PAGE_CONTAINER_ID); + if (!container) return; + + // Show loading state + container.innerHTML = renderLoadingState(); + + // Clear cache and refetch + await clearAnalyticsCache(currentOwner, currentRepo); + const analytics = await getAnalytics(currentOwner, currentRepo); + + if (!analytics) { + container.innerHTML = renderEmptyState(); + return; + } + + currentAnalytics = analytics; + const filtered = filterAnalyticsByPeriod(analytics, currentPeriod); + container.innerHTML = renderAnalyticsPage(currentOwner, currentRepo, filtered, analytics); + attachPeriodListeners(); } /** @@ -366,7 +400,7 @@ function renderAnalyticsPage( AI code attribution analytics -
+
+
@@ -440,7 +481,7 @@ function renderRepositorySection( ); // Colors - const aiColor = "var(--color-severe-fg, #f78166)"; // GitHub's coral orange + const aiColor = "#b86540"; // Mesa Orange const humanColor = "var(--color-success-fg, #238636)"; // GitHub's addition green return ` @@ -552,7 +593,7 @@ function renderContributorsSection(analytics: AnalyticsData): string {
${aiPercent}% AI
-
+
@@ -608,7 +649,7 @@ function renderPullRequestsSection( const aiPercent = pr.added > 0 ? Math.round((pr.aiLines / pr.added) * 100) : 0; const badgeStyle = aiPercent > 50 - ? "background: var(--color-severe-fg); color: white;" + ? "background: #b86540; color: white;" : aiPercent > 0 ? "background: var(--color-attention-emphasis); color: white;" : "background: var(--color-success-emphasis); color: white;"; @@ -648,9 +689,36 @@ function renderPullRequestsSection( /** * Format model name for display + * Handles model IDs like "claude-opus-4-5-20251101" -> "Claude Opus 4.5" */ function formatModelName(model: string): string { - return model + // Known model name mappings for cleaner display + const knownModels: Record = { + "claude": "Claude", + "claude-3-opus": "Claude 3 Opus", + "claude-3-sonnet": "Claude 3 Sonnet", + "claude-3.5-sonnet": "Claude 3.5 Sonnet", + "claude-3-haiku": "Claude 3 Haiku", + "gpt-4": "GPT-4", + "gpt-4o": "GPT-4o", + "gpt-4-turbo": "GPT-4 Turbo", + }; + + // Check for exact match first + if (knownModels[model]) { + return knownModels[model]; + } + + // Handle versioned model names like "claude-opus-4-5-20251101" + // Extract the model family and version, strip the date suffix + const datePattern = /-\d{8}$/; + let cleaned = model.replace(datePattern, ""); + + // Convert patterns like "4-5" to "4.5" for version numbers + cleaned = cleaned.replace(/-(\d+)-(\d+)$/, "-$1.$2"); + + // Title case and replace dashes with spaces + return cleaned .replace(/-/g, " ") .replace(/\b\w/g, (c) => c.toUpperCase()); } diff --git a/packages/chrome/src/content/content.css b/packages/chrome/src/content/content.css index c98125c..229e47d 100644 --- a/packages/chrome/src/content/content.css +++ b/packages/chrome/src/content/content.css @@ -2,7 +2,7 @@ /* Attribution gutter colors */ :root { - --ab-ai-color: var(--color-severe-fg, #f78166); /* GitHub's coral orange */ + --ab-ai-color: #b86540; /* Mesa Orange */ } /* Attribution gutter - box-shadow on the new line number cell */ @@ -24,7 +24,7 @@ padding: 8px 16px; margin: 12px 0; background: var(--bgColor-default, var(--color-canvas-default, #ffffff)); - border: 1px solid rgba(247, 129, 102, 0.25); + border: 1px solid rgba(184, 101, 64, 0.25); border-left: 3px solid var(--ab-ai-color); border-radius: 6px; font-size: 13px; @@ -196,7 +196,7 @@ @media (prefers-color-scheme: dark) { .ab-pr-summary { background: var(--bgColor-default, #0d1117); - border-color: rgba(247, 129, 102, 0.4); + border-color: rgba(184, 101, 64, 0.4); border-left-color: var(--ab-ai-color); } diff --git a/packages/chrome/src/content/content.ts b/packages/chrome/src/content/content.ts index 6e23818..aab2703 100644 --- a/packages/chrome/src/content/content.ts +++ b/packages/chrome/src/content/content.ts @@ -286,7 +286,6 @@ function findAttribution( filePath, filePath.replace(/^\//, ""), `/${filePath}`, - filePath.split("/").slice(-1)[0], // Just filename ]; for (const variant of variants) { @@ -297,6 +296,18 @@ function findAttribution( } } + // Last resort: match by filename only (for React UI which may only show filename) + // Search through the map for any key that ends with the same filename:lineNumber + const filename = filePath.split("/").pop() || filePath; + if (filename && filename !== filePath) { + for (const [mapKey, value] of map.entries()) { + // mapKey format is "path/to/file.ext:lineNumber" + if (mapKey.endsWith(`/${filename}:${lineNumber}`) || mapKey === `${filename}:${lineNumber}`) { + return value; + } + } + } + return null; } diff --git a/packages/chrome/src/content/github-dom.ts b/packages/chrome/src/content/github-dom.ts index 6e1a940..bbfa293 100644 --- a/packages/chrome/src/content/github-dom.ts +++ b/packages/chrome/src/content/github-dom.ts @@ -46,12 +46,12 @@ export function isFilesChangedTab(): boolean { */ export function getDiffContainers(): HTMLElement[] { // Try multiple selectors for GitHub's various diff layouts + // Order matters - try more specific selectors first const selectors = [ ".file", // Standard file container '[data-details-container-group="file"]', // Alternative structure ".js-file", // JS-enhanced file container "diff-layout", // New React-based diff component - "[data-hpc]", // High performance container ]; for (const selector of selectors) { @@ -62,6 +62,38 @@ export function getDiffContainers(): HTMLElement[] { } } + // React UI: [data-hpc] is a parent container - find individual file sections within it + const hpcContainer = document.querySelector("[data-hpc]"); + if (hpcContainer) { + // New React UI: Each file's diff is in a separate table + // Find tables that contain diff lines and use them as containers + const tables = hpcContainer.querySelectorAll("table"); + if (tables.length > 0) { + const diffTables: HTMLElement[] = []; + for (const table of tables) { + // Only include tables that have diff content (diff-line-row or diff-text-cell) + if (table.querySelector("tr.diff-line-row, .diff-text-cell")) { + diffTables.push(table as HTMLElement); + } + } + if (diffTables.length > 0) { + log(`Found ${diffTables.length} diff tables as file containers`); + return diffTables; + } + } + + // Try to find file containers using copilot-diff-entry (used in some new UI versions) + const copilotEntries = hpcContainer.querySelectorAll("copilot-diff-entry"); + if (copilotEntries.length > 0) { + log(`Found ${copilotEntries.length} copilot-diff-entry elements`); + return Array.from(copilotEntries) as HTMLElement[]; + } + + // Fallback: Return the [data-hpc] container itself + log(`Fallback: returning [data-hpc] as single container`); + return [hpcContainer as HTMLElement]; + } + // Fallback: find data-tagsearch-path and traverse up to find container const pathElements = document.querySelectorAll("[data-tagsearch-path]"); log(`Fallback: found ${pathElements.length} path elements`); @@ -123,20 +155,50 @@ export function getFilePath(container: HTMLElement): string { return fileLink.getAttribute("title") || fileLink.textContent?.trim() || ""; } - // React UI: Look for file name in header with CSS module class - // Classes look like: DiffFileHeader-module__file-name--xxxxx - const allElements = container.querySelectorAll("*"); - for (const el of allElements) { - const className = el.className; - if (typeof className === "string" && className.includes("file-name")) { - const text = el.textContent?.trim(); - // Filter out navigation characters and ensure we have a valid file name - if (text && text.length > 0 && !text.includes("…") && text.includes(".")) { - // Clean up any special unicode characters GitHub uses for RTL/LTR marks - const cleanPath = text.replace(/[\u200E\u200F\u202A-\u202E]/g, "").trim(); - if (cleanPath) { - log(`Found file path via React UI: ${cleanPath}`); - return cleanPath; + // React UI: For table containers, look for the file path in parent/sibling structure + // The file header with the path is often a sibling or in an ancestor's child + let current: HTMLElement | null = container; + for (let depth = 0; depth < 10 && current; depth++) { + const parent = current.parentElement; + if (!parent) break; + + // Check if parent or any sibling has the path + const pathInParent = parent.querySelector("[data-tagsearch-path]"); + if (pathInParent) { + // Make sure this path element is associated with our container + // by checking if the path element's container (going up) matches our container's parent + const path = pathInParent.getAttribute("data-tagsearch-path") || ""; + + // Find the closest common ancestor between pathInParent and container + // If pathInParent is within the same file block, use it + let pathAncestor: HTMLElement | null = pathInParent as HTMLElement; + for (let i = 0; i < 10 && pathAncestor; i++) { + if (pathAncestor === parent) { + return path; + } + pathAncestor = pathAncestor.parentElement; + } + } + + current = parent; + } + + // React UI: Look for file name in elements with "file-name" in class + // Search in container and parents + const searchContexts = [container, container.parentElement, container.parentElement?.parentElement].filter(Boolean) as HTMLElement[]; + for (const ctx of searchContexts) { + const allElements = ctx.querySelectorAll("*"); + for (const el of allElements) { + const className = el.className; + if (typeof className === "string" && className.includes("file-name")) { + const text = el.textContent?.trim(); + // Filter out navigation characters and ensure we have a valid file name + if (text && text.length > 0 && !text.includes("…") && text.includes(".")) { + // Clean up any special unicode characters GitHub uses for RTL/LTR marks + const cleanPath = text.replace(/[\u200E\u200F\u202A-\u202E]/g, "").trim(); + if (cleanPath) { + return cleanPath; + } } } } diff --git a/packages/chrome/src/lib/mock-analytics.ts b/packages/chrome/src/lib/mock-analytics.ts index 66e4227..5963b3c 100644 --- a/packages/chrome/src/lib/mock-analytics.ts +++ b/packages/chrome/src/lib/mock-analytics.ts @@ -113,6 +113,23 @@ async function setCachedAnalytics(owner: string, repo: string, data: AnalyticsDa } } +/** + * Clear cached analytics for a repository + */ +export async function clearAnalyticsCache(owner: string, repo: string): Promise { + const cacheKey = `analytics_${owner}_${repo}`; + + // Clear memory cache + memoryCache.delete(cacheKey); + + // Clear storage cache + try { + await chrome.storage.local.remove(cacheKey); + } catch { + // Storage access failed, memory cache still cleared + } +} + /** * Mock analytics data for UI development */ diff --git a/packages/cli/package.json b/packages/cli/package.json index 4944cbc..08c52b4 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@mesadev/agentblame", - "version": "0.2.6", + "version": "0.2.7", "description": "CLI to track AI-generated vs human-written code", "license": "Apache-2.0", "repository": { diff --git a/packages/cli/src/blame.ts b/packages/cli/src/blame.ts index 2a406c9..eb23fb7 100644 --- a/packages/cli/src/blame.ts +++ b/packages/cli/src/blame.ts @@ -24,7 +24,7 @@ const c = { cyan: "\x1b[36m", yellow: "\x1b[33m", green: "\x1b[32m", - orange: "\x1b[38;5;166m", // Mesa Orange - matches gutter color + orange: "\x1b[38;2;184;101;64m", // Soft Mesa Orange #b86540 blue: "\x1b[34m", gray: "\x1b[90m", }; @@ -152,7 +152,7 @@ function outputFormatted(lines: LineAttribution[], filePath: string): void { let attrInfo = ""; let visibleLen = 0; if (attribution) { - const provider = attribution.provider === "cursor" ? "Cursor" : "Claude"; + const provider = attribution.provider === "cursor" ? "Cursor" : attribution.provider === "opencode" ? "OpenCode" : "Claude"; const model = attribution.model && attribution.model !== "claude" ? attribution.model : ""; const label = model ? `${provider} - ${model}` : provider; visibleLen = label.length + 3; // +2 for emoji (renders 2-wide) + 1 space diff --git a/packages/cli/src/capture.ts b/packages/cli/src/capture.ts index bc8dca1..420ddc8 100644 --- a/packages/cli/src/capture.ts +++ b/packages/cli/src/capture.ts @@ -34,7 +34,7 @@ interface CapturedLine { interface CapturedEdit { timestamp: string; - provider: "cursor" | "claudeCode"; + provider: "cursor" | "claudeCode" | "opencode"; filePath: string; model: string | null; lines: CapturedLine[]; @@ -79,12 +79,30 @@ interface ClaudePayload { }; session_id?: string; tool_use_id?: string; + transcript_path?: string; file_path?: string; old_string?: string; new_string?: string; content?: string; } +interface OpenCodePayload { + tool: "edit" | "write"; + sessionID?: string; + callID?: string; + filePath?: string; + // Edit tool fields + oldString?: string; + newString?: string; + before?: string; // Full file content before edit + after?: string; // Full file content after edit + diff?: string; // Unified diff + // Write tool fields + content?: string; + // Model info (extracted by plugin from config) + model?: string; +} + // ============================================================================= // Utilities // ============================================================================= @@ -240,6 +258,40 @@ async function readFileLines(filePath: string): Promise { } } +/** + * Extract model name from Claude Code transcript file. + * The transcript is a JSONL file where assistant messages contain the model field. + * We read from the end to find the most recent model used. + */ +async function extractModelFromTranscript(transcriptPath: string): Promise { + try { + const fs = await import("node:fs/promises"); + const content = await fs.readFile(transcriptPath, "utf8"); + const lines = content.split("\n"); + + // Read from the end to find the most recent assistant message with model info + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i].trim(); + if (!line) continue; + + try { + const entry = JSON.parse(line); + // Assistant messages have message.model field + if (entry.message?.model) { + return entry.message.model; + } + } catch { + // Skip malformed lines + continue; + } + } + + return null; + } catch { + return null; + } +} + /** * Find where old_string exists in file and return line numbers for new_string * Returns null if old_string not found (new file or complex edit) @@ -302,14 +354,14 @@ function findEditLocation( // Payload Processing // ============================================================================= -function parseArgs(): { provider: "cursor" | "claude"; event?: string } { +function parseArgs(): { provider: "cursor" | "claude" | "opencode"; event?: string } { const args = process.argv.slice(2); - let provider: "cursor" | "claude" = "cursor"; + let provider: "cursor" | "claude" | "opencode" = "cursor"; let event: string | undefined; for (let i = 0; i < args.length; i++) { if (args[i] === "--provider" && args[i + 1]) { - provider = args[i + 1] as "cursor" | "claude"; + provider = args[i + 1] as "cursor" | "claude" | "opencode"; i++; } else if (args[i] === "--event" && args[i + 1]) { event = args[i + 1]; @@ -407,8 +459,27 @@ async function processCursorPayload( return edits; } -function processClaudePayload(payload: ClaudePayload): CapturedEdit[] { +async function processClaudePayload(payload: ClaudePayload): Promise { const edits: CapturedEdit[] = []; + + // CRITICAL: Skip payloads that are actually from Cursor. + // Both Cursor and Claude Code can trigger hooks from .claude/settings.json, + // so we need to detect Cursor payloads and skip them here. + // Cursor payloads have cursor_version field, Claude payloads don't. + if ((payload as any).cursor_version) { + return edits; + } + + // CRITICAL: Only process if this is an actual Edit or Write tool usage from Claude. + // Claude Code's hooks fire for various reasons, but we only want to capture + // when Claude actually performed an edit/write operation. + // Without a valid tool_name, this is likely a spurious trigger (e.g., from file + // watcher detecting external changes). + const toolName = payload.tool_name?.toLowerCase() || ""; + if (toolName !== "edit" && toolName !== "write" && toolName !== "multiedit") { + return edits; + } + const timestamp = new Date().toISOString(); // Claude Code has tool_input with the actual content @@ -422,8 +493,26 @@ function processClaudePayload(payload: ClaudePayload): CapturedEdit[] { const sessionId = payload.session_id; const toolUseId = payload.tool_use_id; - // If we have structuredPatch, use it for precise line numbers - if (toolResponse?.structuredPatch && toolResponse.structuredPatch.length > 0) { + // Extract model from transcript file (Claude Code provides transcript_path in hook payload) + let model: string | null = null; + if (payload.transcript_path) { + model = await extractModelFromTranscript(payload.transcript_path); + } + // Fallback to generic "claude" if transcript parsing fails + if (!model) { + model = "claude"; + } + + // For Edit/MultiEdit tools, REQUIRE structuredPatch. + // Without structuredPatch, we cannot accurately determine what Claude added. + // Spurious triggers (e.g., file watcher detecting external changes) won't have + // structuredPatch and would incorrectly capture the entire file. + if (toolName === "edit" || toolName === "multiedit") { + if (!toolResponse?.structuredPatch || toolResponse.structuredPatch.length === 0) { + // No structuredPatch - skip this capture to avoid incorrect attribution + return edits; + } + // Get original file lines for context const originalFileLines = (toolResponse.originalFile || "").split("\n"); @@ -446,7 +535,7 @@ function processClaudePayload(payload: ClaudePayload): CapturedEdit[] { timestamp, provider: "claudeCode", filePath, - model: "claude", + model, lines, content: addedContent, contentHash: computeHash(addedContent), @@ -459,14 +548,14 @@ function processClaudePayload(payload: ClaudePayload): CapturedEdit[] { return edits; } - // Fallback: Get content from tool_input or top-level payload - const content = toolInput?.content || payload.content; - const oldString = toolInput?.old_string || payload.old_string || ""; - const newString = toolInput?.new_string || payload.new_string || ""; + // Handle Write tool (new file creation) + // For Write, we need content + if (toolName === "write") { + const content = toolInput?.content || payload.content; - // Handle Write tool (new file, content only) - if (content && !oldString && !newString) { - if (!content.trim()) return edits; + if (!content || !content.trim()) { + return edits; + } const lines = hashLines(content); if (lines.length === 0) return edits; @@ -475,7 +564,7 @@ function processClaudePayload(payload: ClaudePayload): CapturedEdit[] { timestamp, provider: "claudeCode", filePath, - model: "claude", + model, lines, content, contentHash: computeHash(content), @@ -487,29 +576,148 @@ function processClaudePayload(payload: ClaudePayload): CapturedEdit[] { return edits; } - // Handle Edit tool (old_string -> new_string) without structuredPatch - if (!newString) return edits; + // Unknown tool type that passed the initial check - skip + return edits; +} + +/** + * Process OpenCode payload. + * OpenCode provides before/after file content which allows precise line number extraction. + */ +function processOpenCodePayload(payload: OpenCodePayload): CapturedEdit[] { + const edits: CapturedEdit[] = []; + const timestamp = new Date().toISOString(); - const addedContent = extractAddedContent(oldString, newString); - if (!addedContent.trim()) return edits; - - const lines = hashLines(addedContent); - if (lines.length === 0) return edits; - - edits.push({ - timestamp, - provider: "claudeCode", - filePath, - model: "claude", - lines, - content: addedContent, - contentHash: computeHash(addedContent), - contentHashNormalized: computeNormalizedHash(addedContent), - editType: determineEditType(oldString, newString), - oldContent: oldString || undefined, - sessionId, - toolUseId, - }); + const filePath = payload.filePath; + if (!filePath) return edits; + + const sessionId = payload.sessionID; + const toolUseId = payload.callID; + const model = payload.model || null; + + // Handle write tool (new file creation) + if (payload.tool === "write" && payload.content) { + const content = payload.content; + if (!content.trim()) return edits; + + // For new files, all lines are added + const fileLines = content.split("\n"); + const linesWithNumbers = fileLines + .map((line, i) => ({ content: line, lineNumber: i + 1 })) + .filter(l => l.content.trim()); + + const lines = hashLinesWithNumbers(linesWithNumbers, fileLines); + if (lines.length === 0) return edits; + + edits.push({ + timestamp, + provider: "opencode", + filePath, + model, + lines, + content, + contentHash: computeHash(content), + contentHashNormalized: computeNormalizedHash(content), + editType: "addition", + sessionId, + toolUseId, + }); + + return edits; + } + + // Handle edit tool + if (payload.tool === "edit") { + // OpenCode provides full before/after content - use it for precise line detection + if (payload.before !== undefined && payload.after !== undefined) { + const beforeLines = payload.before.split("\n"); + const afterLines = payload.after.split("\n"); + + // Use diffLines to find added lines with their positions + const parts = diffLines(payload.before, payload.after); + const addedLinesWithNumbers: Array<{ content: string; lineNumber: number }> = []; + + let afterLineIndex = 0; + for (const part of parts) { + const partLines = part.value.split("\n"); + // Remove empty string from split if value ends with \n + if (partLines[partLines.length - 1] === "") { + partLines.pop(); + } + + if (part.added) { + // These lines were added + for (const line of partLines) { + addedLinesWithNumbers.push({ + content: line, + lineNumber: afterLineIndex + 1, // 1-indexed + }); + afterLineIndex++; + } + } else if (part.removed) { + // Removed lines don't affect after line index + } else { + // Context lines - advance the after line index + afterLineIndex += partLines.length; + } + } + + if (addedLinesWithNumbers.length === 0) return edits; + + // Filter empty lines and hash with context + const nonEmptyLines = addedLinesWithNumbers.filter(l => l.content.trim()); + if (nonEmptyLines.length === 0) return edits; + + const lines = hashLinesWithNumbers(nonEmptyLines, afterLines); + if (lines.length === 0) return edits; + + const addedContent = nonEmptyLines.map(l => l.content).join("\n"); + + edits.push({ + timestamp, + provider: "opencode", + filePath, + model, + lines, + content: addedContent, + contentHash: computeHash(addedContent), + contentHashNormalized: computeNormalizedHash(addedContent), + editType: "modification", + oldContent: payload.oldString, + sessionId, + toolUseId, + }); + + return edits; + } + + // Fallback: use oldString/newString if before/after not available + const oldString = payload.oldString || ""; + const newString = payload.newString || ""; + + if (!newString) return edits; + + const addedContent = extractAddedContent(oldString, newString); + if (!addedContent.trim()) return edits; + + const lines = hashLines(addedContent); + if (lines.length === 0) return edits; + + edits.push({ + timestamp, + provider: "opencode", + filePath, + model, + lines, + content: addedContent, + contentHash: computeHash(addedContent), + contentHashNormalized: computeNormalizedHash(addedContent), + editType: determineEditType(oldString, newString), + oldContent: oldString || undefined, + sessionId, + toolUseId, + }); + } return edits; } @@ -548,7 +756,9 @@ export async function runCapture(): Promise { const eventName = event || data.hook_event_name || "afterFileEdit"; edits = await processCursorPayload(payload as CursorPayload, eventName); } else if (provider === "claude") { - edits = processClaudePayload(payload as ClaudePayload); + edits = await processClaudePayload(payload as ClaudePayload); + } else if (provider === "opencode") { + edits = processOpenCodePayload(payload as OpenCodePayload); } // Save all edits to SQLite database diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 18db093..46a3313 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -20,10 +20,12 @@ import { runCapture } from "./capture"; import { installCursorHooks, installClaudeHooks, + installOpenCodeHooks, installGitHook, installGitHubAction, uninstallCursorHooks, uninstallClaudeHooks, + uninstallOpenCodeHooks, uninstallGitHook, uninstallGitHubAction, getRepoRoot, @@ -294,16 +296,22 @@ async function runInit(initArgs: string[] = []): Promise { results.push({ name: "Database", success: false }); } - // Add .agentblame/ to .gitignore + // Add .agentblame/ and .opencode/ to .gitignore try { const gitignorePath = path.join(repoRoot, ".gitignore"); let gitignoreContent = ""; if (fs.existsSync(gitignorePath)) { gitignoreContent = await fs.promises.readFile(gitignorePath, "utf8"); } + let entriesToAdd = ""; if (!gitignoreContent.includes(".agentblame")) { - const entry = "\n# Agent Blame local database\n.agentblame/\n"; - await fs.promises.appendFile(gitignorePath, entry); + entriesToAdd += "\n# Agent Blame local database\n.agentblame/\n"; + } + if (!gitignoreContent.includes(".opencode")) { + entriesToAdd += "\n# OpenCode local plugin (installed by agentblame init)\n.opencode/\n"; + } + if (entriesToAdd) { + await fs.promises.appendFile(gitignorePath, entriesToAdd); } results.push({ name: "Updated .gitignore", success: true }); } catch (err) { @@ -317,6 +325,9 @@ async function runInit(initArgs: string[] = []): Promise { const claudeSuccess = await installClaudeHooks(repoRoot); results.push({ name: "Claude Code hooks", success: claudeSuccess }); + const opencodeSuccess = await installOpenCodeHooks(repoRoot); + results.push({ name: "OpenCode hooks", success: opencodeSuccess }); + // Install repo hooks and workflow const gitHookSuccess = await installGitHook(repoRoot); results.push({ name: "Git post-commit hook", success: gitHookSuccess }); @@ -411,6 +422,9 @@ async function runClean(uninstallArgs: string[] = []): Promise { const claudeSuccess = await uninstallClaudeHooks(repoRoot); results.push({ name: "Claude Code hooks", success: claudeSuccess }); + const opencodeSuccess = await uninstallOpenCodeHooks(repoRoot); + results.push({ name: "OpenCode hooks", success: opencodeSuccess }); + // Remove repo hooks and workflow const gitHookSuccess = await uninstallGitHook(repoRoot); results.push({ name: "Git post-commit hook", success: gitHookSuccess }); diff --git a/packages/cli/src/lib/hooks.ts b/packages/cli/src/lib/hooks.ts index 4620ade..ea1390a 100644 --- a/packages/cli/src/lib/hooks.ts +++ b/packages/cli/src/lib/hooks.ts @@ -22,6 +22,20 @@ export function getClaudeSettingsPath(repoRoot: string): string { return path.join(repoRoot, ".claude", "settings.json"); } +/** + * Get the OpenCode plugin directory path for a repo. + */ +export function getOpenCodePluginDir(repoRoot: string): string { + return path.join(repoRoot, ".opencode", "plugin"); +} + +/** + * Get the OpenCode agentblame plugin file path for a repo. + */ +export function getOpenCodePluginPath(repoRoot: string): string { + return path.join(getOpenCodePluginDir(repoRoot), "agentblame.ts"); +} + /** * Generate the hook command for a given provider. @@ -35,6 +49,81 @@ function getHookCommand( return `agentblame capture --provider ${provider}${eventArg}`; } +/** + * OpenCode plugin template that captures edits and sends to agentblame. + * The plugin hooks into tool.execute.after for edit/write operations. + */ +const OPENCODE_PLUGIN_TEMPLATE = `import type { Plugin } from "@opencode-ai/plugin" +import { execSync } from "child_process" + +export default (async (ctx: any) => { + return { + "tool.execute.after": async (input: any, output: any) => { + // Only capture edit and write tools + if (input?.tool !== "edit" && input?.tool !== "write") { + return + } + + try { + // Get model info from config + let model: string | null = null + if (ctx?.client?.config?.providers) { + try { + const configResult = await ctx.client.config.providers() + const config = configResult?.data || configResult + const activeProvider = config?.connected?.[0] + if (activeProvider && config?.default?.[activeProvider]) { + const modelId = config.default[activeProvider] + // Try to get display name from provider models + const provider = config?.providers?.find((p: any) => p.id === activeProvider) + const modelInfo = provider?.models?.[modelId] + model = modelInfo?.name || modelId + } + } catch { + // Ignore config errors + } + } + + // Build payload based on tool type + const payload: any = { + tool: input.tool, + sessionID: input.sessionID, + callID: input.callID, + } + + if (input.tool === "edit") { + // Edit tool: has before/after content in metadata + payload.filePath = output?.metadata?.filediff?.file || output?.args?.filePath + payload.oldString = output?.args?.oldString + payload.newString = output?.args?.newString + payload.before = output?.metadata?.filediff?.before + payload.after = output?.metadata?.filediff?.after + payload.diff = output?.metadata?.diff + } else if (input.tool === "write") { + // Write tool: has content in args + payload.filePath = output?.args?.filePath || output?.metadata?.filepath + payload.content = output?.args?.content + } + + if (model) { + payload.model = model + } + + // Call agentblame capture with the payload + execSync("agentblame capture --provider opencode", { + input: JSON.stringify(payload), + cwd: ctx?.directory || process.cwd(), + stdio: ["pipe", "inherit", "inherit"], + timeout: 5000, + }) + } catch { + // Silent failure - don't interrupt OpenCode + } + }, + } +}) satisfies Plugin +`; + /** * Install the Cursor hooks at repo-level (.cursor/hooks.json) */ @@ -162,6 +251,61 @@ export async function installClaudeHooks(repoRoot: string): Promise { } } +/** + * Install the OpenCode hooks at repo-level (.opencode/plugin/agentblame.ts) + */ +export async function installOpenCodeHooks(repoRoot: string): Promise { + if (process.platform === "win32") { + console.error("Windows is not supported yet"); + return false; + } + + const pluginDir = getOpenCodePluginDir(repoRoot); + const pluginPath = getOpenCodePluginPath(repoRoot); + + try { + // Create .opencode/plugin directory if it doesn't exist + await fs.promises.mkdir(pluginDir, { recursive: true }); + + // Write the plugin file (always overwrite to ensure latest version) + await fs.promises.writeFile(pluginPath, OPENCODE_PLUGIN_TEMPLATE, "utf8"); + + return true; + } catch (err) { + console.error("Failed to install OpenCode hooks:", err); + return false; + } +} + +/** + * Check if OpenCode hooks are installed for a repo. + */ +export async function areOpenCodeHooksInstalled(repoRoot: string): Promise { + try { + const pluginPath = getOpenCodePluginPath(repoRoot); + const content = await fs.promises.readFile(pluginPath, "utf8"); + return content.includes("agentblame"); + } catch { + return false; + } +} + +/** + * Uninstall OpenCode hooks from a repo + */ +export async function uninstallOpenCodeHooks(repoRoot: string): Promise { + try { + const pluginPath = getOpenCodePluginPath(repoRoot); + if (fs.existsSync(pluginPath)) { + await fs.promises.unlink(pluginPath); + } + return true; + } catch (err) { + console.error("Failed to uninstall OpenCode hooks:", err); + return false; + } +} + /** * Check if Cursor hooks are installed for a repo. */ @@ -206,14 +350,15 @@ export async function areClaudeHooksInstalled(repoRoot: string): Promise { +): Promise<{ cursor: boolean; claude: boolean; opencode: boolean }> { const cursor = await installCursorHooks(repoRoot); const claude = await installClaudeHooks(repoRoot); - return { cursor, claude }; + const opencode = await installOpenCodeHooks(repoRoot); + return { cursor, claude, opencode }; } /** diff --git a/packages/cli/src/lib/types.ts b/packages/cli/src/lib/types.ts index 2724a7f..8b11701 100644 --- a/packages/cli/src/lib/types.ts +++ b/packages/cli/src/lib/types.ts @@ -11,7 +11,7 @@ /** * AI provider that generated the code */ -export type AiProvider = "cursor" | "claudeCode"; +export type AiProvider = "cursor" | "claudeCode" | "opencode"; /** * Attribution category - we only track AI-generated code @@ -178,6 +178,7 @@ export interface GitState { export interface ProviderBreakdown { cursor?: number; claudeCode?: number; + opencode?: number; } /** diff --git a/packages/cli/src/process.ts b/packages/cli/src/process.ts index 10b8413..1eb5620 100644 --- a/packages/cli/src/process.ts +++ b/packages/cli/src/process.ts @@ -33,7 +33,7 @@ const c = { cyan: "\x1b[36m", yellow: "\x1b[33m", green: "\x1b[32m", - orange: "\x1b[38;5;166m", // Mesa Orange - matches gutter color + orange: "\x1b[38;2;184;101;64m", // Soft Mesa Orange #b86540 blue: "\x1b[34m", }; @@ -289,7 +289,7 @@ export async function runProcess(sha?: string): Promise { console.log(`${border}${padRight(aiHeader, aiHeader.length)}${border}`); for (const attr of result.attributions) { - const provider = attr.provider === "cursor" ? "Cursor" : "Claude"; + const provider = attr.provider === "cursor" ? "Cursor" : attr.provider === "opencode" ? "OpenCode" : "Claude"; const model = attr.model && attr.model !== "claude" ? attr.model : ""; const modelStr = model ? ` - ${model}` : ""; const visibleText = ` ${attr.path}:${attr.startLine}-${attr.endLine} [${provider}${modelStr}]`;