From aaa83131f43c4ab4a33c5736b2cf9a7ec09bd4b6 Mon Sep 17 00:00:00 2001 From: Joe <44273333+jslitzkerttcu@users.noreply.github.com> Date: Sun, 28 Dec 2025 16:30:54 -0600 Subject: [PATCH] fix: improve CLI tool detection and add Claude CLI path settings (#393) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(changelog): improve CLI tool detection for git and Claude Fixes changelog generation failure with FileNotFoundError when using GitHub issues option to pull commits. Changes: - Replace execSync with execFileSync(getToolPath('git')) in git-integration.ts for cross-platform compatibility and security - Add Claude CLI to centralized CLI Tool Manager with 4-tier detection - Remove 47 lines of duplicate Claude CLI detection from changelog-service.ts - Add dynamic npm prefix detection in env-utils.ts for all npm setups Benefits: - Cross-platform compatibility (no shell injection risk) - Consistent CLI tool detection across codebase - Works with standard npm, nvm, nvm-windows, and custom installations * feat: add Claude CLI path configuration to Settings UI Integrates Claude CLI path configuration into the Settings UI, building on the Claude CLI detection infrastructure from PR #391. Changes: - Add Claude CLI path input field to Settings UI - Expose Claude CLI detection through IPC handlers - Add i18n translations (English/French) for Claude CLI settings - Update type definitions for Claude CLI configuration - Add browser mock for Claude CLI detection This commit combines: - PR #391's comprehensive Claude CLI detection (detectClaude, validateClaude) - PR #392's Settings UI enhancements 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 * docs: clarify npm prefix detection is cross-platform - Removed misleading Windows-specific comment on line 60 - Updated comment at call site (line 101) to explicitly state cross-platform support - Clarifies that getNpmGlobalPrefix() works on all platforms (macOS, Linux, Windows) Addresses CodeRabbit feedback * fix: improve npm global prefix detection for cross-platform support - Use npm.cmd on Windows with shell option for proper command resolution - Return prefix/bin on macOS/Linux (where npm globals are actually installed) - Return raw prefix on Windows (correct location for npm globals) - Normalize path and verify existence before returning - Preserve existing encoding, timeout, and error handling Addresses CodeRabbit feedback on platform-specific npm prefix handling --------- Co-authored-by: Joe Slitzker Co-authored-by: Claude Sonnet 4.5 --- .../src/main/changelog/changelog-service.ts | 49 +----- .../src/main/changelog/git-integration.ts | 29 ++-- apps/frontend/src/main/cli-tool-manager.ts | 144 +++++++++++++++++- apps/frontend/src/main/env-utils.ts | 49 ++++++ .../main/ipc-handlers/settings-handlers.ts | 7 +- apps/frontend/src/preload/api/settings-api.ts | 2 + .../components/settings/GeneralSettings.tsx | 21 ++- .../src/renderer/lib/mocks/settings-mock.ts | 3 +- .../src/shared/i18n/locales/en/settings.json | 3 + .../src/shared/i18n/locales/fr/settings.json | 3 + apps/frontend/src/shared/types/ipc.ts | 1 + apps/frontend/src/shared/types/settings.ts | 1 + 12 files changed, 249 insertions(+), 63 deletions(-) diff --git a/apps/frontend/src/main/changelog/changelog-service.ts b/apps/frontend/src/main/changelog/changelog-service.ts index fce7ced8..bd4ba9a7 100644 --- a/apps/frontend/src/main/changelog/changelog-service.ts +++ b/apps/frontend/src/main/changelog/changelog-service.ts @@ -1,9 +1,9 @@ import { EventEmitter } from 'events'; import * as path from 'path'; -import * as os from 'os'; import { existsSync, readFileSync, writeFileSync } from 'fs'; import { app } from 'electron'; import { AUTO_BUILD_PATHS, DEFAULT_CHANGELOG_PATH } from '../../shared/constants'; +import { getToolPath } from '../cli-tool-manager'; import type { ChangelogTask, TaskSpecContent, @@ -37,7 +37,7 @@ import { getConfiguredPythonPath } from '../python-env-manager'; export class ChangelogService extends EventEmitter { // Python path will be configured by pythonEnvManager after venv is ready private _pythonPath: string | null = null; - private claudePath: string = 'claude'; + private claudePath: string; private autoBuildSourcePath: string = ''; private cachedEnv: Record | null = null; private debugEnabled: boolean | null = null; @@ -46,48 +46,9 @@ export class ChangelogService extends EventEmitter { constructor() { super(); - this.detectClaudePath(); - this.debug('ChangelogService initialized'); - } - - /** - * Detect the full path to the claude CLI - * Electron apps don't inherit shell PATH, so we need to find it explicitly - */ - private detectClaudePath(): void { - const homeDir = os.homedir(); - - // Platform-specific possible paths - const possiblePaths = process.platform === 'win32' - ? [ - // Windows paths - path.join(homeDir, 'AppData', 'Local', 'Programs', 'claude', 'claude.exe'), - path.join(homeDir, 'AppData', 'Roaming', 'npm', 'claude.cmd'), - path.join(homeDir, '.local', 'bin', 'claude.exe'), - 'C:\\Program Files\\Claude\\claude.exe', - 'C:\\Program Files (x86)\\Claude\\claude.exe', - // Also check if claude is in system PATH - 'claude' - ] - : [ - // Unix paths (macOS/Linux) - '/usr/local/bin/claude', - '/opt/homebrew/bin/claude', - path.join(homeDir, '.local/bin/claude'), - path.join(homeDir, 'bin/claude'), - // Also check if claude is in system PATH - 'claude' - ]; - - for (const claudePath of possiblePaths) { - if (claudePath === 'claude' || existsSync(claudePath)) { - this.claudePath = claudePath; - this.debug('Claude CLI found at:', claudePath); - return; - } - } - - this.debug('Claude CLI not found in common locations, using default'); + // Use centralized CLI tool manager for Claude detection + this.claudePath = getToolPath('claude'); + this.debug('ChangelogService initialized with Claude CLI:', this.claudePath); } /** diff --git a/apps/frontend/src/main/changelog/git-integration.ts b/apps/frontend/src/main/changelog/git-integration.ts index 226556a4..92bee61f 100644 --- a/apps/frontend/src/main/changelog/git-integration.ts +++ b/apps/frontend/src/main/changelog/git-integration.ts @@ -1,4 +1,4 @@ -import { execSync, execFileSync } from 'child_process'; +import { execFileSync } from 'child_process'; import type { GitBranchInfo, GitTagInfo, @@ -89,8 +89,9 @@ export function getBranches(projectPath: string, debugEnabled = false): GitBranc export function getTags(projectPath: string, debugEnabled = false): GitTagInfo[] { try { // Get tags sorted by creation date (newest first) - const output = execSync( - 'git tag -l --sort=-creatordate --format="%(refname:short)|%(creatordate:iso-strict)|%(objectname:short)"', + const output = execFileSync( + getToolPath('git'), + ['tag', '-l', '--sort=-creatordate', '--format=%(refname:short)|%(creatordate:iso-strict)|%(objectname:short)'], { cwd: projectPath, encoding: 'utf-8' @@ -179,40 +180,40 @@ export function getCommits( try { // Build the git log command based on options const format = '%h|%H|%s|%an|%ae|%aI'; - let command = `git log --pretty=format:"${format}"`; + const args = ['log', `--pretty=format:${format}`]; // Add merge commit handling if (!options.includeMergeCommits) { - command += ' --no-merges'; + args.push('--no-merges'); } // Add range/filters based on type switch (options.type) { case 'recent': - command += ` -n ${options.count || 25}`; + args.push('-n', String(options.count || 25)); break; case 'since-date': if (options.sinceDate) { - command += ` --since="${options.sinceDate}"`; + args.push(`--since=${options.sinceDate}`); } break; case 'tag-range': if (options.fromTag) { const toRef = options.toTag || 'HEAD'; - command += ` ${options.fromTag}..${toRef}`; + args.push(`${options.fromTag}..${toRef}`); } break; case 'since-version': // Get all commits since the specified version/tag up to HEAD if (options.fromTag) { - command += ` ${options.fromTag}..HEAD`; + args.push(`${options.fromTag}..HEAD`); } break; } - debug(debugEnabled, 'Getting commits with command:', command); + debug(debugEnabled, 'Getting commits with args:', args); - const output = execSync(command, { + const output = execFileSync(getToolPath('git'), args, { cwd: projectPath, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 // 10MB buffer for large histories @@ -236,11 +237,11 @@ export function getBranchDiffCommits( try { const format = '%h|%H|%s|%an|%ae|%aI'; // Get commits in compareBranch that are not in baseBranch - const command = `git log --pretty=format:"${format}" --no-merges ${options.baseBranch}..${options.compareBranch}`; + const args = ['log', `--pretty=format:${format}`, '--no-merges', `${options.baseBranch}..${options.compareBranch}`]; - debug(debugEnabled, 'Getting branch diff commits with command:', command); + debug(debugEnabled, 'Getting branch diff commits with args:', args); - const output = execSync(command, { + const output = execFileSync(getToolPath('git'), args, { cwd: projectPath, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 diff --git a/apps/frontend/src/main/cli-tool-manager.ts b/apps/frontend/src/main/cli-tool-manager.ts index 8c455da0..fd7ae128 100644 --- a/apps/frontend/src/main/cli-tool-manager.ts +++ b/apps/frontend/src/main/cli-tool-manager.ts @@ -1,7 +1,7 @@ /** * CLI Tool Manager * - * Centralized management for CLI tools (Python, Git, GitHub CLI) used throughout + * Centralized management for CLI tools (Python, Git, GitHub CLI, Claude CLI) used throughout * the application. Provides intelligent multi-level detection with user * configuration support. * @@ -23,6 +23,7 @@ import { execSync, execFileSync } from 'child_process'; import { existsSync } from 'fs'; import path from 'path'; +import os from 'os'; import { app } from 'electron'; import { findExecutable } from './env-utils'; import type { ToolDetectionResult } from '../shared/types'; @@ -30,7 +31,7 @@ import type { ToolDetectionResult } from '../shared/types'; /** * Supported CLI tools managed by this system */ -export type CLITool = 'python' | 'git' | 'gh'; +export type CLITool = 'python' | 'git' | 'gh' | 'claude'; /** * User configuration for CLI tool paths @@ -40,6 +41,7 @@ export interface ToolConfig { pythonPath?: string; gitPath?: string; githubCLIPath?: string; + claudePath?: string; } /** @@ -147,6 +149,8 @@ class CLIToolManager { return this.detectGit(); case 'gh': return this.detectGitHubCLI(); + case 'claude': + return this.detectClaude(); default: return { found: false, @@ -440,6 +444,111 @@ class CLIToolManager { }; } + /** + * Detect Claude CLI with multi-level priority + * + * Priority order: + * 1. User configuration + * 2. Homebrew claude (macOS) + * 3. System PATH + * 4. Windows/macOS/Linux standard locations + * + * @returns Detection result for Claude CLI + */ + private detectClaude(): ToolDetectionResult { + // 1. User configuration + if (this.userConfig.claudePath) { + const validation = this.validateClaude(this.userConfig.claudePath); + if (validation.valid) { + return { + found: true, + path: this.userConfig.claudePath, + version: validation.version, + source: 'user-config', + message: `Using user-configured Claude CLI: ${this.userConfig.claudePath}`, + }; + } + console.warn( + `[Claude CLI] User-configured path invalid: ${validation.message}` + ); + } + + // 2. Homebrew (macOS) + if (process.platform === 'darwin') { + const homebrewPaths = [ + '/opt/homebrew/bin/claude', // Apple Silicon + '/usr/local/bin/claude', // Intel Mac + ]; + + for (const claudePath of homebrewPaths) { + if (existsSync(claudePath)) { + const validation = this.validateClaude(claudePath); + if (validation.valid) { + return { + found: true, + path: claudePath, + version: validation.version, + source: 'homebrew', + message: `Using Homebrew Claude CLI: ${claudePath}`, + }; + } + } + } + } + + // 3. System PATH (augmented) + const claudePath = findExecutable('claude'); + if (claudePath) { + const validation = this.validateClaude(claudePath); + if (validation.valid) { + return { + found: true, + path: claudePath, + version: validation.version, + source: 'system-path', + message: `Using system Claude CLI: ${claudePath}`, + }; + } + } + + // 4. Platform-specific standard locations + const homeDir = os.homedir(); + const platformPaths = process.platform === 'win32' + ? [ + path.join(homeDir, 'AppData', 'Local', 'Programs', 'claude', 'claude.exe'), + path.join(homeDir, 'AppData', 'Roaming', 'npm', 'claude.cmd'), + path.join(homeDir, '.local', 'bin', 'claude.exe'), + 'C:\\Program Files\\Claude\\claude.exe', + 'C:\\Program Files (x86)\\Claude\\claude.exe', + ] + : [ + path.join(homeDir, '.local', 'bin', 'claude'), + path.join(homeDir, 'bin', 'claude'), + ]; + + for (const claudePath of platformPaths) { + if (existsSync(claudePath)) { + const validation = this.validateClaude(claudePath); + if (validation.valid) { + return { + found: true, + path: claudePath, + version: validation.version, + source: 'system-path', + message: `Using Claude CLI: ${claudePath}`, + }; + } + } + } + + // 5. Not found + return { + found: false, + source: 'fallback', + message: 'Claude CLI not found. Install from https://claude.ai/download', + }; + } + /** * Validate Python version and availability * @@ -557,6 +666,37 @@ class CLIToolManager { } } + /** + * Validate Claude CLI availability and version + * + * @param claudeCmd - The Claude CLI command to validate + * @returns Validation result with version information + */ + private validateClaude(claudeCmd: string): ToolValidation { + try { + const version = execFileSync(claudeCmd, ['--version'], { + encoding: 'utf-8', + timeout: 5000, + windowsHide: true, + }).trim(); + + // Claude CLI version output format: "claude-code version X.Y.Z" or similar + const match = version.match(/(\d+\.\d+\.\d+)/); + const versionStr = match ? match[1] : version.split('\n')[0]; + + return { + valid: true, + version: versionStr, + message: `Claude CLI ${versionStr} is available`, + }; + } catch (error) { + return { + valid: false, + message: `Failed to validate Claude CLI: ${error instanceof Error ? error.message : String(error)}`, + }; + } + } + /** * Get bundled Python path for packaged apps * diff --git a/apps/frontend/src/main/env-utils.ts b/apps/frontend/src/main/env-utils.ts index 8be43627..05174199 100644 --- a/apps/frontend/src/main/env-utils.ts +++ b/apps/frontend/src/main/env-utils.ts @@ -12,6 +12,49 @@ import * as os from 'os'; import * as path from 'path'; import * as fs from 'fs'; +import { execFileSync } from 'child_process'; + +/** + * Get npm global prefix directory dynamically + * + * Runs `npm config get prefix` to find where npm globals are installed. + * Works with standard npm, nvm-windows, nvm, and custom installations. + * + * On Windows: returns the prefix directory (e.g., C:\Users\user\AppData\Roaming\npm) + * On macOS/Linux: returns prefix/bin (e.g., /usr/local/bin) + * + * @returns npm global binaries directory, or null if npm not available or path doesn't exist + */ +function getNpmGlobalPrefix(): string | null { + try { + // On Windows, use npm.cmd for proper command resolution + const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + + const rawPrefix = execFileSync(npmCommand, ['config', 'get', 'prefix'], { + encoding: 'utf-8', + timeout: 3000, + windowsHide: true, + shell: process.platform === 'win32', // Enable shell on Windows for .cmd resolution + }).trim(); + + if (!rawPrefix) { + return null; + } + + // On non-Windows platforms, npm globals are installed in prefix/bin + // On Windows, they're installed directly in the prefix directory + const binPath = process.platform === 'win32' + ? rawPrefix + : path.join(rawPrefix, 'bin'); + + // Normalize and verify the path exists + const normalizedPath = path.normalize(binPath); + + return fs.existsSync(normalizedPath) ? normalizedPath : null; + } catch { + return null; + } +} /** * Common binary directories that should be in PATH @@ -75,6 +118,12 @@ export function getAugmentedEnv(additionalPaths?: string[]): Record; git: ReturnType; gh: ReturnType; + claude: ReturnType; }>> => { try { return { @@ -198,6 +202,7 @@ export function registerSettingsHandlers( python: getToolInfo('python'), git: getToolInfo('git'), gh: getToolInfo('gh'), + claude: getToolInfo('claude'), }, }; } catch (error) { diff --git a/apps/frontend/src/preload/api/settings-api.ts b/apps/frontend/src/preload/api/settings-api.ts index d896d74c..263c32d0 100644 --- a/apps/frontend/src/preload/api/settings-api.ts +++ b/apps/frontend/src/preload/api/settings-api.ts @@ -18,6 +18,7 @@ export interface SettingsAPI { python: ToolDetectionResult; git: ToolDetectionResult; gh: ToolDetectionResult; + claude: ToolDetectionResult; }>>; // App Info @@ -42,6 +43,7 @@ export const createSettingsAPI = (): SettingsAPI => ({ python: ToolDetectionResult; git: ToolDetectionResult; gh: ToolDetectionResult; + claude: ToolDetectionResult; }>> => ipcRenderer.invoke(IPC_CHANNELS.SETTINGS_GET_CLI_TOOLS_INFO), diff --git a/apps/frontend/src/renderer/components/settings/GeneralSettings.tsx b/apps/frontend/src/renderer/components/settings/GeneralSettings.tsx index eea5d605..134700bd 100644 --- a/apps/frontend/src/renderer/components/settings/GeneralSettings.tsx +++ b/apps/frontend/src/renderer/components/settings/GeneralSettings.tsx @@ -95,6 +95,7 @@ export function GeneralSettings({ settings, onSettingsChange, section }: General python: ToolDetectionResult; git: ToolDetectionResult; gh: ToolDetectionResult; + claude: ToolDetectionResult; } | null>(null); const [isLoadingTools, setIsLoadingTools] = useState(false); @@ -104,7 +105,7 @@ export function GeneralSettings({ settings, onSettingsChange, section }: General setIsLoadingTools(true); window.electronAPI .getCliToolsInfo() - .then((result: { success: boolean; data?: { python: ToolDetectionResult; git: ToolDetectionResult; gh: ToolDetectionResult } }) => { + .then((result: { success: boolean; data?: { python: ToolDetectionResult; git: ToolDetectionResult; gh: ToolDetectionResult; claude: ToolDetectionResult } }) => { if (result.success && result.data) { setToolsInfo(result.data); } @@ -303,6 +304,24 @@ export function GeneralSettings({ settings, onSettingsChange, section }: General /> )} +
+ +

{t('general.claudePathDescription')}

+ onSettingsChange({ ...settings, claudePath: e.target.value })} + /> + {!settings.claudePath && ( + + )} +

{t('general.autoClaudePathDescription')}

diff --git a/apps/frontend/src/renderer/lib/mocks/settings-mock.ts b/apps/frontend/src/renderer/lib/mocks/settings-mock.ts index d7634a4d..559dc12c 100644 --- a/apps/frontend/src/renderer/lib/mocks/settings-mock.ts +++ b/apps/frontend/src/renderer/lib/mocks/settings-mock.ts @@ -18,7 +18,8 @@ export const settingsMock = { data: { python: { found: false, source: 'fallback' as const, message: 'Not available in browser mode' }, git: { found: false, source: 'fallback' as const, message: 'Not available in browser mode' }, - gh: { found: false, source: 'fallback' as const, message: 'Not available in browser mode' } + gh: { found: false, source: 'fallback' as const, message: 'Not available in browser mode' }, + claude: { found: false, source: 'fallback' as const, message: 'Not available in browser mode' } } }), diff --git a/apps/frontend/src/shared/i18n/locales/en/settings.json b/apps/frontend/src/shared/i18n/locales/en/settings.json index 0ad3a67d..f4016001 100644 --- a/apps/frontend/src/shared/i18n/locales/en/settings.json +++ b/apps/frontend/src/shared/i18n/locales/en/settings.json @@ -74,6 +74,9 @@ "githubCLIPath": "GitHub CLI Path", "githubCLIPathDescription": "Path to GitHub CLI (gh) executable (leave empty for auto-detection)", "githubCLIPathPlaceholder": "gh (default)", + "claudePath": "Claude CLI Path", + "claudePathDescription": "Path to Claude CLI executable (leave empty for auto-detection)", + "claudePathPlaceholder": "claude (default)", "detectedPath": "Auto-detected", "detectedVersion": "Version", "detectedSource": "Source", diff --git a/apps/frontend/src/shared/i18n/locales/fr/settings.json b/apps/frontend/src/shared/i18n/locales/fr/settings.json index 04477a42..ada8a4bc 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/settings.json +++ b/apps/frontend/src/shared/i18n/locales/fr/settings.json @@ -74,6 +74,9 @@ "githubCLIPath": "Chemin GitHub CLI", "githubCLIPathDescription": "Chemin vers l'exécutable GitHub CLI (gh) (laisser vide pour détection automatique)", "githubCLIPathPlaceholder": "gh (par défaut)", + "claudePath": "Chemin Claude CLI", + "claudePathDescription": "Chemin vers l'exécutable Claude CLI (laisser vide pour détection automatique)", + "claudePathPlaceholder": "claude (par défaut)", "detectedPath": "Détecté automatiquement", "detectedVersion": "Version", "detectedSource": "Source", diff --git a/apps/frontend/src/shared/types/ipc.ts b/apps/frontend/src/shared/types/ipc.ts index 41f57954..f7be28c8 100644 --- a/apps/frontend/src/shared/types/ipc.ts +++ b/apps/frontend/src/shared/types/ipc.ts @@ -241,6 +241,7 @@ export interface ElectronAPI { python: import('./cli').ToolDetectionResult; git: import('./cli').ToolDetectionResult; gh: import('./cli').ToolDetectionResult; + claude: import('./cli').ToolDetectionResult; }>>; // Dialog operations diff --git a/apps/frontend/src/shared/types/settings.ts b/apps/frontend/src/shared/types/settings.ts index 626019f9..cc15c413 100644 --- a/apps/frontend/src/shared/types/settings.ts +++ b/apps/frontend/src/shared/types/settings.ts @@ -86,6 +86,7 @@ export interface AppSettings { pythonPath?: string; gitPath?: string; githubCLIPath?: string; + claudePath?: string; autoBuildPath?: string; autoUpdateAutoBuild: boolean; autoNameTerminals: boolean;