From b75a09c88ca1bc85dc0a5da213913dbd6513373c Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Fri, 19 Dec 2025 16:05:56 +0100 Subject: [PATCH 01/23] auto-claude: subtask-1-1 - Add shell import and device code parsing logic to oauth-handlers.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Import `shell` from Electron for browser launching capability - Add DEVICE_CODE_PATTERN regex to parse device code (format: XXXX-XXXX) from gh CLI output - Add DEVICE_URL_PATTERN regex and GITHUB_DEVICE_URL constant for device flow URL - Add parseDeviceCode() helper to extract device code from output - Add parseDeviceUrl() helper to extract or default to GitHub device flow URL - Add DeviceFlowInfo interface for structured device flow output - Add parseDeviceFlowOutput() helper to parse both stdout and stderr for device flow info 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- auto-claude-ui/package-lock.json | 60 +++++++++---------- .../ipc-handlers/github/oauth-handlers.ts | 60 ++++++++++++++++++- 2 files changed, 88 insertions(+), 32 deletions(-) diff --git a/auto-claude-ui/package-lock.json b/auto-claude-ui/package-lock.json index 32602cef..1e185e98 100644 --- a/auto-claude-ui/package-lock.json +++ b/auto-claude-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "auto-claude-ui", - "version": "2.3.0", + "version": "2.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "auto-claude-ui", - "version": "2.3.0", + "version": "2.5.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { @@ -150,7 +150,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -536,7 +535,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -560,7 +558,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -600,7 +597,6 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -995,6 +991,7 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, + "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1016,6 +1013,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -3930,7 +3928,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -4127,7 +4126,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4138,7 +4136,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4230,7 +4227,6 @@ "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/types": "8.49.0", @@ -4630,8 +4626,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/7zip-bin": { "version": "5.2.0", @@ -4653,7 +4648,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4714,7 +4708,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -4887,6 +4880,7 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -5271,7 +5265,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5951,7 +5944,8 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -6295,7 +6289,6 @@ "integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "26.0.12", "builder-util": "26.0.11", @@ -6353,7 +6346,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dotenv": { "version": "16.6.1", @@ -6429,7 +6423,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^22.7.7", @@ -6558,6 +6551,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -6578,6 +6572,7 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -6593,6 +6588,7 @@ "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", "dev": true, "license": "MIT", + "peer": true, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -6603,6 +6599,7 @@ "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 4.0.0" } @@ -6972,7 +6969,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8992,7 +8988,6 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -9936,6 +9931,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -11775,7 +11771,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -11873,7 +11868,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -11910,6 +11904,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -11927,6 +11922,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -11947,6 +11943,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -11962,6 +11959,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -11974,7 +11972,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/proc-log": { "version": "2.0.1", @@ -12078,7 +12077,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -12088,7 +12086,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -13405,8 +13402,7 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.0", @@ -13463,6 +13459,7 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -13489,6 +13486,7 @@ "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -13510,6 +13508,7 @@ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -13523,6 +13522,7 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -13537,6 +13537,7 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -13853,7 +13854,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14194,7 +14194,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -15228,7 +15227,6 @@ "integrity": "sha512-Bd5fw9wlIhtqCCxotZgdTOMwGm1a0u75wARVEY9HMs1X17trvA/lMi4+MGK5EUfYkXVTbX8UDiDKW4OgzHVUZw==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/auto-claude-ui/src/main/ipc-handlers/github/oauth-handlers.ts b/auto-claude-ui/src/main/ipc-handlers/github/oauth-handlers.ts index 95f19f44..1e772a83 100644 --- a/auto-claude-ui/src/main/ipc-handlers/github/oauth-handlers.ts +++ b/auto-claude-ui/src/main/ipc-handlers/github/oauth-handlers.ts @@ -3,7 +3,7 @@ * Provides a simpler OAuth flow than manual PAT creation */ -import { ipcMain } from 'electron'; +import { ipcMain, shell } from 'electron'; import { execSync, execFileSync, spawn } from 'child_process'; import { IPC_CHANNELS } from '../../../shared/constants'; import type { IPCResult } from '../../../shared/types'; @@ -33,6 +33,64 @@ function isValidGitHubRepo(repo: string): boolean { return GITHUB_REPO_PATTERN.test(repo); } +// Regex patterns for parsing device code from gh CLI output +// Expected format: "! First copy your one-time code: XXXX-XXXX" +const DEVICE_CODE_PATTERN = /(?:one-time code|code):\s*([A-Z0-9]{4}-[A-Z0-9]{4})/i; + +// GitHub device flow URL pattern +const DEVICE_URL_PATTERN = /https:\/\/github\.com\/login\/device/i; + +// Default GitHub device flow URL +const GITHUB_DEVICE_URL = 'https://github.com/login/device'; + +/** + * Parse device code from gh CLI stdout output + * Returns the device code (format: XXXX-XXXX) if found, null otherwise + */ +function parseDeviceCode(output: string): string | null { + const match = output.match(DEVICE_CODE_PATTERN); + if (match && match[1]) { + debugLog('Parsed device code:', match[1]); + return match[1]; + } + return null; +} + +/** + * Parse device URL from gh CLI output + * Returns the URL if found, or the default GitHub device URL + */ +function parseDeviceUrl(output: string): string { + const match = output.match(DEVICE_URL_PATTERN); + if (match) { + debugLog('Found device URL in output:', match[0]); + return match[0]; + } + // Default to standard GitHub device flow URL + return GITHUB_DEVICE_URL; +} + +/** + * Result of parsing device flow output from gh CLI + */ +interface DeviceFlowInfo { + deviceCode: string | null; + authUrl: string; +} + +/** + * Parse both device code and URL from combined gh CLI output + * Searches through both stdout and stderr as gh may output to either + */ +function parseDeviceFlowOutput(stdout: string, stderr: string): DeviceFlowInfo { + const combinedOutput = `${stdout}\n${stderr}`; + + return { + deviceCode: parseDeviceCode(combinedOutput), + authUrl: parseDeviceUrl(combinedOutput) + }; +} + /** * Check if gh CLI is installed */ From 6a4c1b452bb789ebca0878ce23e33069ba53999a Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Fri, 19 Dec 2025 16:09:28 +0100 Subject: [PATCH 02/23] auto-claude: subtask-1-2 - Implement device code extraction from gh CLI stdout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modify registerStartGhAuth to: - Extract device code from gh CLI stdout/stderr as data streams in - Use parseDeviceFlowOutput to get device code and auth URL - Open browser via shell.openExternal (bypasses macOS restrictions) - Return device code, auth URL, and browserOpened status in response - Handle browser open failures gracefully (allows manual fallback) Added GitHubAuthStartResult interface with deviceCode, authUrl, and browserOpened fields for rich response data. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../ipc-handlers/github/oauth-handlers.ts | 67 +++++++++++++++++-- 1 file changed, 63 insertions(+), 4 deletions(-) diff --git a/auto-claude-ui/src/main/ipc-handlers/github/oauth-handlers.ts b/auto-claude-ui/src/main/ipc-handlers/github/oauth-handlers.ts index 1e772a83..60f7ed5f 100644 --- a/auto-claude-ui/src/main/ipc-handlers/github/oauth-handlers.ts +++ b/auto-claude-ui/src/main/ipc-handlers/github/oauth-handlers.ts @@ -172,14 +172,26 @@ export function registerCheckGhAuth(): void { ); } +/** + * Result type for GitHub auth start, including device flow information + */ +interface GitHubAuthStartResult { + success: boolean; + message?: string; + deviceCode?: string; + authUrl?: string; + browserOpened?: boolean; +} + /** * Start GitHub OAuth flow using gh CLI - * This will open the browser for device flow authentication + * This will extract the device code from gh CLI output and open the browser + * using Electron's shell.openExternal (bypasses macOS child process restrictions) */ export function registerStartGhAuth(): void { ipcMain.handle( IPC_CHANNELS.GITHUB_START_AUTH, - async (): Promise> => { + async (): Promise> => { debugLog('startGitHubAuth handler called'); return new Promise((resolve) => { try { @@ -193,17 +205,53 @@ export function registerStartGhAuth(): void { let output = ''; let errorOutput = ''; + let deviceCodeExtracted = false; + let extractedDeviceCode: string | null = null; + let extractedAuthUrl: string = GITHUB_DEVICE_URL; + let browserOpenedSuccessfully = false; + + // Function to attempt device code extraction and browser opening + const tryExtractAndOpenBrowser = async () => { + if (deviceCodeExtracted) return; // Already extracted + + const deviceFlowInfo = parseDeviceFlowOutput(output, errorOutput); + + if (deviceFlowInfo.deviceCode) { + deviceCodeExtracted = true; + extractedDeviceCode = deviceFlowInfo.deviceCode; + extractedAuthUrl = deviceFlowInfo.authUrl; + + debugLog('Device code extracted:', extractedDeviceCode); + debugLog('Auth URL:', extractedAuthUrl); + + // Open browser using Electron's shell.openExternal + // This bypasses macOS child process restrictions that block gh CLI's browser launch + try { + await shell.openExternal(extractedAuthUrl); + browserOpenedSuccessfully = true; + debugLog('Browser opened successfully via shell.openExternal'); + } catch (browserError) { + debugLog('Failed to open browser:', browserError instanceof Error ? browserError.message : browserError); + browserOpenedSuccessfully = false; + // Don't fail here - we'll return the device code so user can manually navigate + } + } + }; ghProcess.stdout?.on('data', (data) => { const chunk = data.toString(); output += chunk; debugLog('gh stdout:', chunk); + // Try to extract device code as data comes in + tryExtractAndOpenBrowser(); }); ghProcess.stderr?.on('data', (data) => { const chunk = data.toString(); errorOutput += chunk; debugLog('gh stderr:', chunk); + // gh often outputs to stderr, so check there too + tryExtractAndOpenBrowser(); }); ghProcess.on('close', (code) => { @@ -216,13 +264,24 @@ export function registerStartGhAuth(): void { success: true, data: { success: true, - message: 'Successfully authenticated with GitHub' + message: 'Successfully authenticated with GitHub', + deviceCode: extractedDeviceCode || undefined, + authUrl: extractedAuthUrl, + browserOpened: browserOpenedSuccessfully } }); } else { + // Even if auth failed, return device code info if we extracted it + // This allows user to retry manually resolve({ success: false, - error: errorOutput || `Authentication failed with exit code ${code}` + error: errorOutput || `Authentication failed with exit code ${code}`, + data: extractedDeviceCode ? { + success: false, + deviceCode: extractedDeviceCode, + authUrl: extractedAuthUrl, + browserOpened: browserOpenedSuccessfully + } : undefined }); } }); From 4a4ad6b1df0747eccc4078c2becadce48f06315e Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Fri, 19 Dec 2025 16:13:29 +0100 Subject: [PATCH 03/23] auto-claude: subtask-1-4 - Add error handling and fallback URL return for browser launch failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added fallbackUrl field to GitHubAuthStartResult interface - Updated success case to include fallbackUrl when browser fails to open - Updated failure case to always provide fallbackUrl for manual recovery - Updated gh process error handler to include fallbackUrl - Updated catch block to include fallbackUrl for exception cases This ensures users always have a way to manually navigate to the auth URL when automatic browser opening fails (e.g., due to macOS security restrictions). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../ipc-handlers/github/oauth-handlers.ts | 48 +++++++++++++++---- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/auto-claude-ui/src/main/ipc-handlers/github/oauth-handlers.ts b/auto-claude-ui/src/main/ipc-handlers/github/oauth-handlers.ts index 60f7ed5f..1ff9d22e 100644 --- a/auto-claude-ui/src/main/ipc-handlers/github/oauth-handlers.ts +++ b/auto-claude-ui/src/main/ipc-handlers/github/oauth-handlers.ts @@ -181,6 +181,11 @@ interface GitHubAuthStartResult { deviceCode?: string; authUrl?: string; browserOpened?: boolean; + /** + * Fallback URL provided when browser launch fails. + * The frontend should display this URL so users can manually navigate to complete auth. + */ + fallbackUrl?: string; } /** @@ -260,28 +265,39 @@ export function registerStartGhAuth(): void { debugLog('Full stderr:', errorOutput); if (code === 0) { + // Success case - include fallbackUrl if browser failed to open + // so the user can manually navigate if needed resolve({ success: true, data: { success: true, - message: 'Successfully authenticated with GitHub', + message: browserOpenedSuccessfully + ? 'Successfully authenticated with GitHub' + : 'Authentication successful. Browser could not be opened automatically.', deviceCode: extractedDeviceCode || undefined, authUrl: extractedAuthUrl, - browserOpened: browserOpenedSuccessfully + browserOpened: browserOpenedSuccessfully, + // Provide fallback URL when browser failed to open + fallbackUrl: !browserOpenedSuccessfully ? extractedAuthUrl : undefined } }); } else { // Even if auth failed, return device code info if we extracted it - // This allows user to retry manually + // This allows user to retry manually with the fallback URL + const fallbackUrlForManualAuth = extractedDeviceCode ? extractedAuthUrl : GITHUB_DEVICE_URL; + resolve({ success: false, error: errorOutput || `Authentication failed with exit code ${code}`, - data: extractedDeviceCode ? { + data: { success: false, - deviceCode: extractedDeviceCode, + deviceCode: extractedDeviceCode || undefined, authUrl: extractedAuthUrl, - browserOpened: browserOpenedSuccessfully - } : undefined + browserOpened: browserOpenedSuccessfully, + // Always provide fallback URL on failure for manual recovery + fallbackUrl: fallbackUrlForManualAuth, + message: 'Authentication failed. Please visit the URL manually to complete authentication.' + } }); } }); @@ -290,14 +306,28 @@ export function registerStartGhAuth(): void { debugLog('gh process error:', error.message); resolve({ success: false, - error: error.message + error: error.message, + data: { + success: false, + browserOpened: false, + // Provide fallback URL so user can attempt manual auth + fallbackUrl: GITHUB_DEVICE_URL, + message: 'Failed to start GitHub CLI. Please visit the URL manually to authenticate.' + } }); }); } catch (error) { debugLog('Exception in startGitHubAuth:', error instanceof Error ? error.message : error); resolve({ success: false, - error: error instanceof Error ? error.message : 'Unknown error' + error: error instanceof Error ? error.message : 'Unknown error', + data: { + success: false, + browserOpened: false, + // Provide fallback URL for manual authentication recovery + fallbackUrl: GITHUB_DEVICE_URL, + message: 'An unexpected error occurred. Please visit the URL manually to authenticate.' + } }); } }); From 5f26d3964dafe845c095315a9d1a885985a35ad5 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Fri, 19 Dec 2025 16:17:02 +0100 Subject: [PATCH 04/23] auto-claude: subtask-2-1 - Add device code display state and UI component - Add device code, auth URL, and browser opened state variables to GitHubOAuthFlow - Display prominent device code card during authentication with copy button - Show manual auth URL link when browser fails to open - Update IPC types to include deviceCode, authUrl, browserOpened, and fallbackUrl - Add visual feedback for code copy (checkmark icon when copied) - Instructions adapt based on whether browser opened successfully --- .../project-settings/GitHubOAuthFlow.tsx | 146 ++++++++++++++++-- auto-claude-ui/src/shared/types/ipc.ts | 9 +- 2 files changed, 138 insertions(+), 17 deletions(-) diff --git a/auto-claude-ui/src/renderer/components/project-settings/GitHubOAuthFlow.tsx b/auto-claude-ui/src/renderer/components/project-settings/GitHubOAuthFlow.tsx index c743cd6c..df9e677e 100644 --- a/auto-claude-ui/src/renderer/components/project-settings/GitHubOAuthFlow.tsx +++ b/auto-claude-ui/src/renderer/components/project-settings/GitHubOAuthFlow.tsx @@ -6,7 +6,9 @@ import { AlertCircle, Info, ExternalLink, - Terminal + Terminal, + Copy, + Check } from 'lucide-react'; import { Button } from '../ui/button'; import { Card, CardContent } from '../ui/card'; @@ -40,6 +42,12 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) { const [cliVersion, setCliVersion] = useState(); const [username, setUsername] = useState(); + // Device flow state for displaying code and auth URL + const [deviceCode, setDeviceCode] = useState(null); + const [authUrl, setAuthUrl] = useState(null); + const [browserOpened, setBrowserOpened] = useState(false); + const [codeCopied, setCodeCopied] = useState(false); + // Check gh CLI installation and authentication status on mount // Use a ref to prevent double-execution in React Strict Mode const hasCheckedRef = useRef(false); @@ -138,18 +146,44 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) { setStatus('authenticating'); setError(null); + // Reset device flow state + setDeviceCode(null); + setAuthUrl(null); + setBrowserOpened(false); + setCodeCopied(false); + try { debugLog('Calling startGitHubAuth...'); const result = await window.electronAPI.startGitHubAuth(); debugLog('startGitHubAuth result:', result); + // Capture device flow info if available + if (result.data?.deviceCode) { + debugLog('Device code received:', result.data.deviceCode); + setDeviceCode(result.data.deviceCode); + } + if (result.data?.authUrl) { + debugLog('Auth URL received:', result.data.authUrl); + setAuthUrl(result.data.authUrl); + } + if (result.data?.browserOpened !== undefined) { + debugLog('Browser opened status:', result.data.browserOpened); + setBrowserOpened(result.data.browserOpened); + } + if (result.success && result.data?.success) { debugLog('Auth successful, fetching token...'); // Fetch the token and notify parent await fetchAndNotifyToken(); } else { debugLog('Auth failed:', result.error); - setError(result.error || 'Authentication failed'); + // Include fallback URL info in error message if available + const errorMessage = result.error || 'Authentication failed'; + setError(errorMessage); + // Keep authUrl from response for fallback display + if (result.data?.fallbackUrl) { + setAuthUrl(result.data.fallbackUrl); + } setStatus('error'); } } catch (err) { @@ -169,6 +203,26 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) { checkGitHubStatus(); }; + const handleCopyDeviceCode = async () => { + if (!deviceCode) return; + debugLog('Copying device code to clipboard'); + try { + await navigator.clipboard.writeText(deviceCode); + setCodeCopied(true); + // Reset the copied state after 2 seconds + setTimeout(() => setCodeCopied(false), 2000); + } catch (err) { + debugLog('Failed to copy device code:', err); + } + }; + + const handleOpenAuthUrl = () => { + if (authUrl) { + debugLog('Opening auth URL manually:', authUrl); + window.open(authUrl, '_blank'); + } + }; + debugLog('Rendering with status:', status); return ( @@ -263,21 +317,81 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) { {/* Authenticating */} {status === 'authenticating' && ( - - -
- -
-

- Authenticating... -

-

- Please complete the authentication in your browser. This window will update automatically. -

+
+ + +
+ +
+

+ Authenticating... +

+

+ {browserOpened + ? 'Please complete the authentication in your browser. This window will update automatically.' + : 'Waiting for authentication flow to start...'} +

+
-
- - + + + + {/* Device Code Display */} + {deviceCode && ( + + +
+
+

+ Your one-time code +

+
+ + {deviceCode} + + +
+
+ +
+

+ {browserOpened + ? 'Enter this code in your browser to complete authentication.' + : 'Copy this code, then open the link below to authenticate.'} +

+ {!browserOpened && authUrl && ( + + )} +
+
+
+
+ )} +
)} {/* Success */} diff --git a/auto-claude-ui/src/shared/types/ipc.ts b/auto-claude-ui/src/shared/types/ipc.ts index 050efe6c..26d0a88b 100644 --- a/auto-claude-ui/src/shared/types/ipc.ts +++ b/auto-claude-ui/src/shared/types/ipc.ts @@ -319,7 +319,14 @@ export interface ElectronAPI { // GitHub OAuth operations (gh CLI) checkGitHubCli: () => Promise>; checkGitHubAuth: () => Promise>; - startGitHubAuth: () => Promise>; + startGitHubAuth: () => Promise>; getGitHubToken: () => Promise>; getGitHubUser: () => Promise>; listGitHubUserRepos: () => Promise }>>; From 1a7cf409eb7e5d07decc8dca8a36112682aa0218 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Fri, 19 Dec 2025 16:19:50 +0100 Subject: [PATCH 05/23] auto-claude: subtask-2-2 - Implement fallback URL display when browser launch fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added fallback URL card in error state when authUrl is available - Shows "Complete Authentication Manually" instructions when browser fails to open - Added copyable URL display with Copy button that tracks copy state separately - Added "Open URL in Browser" button to attempt manual browser launch - Shows device code reminder in fallback card if available - Changed Retry button in error state to call handleStartAuth instead of handleRetry for fresh auth attempt - Added urlCopied state variable to track URL copy status independently from device code copy status 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../project-settings/GitHubOAuthFlow.tsx | 80 ++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/auto-claude-ui/src/renderer/components/project-settings/GitHubOAuthFlow.tsx b/auto-claude-ui/src/renderer/components/project-settings/GitHubOAuthFlow.tsx index df9e677e..c51a2132 100644 --- a/auto-claude-ui/src/renderer/components/project-settings/GitHubOAuthFlow.tsx +++ b/auto-claude-ui/src/renderer/components/project-settings/GitHubOAuthFlow.tsx @@ -47,6 +47,7 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) { const [authUrl, setAuthUrl] = useState(null); const [browserOpened, setBrowserOpened] = useState(false); const [codeCopied, setCodeCopied] = useState(false); + const [urlCopied, setUrlCopied] = useState(false); // Check gh CLI installation and authentication status on mount // Use a ref to prevent double-execution in React Strict Mode @@ -151,6 +152,7 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) { setAuthUrl(null); setBrowserOpened(false); setCodeCopied(false); + setUrlCopied(false); try { debugLog('Calling startGitHubAuth...'); @@ -430,8 +432,84 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) { + {/* Fallback URL display when browser failed to open */} + {authUrl && ( + + +
+
+ +
+

+ Complete Authentication Manually +

+

+ The browser couldn't be opened automatically. Please visit the URL below to complete authentication: +

+
+
+ +
+
+ + {authUrl} + + +
+ + +
+ + {/* Device code reminder if available */} + {deviceCode && ( +
+

+ When prompted, enter this code:{' '} + + {deviceCode} + +

+
+ )} +
+
+
+ )} +
- {onCancel && ( From 81e1536801183b80539f34c74c693e40769623c7 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Fri, 19 Dec 2025 16:22:42 +0100 Subject: [PATCH 06/23] auto-claude: subtask-2-3 - Add authentication timeout handling (5 minutes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 5-minute authentication timeout using useCallback and useRef for cleanup - Implement clearAuthTimeout helper to manage timeout lifecycle - Start timeout when auth begins, clear on success/failure/unmount - Add isTimeout state to track timeout vs other errors - Display timeout-specific UI with Clock icon and warning colors - Show clear error message with retry option on timeout - Timeout set to 5 minutes (GitHub device codes expire after 15 minutes) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../project-settings/GitHubOAuthFlow.tsx | 63 ++++++++++++++++--- 1 file changed, 55 insertions(+), 8 deletions(-) diff --git a/auto-claude-ui/src/renderer/components/project-settings/GitHubOAuthFlow.tsx b/auto-claude-ui/src/renderer/components/project-settings/GitHubOAuthFlow.tsx index c51a2132..77e84297 100644 --- a/auto-claude-ui/src/renderer/components/project-settings/GitHubOAuthFlow.tsx +++ b/auto-claude-ui/src/renderer/components/project-settings/GitHubOAuthFlow.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { Github, Loader2, @@ -8,7 +8,8 @@ import { ExternalLink, Terminal, Copy, - Check + Check, + Clock } from 'lucide-react'; import { Button } from '../ui/button'; import { Card, CardContent } from '../ui/card'; @@ -31,6 +32,10 @@ function debugLog(message: string, data?: unknown) { } } +// Authentication timeout in milliseconds (5 minutes) +// GitHub device codes typically expire after 15 minutes, but 5 minutes is a reasonable UX timeout +const AUTH_TIMEOUT_MS = 5 * 60 * 1000; + /** * GitHub OAuth flow component using gh CLI * Guides users through authenticating with GitHub using the gh CLI @@ -48,11 +53,33 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) { const [browserOpened, setBrowserOpened] = useState(false); const [codeCopied, setCodeCopied] = useState(false); const [urlCopied, setUrlCopied] = useState(false); + const [isTimeout, setIsTimeout] = useState(false); + + // Ref to track authentication timeout + const authTimeoutRef = useRef | null>(null); // Check gh CLI installation and authentication status on mount // Use a ref to prevent double-execution in React Strict Mode const hasCheckedRef = useRef(false); + // Clear the authentication timeout + const clearAuthTimeout = useCallback(() => { + if (authTimeoutRef.current) { + debugLog('Clearing auth timeout'); + clearTimeout(authTimeoutRef.current); + authTimeoutRef.current = null; + } + }, []); + + // Handle authentication timeout + const handleAuthTimeout = useCallback(() => { + debugLog('Authentication timeout triggered after 5 minutes'); + setIsTimeout(true); + setError('Authentication timed out. The authentication window was open for too long. Please try again.'); + setStatus('error'); + authTimeoutRef.current = null; + }, []); + useEffect(() => { if (hasCheckedRef.current) { debugLog('Skipping duplicate check (Strict Mode)'); @@ -61,8 +88,13 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) { hasCheckedRef.current = true; debugLog('Component mounted, checking GitHub status...'); checkGitHubStatus(); + + // Cleanup timeout on unmount + return () => { + clearAuthTimeout(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps -- Only run once on mount, checkGitHubStatus is intentionally excluded - }, []); + }, [clearAuthTimeout]); const checkGitHubStatus = async () => { debugLog('checkGitHubStatus() called'); @@ -153,12 +185,21 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) { setBrowserOpened(false); setCodeCopied(false); setUrlCopied(false); + setIsTimeout(false); + + // Clear any existing timeout and start a new one + clearAuthTimeout(); + debugLog(`Starting auth timeout (${AUTH_TIMEOUT_MS / 1000 / 60} minutes)`); + authTimeoutRef.current = setTimeout(handleAuthTimeout, AUTH_TIMEOUT_MS); try { debugLog('Calling startGitHubAuth...'); const result = await window.electronAPI.startGitHubAuth(); debugLog('startGitHubAuth result:', result); + // Clear timeout since we got a response + clearAuthTimeout(); + // Capture device flow info if available if (result.data?.deviceCode) { debugLog('Device code received:', result.data.deviceCode); @@ -189,6 +230,8 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) { setStatus('error'); } } catch (err) { + // Clear timeout on error + clearAuthTimeout(); debugLog('Error in handleStartAuth:', err); setError(err instanceof Error ? err.message : 'Authentication failed'); setStatus('error'); @@ -418,15 +461,19 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) { {/* Error */} {status === 'error' && error && (
- +
- + {isTimeout ? ( + + ) : ( + + )}
-

- Authentication Failed +

+ {isTimeout ? 'Authentication Timed Out' : 'Authentication Failed'}

-

{error}

+

{error}

From e134c4cba95b04a7f98f05985c0afc74810765be Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Fri, 19 Dec 2025 16:26:54 +0100 Subject: [PATCH 07/23] auto-claude: subtask-3-1 - Write unit tests for device code parsing and shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive unit tests for GitHub OAuth handlers: - Device code parsing from gh CLI stdout/stderr output - shell.openExternal success and failure handling - Fallback URL provision when browser launch fails - Error handling for gh CLI process errors and non-zero exit codes - gh CLI check and auth status handlers - Repository format validation for command injection prevention 21 new tests covering all critical OAuth flow paths. Also updates vitest.config.ts to include *.spec.ts files. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../github/__tests__/oauth-handlers.spec.ts | 548 ++++++++++++++++++ auto-claude-ui/vitest.config.ts | 4 +- 2 files changed, 550 insertions(+), 2 deletions(-) create mode 100644 auto-claude-ui/src/main/ipc-handlers/github/__tests__/oauth-handlers.spec.ts diff --git a/auto-claude-ui/src/main/ipc-handlers/github/__tests__/oauth-handlers.spec.ts b/auto-claude-ui/src/main/ipc-handlers/github/__tests__/oauth-handlers.spec.ts new file mode 100644 index 00000000..6ff4db97 --- /dev/null +++ b/auto-claude-ui/src/main/ipc-handlers/github/__tests__/oauth-handlers.spec.ts @@ -0,0 +1,548 @@ +/** + * Unit tests for GitHub OAuth handlers + * Tests device code parsing, shell.openExternal handling, and error recovery + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EventEmitter } from 'events'; + +// Mock child_process before importing +const mockSpawn = vi.fn(); +const mockExecSync = vi.fn(); +const mockExecFileSync = vi.fn(); + +vi.mock('child_process', () => ({ + spawn: (...args: unknown[]) => mockSpawn(...args), + execSync: (...args: unknown[]) => mockExecSync(...args), + execFileSync: (...args: unknown[]) => mockExecFileSync(...args) +})); + +// Mock shell.openExternal +const mockOpenExternal = vi.fn(); + +vi.mock('electron', () => { + const mockIpcMain = new (class extends EventEmitter { + private handlers: Map = new Map(); + + handle(channel: string, handler: Function): void { + this.handlers.set(channel, handler); + } + + removeHandler(channel: string): void { + this.handlers.delete(channel); + } + + async invokeHandler(channel: string, event: unknown, ...args: unknown[]): Promise { + const handler = this.handlers.get(channel); + if (handler) { + return handler(event, ...args); + } + throw new Error(`No handler for channel: ${channel}`); + } + + getHandler(channel: string): Function | undefined { + return this.handlers.get(channel); + } + })(); + + return { + ipcMain: mockIpcMain, + shell: { + openExternal: (...args: unknown[]) => mockOpenExternal(...args) + } + }; +}); + +// Mock @electron-toolkit/utils +vi.mock('@electron-toolkit/utils', () => ({ + is: { + dev: true, + windows: process.platform === 'win32', + macos: process.platform === 'darwin', + linux: process.platform === 'linux' + } +})); + +// Create mock process for spawn +function createMockProcess(): EventEmitter & { + stdout: EventEmitter | null; + stderr: EventEmitter | null; + stdin: { write: ReturnType; end: ReturnType } | null; +} { + const proc = new EventEmitter() as EventEmitter & { + stdout: EventEmitter | null; + stderr: EventEmitter | null; + stdin: { write: ReturnType; end: ReturnType } | null; + }; + proc.stdout = new EventEmitter(); + proc.stderr = new EventEmitter(); + proc.stdin = { write: vi.fn(), end: vi.fn() }; + return proc; +} + +describe('GitHub OAuth Handlers', () => { + let ipcMain: EventEmitter & { + handlers: Map; + invokeHandler: (channel: string, event: unknown, ...args: unknown[]) => Promise; + getHandler: (channel: string) => Function | undefined; + }; + + beforeEach(async () => { + vi.clearAllMocks(); + vi.resetModules(); + + // Get mocked ipcMain + const electron = await import('electron'); + ipcMain = electron.ipcMain as unknown as typeof ipcMain; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('Device Code Parsing', () => { + it('should parse device code from standard gh CLI output format', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + mockOpenExternal.mockResolvedValue(undefined); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + // Start the handler + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + // Simulate gh CLI output with device code + mockProcess.stderr?.emit('data', '! First copy your one-time code: ABCD-1234\n'); + mockProcess.stderr?.emit('data', '- Press Enter to open github.com in your browser...\n'); + + // Complete the process + mockProcess.emit('close', 0); + + const result = await resultPromise; + + expect(result).toHaveProperty('success', true); + expect(result).toHaveProperty('data'); + const data = (result as { data: { deviceCode: string } }).data; + expect(data.deviceCode).toBe('ABCD-1234'); + }); + + it('should parse device code from alternate output format (lowercase "code")', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + mockOpenExternal.mockResolvedValue(undefined); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + // Alternate format: "code: XXXX-XXXX" without "one-time" + mockProcess.stderr?.emit('data', 'Enter the code: EFGH-5678\n'); + mockProcess.emit('close', 0); + + const result = await resultPromise; + + expect(result).toHaveProperty('success', true); + const data = (result as { data: { deviceCode: string } }).data; + expect(data.deviceCode).toBe('EFGH-5678'); + }); + + it('should parse device code from stdout (not just stderr)', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + mockOpenExternal.mockResolvedValue(undefined); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + // Device code in stdout instead of stderr + mockProcess.stdout?.emit('data', '! First copy your one-time code: IJKL-9012\n'); + mockProcess.emit('close', 0); + + const result = await resultPromise; + + expect(result).toHaveProperty('success', true); + const data = (result as { data: { deviceCode: string } }).data; + expect(data.deviceCode).toBe('IJKL-9012'); + }); + + it('should handle output without device code gracefully', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + // Output without device code + mockProcess.stderr?.emit('data', 'Some other message\n'); + mockProcess.emit('close', 0); + + const result = await resultPromise; + + expect(result).toHaveProperty('success', true); + const data = (result as { data: { deviceCode?: string } }).data; + expect(data.deviceCode).toBeUndefined(); + }); + + it('should extract URL from output containing https://github.com/login/device', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + mockOpenExternal.mockResolvedValue(undefined); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + mockProcess.stderr?.emit('data', '! First copy your one-time code: MNOP-3456\n'); + mockProcess.stderr?.emit('data', 'Then visit https://github.com/login/device to authenticate\n'); + mockProcess.emit('close', 0); + + const result = await resultPromise; + + expect(result).toHaveProperty('success', true); + const data = (result as { data: { authUrl: string } }).data; + expect(data.authUrl).toBe('https://github.com/login/device'); + }); + }); + + describe('shell.openExternal Handling', () => { + it('should call shell.openExternal with extracted URL when device code found', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + mockOpenExternal.mockResolvedValue(undefined); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + mockProcess.stderr?.emit('data', '! First copy your one-time code: QRST-7890\n'); + + // Wait for next tick to allow async browser opening + await new Promise(resolve => setTimeout(resolve, 10)); + + mockProcess.emit('close', 0); + await resultPromise; + + expect(mockOpenExternal).toHaveBeenCalledWith('https://github.com/login/device'); + }); + + it('should set browserOpened to true when shell.openExternal succeeds', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + mockOpenExternal.mockResolvedValue(undefined); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + mockProcess.stderr?.emit('data', '! First copy your one-time code: UVWX-1234\n'); + + // Wait for async browser opening + await new Promise(resolve => setTimeout(resolve, 10)); + + mockProcess.emit('close', 0); + const result = await resultPromise; + + expect(result).toHaveProperty('success', true); + const data = (result as { data: { browserOpened: boolean } }).data; + expect(data.browserOpened).toBe(true); + }); + + it('should set browserOpened to false when shell.openExternal fails', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + mockOpenExternal.mockRejectedValue(new Error('Failed to open browser')); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + mockProcess.stderr?.emit('data', '! First copy your one-time code: YZAB-5678\n'); + + // Wait for async browser opening to fail + await new Promise(resolve => setTimeout(resolve, 10)); + + mockProcess.emit('close', 0); + const result = await resultPromise; + + expect(result).toHaveProperty('success', true); + const data = (result as { data: { browserOpened: boolean } }).data; + expect(data.browserOpened).toBe(false); + }); + + it('should provide fallbackUrl when browser fails to open', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + mockOpenExternal.mockRejectedValue(new Error('Failed to open browser')); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + mockProcess.stderr?.emit('data', '! First copy your one-time code: CDEF-9012\n'); + + // Wait for async browser opening to fail + await new Promise(resolve => setTimeout(resolve, 10)); + + mockProcess.emit('close', 0); + const result = await resultPromise; + + expect(result).toHaveProperty('success', true); + const data = (result as { data: { fallbackUrl?: string } }).data; + expect(data.fallbackUrl).toBe('https://github.com/login/device'); + }); + + it('should not provide fallbackUrl when browser opens successfully', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + mockOpenExternal.mockResolvedValue(undefined); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + mockProcess.stderr?.emit('data', '! First copy your one-time code: GHIJ-3456\n'); + + // Wait for async browser opening + await new Promise(resolve => setTimeout(resolve, 10)); + + mockProcess.emit('close', 0); + const result = await resultPromise; + + expect(result).toHaveProperty('success', true); + const data = (result as { data: { fallbackUrl?: string } }).data; + expect(data.fallbackUrl).toBeUndefined(); + }); + }); + + describe('Error Handling', () => { + it('should handle gh CLI process error', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + // Emit error event + mockProcess.emit('error', new Error('spawn gh ENOENT')); + + const result = await resultPromise; + + expect(result).toHaveProperty('success', false); + expect(result).toHaveProperty('error', 'spawn gh ENOENT'); + const data = (result as { data: { fallbackUrl: string } }).data; + expect(data.fallbackUrl).toBe('https://github.com/login/device'); + }); + + it('should handle non-zero exit code', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + mockProcess.stderr?.emit('data', 'error: some authentication error\n'); + mockProcess.emit('close', 1); + + const result = await resultPromise; + + expect(result).toHaveProperty('success', false); + const data = (result as { data: { fallbackUrl: string } }).data; + expect(data.fallbackUrl).toBe('https://github.com/login/device'); + }); + + it('should include device code in error result if it was extracted before failure', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + mockOpenExternal.mockResolvedValue(undefined); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + // Device code output followed by failure + mockProcess.stderr?.emit('data', '! First copy your one-time code: KLMN-7890\n'); + + // Wait for async browser opening + await new Promise(resolve => setTimeout(resolve, 10)); + + mockProcess.stderr?.emit('data', 'error: authentication failed\n'); + mockProcess.emit('close', 1); + + const result = await resultPromise; + + expect(result).toHaveProperty('success', false); + const data = (result as { data: { deviceCode: string; fallbackUrl: string } }).data; + expect(data.deviceCode).toBe('KLMN-7890'); + expect(data.fallbackUrl).toBe('https://github.com/login/device'); + }); + + it('should provide user-friendly error message on process spawn failure', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + mockProcess.emit('error', new Error('spawn gh ENOENT')); + + const result = await resultPromise; + + expect(result).toHaveProperty('success', false); + const data = (result as { data: { message: string } }).data; + expect(data.message).toContain('Failed to start GitHub CLI'); + }); + }); + + describe('gh CLI Check Handler', () => { + it('should return installed: true when gh CLI is found', async () => { + mockExecSync.mockImplementation((cmd: string) => { + if (cmd.includes('which gh') || cmd.includes('where gh')) { + return '/usr/local/bin/gh\n'; + } + if (cmd === 'gh --version') { + return 'gh version 2.65.0 (2024-01-15)\n'; + } + return ''; + }); + + const { registerCheckGhCli } = await import('../oauth-handlers'); + registerCheckGhCli(); + + const result = await ipcMain.invokeHandler('github:checkCli', {}); + + expect(result).toHaveProperty('success', true); + const data = (result as { data: { installed: boolean; version: string } }).data; + expect(data.installed).toBe(true); + expect(data.version).toContain('gh version'); + }); + + it('should return installed: false when gh CLI is not found', async () => { + mockExecSync.mockImplementation(() => { + throw new Error('Command not found'); + }); + + const { registerCheckGhCli } = await import('../oauth-handlers'); + registerCheckGhCli(); + + const result = await ipcMain.invokeHandler('github:checkCli', {}); + + expect(result).toHaveProperty('success', true); + const data = (result as { data: { installed: boolean } }).data; + expect(data.installed).toBe(false); + }); + }); + + describe('gh Auth Check Handler', () => { + it('should return authenticated: true with username when logged in', async () => { + mockExecSync.mockImplementation((cmd: string) => { + if (cmd === 'gh auth status') { + return 'Logged in to github.com as testuser\n'; + } + if (cmd === 'gh api user --jq .login') { + return 'testuser\n'; + } + return ''; + }); + + const { registerCheckGhAuth } = await import('../oauth-handlers'); + registerCheckGhAuth(); + + const result = await ipcMain.invokeHandler('github:checkAuth', {}); + + expect(result).toHaveProperty('success', true); + const data = (result as { data: { authenticated: boolean; username: string } }).data; + expect(data.authenticated).toBe(true); + expect(data.username).toBe('testuser'); + }); + + it('should return authenticated: false when not logged in', async () => { + mockExecSync.mockImplementation(() => { + throw new Error('You are not logged into any GitHub hosts'); + }); + + const { registerCheckGhAuth } = await import('../oauth-handlers'); + registerCheckGhAuth(); + + const result = await ipcMain.invokeHandler('github:checkAuth', {}); + + expect(result).toHaveProperty('success', true); + const data = (result as { data: { authenticated: boolean } }).data; + expect(data.authenticated).toBe(false); + }); + }); + + describe('Spawn Arguments', () => { + it('should spawn gh with correct auth login arguments', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + ipcMain.invokeHandler('github:startAuth', {}); + + expect(mockSpawn).toHaveBeenCalledWith( + 'gh', + ['auth', 'login', '--web', '--scopes', 'repo'], + expect.objectContaining({ + stdio: ['pipe', 'pipe', 'pipe'] + }) + ); + }); + }); + + describe('Repository Validation', () => { + it('should reject invalid repository format', async () => { + const { registerGetGitHubBranches } = await import('../oauth-handlers'); + registerGetGitHubBranches(); + + // Test with injection attempt + const result = await ipcMain.invokeHandler( + 'github:getBranches', + {}, + 'owner/repo; rm -rf /', + 'token' + ); + + expect(result).toHaveProperty('success', false); + expect(result).toHaveProperty('error', 'Invalid repository format. Expected: owner/repo'); + }); + + it('should accept valid repository format', async () => { + mockExecFileSync.mockReturnValue('main\nfeature-branch\n'); + + const { registerGetGitHubBranches } = await import('../oauth-handlers'); + registerGetGitHubBranches(); + + const result = await ipcMain.invokeHandler( + 'github:getBranches', + {}, + 'valid-owner/valid-repo', + 'token' + ); + + expect(result).toHaveProperty('success', true); + const data = (result as { data: string[] }).data; + expect(data).toContain('main'); + expect(data).toContain('feature-branch'); + }); + }); +}); diff --git a/auto-claude-ui/vitest.config.ts b/auto-claude-ui/vitest.config.ts index abc39cc8..6eb2f5ee 100644 --- a/auto-claude-ui/vitest.config.ts +++ b/auto-claude-ui/vitest.config.ts @@ -5,13 +5,13 @@ export default defineConfig({ test: { globals: true, environment: 'node', - include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], + include: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'src/**/*.spec.ts', 'src/**/*.spec.tsx'], exclude: ['node_modules', 'dist', 'out'], coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], include: ['src/**/*.ts', 'src/**/*.tsx'], - exclude: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'src/**/*.d.ts'] + exclude: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'src/**/*.spec.ts', 'src/**/*.spec.tsx', 'src/**/*.d.ts'] }, // Mock Electron modules for unit tests alias: { From f82bd5b8710eccb59e61ff319afcbb03f6635ad8 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Sat, 20 Dec 2025 12:02:30 +0100 Subject: [PATCH 08/23] linting --- auto-claude/core/auth.py | 8 +++++++- .../file_evolution/modification_tracker.py | 4 +++- auto-claude/merge/timeline_tracker.py | 4 +++- auto-claude/runners/insights_runner.py | 20 ++++++++++++++----- 4 files changed, 28 insertions(+), 8 deletions(-) diff --git a/auto-claude/core/auth.py b/auto-claude/core/auth.py index 7ba8c0f4..76e54eb5 100644 --- a/auto-claude/core/auth.py +++ b/auto-claude/core/auth.py @@ -47,7 +47,13 @@ def get_token_from_keychain() -> str | None: try: # Query macOS Keychain for Claude Code credentials result = subprocess.run( - ["/usr/bin/security", "find-generic-password", "-s", "Claude Code-credentials", "-w"], + [ + "/usr/bin/security", + "find-generic-password", + "-s", + "Claude Code-credentials", + "-w", + ], capture_output=True, text=True, timeout=5, diff --git a/auto-claude/merge/file_evolution/modification_tracker.py b/auto-claude/merge/file_evolution/modification_tracker.py index 5a4416de..c30d8848 100644 --- a/auto-claude/merge/file_evolution/modification_tracker.py +++ b/auto-claude/merge/file_evolution/modification_tracker.py @@ -196,7 +196,9 @@ class ModificationTracker: try: new_content = current_file.read_text(encoding="utf-8") except UnicodeDecodeError: - new_content = current_file.read_text(encoding="utf-8", errors="replace") + new_content = current_file.read_text( + encoding="utf-8", errors="replace" + ) else: # File was deleted new_content = "" diff --git a/auto-claude/merge/timeline_tracker.py b/auto-claude/merge/timeline_tracker.py index 92ca006b..d557747d 100644 --- a/auto-claude/merge/timeline_tracker.py +++ b/auto-claude/merge/timeline_tracker.py @@ -509,7 +509,9 @@ class FileTimelineTracker: try: content = full_path.read_text(encoding="utf-8") except UnicodeDecodeError: - content = full_path.read_text(encoding="utf-8", errors="replace") + content = full_path.read_text( + encoding="utf-8", errors="replace" + ) self.on_task_worktree_change(task_id, file_path, content) debug_success(MODULE, f"Captured {len(changed_files)} files from worktree") diff --git a/auto-claude/runners/insights_runner.py b/auto-claude/runners/insights_runner.py index f6fae98d..a2de9f94 100644 --- a/auto-claude/runners/insights_runner.py +++ b/auto-claude/runners/insights_runner.py @@ -331,7 +331,9 @@ def main(): parser.add_argument("--project-dir", required=True, help="Project directory path") parser.add_argument("--message", required=True, help="User message") parser.add_argument("--history", default="[]", help="JSON conversation history") - parser.add_argument("--history-file", help="Path to JSON file containing conversation history") + parser.add_argument( + "--history-file", help="Path to JSON file containing conversation history" + ) parser.add_argument( "--model", default="claude-sonnet-4-5-20250929", @@ -364,13 +366,21 @@ def main(): # Load history from file if provided, otherwise parse inline JSON try: if args.history_file: - debug("insights_runner", "Loading history from file", file=args.history_file) - with open(args.history_file, 'r', encoding='utf-8') as f: + debug( + "insights_runner", "Loading history from file", file=args.history_file + ) + with open(args.history_file, encoding="utf-8") as f: history = json.load(f) - debug_detailed("insights_runner", "Loaded history from file", history_length=len(history)) + debug_detailed( + "insights_runner", + "Loaded history from file", + history_length=len(history), + ) else: history = json.loads(args.history) - debug_detailed("insights_runner", "Parsed inline history", history_length=len(history)) + debug_detailed( + "insights_runner", "Parsed inline history", history_length=len(history) + ) except (json.JSONDecodeError, FileNotFoundError, OSError) as e: debug_error("insights_runner", f"Failed to load history: {e}") history = [] From 7f0eeba366903fcbcb921acc26226dacc90c4ab1 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Sat, 20 Dec 2025 12:08:45 +0100 Subject: [PATCH 09/23] chore: bump version to 2.6.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- auto-claude-ui/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auto-claude-ui/package.json b/auto-claude-ui/package.json index 437e249d..dc802097 100644 --- a/auto-claude-ui/package.json +++ b/auto-claude-ui/package.json @@ -1,6 +1,6 @@ { "name": "auto-claude-ui", - "version": "2.5.6", + "version": "2.6.0", "description": "Desktop UI for Auto Claude autonomous coding framework", "main": "./out/main/index.js", "author": "Auto Claude Team", From 37ace0a39af8f651c4ed9ad7a5506590a768add3 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Sat, 20 Dec 2025 12:10:05 +0100 Subject: [PATCH 10/23] fix: update default selected agent profile to 'auto' Changed the default value for the selected agent profile from 'balanced' to 'auto' in the AgentProfiles component to improve user experience and align with expected behavior. --- auto-claude-ui/src/renderer/components/AgentProfiles.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auto-claude-ui/src/renderer/components/AgentProfiles.tsx b/auto-claude-ui/src/renderer/components/AgentProfiles.tsx index e47a5dd0..83bd7a90 100644 --- a/auto-claude-ui/src/renderer/components/AgentProfiles.tsx +++ b/auto-claude-ui/src/renderer/components/AgentProfiles.tsx @@ -19,7 +19,7 @@ const iconMap: Record = { */ export function AgentProfiles() { const settings = useSettingsStore((state) => state.settings); - const selectedProfileId = settings.selectedAgentProfile || 'balanced'; + const selectedProfileId = settings.selectedAgentProfile || 'auto'; const handleSelectProfile = async (profileId: string) => { await saveSettings({ selectedAgentProfile: profileId }); From 08aa2ff02b550e239d8c206cc5e36ab8866faf66 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Sat, 20 Dec 2025 12:18:39 +0100 Subject: [PATCH 11/23] fix: default agent profile to 'Auto (Optimized)' for all users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add one-time migration to reset selectedAgentProfile to 'auto' for existing users. This ensures the optimized per-phase model selection is the default experience for everyone. The migration: - Runs once on settings load (tracked by _migratedAgentProfileToAuto flag) - Sets selectedAgentProfile to 'auto' - Persists the change to settings.json - Users can still change their preference afterward 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../main/ipc-handlers/settings-handlers.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/auto-claude-ui/src/main/ipc-handlers/settings-handlers.ts b/auto-claude-ui/src/main/ipc-handlers/settings-handlers.ts index 9297a838..30f06a7d 100644 --- a/auto-claude-ui/src/main/ipc-handlers/settings-handlers.ts +++ b/auto-claude-ui/src/main/ipc-handlers/settings-handlers.ts @@ -95,6 +95,7 @@ export function registerSettingsHandlers( IPC_CHANNELS.SETTINGS_GET, async (): Promise> => { let settings = { ...DEFAULT_APP_SETTINGS }; + let needsSave = false; if (existsSync(settingsPath)) { try { @@ -105,6 +106,15 @@ export function registerSettingsHandlers( } } + // Migration: Reset agent profile to 'auto' for existing users (one-time) + // This ensures all users get the optimized 'auto' profile as the default + const settingsAny = settings as Record; + if (!settingsAny._migratedAgentProfileToAuto) { + settings.selectedAgentProfile = 'auto'; + settingsAny._migratedAgentProfileToAuto = true; + needsSave = true; + } + // If no manual autoBuildPath is set, try to auto-detect if (!settings.autoBuildPath) { const detectedPath = detectAutoBuildSourcePath(); @@ -113,6 +123,15 @@ export function registerSettingsHandlers( } } + // Persist migration changes + if (needsSave) { + try { + writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); + } catch { + // Ignore write errors during migration + } + } + return { success: true, data: settings as AppSettings }; } ); From cdda3ff2776e509d5e6f777657daaa2831d64fe0 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Sat, 20 Dec 2025 12:20:02 +0100 Subject: [PATCH 12/23] Suggested commit message --- .../ipc-handlers/task/worktree-handlers.ts | 18 +- .../task-detail/TaskDetailModal.tsx | 2 + .../task-detail/TaskDetailPanel.tsx | 2 + .../components/task-detail/TaskReview.tsx | 3 + .../task-detail/hooks/useTaskDetail.ts | 3 + .../task-review/StagedSuccessMessage.tsx | 63 +++- auto-claude-ui/src/shared/types/task.ts | 2 + auto-claude/cli/workspace_commands.py | 78 +++- auto-claude/commit_message.py | 349 ++++++++++++++++++ 9 files changed, 516 insertions(+), 4 deletions(-) create mode 100644 auto-claude/commit_message.py diff --git a/auto-claude-ui/src/main/ipc-handlers/task/worktree-handlers.ts b/auto-claude-ui/src/main/ipc-handlers/task/worktree-handlers.ts index acb3a821..a34a7d49 100644 --- a/auto-claude-ui/src/main/ipc-handlers/task/worktree-handlers.ts +++ b/auto-claude-ui/src/main/ipc-handlers/task/worktree-handlers.ts @@ -500,6 +500,21 @@ export function registerWorktreeHandlers( debug('Merge result. isStageOnly:', isStageOnly, 'newStatus:', newStatus, 'staged:', staged); + // Read suggested commit message if staging succeeded + let suggestedCommitMessage: string | undefined; + if (staged) { + const commitMsgPath = path.join(specDir, 'suggested_commit_message.txt'); + try { + if (existsSync(commitMsgPath)) { + const { readFileSync } = require('fs'); + suggestedCommitMessage = readFileSync(commitMsgPath, 'utf-8').trim(); + debug('Read suggested commit message:', suggestedCommitMessage?.substring(0, 100)); + } + } catch (e) { + debug('Failed to read suggested commit message:', e); + } + } + // Persist the status change to implementation_plan.json const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN); try { @@ -531,7 +546,8 @@ export function registerWorktreeHandlers( success: true, message, staged, - projectPath: staged ? project.path : undefined + projectPath: staged ? project.path : undefined, + suggestedCommitMessage } }); } else { diff --git a/auto-claude-ui/src/renderer/components/task-detail/TaskDetailModal.tsx b/auto-claude-ui/src/renderer/components/task-detail/TaskDetailModal.tsx index 6163e9f6..cd8a743c 100644 --- a/auto-claude-ui/src/renderer/components/task-detail/TaskDetailModal.tsx +++ b/auto-claude-ui/src/renderer/components/task-detail/TaskDetailModal.tsx @@ -120,6 +120,7 @@ function TaskDetailModalContent({ open, task, onOpenChange }: { open: boolean; t state.setWorkspaceError(null); state.setStagedSuccess(result.data.message || 'Changes staged in main project'); state.setStagedProjectPath(result.data.projectPath); + state.setSuggestedCommitMessage(result.data.suggestedCommitMessage); } else { onOpenChange(false); } @@ -393,6 +394,7 @@ function TaskDetailModalContent({ open, task, onOpenChange }: { open: boolean; t stageOnly={state.stageOnly} stagedSuccess={state.stagedSuccess} stagedProjectPath={state.stagedProjectPath} + suggestedCommitMessage={state.suggestedCommitMessage} mergePreview={state.mergePreview} isLoadingPreview={state.isLoadingPreview} showConflictDialog={state.showConflictDialog} diff --git a/auto-claude-ui/src/renderer/components/task-detail/TaskDetailPanel.tsx b/auto-claude-ui/src/renderer/components/task-detail/TaskDetailPanel.tsx index 7090f5d7..cb9e6237 100644 --- a/auto-claude-ui/src/renderer/components/task-detail/TaskDetailPanel.tsx +++ b/auto-claude-ui/src/renderer/components/task-detail/TaskDetailPanel.tsx @@ -84,6 +84,7 @@ export function TaskDetailPanel({ task, onClose }: TaskDetailPanelProps) { state.setWorkspaceError(null); state.setStagedSuccess(result.data.message || 'Changes staged in main project'); state.setStagedProjectPath(result.data.projectPath); + state.setSuggestedCommitMessage(result.data.suggestedCommitMessage); } else { console.warn('[TaskDetailPanel] Full merge success, closing panel'); onClose(); @@ -196,6 +197,7 @@ export function TaskDetailPanel({ task, onClose }: TaskDetailPanelProps) { stageOnly={state.stageOnly} stagedSuccess={state.stagedSuccess} stagedProjectPath={state.stagedProjectPath} + suggestedCommitMessage={state.suggestedCommitMessage} mergePreview={state.mergePreview} isLoadingPreview={state.isLoadingPreview} showConflictDialog={state.showConflictDialog} diff --git a/auto-claude-ui/src/renderer/components/task-detail/TaskReview.tsx b/auto-claude-ui/src/renderer/components/task-detail/TaskReview.tsx index bf633524..6f7bdb45 100644 --- a/auto-claude-ui/src/renderer/components/task-detail/TaskReview.tsx +++ b/auto-claude-ui/src/renderer/components/task-detail/TaskReview.tsx @@ -26,6 +26,7 @@ interface TaskReviewProps { stageOnly: boolean; stagedSuccess: string | null; stagedProjectPath: string | undefined; + suggestedCommitMessage: string | undefined; mergePreview: { files: string[]; conflicts: MergeConflict[]; summary: MergeStats; gitConflicts?: GitConflictInfo; uncommittedChanges?: { hasChanges: boolean; files: string[]; count: number } | null } | null; isLoadingPreview: boolean; showConflictDialog: boolean; @@ -64,6 +65,7 @@ export function TaskReview({ stageOnly, stagedSuccess, stagedProjectPath, + suggestedCommitMessage, mergePreview, isLoadingPreview, showConflictDialog, @@ -88,6 +90,7 @@ export function TaskReview({ stagedSuccess={stagedSuccess} stagedProjectPath={stagedProjectPath} task={task} + suggestedCommitMessage={suggestedCommitMessage} /> )} diff --git a/auto-claude-ui/src/renderer/components/task-detail/hooks/useTaskDetail.ts b/auto-claude-ui/src/renderer/components/task-detail/hooks/useTaskDetail.ts index 71ad2a2c..9d5aab0c 100644 --- a/auto-claude-ui/src/renderer/components/task-detail/hooks/useTaskDetail.ts +++ b/auto-claude-ui/src/renderer/components/task-detail/hooks/useTaskDetail.ts @@ -30,6 +30,7 @@ export function useTaskDetail({ task }: UseTaskDetailOptions) { const [stageOnly, setStageOnly] = useState(task.status === 'human_review'); const [stagedSuccess, setStagedSuccess] = useState(null); const [stagedProjectPath, setStagedProjectPath] = useState(undefined); + const [suggestedCommitMessage, setSuggestedCommitMessage] = useState(undefined); const [phaseLogs, setPhaseLogs] = useState(null); const [isLoadingLogs, setIsLoadingLogs] = useState(false); const [expandedPhases, setExpandedPhases] = useState>(new Set()); @@ -279,6 +280,7 @@ export function useTaskDetail({ task }: UseTaskDetailOptions) { stageOnly, stagedSuccess, stagedProjectPath, + suggestedCommitMessage, phaseLogs, isLoadingLogs, expandedPhases, @@ -318,6 +320,7 @@ export function useTaskDetail({ task }: UseTaskDetailOptions) { setStageOnly, setStagedSuccess, setStagedProjectPath, + setSuggestedCommitMessage, setPhaseLogs, setIsLoadingLogs, setExpandedPhases, diff --git a/auto-claude-ui/src/renderer/components/task-detail/task-review/StagedSuccessMessage.tsx b/auto-claude-ui/src/renderer/components/task-detail/task-review/StagedSuccessMessage.tsx index 802f4e78..95d3e5f3 100644 --- a/auto-claude-ui/src/renderer/components/task-detail/task-review/StagedSuccessMessage.tsx +++ b/auto-claude-ui/src/renderer/components/task-detail/task-review/StagedSuccessMessage.tsx @@ -1,11 +1,14 @@ -import { GitMerge, ExternalLink } from 'lucide-react'; +import { useState } from 'react'; +import { GitMerge, ExternalLink, Copy, Check, Sparkles } from 'lucide-react'; import { Button } from '../../ui/button'; +import { Textarea } from '../../ui/textarea'; import type { Task } from '../../../../shared/types'; interface StagedSuccessMessageProps { stagedSuccess: string; stagedProjectPath: string | undefined; task: Task; + suggestedCommitMessage?: string; } /** @@ -14,8 +17,23 @@ interface StagedSuccessMessageProps { export function StagedSuccessMessage({ stagedSuccess, stagedProjectPath, - task + task, + suggestedCommitMessage }: StagedSuccessMessageProps) { + const [commitMessage, setCommitMessage] = useState(suggestedCommitMessage || ''); + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + if (!commitMessage) return; + try { + await navigator.clipboard.writeText(commitMessage); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + return (

@@ -25,6 +43,47 @@ export function StagedSuccessMessage({

{stagedSuccess}

+ + {/* Commit Message Section */} + {suggestedCommitMessage && ( +
+
+

+ + AI-generated commit message +

+ +
+