diff --git a/CHANGELOG.md b/CHANGELOG.md index e7dd43d..b2e0616 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.9.9 - Unreleased + +### Added (CLI) +- **ChatGPT Desktop diagnostic.** New `codeburn doctor chatgpt-desktop` + command for issue #234. It probes `com.openai.chat` and `com.openai.atlas` + local SQLite storage read-only and prints schema metadata plus redacted local + paths: database filenames, table names, and usage-like column names. It + intentionally does not register a ChatGPT provider or estimate costs from + message text until token/cost fields are confirmed in the local schema. + ## 0.9.8 - 2026-05-10 ### Added (CLI) diff --git a/README.md b/README.md index b370022..5ee34ad 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,17 @@ codeburn models --task feature # filter to feature-development work codeburn models --provider claude # filter to one provider ``` +### Diagnostics + +```bash +codeburn doctor chatgpt-desktop # schema-only probe for issue #234 +``` + +The ChatGPT Desktop diagnostic prints macOS local SQLite database names, table +names, column names, and redacted local paths from `com.openai.chat` / +`com.openai.atlas`. It does not read conversation rows or message text. Use +`--format json` when sharing output in an issue. + Arrow keys switch between Today, 7 Days, 30 Days, Month, and 6 Months (use `--from` / `--to` for an exact historical window). Press `q` to quit, `1` `2` `3` `4` `5` as shortcuts, `c` to open model comparison, `o` to open optimize. The dashboard auto-refreshes every 30 seconds by default (`--refresh 0` to disable). It also shows average cost per session and the five most expensive sessions across all projects. ## Supported Providers diff --git a/docs/providers/README.md b/docs/providers/README.md index 05f43db..a4bdde4 100644 --- a/docs/providers/README.md +++ b/docs/providers/README.md @@ -40,6 +40,12 @@ For the architectural picture, see `../architecture.md`. |---|---|---| | [vscode-cline-parser](vscode-cline-parser.md) | `kilo-code`, `roo-code` | `src/providers/vscode-cline-parser.ts` | +### Investigations + +| Topic | Source | CLI | +|---|---|---| +| [ChatGPT Desktop](chatgpt-desktop.md) | `src/chatgpt-desktop-diagnostics.ts` | `codeburn doctor chatgpt-desktop` | + ## File Format Each provider doc has the same structure: diff --git a/docs/providers/chatgpt-desktop.md b/docs/providers/chatgpt-desktop.md new file mode 100644 index 0000000..e14a204 --- /dev/null +++ b/docs/providers/chatgpt-desktop.md @@ -0,0 +1,48 @@ +# ChatGPT Desktop + +Pre-provider investigation for the ChatGPT desktop app on macOS. + +- **Source:** `src/chatgpt-desktop-diagnostics.ts` +- **CLI:** `codeburn doctor chatgpt-desktop` +- **Test:** `tests/chatgpt-desktop-diagnostics.test.ts` + +## Status + +Not a usage provider yet. CodeBurn should only add a real ChatGPT Desktop provider if the local app storage exposes defensible token/cost data. Estimating cost from message text length would be misleading for ChatGPT subscriptions and should not be shipped as provider usage. + +## What the diagnostic checks + +The diagnostic scans known macOS storage roots: + +``` +~/Library/Application Support/com.openai.chat/ +~/Library/Application Support/com.openai.atlas/ +``` + +It recursively finds `.sqlite`, `.sqlite3`, and `.db` files under those roots, opens them read-only, and prints schema metadata only: + +- root path found/missing, with the home directory redacted to `~` in CLI output +- database relative path +- table/view count +- column names that look usage-related (`token`, `usage`, `cost`, `model`, `prompt`, `completion`, `input`, `output`) + +It does **not** read conversation rows, message text, or column values. + +## Expected use for issue #234 + +Ask affected users to run: + +``` +codeburn doctor chatgpt-desktop --format json +``` + +If the app data lives somewhere else, point the diagnostic at one or more +custom roots. Use the platform path delimiter (`:` on macOS/Linux, `;` on +Windows): + +``` +CODEBURN_CHATGPT_DESKTOP_DIRS="/path/to/com.openai.chat:/path/to/com.openai.atlas" \ + codeburn doctor chatgpt-desktop --format json +``` + +The JSON output is intended for issue triage: it contains schema metadata and redacted local paths, not row values. If it shows stable token/cost columns, add a real provider with fixtures based on that schema. If it only shows logging/state tables with no token-level accounting, keep #234 open as blocked on upstream storage/API data rather than adding approximate costs. diff --git a/src/chatgpt-desktop-diagnostics.ts b/src/chatgpt-desktop-diagnostics.ts new file mode 100644 index 0000000..7d0d20b --- /dev/null +++ b/src/chatgpt-desktop-diagnostics.ts @@ -0,0 +1,282 @@ +import { readdir, stat } from 'fs/promises' +import { homedir } from 'os' +import { delimiter, join, relative } from 'path' + +import { getSqliteLoadError, isSqliteAvailable, openDatabase, type SqliteDatabase } from './sqlite.js' + +const SQLITE_EXTENSIONS = ['.sqlite', '.sqlite3', '.db'] +const USAGE_COLUMN_RE = /(token|usage|cost|model|prompt|completion|input|output)/i + +export type ChatGPTDesktopColumn = { + name: string + type: string +} + +export type ChatGPTDesktopTable = { + name: string + columns: ChatGPTDesktopColumn[] + usageLikeColumns: string[] +} + +export type ChatGPTDesktopDatabase = { + path: string + root: string + relativePath: string + tables: ChatGPTDesktopTable[] + error?: string +} + +export type ChatGPTDesktopRoot = { + path: string + exists: boolean +} + +export type ChatGPTDesktopDiagnostics = { + sqliteAvailable: boolean + sqliteError?: string + roots: ChatGPTDesktopRoot[] + databases: ChatGPTDesktopDatabase[] + conclusion: 'storage-not-found' | 'sqlite-unavailable' | 'no-databases' | 'usage-candidates-found' | 'no-usage-candidates' +} + +type InspectOptions = { + roots?: string[] + maxDepth?: number +} + +type SqliteSchemaRow = { + name: string + type: string + sql: string | null +} + +type PragmaTableInfoRow = { + name: string + type: string | null +} + +export function defaultChatGPTDesktopRoots(): string[] { + const env = process.env['CODEBURN_CHATGPT_DESKTOP_DIRS'] + if (env) return env.split(delimiter).map(s => s.trim()).filter(Boolean) + + const home = homedir() + return [ + join(home, 'Library', 'Application Support', 'com.openai.chat'), + join(home, 'Library', 'Application Support', 'com.openai.atlas'), + ] +} + +function isSqlitePath(path: string): boolean { + const lower = path.toLowerCase() + if (lower.endsWith('-wal') || lower.endsWith('-shm')) return false + return SQLITE_EXTENSIONS.some(ext => lower.endsWith(ext)) +} + +function redactHomePath(path: string): string { + const home = homedir() + if (path === home || path.startsWith(`${home}/`) || path.startsWith(`${home}\\`)) { + return `~${path.slice(home.length)}` + } + return path +} + +function redactHomeInText(text: string): string { + const home = homedir() + return home ? text.split(home).join('~') : text +} + +async function pathExists(path: string): Promise { + return stat(path).then(s => s.isDirectory()).catch(() => false) +} + +async function findSqliteFiles(root: string, maxDepth: number): Promise { + const out: string[] = [] + + async function walk(dir: string, depth: number): Promise { + if (depth > maxDepth) return + + let entries: Array<{ name: string; isDirectory(): boolean; isFile(): boolean }> + try { + entries = await readdir(dir, { withFileTypes: true }) + } catch { + return + } + + for (const entry of entries) { + const full = join(dir, entry.name) + if (entry.isDirectory()) { + await walk(full, depth + 1) + } else if (entry.isFile() && isSqlitePath(full)) { + out.push(full) + } + } + } + + await walk(root, 0) + return out.sort() +} + +function quoteIdentifier(identifier: string): string { + return `"${identifier.replace(/"/g, '""')}"` +} + +function inspectDatabase(db: SqliteDatabase, dbPath: string, root: string): ChatGPTDesktopDatabase { + const schemaRows = db.query( + `SELECT name, type, sql + FROM sqlite_schema + WHERE type IN ('table', 'view') + AND name NOT LIKE 'sqlite_%' + ORDER BY name`, + ) + + const tables: ChatGPTDesktopTable[] = [] + for (const row of schemaRows) { + let columns: ChatGPTDesktopColumn[] = [] + try { + columns = db.query(`PRAGMA table_info(${quoteIdentifier(row.name)})`) + .map(c => ({ name: c.name, type: c.type ?? '' })) + } catch { + columns = [] + } + + const usageLikeColumns = columns + .map(c => c.name) + .filter(name => USAGE_COLUMN_RE.test(name)) + + tables.push({ + name: row.name, + columns, + usageLikeColumns, + }) + } + + return { + path: dbPath, + root, + relativePath: relative(root, dbPath) || dbPath, + tables, + } +} + +export async function inspectChatGPTDesktop(options: InspectOptions = {}): Promise { + const roots = options.roots ?? defaultChatGPTDesktopRoots() + const maxDepth = options.maxDepth ?? 4 + const rootStatuses: ChatGPTDesktopRoot[] = [] + + for (const root of roots) { + rootStatuses.push({ path: root, exists: await pathExists(root) }) + } + + if (!isSqliteAvailable()) { + return { + sqliteAvailable: false, + sqliteError: getSqliteLoadError(), + roots: rootStatuses, + databases: [], + conclusion: 'sqlite-unavailable', + } + } + + const databases: ChatGPTDesktopDatabase[] = [] + for (const root of rootStatuses.filter(r => r.exists).map(r => r.path)) { + const files = await findSqliteFiles(root, maxDepth) + for (const dbPath of files) { + let db: SqliteDatabase | null = null + try { + // Shared SQLite wrapper opens databases with node:sqlite readOnly: true. + db = openDatabase(dbPath) + databases.push(inspectDatabase(db, dbPath, root)) + } catch (err) { + databases.push({ + path: dbPath, + root, + relativePath: relative(root, dbPath) || dbPath, + tables: [], + error: err instanceof Error ? err.message : String(err), + }) + } finally { + db?.close() + } + } + } + + const anyRoot = rootStatuses.some(r => r.exists) + const anyUsageCandidates = databases.some(db => db.tables.some(t => t.usageLikeColumns.length > 0)) + const conclusion: ChatGPTDesktopDiagnostics['conclusion'] = + !anyRoot ? 'storage-not-found' + : databases.length === 0 ? 'no-databases' + : anyUsageCandidates ? 'usage-candidates-found' + : 'no-usage-candidates' + + return { + sqliteAvailable: true, + roots: rootStatuses, + databases, + conclusion, + } +} + +export function redactChatGPTDesktopDiagnostics(report: ChatGPTDesktopDiagnostics): ChatGPTDesktopDiagnostics { + return { + ...report, + sqliteError: report.sqliteError ? redactHomeInText(report.sqliteError) : undefined, + roots: report.roots.map(root => ({ + ...root, + path: redactHomePath(root.path), + })), + databases: report.databases.map(db => ({ + ...db, + path: redactHomePath(db.path), + root: redactHomePath(db.root), + error: db.error ? redactHomeInText(db.error) : undefined, + })), + } +} + +export function renderChatGPTDesktopDiagnostics(report: ChatGPTDesktopDiagnostics): string { + const displayReport = redactChatGPTDesktopDiagnostics(report) + const lines: string[] = [] + lines.push('ChatGPT Desktop diagnostics') + lines.push('') + lines.push('Storage roots:') + for (const root of displayReport.roots) { + lines.push(` ${root.exists ? 'found' : 'missing'} ${root.path}`) + } + + if (!displayReport.sqliteAvailable) { + lines.push('') + lines.push('SQLite driver unavailable:') + lines.push(` ${displayReport.sqliteError ?? redactHomeInText(getSqliteLoadError())}`) + return lines.join('\n') + } + + lines.push('') + if (displayReport.databases.length === 0) { + lines.push('SQLite databases: none found') + } else { + lines.push(`SQLite databases: ${displayReport.databases.length}`) + for (const db of displayReport.databases) { + lines.push(` ${db.relativePath}`) + if (db.error) { + lines.push(` error: ${db.error}`) + continue + } + lines.push(` tables/views: ${db.tables.length}`) + const candidates = db.tables.filter(t => t.usageLikeColumns.length > 0) + if (candidates.length === 0) { + lines.push(' usage-like columns: none') + } else { + lines.push(' usage-like columns:') + for (const table of candidates) { + lines.push(` ${table.name}: ${table.usageLikeColumns.join(', ')}`) + } + } + } + } + + lines.push('') + lines.push(`Conclusion: ${displayReport.conclusion}`) + lines.push('This command prints schema metadata and redacted local paths only. It does not read conversation rows or message text.') + + return lines.join('\n') +} diff --git a/src/cli.ts b/src/cli.ts index 4ebfe33..e9d34d4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -19,6 +19,11 @@ import { getAllProviders } from './providers/index.js' import { clearPlan, readConfig, readPlan, saveConfig, savePlan, getConfigFilePath, type PlanId } from './config.js' import { clampResetDay, getPlanUsageOrNull, type PlanUsage } from './plan-usage.js' import { getPresetPlan, isPlanId, isPlanProvider, planDisplayName } from './plans.js' +import { + inspectChatGPTDesktop, + redactChatGPTDesktopDiagnostics, + renderChatGPTDesktopDiagnostics, +} from './chatgpt-desktop-diagnostics.js' import { createRequire } from 'node:module' const require = createRequire(import.meta.url) @@ -658,6 +663,28 @@ program } }) +program + .command('doctor [target]') + .description('Run local diagnostics for provider support investigations') + .option('--format ', 'Output format: text, json', 'text') + .action(async (target = 'chatgpt-desktop', opts: { format?: string }) => { + const normalized = target.toLowerCase() + if (!['chatgpt-desktop', 'chatgpt'].includes(normalized)) { + console.error(`\n Unknown doctor target: ${target}`) + console.error(' Available targets: chatgpt-desktop (alias: chatgpt)\n') + process.exitCode = 1 + return + } + + assertFormat(opts.format ?? 'text', ['text', 'json'], 'doctor') + const report = await inspectChatGPTDesktop() + if (opts.format === 'json') { + console.log(JSON.stringify(redactChatGPTDesktopDiagnostics(report), null, 2)) + } else { + console.log(renderChatGPTDesktopDiagnostics(report)) + } + }) + program .command('currency [code]') .description('Set display currency (e.g. codeburn currency GBP)') diff --git a/tests/chatgpt-desktop-diagnostics.test.ts b/tests/chatgpt-desktop-diagnostics.test.ts new file mode 100644 index 0000000..d1495df --- /dev/null +++ b/tests/chatgpt-desktop-diagnostics.test.ts @@ -0,0 +1,116 @@ +import { mkdir, mkdtemp, rm } from 'fs/promises' +import { mkdirSync } from 'fs' +import { join } from 'path' +import { homedir, tmpdir } from 'os' +import { createRequire } from 'node:module' + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { + inspectChatGPTDesktop, + redactChatGPTDesktopDiagnostics, + renderChatGPTDesktopDiagnostics, +} from '../src/chatgpt-desktop-diagnostics.js' +import { isSqliteAvailable } from '../src/sqlite.js' + +const requireForTest = createRequire(import.meta.url) + +type TestDb = { + exec(sql: string): void + close(): void +} + +let tmpRoot: string + +beforeEach(async () => { + tmpRoot = await mkdtemp(join(tmpdir(), 'chatgpt-desktop-test-')) +}) + +afterEach(async () => { + await rm(tmpRoot, { recursive: true, force: true }) +}) + +function createSqliteDb(path: string): void { + mkdirSync(join(path, '..'), { recursive: true }) + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const db: TestDb = new Database(path) + db.exec(` + CREATE TABLE conversations ( + id TEXT PRIMARY KEY, + title TEXT, + model_slug TEXT, + created_at INTEGER + ) + `) + db.exec(` + CREATE TABLE message_usage ( + message_id TEXT, + prompt_tokens INTEGER, + completion_tokens INTEGER, + total_cost_usd REAL + ) + `) + db.exec(`INSERT INTO conversations (id, title, model_slug, created_at) VALUES ('secret-id', 'private title', 'gpt-5', 1)`) + db.close() +} + +describe.skipIf(!isSqliteAvailable())('ChatGPT Desktop diagnostics', () => { + it('reports missing storage roots without reading data rows', async () => { + const report = await inspectChatGPTDesktop({ + roots: [join(tmpRoot, 'missing-chat'), join(tmpRoot, 'missing-atlas')], + }) + + expect(report.conclusion).toBe('storage-not-found') + expect(report.databases).toEqual([]) + expect(report.roots.every(r => !r.exists)).toBe(true) + }) + + it('scans sqlite schemas and surfaces usage-like column names only', async () => { + const root = join(tmpRoot, 'com.openai.chat') + await mkdir(root, { recursive: true }) + createSqliteDb(join(root, 'state_5.sqlite')) + + const report = await inspectChatGPTDesktop({ roots: [root] }) + + expect(report.conclusion).toBe('usage-candidates-found') + expect(report.databases).toHaveLength(1) + expect(report.databases[0]!.relativePath).toBe('state_5.sqlite') + + const usage = report.databases[0]!.tables.find(t => t.name === 'message_usage') + expect(usage?.usageLikeColumns).toEqual([ + 'prompt_tokens', + 'completion_tokens', + 'total_cost_usd', + ]) + + const rendered = renderChatGPTDesktopDiagnostics(report) + expect(rendered).toContain('state_5.sqlite') + expect(rendered).toContain('message_usage: prompt_tokens, completion_tokens, total_cost_usd') + expect(rendered).not.toContain('private title') + expect(rendered).not.toContain('secret-id') + }) + + it('redacts the home directory in shareable output', () => { + const homeRoot = join(homedir(), 'Library', 'Application Support', 'com.openai.chat') + const report = { + sqliteAvailable: true, + roots: [{ path: homeRoot, exists: true }], + databases: [{ + path: join(homeRoot, 'state_5.sqlite'), + root: homeRoot, + relativePath: 'state_5.sqlite', + tables: [], + }], + conclusion: 'no-usage-candidates' as const, + } + + const redacted = redactChatGPTDesktopDiagnostics(report) + expect(redacted.roots[0]!.path).toBe(`~${homeRoot.slice(homedir().length)}`) + expect(redacted.databases[0]!.path).toBe(`~${join(homeRoot, 'state_5.sqlite').slice(homedir().length)}`) + + const rendered = renderChatGPTDesktopDiagnostics(report) + expect(rendered).toContain('~/Library/Application Support/com.openai.chat') + expect(rendered).not.toContain(homedir()) + expect(report.roots[0]!.path).toBe(homeRoot) + }) +})