From b50894f81b9402044e9f6923e172fb30a89b6267 Mon Sep 17 00:00:00 2001 From: Kushagra Agarwal Date: Sun, 1 Feb 2026 19:02:07 +0530 Subject: [PATCH 1/4] Add send-feedback command to CLI --- packages/cli/package.json | 2 +- packages/cli/src/commands/feedback.ts | 334 ++++++++++++++++++++++++++ packages/cli/src/index.ts | 13 + 3 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/commands/feedback.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 683a32e..eba73e2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@leanmcp/cli", - "version": "0.5.6", + "version": "0.5.7", "description": "Command-line interface for scaffolding LeanMCP projects", "bin": { "leanmcp": "bin/leanmcp.js" diff --git a/packages/cli/src/commands/feedback.ts b/packages/cli/src/commands/feedback.ts new file mode 100644 index 0000000..68a6dfa --- /dev/null +++ b/packages/cli/src/commands/feedback.ts @@ -0,0 +1,334 @@ +/** + * leanmcp send-feedback command + * + * Sends feedback to the LeanMCP API with support for authenticated and anonymous feedback. + * Supports multi-line messages and optional log file attachments. + */ +import ora from 'ora'; +import fs from 'fs-extra'; +import path from 'path'; +import os from 'os'; +import { getApiKey, getApiUrl } from './login'; +import { logger, chalk, debug as loggerDebug } from '../logger'; + +// Debug mode flag +let DEBUG_MODE = false; + +export function setFeedbackDebugMode(enabled: boolean) { + DEBUG_MODE = enabled; +} + +function debug(message: string, ...args: any[]) { + if (DEBUG_MODE) { + console.log(chalk.gray(`[DEBUG] ${message}`), ...args); + } +} + +interface FeedbackAttachment { + name: string; + content: string; + size: number; + type: string; +} + +/** + * Read and encode a file as base64 + */ +async function readFileAsBase64(filePath: string): Promise<{ content: string; size: number; type: string }> { + try { + const absolutePath = path.resolve(filePath); + const stats = await fs.stat(absolutePath); + + if (!stats.isFile()) { + throw new Error(`${filePath} is not a file`); + } + + const content = await fs.readFile(absolutePath, 'base64'); + const ext = path.extname(absolutePath).toLowerCase(); + + // Simple MIME type detection + let mimeType = 'application/octet-stream'; + switch (ext) { + case '.txt': + case '.log': + mimeType = 'text/plain'; + break; + case '.json': + mimeType = 'application/json'; + break; + case '.js': + mimeType = 'application/javascript'; + break; + case '.ts': + mimeType = 'application/typescript'; + break; + case '.md': + mimeType = 'text/markdown'; + break; + } + + return { + content, + size: stats.size, + type: mimeType, + }; + } catch (error) { + throw new Error(`Failed to read file ${filePath}: ${error instanceof Error ? error.message : String(error)}`); + } +} + +/** + * Collect log files from various locations + */ +async function collectLogFiles(): Promise { + const attachments: FeedbackAttachment[] = []; + const logLocations = [ + path.join(os.homedir(), '.leanmcp', 'logs'), + path.join(process.cwd(), 'logs'), + path.join(process.cwd(), '.leanmcp', 'logs'), + ]; + + for (const logDir of logLocations) { + try { + if (await fs.pathExists(logDir)) { + const files = await fs.readdir(logDir); + + for (const file of files) { + const filePath = path.join(logDir, file); + try { + const fileData = await readFileAsBase64(filePath); + attachments.push({ + name: file, + ...fileData, + }); + } catch (error) { + debug(`Failed to read log file ${filePath}: ${error}`); + } + } + } + } catch (error) { + debug(`Failed to scan log directory ${logDir}: ${error}`); + } + } + + // Also try to collect npm debug log if it exists + const npmDebugLog = path.join(os.homedir(), '.npm', '_logs'); + try { + if (await fs.pathExists(npmDebugLog)) { + const logFiles = await fs.readdir(npmDebugLog); + const latestLog = logFiles + .filter(file => file.endsWith('.log')) + .sort() + .pop(); // Get the latest log file + + if (latestLog) { + const filePath = path.join(npmDebugLog, latestLog); + try { + const fileData = await readFileAsBase64(filePath); + attachments.push({ + name: `npm-${latestLog}`, + ...fileData, + }); + } catch (error) { + debug(`Failed to read npm debug log: ${error}`); + } + } + } + } catch (error) { + debug(`Failed to collect npm debug logs: ${error}`); + } + + return attachments; +} + +/** + * Send feedback to the API + */ +async function sendFeedbackToApi( + message: string, + attachments: FeedbackAttachment[] = [], + isAnonymous = false, +): Promise { + const apiUrl = await getApiUrl(); + const endpoint = isAnonymous ? '/feedback/anonymous' : '/feedback'; + const url = `${apiUrl}${endpoint}`; + + debug('API URL:', apiUrl); + debug('Endpoint:', endpoint); + debug('Message length:', message.length); + debug('Attachments count:', attachments.length); + + const headers: Record = { + 'Content-Type': 'application/json', + }; + + // Add authorization for non-anonymous feedback + if (!isAnonymous) { + const apiKey = await getApiKey(); + if (!apiKey) { + throw new Error('Not authenticated. Please run `leanmcp login` first.'); + } + headers['Authorization'] = `Bearer ${apiKey}`; + } + + const payload = { + message, + attachments: attachments.map(att => ({ + name: att.name, + content: att.content, + size: att.size, + type: att.type, + })), + }; + + debug('Sending feedback request...'); + + const response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify(payload), + }); + + debug('Response status:', response.status); + debug('Response ok:', response.ok); + + if (!response.ok) { + const errorText = await response.text(); + debug('Error response:', errorText); + + if (response.status === 401) { + throw new Error('Authentication failed. Please run `leanmcp login` to re-authenticate.'); + } else if (response.status === 413) { + throw new Error('Attachments too large. Please try again without log files.'); + } else { + throw new Error(`Failed to send feedback: ${response.status} ${response.statusText}`); + } + } + + return await response.json(); +} + +/** + * Main feedback command implementation + */ +export async function sendFeedbackCommand( + message: string | undefined, + options: { anon?: boolean; includeLogs?: boolean }, +): Promise { + logger.info('\nLeanMCP Feedback\n'); + + const isAnonymous = options.anon || false; + const includeLogs = options.includeLogs || false; + + debug('Feedback options:', { isAnonymous, includeLogs }); + + // 1. Get feedback message (handle piped input or argument) + let feedbackMessage = message; + + // Handle piped input if message is not provided + if (!feedbackMessage && !process.stdin.isTTY) { + debug('Reading feedback message from stdin...'); + feedbackMessage = await new Promise((resolve) => { + let data = ''; + process.stdin.on('data', (chunk) => { + data += chunk; + }); + process.stdin.on('end', () => { + resolve(data.trim()); + }); + }); + } + + // Validate message + if (!feedbackMessage || feedbackMessage.trim().length === 0) { + logger.error('Feedback message cannot be empty.'); + logger.info('Usage examples:'); + logger.info(' leanmcp send-feedback "Your message"'); + logger.gray(' leanmcp send-feedback << EOF'); + logger.gray(' multi-line'); + logger.gray(' message'); + logger.gray(' EOF'); + logger.info(' leanmcp send-feedback --anon "Anonymous feedback"'); + logger.info(' leanmcp send-feedback "Issue with deploy" --include-logs'); + process.exit(1); + } + + if (feedbackMessage.length > 5000) { + logger.error('Feedback message is too long (max 5000 characters).'); + process.exit(1); + } + + // 2. Check authentication for non-anonymous feedback + if (!isAnonymous) { + const apiKey = await getApiKey(); + if (!apiKey) { + logger.error('need to login'); + logger.info('Please run `leanmcp login` to authenticate, or use `--anon` for anonymous feedback.'); + process.exit(1); + } + } + + let attachments: FeedbackAttachment[] = []; + + // 3. Collect log files if requested + if (includeLogs) { + const spinner = ora('Collecting log files...').start(); + + try { + attachments = await collectLogFiles(); + spinner.succeed(`Collected ${attachments.length} log file(s)`); + + if (attachments.length > 0) { + logger.log('Log files:', chalk.gray); + attachments.forEach(att => { + logger.log(` - ${att.name} (${(att.size / 1024).toFixed(1)} KB)`, chalk.gray); + }); + logger.log(''); + } else { + logger.log('No log files found.', chalk.gray); + logger.log(''); + } + } catch (error) { + spinner.fail('Failed to collect log files'); + debug('Log collection error:', error); + logger.warn('Continuing without log files...'); + } + } + + // 4. Send feedback + const spinner = ora('Sending feedback...').start(); + + try { + const result = await sendFeedbackToApi(feedbackMessage, attachments, isAnonymous); + spinner.succeed('Feedback sent successfully!'); + + logger.success('\nThank you for your feedback!'); + logger.log(`Feedback ID: ${result.id}`, chalk.gray); + + if (isAnonymous) { + logger.log('Type: Anonymous', chalk.gray); + } else { + logger.log('Type: Authenticated', chalk.gray); + } + + if (attachments.length > 0) { + logger.log(`Attachments: ${attachments.length}`, chalk.gray); + } + + logger.log('\nWe appreciate your input and will review it soon.', chalk.cyan); + } catch (error) { + spinner.fail('Failed to send feedback'); + + if (error instanceof Error) { + logger.error(`\n${error.message}`); + } else { + logger.error('\nAn unknown error occurred.'); + } + + if (DEBUG_MODE) { + debug('Full error:', error); + } + + process.exit(1); + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index f114d58..863b7b1 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -15,6 +15,7 @@ import { setDebugMode as setLoginDebugMode, } from './commands/login'; import { deployCommand, setDeployDebugMode } from './commands/deploy'; +import { sendFeedbackCommand } from './commands/feedback'; import { projectsListCommand, projectsGetCommand, @@ -363,9 +364,21 @@ program logger.log('To deploy to LeanMCP cloud:', chalk.cyan); logger.log(` cd ${projectName}`, chalk.gray); logger.log(` leanmcp deploy .`, chalk.gray); + logger.log('\nSend us feedback:', chalk.cyan); + logger.log(' leanmcp send-feedback "Great tool!"\n', chalk.gray); } }); +program + .command('send-feedback [message]') + .description('Send feedback to the LeanMCP team') + .option('--anon', 'Send feedback anonymously') + .option('--include-logs', 'Include local log files with feedback') + .action(async (message, options) => { + trackCommand('send-feedback', { hasMessage: !!message, ...options }); + await sendFeedbackCommand(message, options); + }); + program .command('add ') .description('Add a new MCP service to your project') From 121c5da154e020bb2a9fc5652bc3749fa2cfd3b9 Mon Sep 17 00:00:00 2001 From: Kushagra Agarwal Date: Sun, 1 Feb 2026 19:03:07 +0530 Subject: [PATCH 2/4] Lint ficx --- packages/cli/src/commands/feedback.ts | 44 +++++++++++++++------------ 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/commands/feedback.ts b/packages/cli/src/commands/feedback.ts index 68a6dfa..e217185 100644 --- a/packages/cli/src/commands/feedback.ts +++ b/packages/cli/src/commands/feedback.ts @@ -34,18 +34,20 @@ interface FeedbackAttachment { /** * Read and encode a file as base64 */ -async function readFileAsBase64(filePath: string): Promise<{ content: string; size: number; type: string }> { +async function readFileAsBase64( + filePath: string +): Promise<{ content: string; size: number; type: string }> { try { const absolutePath = path.resolve(filePath); const stats = await fs.stat(absolutePath); - + if (!stats.isFile()) { throw new Error(`${filePath} is not a file`); } const content = await fs.readFile(absolutePath, 'base64'); const ext = path.extname(absolutePath).toLowerCase(); - + // Simple MIME type detection let mimeType = 'application/octet-stream'; switch (ext) { @@ -73,7 +75,9 @@ async function readFileAsBase64(filePath: string): Promise<{ content: string; si type: mimeType, }; } catch (error) { - throw new Error(`Failed to read file ${filePath}: ${error instanceof Error ? error.message : String(error)}`); + throw new Error( + `Failed to read file ${filePath}: ${error instanceof Error ? error.message : String(error)}` + ); } } @@ -92,7 +96,7 @@ async function collectLogFiles(): Promise { try { if (await fs.pathExists(logDir)) { const files = await fs.readdir(logDir); - + for (const file of files) { const filePath = path.join(logDir, file); try { @@ -117,7 +121,7 @@ async function collectLogFiles(): Promise { if (await fs.pathExists(npmDebugLog)) { const logFiles = await fs.readdir(npmDebugLog); const latestLog = logFiles - .filter(file => file.endsWith('.log')) + .filter((file) => file.endsWith('.log')) .sort() .pop(); // Get the latest log file @@ -147,7 +151,7 @@ async function collectLogFiles(): Promise { async function sendFeedbackToApi( message: string, attachments: FeedbackAttachment[] = [], - isAnonymous = false, + isAnonymous = false ): Promise { const apiUrl = await getApiUrl(); const endpoint = isAnonymous ? '/feedback/anonymous' : '/feedback'; @@ -173,7 +177,7 @@ async function sendFeedbackToApi( const payload = { message, - attachments: attachments.map(att => ({ + attachments: attachments.map((att) => ({ name: att.name, content: att.content, size: att.size, @@ -213,7 +217,7 @@ async function sendFeedbackToApi( */ export async function sendFeedbackCommand( message: string | undefined, - options: { anon?: boolean; includeLogs?: boolean }, + options: { anon?: boolean; includeLogs?: boolean } ): Promise { logger.info('\nLeanMCP Feedback\n'); @@ -263,7 +267,9 @@ export async function sendFeedbackCommand( const apiKey = await getApiKey(); if (!apiKey) { logger.error('need to login'); - logger.info('Please run `leanmcp login` to authenticate, or use `--anon` for anonymous feedback.'); + logger.info( + 'Please run `leanmcp login` to authenticate, or use `--anon` for anonymous feedback.' + ); process.exit(1); } } @@ -273,14 +279,14 @@ export async function sendFeedbackCommand( // 3. Collect log files if requested if (includeLogs) { const spinner = ora('Collecting log files...').start(); - + try { attachments = await collectLogFiles(); spinner.succeed(`Collected ${attachments.length} log file(s)`); - + if (attachments.length > 0) { logger.log('Log files:', chalk.gray); - attachments.forEach(att => { + attachments.forEach((att) => { logger.log(` - ${att.name} (${(att.size / 1024).toFixed(1)} KB)`, chalk.gray); }); logger.log(''); @@ -304,31 +310,31 @@ export async function sendFeedbackCommand( logger.success('\nThank you for your feedback!'); logger.log(`Feedback ID: ${result.id}`, chalk.gray); - + if (isAnonymous) { logger.log('Type: Anonymous', chalk.gray); } else { logger.log('Type: Authenticated', chalk.gray); } - + if (attachments.length > 0) { logger.log(`Attachments: ${attachments.length}`, chalk.gray); } - + logger.log('\nWe appreciate your input and will review it soon.', chalk.cyan); } catch (error) { spinner.fail('Failed to send feedback'); - + if (error instanceof Error) { logger.error(`\n${error.message}`); } else { logger.error('\nAn unknown error occurred.'); } - + if (DEBUG_MODE) { debug('Full error:', error); } - + process.exit(1); } } From e906306ec7c44a95fffba98a45b995270980eeb1 Mon Sep 17 00:00:00 2001 From: Kushagra Agarwal Date: Mon, 2 Feb 2026 00:21:57 +0530 Subject: [PATCH 3/4] Store logs in local files in .leanmcp/logs --- packages/cli/package.json | 4 +- packages/cli/src/commands/feedback.ts | 36 ++++++++++++--- packages/cli/src/logger.ts | 66 ++++++++++++++++++++++++--- 3 files changed, 90 insertions(+), 16 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index eba73e2..52b9312 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@leanmcp/cli", - "version": "0.5.7", + "version": "0.5.8", "description": "Command-line interface for scaffolding LeanMCP projects", "bin": { "leanmcp": "bin/leanmcp.js" @@ -71,4 +71,4 @@ "publishConfig": { "access": "public" } -} +} \ No newline at end of file diff --git a/packages/cli/src/commands/feedback.ts b/packages/cli/src/commands/feedback.ts index e217185..cd90020 100644 --- a/packages/cli/src/commands/feedback.ts +++ b/packages/cli/src/commands/feedback.ts @@ -97,16 +97,38 @@ async function collectLogFiles(): Promise { if (await fs.pathExists(logDir)) { const files = await fs.readdir(logDir); + // Filter for .log files and get stats + const logFiles = []; for (const file of files) { - const filePath = path.join(logDir, file); + if (!file.endsWith('.log')) continue; + + try { + const filePath = path.join(logDir, file); + const stats = await fs.stat(filePath); + logFiles.push({ file, filePath, mtime: stats.mtimeMs }); + } catch (e) { + // Ignore stat errors + } + } + + // Sort by modification time (newest first) and take top 3 + const recentLogs = logFiles + .sort((a, b) => b.mtime - a.mtime) + .slice(0, 3); + + for (const log of recentLogs) { try { - const fileData = await readFileAsBase64(filePath); - attachments.push({ - name: file, - ...fileData, - }); + const fileData = await readFileAsBase64(log.filePath); + + // Check if we already have this file (avoid duplicates from multiple paths) + if (!attachments.some(a => a.name === log.file)) { + attachments.push({ + name: log.file, + ...fileData, + }); + } } catch (error) { - debug(`Failed to read log file ${filePath}: ${error}`); + debug(`Failed to read log file ${log.filePath}: ${error}`); } } } diff --git a/packages/cli/src/logger.ts b/packages/cli/src/logger.ts index edecbed..895893c 100644 --- a/packages/cli/src/logger.ts +++ b/packages/cli/src/logger.ts @@ -1,6 +1,8 @@ import chalk from 'chalk'; import os from 'os'; import crypto from 'crypto'; +import path from 'path'; +import fs from 'fs-extra'; import { execSync } from 'child_process'; // PostHog configuration @@ -137,6 +139,9 @@ const sendToPostHog = (eventName: string, properties: Record = {}): }); }; +// Export chalk for convenience +export { chalk }; + // Type for chalk style functions type ChalkFunction = | typeof chalk.cyan @@ -148,13 +153,55 @@ type ChalkFunction = | typeof chalk.white | typeof chalk.bold; +// Ensure log directory exists +const LOG_DIR = path.join(os.homedir(), '.leanmcp', 'logs'); +try { + fs.ensureDirSync(LOG_DIR); +} catch (err) { + // Fail silently if we can't create the log dir +} + +/** + * Format date for log timestamp + */ +function getTimestamp(): string { + return new Date().toISOString(); +} + +/** + * Get current log file path (cli-YYYY-MM-DD.log) + */ +function getLogFilePath(): string { + const date = new Date().toISOString().split('T')[0]; + return path.join(LOG_DIR, `cli-${date}.log`); +} + +/** + * Redact sensitive info from logs + */ +function redactSensitive(text: string): string { + if (!text) return text; + // Redact API keys (leanmcp_... or airtain_...) + return text.replace(/(leanmcp_|airtain_)[a-zA-Z0-9]{20,}/g, '$1********************'); +} + /** * Logger class with log, warn, error methods - * All methods send telemetry to PostHog with appropriate event names + * All methods send telemetry to PostHog and write to local log file */ class LoggerClass { + private writeToFile(level: string, message: string): void { + try { + const timestamp = getTimestamp(); + const logLine = `[${timestamp}] [${level.toUpperCase()}] ${redactSensitive(message)}\n`; + fs.appendFileSync(getLogFilePath(), logLine); + } catch (err) { + // Fail silently on file write errors to avoid crashing CLI + } + } + /** - * Log a message to console and send cli_log event to PostHog + * Log a message to console, file, and send cli_log event to PostHog * @param text - The text to log * @param styleFn - Optional chalk style function (e.g., chalk.cyan, chalk.green) */ @@ -164,6 +211,7 @@ class LoggerClass { } else { console.log(text); } + this.writeToFile('log', text); sendToPostHog('cli_log', { message: text, level: 'log', @@ -171,13 +219,14 @@ class LoggerClass { } /** - * Log a warning to console and send cli_warn event to PostHog + * Log a warning to console, file, and send cli_warn event to PostHog * @param text - The warning text * @param styleFn - Optional chalk style function (defaults to chalk.yellow) */ warn(text: string, styleFn?: ChalkFunction): void { const style = styleFn || chalk.yellow; console.log(style(text)); + this.writeToFile('warn', text); sendToPostHog('cli_warn', { message: text, level: 'warn', @@ -185,13 +234,14 @@ class LoggerClass { } /** - * Log an error to console and send cli_error event to PostHog + * Log an error to console, file, and send cli_error event to PostHog * @param text - The error text * @param styleFn - Optional chalk style function (defaults to chalk.red) */ error(text: string, styleFn?: ChalkFunction): void { const style = styleFn || chalk.red; console.error(style(text)); + this.writeToFile('error', text); sendToPostHog('cli_error', { message: text, level: 'error', @@ -204,6 +254,7 @@ class LoggerClass { */ info(text: string): void { console.log(chalk.cyan(text)); + this.writeToFile('info', text); sendToPostHog('cli_log', { message: text, level: 'info', @@ -216,6 +267,7 @@ class LoggerClass { */ success(text: string): void { console.log(chalk.green(text)); + this.writeToFile('success', text); sendToPostHog('cli_log', { message: text, level: 'success', @@ -228,6 +280,7 @@ class LoggerClass { */ gray(text: string): void { console.log(chalk.gray(text)); + this.writeToFile('debug', text); // Gray usually indicates debug/verbose info sendToPostHog('cli_log', { message: text, level: 'gray', @@ -250,6 +303,8 @@ export const log = (text: string, styleFn?: ChalkFunction): void => { */ export const trackEvent = (eventName: string, properties: Record = {}): void => { sendToPostHog(eventName, properties); + // We don't necessarily log all telemetry events to file, but we could if verbose logging was enabled. + // For now, let's keep the log file focused on user-visible output and errors. }; /** @@ -263,6 +318,3 @@ export const trackCommand = (command: string, options: Record = {}) options, }); }; - -// Export chalk for convenience -export { chalk }; From c9b74c96eef2ec7b1d478c8061b0f559ddb82a8e Mon Sep 17 00:00:00 2001 From: Kushagra Agarwal Date: Mon, 2 Feb 2026 00:23:01 +0530 Subject: [PATCH 4/4] Lint fix --- packages/cli/package.json | 2 +- packages/cli/src/commands/feedback.ts | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 52b9312..701a710 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -71,4 +71,4 @@ "publishConfig": { "access": "public" } -} \ No newline at end of file +} diff --git a/packages/cli/src/commands/feedback.ts b/packages/cli/src/commands/feedback.ts index cd90020..48ea967 100644 --- a/packages/cli/src/commands/feedback.ts +++ b/packages/cli/src/commands/feedback.ts @@ -112,16 +112,14 @@ async function collectLogFiles(): Promise { } // Sort by modification time (newest first) and take top 3 - const recentLogs = logFiles - .sort((a, b) => b.mtime - a.mtime) - .slice(0, 3); + const recentLogs = logFiles.sort((a, b) => b.mtime - a.mtime).slice(0, 3); for (const log of recentLogs) { try { const fileData = await readFileAsBase64(log.filePath); // Check if we already have this file (avoid duplicates from multiple paths) - if (!attachments.some(a => a.name === log.file)) { + if (!attachments.some((a) => a.name === log.file)) { attachments.push({ name: log.file, ...fileData,