diff --git a/package.json b/package.json index 2b962b66..98a21627 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,8 @@ "changelog:generate": "node scripts/generate-changelog.js", "usage:analyze": "node scripts/analyze-usage.js", "usage:today": "node scripts/analyze-usage.js $(date +%Y-%m-%d)", - "translate-readme": "npx tsx scripts/translate-readme/cli.ts -v README.md zh ko ja" + "translate-readme": "npx tsx scripts/translate-readme/cli.ts -v README.md zh ko ja", + "bug-report": "npx tsx scripts/bug-report/cli.ts" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.1.67", diff --git a/scripts/bug-report/cli.ts b/scripts/bug-report/cli.ts new file mode 100644 index 00000000..b2ac395d --- /dev/null +++ b/scripts/bug-report/cli.ts @@ -0,0 +1,275 @@ +#!/usr/bin/env npx tsx + +import { generateBugReport } from "./index.ts"; +import { collectDiagnostics } from "./collector.ts"; +import * as fs from "fs/promises"; +import * as path from "path"; +import * as os from "os"; +import * as readline from "readline"; +import { exec } from "child_process"; +import { promisify } from "util"; + +const execAsync = promisify(exec); + +interface CliArgs { + output?: string; + verbose: boolean; + noLogs: boolean; + help: boolean; +} + +function parseArgs(): CliArgs { + const args = process.argv.slice(2); + const parsed: CliArgs = { + verbose: false, + noLogs: false, + help: false, + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + switch (arg) { + case "-h": + case "--help": + parsed.help = true; + break; + case "-v": + case "--verbose": + parsed.verbose = true; + break; + case "--no-logs": + parsed.noLogs = true; + break; + case "-o": + case "--output": + parsed.output = args[++i]; + break; + } + } + + return parsed; +} + +function printHelp(): void { + console.log(` +bug-report - Generate bug reports for claude-mem + +USAGE: + npm run bug-report [options] + +OPTIONS: + -o, --output Save report to file (default: stdout + timestamped file) + -v, --verbose Show all collected diagnostics + --no-logs Skip log collection (for privacy) + -h, --help Show this help message + +DESCRIPTION: + This script collects system diagnostics, prompts you for issue details, + and generates a formatted GitHub issue for claude-mem using the Claude Agent SDK. + + The generated report will be saved to ~/bug-report-YYYY-MM-DD-HHMMSS.md + and displayed in your terminal for easy copy-pasting to GitHub. + +EXAMPLES: + # Generate a bug report interactively + npm run bug-report + + # Generate without including logs (for privacy) + npm run bug-report --no-logs + + # Save to a specific file + npm run bug-report --output ~/my-bug-report.md + + # Show all diagnostic details during collection + npm run bug-report --verbose +`); +} + +async function promptUser(question: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +async function promptMultiline(prompt: string): Promise { + console.log(prompt); + console.log("(Press Enter on an empty line to finish)\n"); + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const lines: string[] = []; + + return new Promise((resolve) => { + rl.on("line", (line) => { + // Empty line means we're done + if (line.trim() === "" && lines.length > 0) { + rl.close(); + resolve(lines.join("\n")); + } else if (line.trim() !== "") { + // Only add non-empty lines (or preserve empty lines in the middle) + lines.push(line); + } + }); + + rl.on("close", () => { + resolve(lines.join("\n")); + }); + }); +} + +async function main() { + const args = parseArgs(); + + if (args.help) { + printHelp(); + process.exit(0); + } + + console.log("šŸŒŽ Leave report in ANY language, and it will auto translate to English\n"); + console.log("šŸ” Collecting system diagnostics..."); + + // Collect diagnostics + const diagnostics = await collectDiagnostics({ + includeLogs: !args.noLogs, + }); + + console.log("āœ“ Version information collected"); + console.log("āœ“ Platform details collected"); + console.log("āœ“ Worker status checked"); + if (!args.noLogs) { + console.log( + `āœ“ Logs extracted (last ${diagnostics.logs.workerLog.length + diagnostics.logs.silentLog.length} lines)` + ); + } + console.log("āœ“ Configuration loaded\n"); + + // Show summary + console.log("šŸ“‹ System Summary:"); + console.log(` Claude-mem: v${diagnostics.versions.claudeMem}`); + console.log(` Claude Code: ${diagnostics.versions.claudeCode}`); + console.log( + ` Platform: ${diagnostics.platform.osVersion} (${diagnostics.platform.arch})` + ); + console.log( + ` Worker: ${diagnostics.worker.running ? `Running (PID ${diagnostics.worker.pid}, port ${diagnostics.worker.port})` : "Not running"}\n` + ); + + if (args.verbose) { + console.log("šŸ“Š Detailed Diagnostics:"); + console.log(JSON.stringify(diagnostics, null, 2)); + console.log(); + } + + // Prompt for issue details + const issueDescription = await promptMultiline( + "Please describe the issue you're experiencing:" + ); + + if (!issueDescription.trim()) { + console.error("āŒ Issue description is required"); + process.exit(1); + } + + console.log(); + const expectedBehavior = await promptMultiline( + "Expected behavior (leave blank to skip):" + ); + + console.log(); + const stepsToReproduce = await promptMultiline( + "Steps to reproduce (leave blank to skip):" + ); + + console.log(); + const confirm = await promptUser( + "Generate bug report? (y/n): " + ); + + if (confirm.toLowerCase() !== "y" && confirm.toLowerCase() !== "yes") { + console.log("āŒ Bug report generation cancelled"); + process.exit(0); + } + + console.log("\nšŸ¤– Generating bug report with Claude..."); + + // Generate the bug report + const result = await generateBugReport({ + issueDescription, + expectedBehavior: expectedBehavior.trim() || undefined, + stepsToReproduce: stepsToReproduce.trim() || undefined, + includeLogs: !args.noLogs, + }); + + if (!result.success) { + console.error("āŒ Failed to generate bug report:", result.error); + process.exit(1); + } + + console.log("āœ“ Issue formatted successfully\n"); + + // Generate output file path + const timestamp = new Date() + .toISOString() + .replace(/:/g, "") + .replace(/\..+/, "") + .replace("T", "-"); + const defaultOutputPath = path.join( + os.homedir(), + `bug-report-${timestamp}.md` + ); + const outputPath = args.output || defaultOutputPath; + + // Save to file + await fs.writeFile(outputPath, result.body, "utf-8"); + + // Build GitHub URL with pre-filled title and body + const encodedTitle = encodeURIComponent(result.title); + const encodedBody = encodeURIComponent(result.body); + const githubUrl = `https://github.com/thedotmack/claude-mem/issues/new?title=${encodedTitle}&body=${encodedBody}`; + + // Display the report + console.log("─".repeat(60)); + console.log("šŸ“‹ BUG REPORT GENERATED"); + console.log("─".repeat(60)); + console.log(); + console.log(result.body); + console.log(); + console.log("─".repeat(60)); + console.log("Suggested labels: bug, needs-triage"); + console.log(`Report saved to: ${outputPath}`); + console.log("─".repeat(60)); + console.log(); + + // Open GitHub issue in browser + console.log("🌐 Opening GitHub issue form in your browser..."); + try { + const openCommand = + process.platform === "darwin" + ? "open" + : process.platform === "win32" + ? "start" + : "xdg-open"; + + await execAsync(`${openCommand} "${githubUrl}"`); + console.log("āœ“ Browser opened successfully"); + } catch (error) { + console.error("āŒ Failed to open browser. Please visit:"); + console.error(githubUrl); + } +} + +main().catch((error) => { + console.error("Fatal error:", error); + process.exit(1); +}); diff --git a/scripts/bug-report/collector.ts b/scripts/bug-report/collector.ts new file mode 100644 index 00000000..74a24522 --- /dev/null +++ b/scripts/bug-report/collector.ts @@ -0,0 +1,364 @@ +import * as fs from "fs/promises"; +import * as path from "path"; +import { exec } from "child_process"; +import { promisify } from "util"; +import * as os from "os"; + +const execAsync = promisify(exec); + +export interface SystemDiagnostics { + versions: { + claudeMem: string; + claudeCode: string; + node: string; + bun: string; + }; + platform: { + os: string; + osVersion: string; + arch: string; + }; + paths: { + pluginPath: string; + dataDir: string; + cwd: string; + isDevMode: boolean; + }; + worker: { + running: boolean; + pid?: number; + port?: number; + uptime?: number; + version?: string; + health?: any; + stats?: any; + }; + logs: { + workerLog: string[]; + silentLog: string[]; + }; + database: { + path: string; + exists: boolean; + size?: number; + counts?: { + observations: number; + sessions: number; + summaries: number; + }; + }; + config: { + settingsPath: string; + settingsExist: boolean; + settings?: Record; + }; +} + +function sanitizePath(filePath: string): string { + const homeDir = os.homedir(); + return filePath.replace(homeDir, "~"); +} + +async function getClaudememVersion(): Promise { + try { + const packageJsonPath = path.join(process.cwd(), "package.json"); + const content = await fs.readFile(packageJsonPath, "utf-8"); + const pkg = JSON.parse(content); + return pkg.version || "unknown"; + } catch (error) { + return "unknown"; + } +} + +async function getClaudeCodeVersion(): Promise { + try { + const { stdout } = await execAsync("claude --version"); + return stdout.trim(); + } catch (error) { + return "not installed or not in PATH"; + } +} + +async function getBunVersion(): Promise { + try { + const { stdout } = await execAsync("bun --version"); + return stdout.trim(); + } catch (error) { + return "not installed"; + } +} + +async function getOsVersion(): Promise { + try { + if (process.platform === "darwin") { + const { stdout } = await execAsync("sw_vers -productVersion"); + return `macOS ${stdout.trim()}`; + } else if (process.platform === "linux") { + const { stdout } = await execAsync("uname -sr"); + return stdout.trim(); + } else if (process.platform === "win32") { + const { stdout } = await execAsync("ver"); + return stdout.trim(); + } + return "unknown"; + } catch (error) { + return "unknown"; + } +} + +async function checkWorkerHealth(port: number): Promise { + try { + const response = await fetch(`http://127.0.0.1:${port}/health`, { + signal: AbortSignal.timeout(2000), + }); + return await response.json(); + } catch (error) { + return null; + } +} + +async function getWorkerStats(port: number): Promise { + try { + const response = await fetch(`http://127.0.0.1:${port}/api/stats`, { + signal: AbortSignal.timeout(2000), + }); + return await response.json(); + } catch (error) { + return null; + } +} + +async function readPidFile(dataDir: string): Promise { + try { + const pidPath = path.join(dataDir, "worker.pid"); + const content = await fs.readFile(pidPath, "utf-8"); + return JSON.parse(content); + } catch (error) { + return null; + } +} + +async function readLogLines(logPath: string, lines: number): Promise { + try { + const content = await fs.readFile(logPath, "utf-8"); + const allLines = content.split("\n").filter((line) => line.trim()); + return allLines.slice(-lines); + } catch (error) { + return []; + } +} + +async function getSettings( + dataDir: string +): Promise<{ exists: boolean; settings?: Record }> { + try { + const settingsPath = path.join(dataDir, "settings.json"); + const content = await fs.readFile(settingsPath, "utf-8"); + const settings = JSON.parse(content); + return { exists: true, settings }; + } catch (error) { + return { exists: false }; + } +} + +async function getDatabaseInfo( + dataDir: string +): Promise<{ exists: boolean; size?: number }> { + try { + const dbPath = path.join(dataDir, "claude-mem.db"); + const stats = await fs.stat(dbPath); + return { exists: true, size: stats.size }; + } catch (error) { + return { exists: false }; + } +} + +export async function collectDiagnostics( + options: { includeLogs?: boolean } = {} +): Promise { + const homeDir = os.homedir(); + const dataDir = path.join(homeDir, ".claude-mem"); + const pluginPath = path.join( + homeDir, + ".claude", + "plugins", + "marketplaces", + "thedotmack" + ); + const cwd = process.cwd(); + const isDevMode = cwd.includes("claude-mem") && !cwd.includes(".claude"); + + // Collect version information + const [claudeMem, claudeCode, bun, osVersion] = await Promise.all([ + getClaudememVersion(), + getClaudeCodeVersion(), + getBunVersion(), + getOsVersion(), + ]); + + const versions = { + claudeMem, + claudeCode, + node: process.version, + bun, + }; + + const platform = { + os: process.platform, + osVersion, + arch: process.arch, + }; + + const paths = { + pluginPath: sanitizePath(pluginPath), + dataDir: sanitizePath(dataDir), + cwd: sanitizePath(cwd), + isDevMode, + }; + + // Check worker status + const pidInfo = await readPidFile(dataDir); + const workerPort = pidInfo?.port || 37777; + + const [health, stats] = await Promise.all([ + checkWorkerHealth(workerPort), + getWorkerStats(workerPort), + ]); + + const worker = { + running: health !== null, + pid: pidInfo?.pid, + port: workerPort, + uptime: stats?.worker?.uptime, + version: stats?.worker?.version, + health, + stats, + }; + + // Collect logs if requested + let workerLog: string[] = []; + let silentLog: string[] = []; + + if (options.includeLogs !== false) { + const today = new Date().toISOString().split("T")[0]; + const workerLogPath = path.join(dataDir, "logs", `worker-${today}.log`); + const silentLogPath = path.join(dataDir, "silent.log"); + + [workerLog, silentLog] = await Promise.all([ + readLogLines(workerLogPath, 50), + readLogLines(silentLogPath, 50), + ]); + } + + const logs = { + workerLog: workerLog.map(sanitizePath), + silentLog: silentLog.map(sanitizePath), + }; + + // Database info + const dbInfo = await getDatabaseInfo(dataDir); + const database = { + path: sanitizePath(path.join(dataDir, "claude-mem.db")), + exists: dbInfo.exists, + size: dbInfo.size, + // TODO: Add table counts if we want to query the database + }; + + // Configuration + const settingsInfo = await getSettings(dataDir); + const config = { + settingsPath: sanitizePath(path.join(dataDir, "settings.json")), + settingsExist: settingsInfo.exists, + settings: settingsInfo.settings, + }; + + return { + versions, + platform, + paths, + worker, + logs, + database, + config, + }; +} + +export function formatDiagnostics(diagnostics: SystemDiagnostics): string { + let output = ""; + + output += "## Environment\n\n"; + output += `- **Claude-mem**: ${diagnostics.versions.claudeMem}\n`; + output += `- **Claude Code**: ${diagnostics.versions.claudeCode}\n`; + output += `- **Node.js**: ${diagnostics.versions.node}\n`; + output += `- **Bun**: ${diagnostics.versions.bun}\n`; + output += `- **OS**: ${diagnostics.platform.osVersion} (${diagnostics.platform.arch})\n`; + output += `- **Platform**: ${diagnostics.platform.os}\n\n`; + + output += "## Paths\n\n"; + output += `- **Plugin**: ${diagnostics.paths.pluginPath}\n`; + output += `- **Data Directory**: ${diagnostics.paths.dataDir}\n`; + output += `- **Current Directory**: ${diagnostics.paths.cwd}\n`; + output += `- **Dev Mode**: ${diagnostics.paths.isDevMode ? "Yes" : "No"}\n\n`; + + output += "## Worker Status\n\n"; + output += `- **Running**: ${diagnostics.worker.running ? "Yes" : "No"}\n`; + if (diagnostics.worker.running) { + output += `- **PID**: ${diagnostics.worker.pid || "unknown"}\n`; + output += `- **Port**: ${diagnostics.worker.port}\n`; + if (diagnostics.worker.uptime !== undefined) { + const uptimeMinutes = Math.floor(diagnostics.worker.uptime / 60); + output += `- **Uptime**: ${uptimeMinutes} minutes\n`; + } + if (diagnostics.worker.stats) { + output += `- **Active Sessions**: ${diagnostics.worker.stats.worker?.activeSessions || 0}\n`; + output += `- **SSE Clients**: ${diagnostics.worker.stats.worker?.sseClients || 0}\n`; + } + } + output += "\n"; + + output += "## Database\n\n"; + output += `- **Path**: ${diagnostics.database.path}\n`; + output += `- **Exists**: ${diagnostics.database.exists ? "Yes" : "No"}\n`; + if (diagnostics.database.size) { + const sizeKB = (diagnostics.database.size / 1024).toFixed(2); + output += `- **Size**: ${sizeKB} KB\n`; + } + output += "\n"; + + output += "## Configuration\n\n"; + output += `- **Settings File**: ${diagnostics.config.settingsPath}\n`; + output += `- **Settings Exist**: ${diagnostics.config.settingsExist ? "Yes" : "No"}\n`; + if (diagnostics.config.settings) { + output += "- **Key Settings**:\n"; + const keySettings = [ + "CLAUDE_MEM_MODEL", + "CLAUDE_MEM_WORKER_PORT", + "CLAUDE_MEM_WORKER_HOST", + "CLAUDE_MEM_LOG_LEVEL", + "CLAUDE_MEM_CONTEXT_OBSERVATIONS", + ]; + for (const key of keySettings) { + if (diagnostics.config.settings[key]) { + output += ` - ${key}: ${diagnostics.config.settings[key]}\n`; + } + } + } + output += "\n"; + + // Add logs if present + if (diagnostics.logs.workerLog.length > 0) { + output += "## Recent Worker Logs (Last 50 Lines)\n\n"; + output += "```\n"; + output += diagnostics.logs.workerLog.join("\n"); + output += "\n```\n\n"; + } + + if (diagnostics.logs.silentLog.length > 0) { + output += "## Silent Debug Log (Last 50 Lines)\n\n"; + output += "```\n"; + output += diagnostics.logs.silentLog.join("\n"); + output += "\n```\n\n"; + } + + return output; +} diff --git a/scripts/bug-report/index.ts b/scripts/bug-report/index.ts new file mode 100644 index 00000000..10e2a155 --- /dev/null +++ b/scripts/bug-report/index.ts @@ -0,0 +1,195 @@ +import { + query, + type SDKMessage, + type SDKResultMessage, +} from "@anthropic-ai/claude-agent-sdk"; +import { + collectDiagnostics, + formatDiagnostics, + type SystemDiagnostics, +} from "./collector.ts"; + +export interface BugReportInput { + issueDescription: string; + expectedBehavior?: string; + stepsToReproduce?: string; + includeLogs?: boolean; +} + +export interface BugReportResult { + title: string; + body: string; + success: boolean; + error?: string; +} + +export async function generateBugReport( + input: BugReportInput +): Promise { + try { + // Collect system diagnostics + const diagnostics = await collectDiagnostics({ + includeLogs: input.includeLogs !== false, + }); + + const formattedDiagnostics = formatDiagnostics(diagnostics); + + // Build the prompt + const prompt = buildPrompt( + formattedDiagnostics, + input.issueDescription, + input.expectedBehavior, + input.stepsToReproduce + ); + + // Use Agent SDK to generate formatted issue + let generatedMarkdown = ""; + let charCount = 0; + const startTime = Date.now(); + + const stream = query({ + prompt, + options: { + model: "sonnet", + systemPrompt: `You are a GitHub issue formatter. Format bug reports clearly and professionally.`, + permissionMode: "bypassPermissions", + allowDangerouslySkipPermissions: true, + includePartialMessages: true, + }, + }); + + // Progress spinner frames + const spinnerFrames = ["ā ‹", "ā ™", "ā ¹", "ā ø", "ā ¼", "ā “", "ā ¦", "ā §", "ā ‡", "ā "]; + let spinnerIdx = 0; + + // Stream the response + for await (const message of stream) { + if (message.type === "stream_event") { + const event = message.event as { type: string; delta?: { type: string; text?: string } }; + if (event.type === "content_block_delta" && event.delta?.type === "text_delta" && event.delta.text) { + generatedMarkdown += event.delta.text; + charCount += event.delta.text.length; + + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + const spinner = spinnerFrames[spinnerIdx++ % spinnerFrames.length]; + process.stdout.write(`\r ${spinner} Generating... ${charCount} chars (${elapsed}s)`); + } + } + + // Handle full assistant messages (fallback) + if (message.type === "assistant") { + for (const block of message.message.content) { + if (block.type === "text" && !generatedMarkdown) { + generatedMarkdown = block.text; + charCount = generatedMarkdown.length; + } + } + } + + // Handle result + if (message.type === "result") { + const result = message as SDKResultMessage; + if (result.subtype === "success" && !generatedMarkdown && result.result) { + generatedMarkdown = result.result; + charCount = generatedMarkdown.length; + } + } + } + + // Clear the progress line + process.stdout.write("\r" + " ".repeat(60) + "\r"); + + // Extract title from markdown (first heading) + const titleMatch = generatedMarkdown.match(/^#\s+(.+)$/m); + const title = titleMatch ? titleMatch[1] : "Bug Report"; + + return { + title, + body: generatedMarkdown, + success: true, + }; + } catch (error) { + // Fallback to template-based generation + console.error("Agent SDK failed, using template fallback:", error); + return generateTemplateFallback(input); + } +} + +function buildPrompt( + diagnostics: string, + issueDescription: string, + expectedBehavior?: string, + stepsToReproduce?: string +): string { + let prompt = `You are a GitHub issue formatter. Given system diagnostics and a user's bug description, create a well-structured GitHub issue for the claude-mem repository. + +SYSTEM DIAGNOSTICS: +${diagnostics} + +USER DESCRIPTION: +${issueDescription} +`; + + if (expectedBehavior) { + prompt += `\nEXPECTED BEHAVIOR: +${expectedBehavior} +`; + } + + if (stepsToReproduce) { + prompt += `\nSTEPS TO REPRODUCE: +${stepsToReproduce} +`; + } + + prompt += ` + +IMPORTANT: If any part of the user's description is in a language other than English, translate it to English while preserving technical accuracy and meaning. + +Create a GitHub issue with: +1. Clear, descriptive title (max 80 chars) in English - start with a single # heading +2. Problem statement summarizing the issue in English +3. Environment section (versions, platform) from the diagnostics +4. Steps to reproduce (if provided) in English +5. Expected vs actual behavior in English +6. Relevant logs (formatted as code blocks) if present in diagnostics +7. Any additional context that would help diagnose the issue + +Format the output as valid GitHub Markdown. Make sure the title is a single # heading at the very top. +Do NOT add meta-commentary like "Here's a formatted issue" - just output the raw markdown. +All content must be in English for the GitHub issue. +`; + + return prompt; +} + +async function generateTemplateFallback( + input: BugReportInput +): Promise { + const diagnostics = await collectDiagnostics({ + includeLogs: input.includeLogs !== false, + }); + const formattedDiagnostics = formatDiagnostics(diagnostics); + + let body = `# Bug Report\n\n`; + body += `## Description\n\n`; + body += `${input.issueDescription}\n\n`; + + if (input.expectedBehavior) { + body += `## Expected Behavior\n\n`; + body += `${input.expectedBehavior}\n\n`; + } + + if (input.stepsToReproduce) { + body += `## Steps to Reproduce\n\n`; + body += `${input.stepsToReproduce}\n\n`; + } + + body += formattedDiagnostics; + + return { + title: "Bug Report", + body, + success: true, + }; +}