import path from 'node:path'; import process from 'node:process'; import { spawn } from 'node:child_process'; import { closeSync, mkdirSync, openSync, readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { Cli, z } from 'incur'; import { DEFAULT_BROWSER_ENDPOINT, downloadThreadAttachment, exportThreadSnapshot, isCaptureIdentityDigest, parseThreadCaptureIdentity, type ThreadCaptureIdentity, } from './chatgpt-thread-lib.mjs'; import { collectThreadDiagnostics } from './chatgpt-thread-diagnostics-lib.mjs'; import { formatCodexHomeForDisplay, formatPathForDisplay } from './codex-session-lib.mjs'; import { chatIdFromUrl, parseWakeDelayToMs, runWakeFlow, type WakeRecursiveInfo } from './chatgpt-thread-wake-lib.mjs'; const cliEntryPath = fileURLToPath(new URL('./bin.mjs', import.meta.url)); function normalizeConversationUrl(chatUrl: string): string { try { const parsed = new URL(chatUrl); const match = parsed.pathname.match(/^\/c\/([^/?#]+)\/?$/u); const chatId = match?.[1]; if (!chatId) { throw new Error('missing-chat-id'); } return `${parsed.origin}/c/${chatId}`; } catch { throw new Error( `Expected a full ChatGPT conversation URL like https://chatgpt.com/c/; received ${chatUrl}`, ); } } function defaultWakeOutputDir(chatUrl: string): string { const chatId = chatIdFromUrl(chatUrl); const timestamp = new Date().toISOString().replaceAll(':', '').replace(/\.\d{3}Z$/u, 'Z'); return path.join(process.cwd(), 'output-packages', 'chatgpt-watch', `${chatId}-${timestamp}`); } type DetachedWakeCliOptions = { browserEndpoint: string; captureMetadata?: string; chatUrl: string; codexHome?: string; delay: string; detach: boolean; downloadTimeoutMs: number; fullAuto: boolean; outputDir: string; pollInterval: string; pollJitter: string; pollTimeout?: string; pollUntilComplete: boolean; recursiveDepth: number; recursivePrompt?: string; repoDir: string; resumePrompt?: string; sessionId?: string; skipResume: boolean; tabLifecycle: 'keep' | 'close-created' | 'close-harvested'; }; export function buildDetachedWakeCommandArgs(options: DetachedWakeCliOptions): string[] { const args = [ cliEntryPath, 'thread', 'wake', '--browser-endpoint', options.browserEndpoint, '--chat-url', options.chatUrl, '--delay', options.delay, '--download-timeout-ms', String(options.downloadTimeoutMs), '--output-dir', options.outputDir, '--poll-interval', options.pollInterval, '--poll-jitter', options.pollJitter, '--recursive-depth', String(options.recursiveDepth), '--repo-dir', options.repoDir, '--tab-lifecycle', options.tabLifecycle, ]; if (options.codexHome) { args.push('--codex-home', options.codexHome); } if (options.captureMetadata) { args.push('--capture-metadata', options.captureMetadata); } if (options.fullAuto) { args.push('--full-auto'); } if (options.pollTimeout) { args.push('--poll-timeout', options.pollTimeout); } if (options.pollUntilComplete === false) { args.push('--no-poll-until-complete'); } if (options.recursivePrompt) { args.push('--recursive-prompt', options.recursivePrompt); } if (options.resumePrompt) { args.push('--resume-prompt', options.resumePrompt); } if (options.sessionId) { args.push('--session-id', options.sessionId); } if (options.skipResume) { args.push('--skip-resume'); } return args; } export function launchDetachedWakeProcess(input: { args: string[]; cwd: string; env?: NodeJS.ProcessEnv; logPath: string; }): { wakePid: number } { mkdirSync(path.dirname(input.logPath), { recursive: true }); const logFd = openSync(input.logPath, 'a'); try { const child = spawn(process.execPath, input.args, { cwd: input.cwd, env: input.env ?? process.env, detached: true, stdio: ['ignore', logFd, logFd], }); child.unref(); if (!child.pid) { throw new Error('Detached wake launch did not return a process id.'); } return { wakePid: child.pid }; } finally { closeSync(logFd); } } function formatWakeRecursiveInfoForDisplay(recursive: WakeRecursiveInfo | undefined, repoDir: string) { if (!recursive) { return undefined; } return { descendantOutputDir: formatPathForDisplay(recursive.descendantOutputDir, repoDir), descendantStatusPath: formatPathForDisplay(recursive.descendantStatusPath, repoDir), descendantWakeLaunchPath: formatPathForDisplay(recursive.descendantWakeLaunchPath, repoDir), descendantWakeLogPath: formatPathForDisplay(recursive.descendantWakeLogPath, repoDir), followupReceiptPath: formatPathForDisplay(recursive.followupReceiptPath, repoDir), followupScriptPath: formatPathForDisplay(recursive.followupScriptPath, repoDir), nextDepth: recursive.nextDepth, reviewDiagnosticsLaunchPath: formatPathForDisplay(recursive.reviewDiagnosticsLaunchPath, repoDir), reviewDiagnosticsLogPath: formatPathForDisplay(recursive.reviewDiagnosticsLogPath, repoDir), reviewDiagnosticsOutputDir: formatPathForDisplay(recursive.reviewDiagnosticsOutputDir, repoDir), reviewDiagnosticsStatusPath: formatPathForDisplay(recursive.reviewDiagnosticsStatusPath, repoDir), requestedDepth: recursive.requestedDepth, reviewSendLogPath: formatPathForDisplay(recursive.reviewSendLogPath, repoDir), reviewTimeoutMs: recursive.reviewTimeoutMs, }; } function loadCaptureMetadata(filePath: string | undefined): ThreadCaptureIdentity | undefined { if (!filePath) return undefined; const resolvedPath = path.resolve(filePath); const filename = path.basename(resolvedPath); if (filename === '.env' || filename.startsWith('.env.')) { throw new Error('Capture metadata must not be loaded from an environment file.'); } try { return parseThreadCaptureIdentity(JSON.parse(readFileSync(resolvedPath, 'utf8'))); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`Could not load exact ReviewGPT capture metadata: ${message}`); } } export function createThreadCli() { const cli = Cli.create('thread', { description: 'Export ChatGPT threads, download patch, diff, or zip attachments, and launch delayed Codex follow-up work.', }); cli.command('export', { description: 'Export the visible contents of an authenticated ChatGPT thread from the managed browser.', options: z.object({ browserEndpoint: z.string().default(DEFAULT_BROWSER_ENDPOINT).describe('Remote debugging endpoint for the managed browser.'), captureMetadata: z.string().optional().describe('Exact capture metadata emitted by a waited send; fail closed unless the same target, response, and artifacts are present.'), chatUrl: z.string().describe('Full ChatGPT conversation URL (/c/) to export.'), output: z.string().describe('Output JSON file path.'), }), examples: [ { description: 'Export a ChatGPT thread snapshot', options: { chatUrl: 'https://chatgpt.com/c/69c71d43-0e38-8330-9df8-c4e10f5bf536', output: 'output-packages/thread.json', }, }, ], output: z.object({ exportPath: z.string().describe('Thread export JSON path.'), }), async run(c) { const chatUrl = normalizeConversationUrl(c.options.chatUrl); const outputPath = path.resolve(c.options.output); const captureIdentity = loadCaptureMetadata(c.options.captureMetadata); await exportThreadSnapshot(c.options.browserEndpoint, chatUrl, outputPath, { captureIdentity }); return { exportPath: formatPathForDisplay(outputPath), }; }, }); cli.command('download', { description: 'Download an assistant-owned attachment or artifact from an authenticated ChatGPT thread.', options: z.object({ artifactIndex: z.number().int().min(0).optional().describe('Assistant artifact index from the latest request in thread.json. Prefer this over button text when possible.'), attachmentText: z.string().optional().describe('Legacy attachment button label to click and download.'), browserEndpoint: z.string().default(DEFAULT_BROWSER_ENDPOINT).describe('Remote debugging endpoint for the managed browser.'), captureMetadata: z.string().optional().describe('Exact capture metadata emitted by a waited send; constrain the download to that assistant turn and artifact identity.'), chatUrl: z.string().describe('Full ChatGPT conversation URL (/c/) containing the attachment.'), outputDir: z.string().describe('Directory where the download should be written.'), timeoutMs: z.number().default(30_000).describe('Attachment download timeout in milliseconds.'), }), examples: [ { description: 'Download an attachment from a thread', options: { artifactIndex: 0, chatUrl: 'https://chatgpt.com/c/69c71d43-0e38-8330-9df8-c4e10f5bf536', outputDir: 'output-packages/downloads', }, }, ], output: z.object({ downloadedFile: z.string().describe('Downloaded attachment path.'), }), async run(c) { const chatUrl = normalizeConversationUrl(c.options.chatUrl); const captureIdentity = loadCaptureMetadata(c.options.captureMetadata); if (c.options.artifactIndex === undefined && !c.options.attachmentText?.trim()) { throw new Error('thread download requires --artifact-index or --attachment-text.'); } if (captureIdentity && !captureIdentity.assistantResponse) { throw new Error('Exact capture metadata cannot download an artifact before the assistant response identity is captured.'); } if (captureIdentity?.assistantResponse) { if (c.options.artifactIndex === undefined) { throw new Error('Exact capture metadata requires --artifact-index for an unambiguous download.'); } if (!captureIdentity.artifacts[c.options.artifactIndex]) { throw new Error('Requested artifact index is not present in the exact waited capture metadata.'); } } const capturedArtifact = c.options.artifactIndex === undefined ? undefined : captureIdentity?.artifacts[c.options.artifactIndex]; const downloadedFile = await downloadThreadAttachment( c.options.browserEndpoint, chatUrl, c.options.attachmentText?.trim() || (capturedArtifact && !isCaptureIdentityDigest(capturedArtifact.label) ? capturedArtifact.label : ''), path.resolve(c.options.outputDir), c.options.timeoutMs, { artifactIndex: c.options.artifactIndex, artifactIndexInAssistantTurn: c.options.artifactIndex === undefined ? undefined : capturedArtifact?.artifactIndexInAssistantTurn, assistantTurnId: captureIdentity?.assistantResponse?.assistantTurnId, assistantTurnIndex: captureIdentity?.assistantResponse?.assistantTurnIndex, ...(capturedArtifact && !isCaptureIdentityDigest(capturedArtifact.href) ? { href: capturedArtifact.href } : {}), }, { captureIdentity }, ); return { downloadedFile: formatPathForDisplay(downloadedFile), }; }, }); cli.command('diagnose', { description: 'Capture a structured diagnostics bundle for a ChatGPT thread send/wake failure from the managed browser.', options: z.object({ browserEndpoint: z.string().default(DEFAULT_BROWSER_ENDPOINT).describe('Remote debugging endpoint for the managed browser.'), chatUrl: z.string().describe('Full ChatGPT conversation URL (/c/) to inspect.'), commandLabel: z.string().default('review:gpt').describe('Short label for the failing command, used in the diagnostics bundle name.'), exitCode: z.number().optional().describe('Optional exit code recorded in the diagnostics status file.'), logFile: z.string().optional().describe('Optional failing command log file to copy into the diagnostics bundle.'), outputDir: z.string().optional().describe('Optional diagnostics output directory. Defaults to output-packages/review-gpt-diagnostics/-