From 23c6b52bb2701c1ecddcd31bc6de491597bc37b9 Mon Sep 17 00:00:00 2001 From: jwfing Date: Fri, 10 Apr 2026 16:53:39 -0700 Subject: [PATCH 1/6] feat(diagnose): add AI diagnosis with SSE streaming and rating - Add `--ai ` option to `diagnose` command - Stream analysis from POST /diagnostic/v1/analyze via SSE with real-time delta output, tool_call indicators, and error handling - Collect and send client_info (cli_version, node_version, os) in context - Extract sessionId from done event for rating submission - Add interactive rating prompt (helpful/not_helpful/incorrect) via POST /diagnostic/v1/sessions/{sessionId}/rating - Refactor data collection into reusable collectDiagnosticData() - Fix unused CLIError import in env-vars.ts Co-Authored-By: Claude Opus 4.6 (1M context) --- package.json | 2 +- src/commands/deployments/env-vars.ts | 2 +- src/commands/diagnose/index.ts | 293 ++++++++++++++++++++------- src/lib/api/platform.ts | 86 ++++++++ 4 files changed, 303 insertions(+), 80 deletions(-) diff --git a/package.json b/package.json index 0aad093..2807a32 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@insforge/cli", - "version": "0.1.43", + "version": "0.1.44", "description": "InsForge CLI - Command line tool for InsForge platform", "type": "module", "bin": { diff --git a/src/commands/deployments/env-vars.ts b/src/commands/deployments/env-vars.ts index 0265554..8729372 100644 --- a/src/commands/deployments/env-vars.ts +++ b/src/commands/deployments/env-vars.ts @@ -1,7 +1,7 @@ import type { Command } from 'commander'; import { ossFetch } from '../../lib/api/oss.js'; import { requireAuth } from '../../lib/credentials.js'; -import { handleError, getRootOpts, ProjectNotLinkedError, CLIError } from '../../lib/errors.js'; +import { handleError, getRootOpts, ProjectNotLinkedError } from '../../lib/errors.js'; import { getProjectConfig } from '../../lib/config.js'; import { outputJson, outputTable, outputSuccess } from '../../lib/output.js'; import { reportCliUsage } from '../../lib/skills.js'; diff --git a/src/commands/diagnose/index.ts b/src/commands/diagnose/index.ts index 6c88095..3accc0f 100644 --- a/src/commands/diagnose/index.ts +++ b/src/commands/diagnose/index.ts @@ -1,10 +1,13 @@ import type { Command } from 'commander'; +import * as os from 'node:os'; +import * as clack from '@clack/prompts'; import { requireAuth } from '../../lib/credentials.js'; -import { handleError, getRootOpts, ProjectNotLinkedError } from '../../lib/errors.js'; +import { handleError, getRootOpts, CLIError, ProjectNotLinkedError } from '../../lib/errors.js'; import { getProjectConfig } from '../../lib/config.js'; import { outputJson } from '../../lib/output.js'; import { reportCliUsage } from '../../lib/skills.js'; import { trackDiagnose, shutdownAnalytics } from '../../lib/analytics.js'; +import { streamDiagnosticAnalysis, rateDiagnosticSession } from '../../lib/api/platform.js'; import { fetchMetricsSummary, registerDiagnoseMetricsCommand } from './metrics.js'; import { fetchAdvisorSummary, registerDiagnoseAdvisorCommand } from './advisor.js'; @@ -15,11 +18,87 @@ function sectionHeader(title: string): string { return `── ${title} ${'─'.repeat(Math.max(0, 44 - title.length))}`; } +interface DiagnosticContext { + metrics: unknown | null; + advisor: unknown | null; + db: unknown | null; + logs: unknown | null; + errors: string[]; +} + +async function collectDiagnosticData( + projectId: string, + ossMode: boolean, + apiUrl?: string, +): Promise { + const metricsPromise = ossMode + ? Promise.reject(new Error('Platform login required (linked via --api-key)')) + : fetchMetricsSummary(projectId, apiUrl); + const advisorPromise = ossMode + ? Promise.reject(new Error('Platform login required (linked via --api-key)')) + : fetchAdvisorSummary(projectId, apiUrl); + + const [metricsResult, advisorResult, dbResult, logsResult] = await Promise.allSettled([ + metricsPromise, + advisorPromise, + runDbChecks(), + fetchLogsSummary(100), + ]); + + const errors: string[] = []; + let metrics: unknown | null = null; + let advisor: unknown | null = null; + let db: unknown | null = null; + let logs: unknown | null = null; + + if (metricsResult.status === 'fulfilled') { + const data = metricsResult.value; + metrics = data.metrics.map((m) => { + if (m.data.length === 0) return { metric: m.metric, latest: null, avg: null, max: null }; + let sum = 0; + let max = -Infinity; + for (const d of m.data) { + sum += d.value; + if (d.value > max) max = d.value; + } + return { + metric: m.metric, + latest: m.data[m.data.length - 1].value, + avg: sum / m.data.length, + max, + }; + }); + } else { + errors.push(metricsResult.reason?.message ?? 'Metrics unavailable'); + } + + if (advisorResult.status === 'fulfilled') { + advisor = advisorResult.value; + } else { + errors.push(advisorResult.reason?.message ?? 'Advisor unavailable'); + } + + if (dbResult.status === 'fulfilled') { + db = dbResult.value; + } else { + errors.push(dbResult.reason?.message ?? 'DB checks unavailable'); + } + + if (logsResult.status === 'fulfilled') { + logs = logsResult.value; + } else { + errors.push(logsResult.reason?.message ?? 'Logs unavailable'); + } + + return { metrics, advisor, db, logs, errors }; +} + export function registerDiagnoseCommands(diagnoseCmd: Command): void { // Comprehensive report (no subcommand) diagnoseCmd .description('Backend diagnostics — run with no subcommand for a full health report') - .action(async (_opts, cmd) => { + .option('--ai ', 'Ask AI to analyze your diagnostic data (1-2000 chars)') + .action(async (opts, cmd) => { const { json, apiUrl } = getRootOpts(cmd); try { await requireAuth(apiUrl); @@ -31,85 +110,144 @@ export function registerDiagnoseCommands(diagnoseCmd: Command): void { const ossMode = config.project_id === 'oss-project'; // Track diagnose usage in PostHog - trackDiagnose('report', config); - - // In OSS mode (linked via --api-key), skip Platform API calls (metrics/advisor) - const metricsPromise = ossMode - ? Promise.reject(new Error('Platform login required (linked via --api-key)')) - : fetchMetricsSummary(projectId, apiUrl); - const advisorPromise = ossMode - ? Promise.reject(new Error('Platform login required (linked via --api-key)')) - : fetchAdvisorSummary(projectId, apiUrl); - - const [metricsResult, advisorResult, dbResult, logsResult] = await Promise.allSettled([ - metricsPromise, - advisorPromise, - runDbChecks(), - fetchLogsSummary(100), - ]); + trackDiagnose(opts.ai ? 'ai' : 'report', config); - if (json) { - const report: Record = { project: projectName, errors: [] }; - const errors: string[] = []; - - if (metricsResult.status === 'fulfilled') { - const data = metricsResult.value; - report.metrics = data.metrics.map((m) => { - if (m.data.length === 0) return { metric: m.metric, latest: null, avg: null, max: null }; - let sum = 0; - let max = -Infinity; - for (const d of m.data) { - sum += d.value; - if (d.value > max) max = d.value; - } - return { - metric: m.metric, - latest: m.data[m.data.length - 1].value, - avg: sum / m.data.length, - max, - }; - }); - } else { - report.metrics = null; - errors.push(metricsResult.reason?.message ?? 'Metrics unavailable'); + // AI analysis mode + if (opts.ai) { + const question = String(opts.ai).trim(); + if (question.length === 0 || question.length > 2000) { + throw new CLIError('Question must be between 1 and 2000 characters.'); } - if (advisorResult.status === 'fulfilled') { - report.advisor = advisorResult.value; - } else { - report.advisor = null; - errors.push(advisorResult.reason?.message ?? 'Advisor unavailable'); + const s = !json ? clack.spinner() : null; + s?.start('Collecting diagnostic data...'); + + const data = await collectDiagnosticData(projectId, ossMode, apiUrl); + + // Read CLI version from package.json + const { readFileSync } = await import('node:fs'); + const { join } = await import('node:path'); + const { fileURLToPath } = await import('node:url'); + let cliVersion = 'unknown'; + try { + const dir = typeof __dirname !== 'undefined' ? __dirname : fileURLToPath(new URL('.', import.meta.url)); + const pkg = JSON.parse(readFileSync(join(dir, '../../../package.json'), 'utf-8')) as { version: string }; + cliVersion = pkg.version; + } catch { /* ignore */ } + + s?.stop('Data collected'); + + if (!json) { + console.log(`\n AI Diagnosis — ${projectName}\n`); + console.log(sectionHeader('Question')); + console.log(` ${question}\n`); + console.log(sectionHeader('Analysis')); } - if (dbResult.status === 'fulfilled') { - report.db = dbResult.value; - } else { - report.db = null; - errors.push(dbResult.reason?.message ?? 'DB checks unavailable'); + let sessionId: string | undefined; + let fullText = ''; + const jsonEvents: Record[] = []; + + await streamDiagnosticAnalysis({ + project_id: projectId, + question, + context: { + context_version: 'diagnostic-v1', + metrics: data.metrics ?? undefined, + advisor: data.advisor ?? undefined, + db: data.db ?? undefined, + logs: data.logs ?? undefined, + client_info: { + cli_version: cliVersion, + node_version: process.version, + os: `${os.platform()} ${os.release()}`, + }, + }, + }, (event) => { + if (json) { + jsonEvents.push({ type: event.type, ...event.data }); + return; + } + + switch (event.type) { + case 'delta': + process.stdout.write(String(event.data.text ?? '')); + fullText += String(event.data.text ?? ''); + break; + case 'tool_call': + console.log(`\n [calling ${event.data.toolName}...]`); + break; + case 'tool_result': + // silently consume tool results + break; + case 'done': + sessionId = event.data.sessionId as string | undefined; + break; + case 'error': + console.error(`\n Error: ${event.data.message ?? 'Unknown error'}`); + break; + } + }, apiUrl); + + if (!json) { + // Ensure newline after streamed text + if (fullText && !fullText.endsWith('\n')) console.log(''); + console.log(''); } - if (logsResult.status === 'fulfilled') { - report.logs = logsResult.value; - } else { - report.logs = null; - errors.push(logsResult.reason?.message ?? 'Logs unavailable'); + if (json) { + outputJson({ sessionId, events: jsonEvents }); } - report.errors = errors; - outputJson(report); + // Optional rating prompt (interactive only) + if (!json && sessionId) { + const ratingChoice = await clack.select({ + message: 'Was this analysis helpful?', + options: [ + { value: 'skip', label: 'Skip', hint: 'no rating' }, + { value: 'helpful', label: 'Helpful', hint: 'solved or pointed in right direction' }, + { value: 'not_helpful', label: 'Not helpful', hint: 'didn\'t apply to the problem' }, + { value: 'incorrect', label: 'Incorrect', hint: 'diagnosis was wrong or misleading' }, + ], + }); + + if (!clack.isCancel(ratingChoice) && ratingChoice !== 'skip') { + try { + await rateDiagnosticSession( + sessionId, + ratingChoice as 'helpful' | 'not_helpful' | 'incorrect', + undefined, + apiUrl, + ); + clack.log.success('Thanks for your feedback!'); + } catch { + clack.log.warn('Failed to submit rating.'); + } + } + } + + await reportCliUsage('cli.diagnose.ai', true); + return; + } + + // Standard report mode + const data = await collectDiagnosticData(projectId, ossMode, apiUrl); + + if (json) { + outputJson({ project: projectName, ...data }); } else { console.log(`\n InsForge Health Report — ${projectName}\n`); // Metrics section console.log(sectionHeader('System Metrics (last 1h)')); - if (metricsResult.status === 'fulfilled') { - const metrics = metricsResult.value.metrics; - if (metrics.length === 0) { + if (data.metrics) { + const metricsArr = data.metrics as { metric: string; latest: number | null }[]; + if (metricsArr.length === 0) { console.log(' No metrics data available.'); } else { const vals: Record = {}; - for (const m of metrics) { - if (m.data.length > 0) vals[m.metric] = m.data[m.data.length - 1].value; + for (const m of metricsArr) { + if (m.latest !== null) vals[m.metric] = m.latest; } const cpu = vals.cpu_usage !== undefined ? `${vals.cpu_usage.toFixed(1)}%` : 'N/A'; const mem = vals.memory_usage !== undefined ? `${vals.memory_usage.toFixed(1)}%` : 'N/A'; @@ -120,26 +258,25 @@ export function registerDiagnoseCommands(diagnoseCmd: Command): void { console.log(` Disk: ${disk} Network: ↓${netIn} ↑${netOut}`); } } else { - console.log(` N/A — ${metricsResult.reason?.message ?? 'unavailable'}`); + console.log(` N/A — ${data.errors.find((e) => e.includes('Metrics') || e.includes('Platform')) ?? 'unavailable'}`); } // Advisor section console.log('\n' + sectionHeader('Advisor Scan')); - if (advisorResult.status === 'fulfilled') { - const scan = advisorResult.value; - const s = scan.summary; + if (data.advisor) { + const scan = data.advisor as { scannedAt: string; status: string; summary: { critical: number; warning: number; info: number } }; const date = new Date(scan.scannedAt).toLocaleDateString(); - console.log(` ${date} (${scan.status}) — ${s.critical} critical · ${s.warning} warning · ${s.info} info`); + console.log(` ${date} (${scan.status}) — ${scan.summary.critical} critical · ${scan.summary.warning} warning · ${scan.summary.info} info`); } else { - console.log(` N/A — ${advisorResult.reason?.message ?? 'unavailable'}`); + console.log(` N/A — ${data.errors.find((e) => e.includes('Advisor') || e.includes('Platform')) ?? 'unavailable'}`); } // DB section console.log('\n' + sectionHeader('Database')); - if (dbResult.status === 'fulfilled') { - const db = dbResult.value; - const conn = db.connections?.[0] as Record | undefined; - const cache = db['cache-hit']?.[0] as Record | undefined; + if (data.db) { + const db = data.db as Record[]>; + const conn = db.connections?.[0]; + const cache = db['cache-hit']?.[0]; const deadTuples = (db.bloat ?? []).reduce( (sum: number, r: Record) => sum + (Number(r.dead_tuples) || 0), 0, @@ -153,17 +290,17 @@ export function registerDiagnoseCommands(diagnoseCmd: Command): void { ` Dead tuples: ${deadTuples.toLocaleString()} Locks waiting: ${lockCount}`, ); } else { - console.log(` N/A — ${dbResult.reason?.message ?? 'unavailable'}`); + console.log(` N/A — ${data.errors.find((e) => e.includes('DB')) ?? 'unavailable'}`); } // Logs section console.log('\n' + sectionHeader('Recent Errors (last 100 logs/source)')); - if (logsResult.status === 'fulfilled') { - const summaries = logsResult.value; + if (data.logs) { + const summaries = data.logs as { source: string; errors: unknown[] }[]; const parts = summaries.map((s) => `${s.source}: ${s.errors.length}`); console.log(` ${parts.join(' ')}`); } else { - console.log(` N/A — ${logsResult.reason?.message ?? 'unavailable'}`); + console.log(` N/A — ${data.errors.find((e) => e.includes('Logs')) ?? 'unavailable'}`); } console.log(''); diff --git a/src/lib/api/platform.ts b/src/lib/api/platform.ts index 560bca3..d6647bb 100644 --- a/src/lib/api/platform.ts +++ b/src/lib/api/platform.ts @@ -128,6 +128,92 @@ export async function reportAgentConnected( }); } +export interface DiagnosticRequest { + project_id: string; + question: string; + context: { + context_version: string; + metrics?: unknown; + advisor?: unknown; + db?: unknown; + logs?: unknown; + client_info?: { + cli_version?: string; + node_version?: string; + os?: string; + }; + }; +} + +export interface DiagnosticSSEEvent { + type: 'delta' | 'tool_call' | 'tool_result' | 'done' | 'error'; + data: Record; +} + +export type DiagnosticEventHandler = (event: DiagnosticSSEEvent) => void; + +/** + * Stream diagnostic analysis via SSE. Calls `onEvent` for each SSE event. + * Returns the raw Response so the caller can handle errors before streaming. + */ +export async function streamDiagnosticAnalysis( + payload: DiagnosticRequest, + onEvent: DiagnosticEventHandler, + apiUrl?: string, +): Promise { + const res = await platformFetch('/diagnostic/v1/analyze', { + method: 'POST', + body: JSON.stringify(payload), + }, apiUrl); + + const body = res.body; + if (!body) throw new CLIError('No response body from diagnostic API.'); + + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let currentEvent = ''; + + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + + for (const line of lines) { + if (line.startsWith('event:')) { + currentEvent = line.slice(6).trim(); + } else if (line.startsWith('data:')) { + const raw = line.slice(5).trim(); + if (!raw) continue; + try { + const data = JSON.parse(raw) as Record; + onEvent({ type: currentEvent as DiagnosticSSEEvent['type'], data }); + } catch { + // skip malformed JSON + } + } + // blank lines or comments are ignored + } + } +} + +export async function rateDiagnosticSession( + sessionId: string, + rating: 'helpful' | 'not_helpful' | 'incorrect', + comment?: string, + apiUrl?: string, +): Promise { + const body: Record = { rating }; + if (comment) body.comment = comment; + await platformFetch(`/diagnostic/v1/sessions/${sessionId}/rating`, { + method: 'POST', + body: JSON.stringify(body), + }, apiUrl); +} + export async function createProject( orgId: string, name: string, From a5a91379487b77954cbe2e279e8ad9184874d065 Mon Sep 17 00:00:00 2001 From: jwfing Date: Fri, 10 Apr 2026 17:09:00 -0700 Subject: [PATCH 2/6] fix: address PR #59 review comments - SSE parser: initialize currentEvent to 'delta', validate against known event types before casting - SSE parser: flush remaining buffer and multi-byte sequences after stream ends - CLI version: inject via tsup define (process.env.CLI_VERSION) instead of runtime package.json path traversal that breaks in bundled output - sessionId: capture from done event before JSON early-return so it's included in JSON output - Stream errors: record as CLIError and throw after streaming completes instead of silently succeeding - Telemetry: use consistent usageEvent variable for both success and failure paths (cli.diagnose.ai vs cli.diagnose) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/diagnose/index.ts | 36 +++++++++++++++-------------- src/lib/api/platform.ts | 42 ++++++++++++++++++++++------------ tsup.config.ts | 4 ++++ 3 files changed, 51 insertions(+), 31 deletions(-) diff --git a/src/commands/diagnose/index.ts b/src/commands/diagnose/index.ts index 3accc0f..8b231c1 100644 --- a/src/commands/diagnose/index.ts +++ b/src/commands/diagnose/index.ts @@ -100,6 +100,7 @@ export function registerDiagnoseCommands(diagnoseCmd: Command): void { .option('--ai ', 'Ask AI to analyze your diagnostic data (1-2000 chars)') .action(async (opts, cmd) => { const { json, apiUrl } = getRootOpts(cmd); + const usageEvent = opts.ai ? 'cli.diagnose.ai' : 'cli.diagnose'; try { await requireAuth(apiUrl); const config = getProjectConfig(); @@ -108,8 +109,6 @@ export function registerDiagnoseCommands(diagnoseCmd: Command): void { const projectId = config.project_id; const projectName = config.project_name; const ossMode = config.project_id === 'oss-project'; - - // Track diagnose usage in PostHog trackDiagnose(opts.ai ? 'ai' : 'report', config); // AI analysis mode @@ -124,16 +123,7 @@ export function registerDiagnoseCommands(diagnoseCmd: Command): void { const data = await collectDiagnosticData(projectId, ossMode, apiUrl); - // Read CLI version from package.json - const { readFileSync } = await import('node:fs'); - const { join } = await import('node:path'); - const { fileURLToPath } = await import('node:url'); - let cliVersion = 'unknown'; - try { - const dir = typeof __dirname !== 'undefined' ? __dirname : fileURLToPath(new URL('.', import.meta.url)); - const pkg = JSON.parse(readFileSync(join(dir, '../../../package.json'), 'utf-8')) as { version: string }; - cliVersion = pkg.version; - } catch { /* ignore */ } + const cliVersion = process.env.CLI_VERSION || 'unknown'; s?.stop('Data collected'); @@ -147,6 +137,7 @@ export function registerDiagnoseCommands(diagnoseCmd: Command): void { let sessionId: string | undefined; let fullText = ''; const jsonEvents: Record[] = []; + let streamError: CLIError | undefined; await streamDiagnosticAnalysis({ project_id: projectId, @@ -164,6 +155,14 @@ export function registerDiagnoseCommands(diagnoseCmd: Command): void { }, }, }, (event) => { + // Capture sessionId and errors before any early return + if (event.type === 'done') { + sessionId = event.data.sessionId as string | undefined; + } + if (event.type === 'error') { + streamError = new CLIError(String(event.data.message ?? 'Unknown diagnostic error')); + } + if (json) { jsonEvents.push({ type: event.type, ...event.data }); return; @@ -181,14 +180,17 @@ export function registerDiagnoseCommands(diagnoseCmd: Command): void { // silently consume tool results break; case 'done': - sessionId = event.data.sessionId as string | undefined; break; case 'error': - console.error(`\n Error: ${event.data.message ?? 'Unknown error'}`); + console.error(`\n Error: ${streamError?.message ?? 'Unknown error'}`); break; } }, apiUrl); + if (streamError) { + throw streamError; + } + if (!json) { // Ensure newline after streamed text if (fullText && !fullText.endsWith('\n')) console.log(''); @@ -226,7 +228,7 @@ export function registerDiagnoseCommands(diagnoseCmd: Command): void { } } - await reportCliUsage('cli.diagnose.ai', true); + await reportCliUsage(usageEvent, true); return; } @@ -305,9 +307,9 @@ export function registerDiagnoseCommands(diagnoseCmd: Command): void { console.log(''); } - await reportCliUsage('cli.diagnose', true); + await reportCliUsage(usageEvent, true); } catch (err) { - await reportCliUsage('cli.diagnose', false); + await reportCliUsage(usageEvent, false); handleError(err, json); } finally { await shutdownAnalytics(); diff --git a/src/lib/api/platform.ts b/src/lib/api/platform.ts index d6647bb..e823b81 100644 --- a/src/lib/api/platform.ts +++ b/src/lib/api/platform.ts @@ -172,7 +172,27 @@ export async function streamDiagnosticAnalysis( const reader = body.getReader(); const decoder = new TextDecoder(); let buffer = ''; - let currentEvent = ''; + let currentEvent: DiagnosticSSEEvent['type'] = 'delta'; + + const VALID_EVENTS = new Set(['delta', 'tool_call', 'tool_result', 'done', 'error']); + + const processLine = (line: string): void => { + if (line.startsWith('event:')) { + const evt = line.slice(6).trim(); + if (VALID_EVENTS.has(evt)) { + currentEvent = evt as DiagnosticSSEEvent['type']; + } + } else if (line.startsWith('data:')) { + const raw = line.slice(5).trim(); + if (!raw) return; + try { + const data = JSON.parse(raw) as Record; + onEvent({ type: currentEvent, data }); + } catch { + // skip malformed JSON + } + } + }; for (;;) { const { done, value } = await reader.read(); @@ -183,21 +203,15 @@ export async function streamDiagnosticAnalysis( buffer = lines.pop() ?? ''; for (const line of lines) { - if (line.startsWith('event:')) { - currentEvent = line.slice(6).trim(); - } else if (line.startsWith('data:')) { - const raw = line.slice(5).trim(); - if (!raw) continue; - try { - const data = JSON.parse(raw) as Record; - onEvent({ type: currentEvent as DiagnosticSSEEvent['type'], data }); - } catch { - // skip malformed JSON - } - } - // blank lines or comments are ignored + processLine(line); } } + + // Flush remaining bytes from multi-byte sequences + buffer += decoder.decode(); + if (buffer.trim()) { + processLine(buffer); + } } export async function rateDiagnosticSession( diff --git a/tsup.config.ts b/tsup.config.ts index a89eb95..9485dee 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,5 +1,8 @@ +import { readFileSync } from 'node:fs'; import { defineConfig } from 'tsup'; +const pkg = JSON.parse(readFileSync('./package.json', 'utf-8')) as { version: string }; + export default defineConfig({ entry: ['src/index.ts'], format: ['esm'], @@ -14,5 +17,6 @@ export default defineConfig({ }, define: { 'process.env.POSTHOG_API_KEY': JSON.stringify(process.env.POSTHOG_API_KEY || ''), + 'process.env.CLI_VERSION': JSON.stringify(pkg.version), }, }); From e6db246533fa7594e924afaf8584b5a0602203d7 Mon Sep 17 00:00:00 2001 From: jwfing Date: Fri, 10 Apr 2026 20:27:21 -0700 Subject: [PATCH 3/6] fix: use snake_case field names for SSE event data Align with backend OpenAPI spec: session_id instead of sessionId, tool_name instead of toolName. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/diagnose/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/diagnose/index.ts b/src/commands/diagnose/index.ts index 8b231c1..261b62b 100644 --- a/src/commands/diagnose/index.ts +++ b/src/commands/diagnose/index.ts @@ -157,7 +157,7 @@ export function registerDiagnoseCommands(diagnoseCmd: Command): void { }, (event) => { // Capture sessionId and errors before any early return if (event.type === 'done') { - sessionId = event.data.sessionId as string | undefined; + sessionId = event.data.session_id as string | undefined; } if (event.type === 'error') { streamError = new CLIError(String(event.data.message ?? 'Unknown diagnostic error')); @@ -174,7 +174,7 @@ export function registerDiagnoseCommands(diagnoseCmd: Command): void { fullText += String(event.data.text ?? ''); break; case 'tool_call': - console.log(`\n [calling ${event.data.toolName}...]`); + console.log(`\n [calling ${event.data.tool_name}...]`); break; case 'tool_result': // silently consume tool results From bccbdc9aeb90ef0e34070ecb22f4911efcbd58a9 Mon Sep 17 00:00:00 2001 From: jwfing Date: Fri, 10 Apr 2026 20:50:14 -0700 Subject: [PATCH 4/6] fix: reset currentEvent on unknown SSE event type Set currentEvent to null when event type is not in VALID_EVENTS, and skip data lines when currentEvent is null to prevent emitting events with a stale type. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/api/platform.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/lib/api/platform.ts b/src/lib/api/platform.ts index e823b81..f1ab9a7 100644 --- a/src/lib/api/platform.ts +++ b/src/lib/api/platform.ts @@ -172,17 +172,16 @@ export async function streamDiagnosticAnalysis( const reader = body.getReader(); const decoder = new TextDecoder(); let buffer = ''; - let currentEvent: DiagnosticSSEEvent['type'] = 'delta'; + let currentEvent: DiagnosticSSEEvent['type'] | null = 'delta'; const VALID_EVENTS = new Set(['delta', 'tool_call', 'tool_result', 'done', 'error']); const processLine = (line: string): void => { if (line.startsWith('event:')) { const evt = line.slice(6).trim(); - if (VALID_EVENTS.has(evt)) { - currentEvent = evt as DiagnosticSSEEvent['type']; - } + currentEvent = VALID_EVENTS.has(evt) ? evt as DiagnosticSSEEvent['type'] : null; } else if (line.startsWith('data:')) { + if (!currentEvent) return; const raw = line.slice(5).trim(); if (!raw) return; try { From 9fd8ee7ce74785c55809bc573a01181c9e4dbfca Mon Sep 17 00:00:00 2001 From: jwfing Date: Sun, 12 Apr 2026 15:35:13 -0700 Subject: [PATCH 5/6] fix: transform diagnostic context to match backend schema - Filter out metrics with no data points (avoid null values) - Strip extra advisor fields (scanId, scanType, etc.), only send summary + collectorErrors as string[] - Stringify all db values to match spec types (string not number) - Explicitly pick log error fields (timestamp, message, source) - Improve platformFetch error message to include response message field - Add INSFORGE_DEBUG env var for request logging Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/diagnose/index.ts | 112 ++++++++++++++++++++++++--------- src/lib/api/platform.ts | 17 +++-- 2 files changed, 95 insertions(+), 34 deletions(-) diff --git a/src/commands/diagnose/index.ts b/src/commands/diagnose/index.ts index 261b62b..41491be 100644 --- a/src/commands/diagnose/index.ts +++ b/src/commands/diagnose/index.ts @@ -53,21 +53,22 @@ async function collectDiagnosticData( if (metricsResult.status === 'fulfilled') { const data = metricsResult.value; - metrics = data.metrics.map((m) => { - if (m.data.length === 0) return { metric: m.metric, latest: null, avg: null, max: null }; - let sum = 0; - let max = -Infinity; - for (const d of m.data) { - sum += d.value; - if (d.value > max) max = d.value; - } - return { - metric: m.metric, - latest: m.data[m.data.length - 1].value, - avg: sum / m.data.length, - max, - }; - }); + metrics = data.metrics + .filter((m) => m.data.length > 0) + .map((m) => { + let sum = 0; + let max = -Infinity; + for (const d of m.data) { + sum += d.value; + if (d.value > max) max = d.value; + } + return { + metric: m.metric, + latest: m.data[m.data.length - 1].value, + avg: sum / m.data.length, + max, + }; + }); } else { errors.push(metricsResult.reason?.message ?? 'Metrics unavailable'); } @@ -139,22 +140,73 @@ export function registerDiagnoseCommands(diagnoseCmd: Command): void { const jsonEvents: Record[] = []; let streamError: CLIError | undefined; + // Build context — transform collected data to match backend schema exactly + const context: Record = { + context_version: 'diagnostic-v1', + client_info: { + cli_version: cliVersion, + node_version: process.version, + os: `${os.platform()} ${os.release()}`, + }, + }; + if (Array.isArray(data.metrics) && data.metrics.length > 0) { + context.metrics = data.metrics; + } + if (data.advisor) { + // Spec only accepts: { summary, collectorErrors: string[] } + const adv = data.advisor as Record; + const summary = adv.summary as Record | undefined; + const rawErrors = adv.collectorErrors as unknown[] | undefined; + if (summary) { + context.advisor = { + summary: { + total: summary.total ?? 0, + critical: summary.critical ?? 0, + warning: summary.warning ?? 0, + info: summary.info ?? 0, + }, + collectorErrors: rawErrors?.map((e) => + typeof e === 'string' ? e : JSON.stringify(e), + ) ?? [], + }; + } + } + if (data.db) { + // Stringify all values to match spec (active: string, dead_tuples: string, etc.) + const rawDb = data.db as Record[]>; + const safeDb: Record[]> = {}; + for (const [key, rows] of Object.entries(rawDb)) { + safeDb[key] = rows.map((row) => { + const out: Record = {}; + for (const [k, v] of Object.entries(row)) { + out[k] = v == null ? '' : String(v); + } + return out; + }); + } + if (Object.keys(safeDb).length > 0) { + context.db = safeDb; + } + } + if (Array.isArray(data.logs) && data.logs.length > 0) { + // Spec: { source: string, total: integer, errors: [{timestamp, message, source}] } + context.logs = (data.logs as { source: string; total: number; errors: { timestamp: string; message: string; source: string }[] }[]) + .map((s) => ({ + source: s.source, + total: s.total, + errors: s.errors.map((e) => ({ + timestamp: e.timestamp ?? '', + message: e.message ?? '', + source: e.source ?? '', + })), + })); + } + await streamDiagnosticAnalysis({ project_id: projectId, question, - context: { - context_version: 'diagnostic-v1', - metrics: data.metrics ?? undefined, - advisor: data.advisor ?? undefined, - db: data.db ?? undefined, - logs: data.logs ?? undefined, - client_info: { - cli_version: cliVersion, - node_version: process.version, - os: `${os.platform()} ${os.release()}`, - }, - }, - }, (event) => { + context, + } as Parameters[0], (event) => { // Capture sessionId and errors before any early return if (event.type === 'done') { sessionId = event.data.session_id as string | undefined; @@ -243,13 +295,13 @@ export function registerDiagnoseCommands(diagnoseCmd: Command): void { // Metrics section console.log(sectionHeader('System Metrics (last 1h)')); if (data.metrics) { - const metricsArr = data.metrics as { metric: string; latest: number | null }[]; + const metricsArr = data.metrics as { metric: string; latest: number }[]; if (metricsArr.length === 0) { console.log(' No metrics data available.'); } else { const vals: Record = {}; for (const m of metricsArr) { - if (m.latest !== null) vals[m.metric] = m.latest; + vals[m.metric] = m.latest; } const cpu = vals.cpu_usage !== undefined ? `${vals.cpu_usage.toFixed(1)}%` : 'N/A'; const mem = vals.memory_usage !== undefined ? `${vals.memory_usage.toFixed(1)}%` : 'N/A'; diff --git a/src/lib/api/platform.ts b/src/lib/api/platform.ts index f1ab9a7..09480d6 100644 --- a/src/lib/api/platform.ts +++ b/src/lib/api/platform.ts @@ -19,14 +19,22 @@ export async function platformFetch( if (!token) { throw new AuthError(); } - const headers: Record = { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, ...(options.headers as Record ?? {}), }; - const res = await fetch(`${baseUrl}${path}`, { ...options, headers }); + const url = `${baseUrl}${path}`; + if (process.env.INSFORGE_DEBUG) { + console.error(`[DEBUG] ${options.method ?? 'GET'} ${url}`); + console.error(`[DEBUG] Headers: ${JSON.stringify(headers, null, 2)}`); + if (options.body) { + console.error(`[DEBUG] Body: ${typeof options.body === 'string' ? options.body : JSON.stringify(options.body)}`); + } + } + + const res = await fetch(url, { ...options, headers }); // Auto-refresh on 401 if (res.status === 401) { @@ -41,8 +49,9 @@ export async function platformFetch( } if (!res.ok) { - const err = await res.json().catch(() => ({})) as { error?: string }; - throw new CLIError(err.error ?? `Request failed: ${res.status}`, res.status === 403 ? 5 : 1); + const err = await res.json().catch(() => ({})) as { error?: string; message?: string }; + const msg = err.message ? `${err.error ?? res.status}: ${err.message}` : (err.error ?? `Request failed: ${res.status}`); + throw new CLIError(msg, res.status === 403 ? 5 : 1); } return res; From d5224b57f938e7aed1ebc9a619768cffdf413467 Mon Sep 17 00:00:00 2001 From: jwfing Date: Sun, 12 Apr 2026 15:35:59 -0700 Subject: [PATCH 6/6] fix(link): use valid UUID format for OSS project/org IDs Replace non-hex placeholder IDs (oss-project, oss-org) with properly formatted UUIDs for OSS/self-hosted linking. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/projects/link.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/commands/projects/link.ts b/src/commands/projects/link.ts index 5fa2726..46903cc 100644 --- a/src/commands/projects/link.ts +++ b/src/commands/projects/link.ts @@ -75,11 +75,11 @@ export function registerProjectLinkCommand(program: Command): void { // Direct OSS/Self-hosted linking bypasses OAuth const projectConfig: ProjectConfig = { - project_id: 'oss-project', + project_id: 'fa4e0000-1234-5678-90ab-0e02b2c3d479', project_name: 'oss-project', - org_id: 'oss-org', - appkey: 'oss', - region: 'local', + org_id: 'fa4e0001-1234-5678-90ab-0e02b2c3d479', + appkey: 'ossfkey', + region: 'us-test', api_key: opts.apiKey, oss_host: opts.apiBaseUrl.replace(/\/$/, ''), // remove trailing slash if any };