From 57cd5a9aa68323d91b5ebe7003327df7bfd53eb7 Mon Sep 17 00:00:00 2001 From: DawidGrzejek Date: Tue, 16 Dec 2025 12:34:32 +0100 Subject: [PATCH 1/3] fix: add Windows support for Claude CLI detection and execution - Skip bash/zsh shell detection on Windows platform - Use PowerShell for base64 decoding on Windows - Set appropriate shell (powershell.exe vs bash) based on platform - Conditional PATH environment variable configuration - Bump version to 1.0.1 and rename package to claude-commit --- package-lock.json | 8 ++-- src/extension.ts | 116 ++++++++++++++++++++++++++-------------------- 2 files changed, 69 insertions(+), 55 deletions(-) diff --git a/package-lock.json b/package-lock.json index e280186..b500372 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "claude-code-ai-commit-message-button", - "version": "1.0.0", + "name": "claude-commit", + "version": "1.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "claude-code-ai-commit-message-button", - "version": "1.0.0", + "name": "claude-commit", + "version": "1.0.1", "license": "MIT", "devDependencies": { "@types/mocha": "^10.0.10", diff --git a/src/extension.ts b/src/extension.ts index 3bf6eab..06b1fe9 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -90,57 +90,61 @@ class ClaudeCLIExecutor { } } - // Try using bash shell - if (this.debugMode) { - outputChannel.appendLine('\n[BASH SHELL] Trying bash -l -c "which claude"...'); - } - try { - const bashCmd = '/bin/bash -l -c "which claude"'; - if (this.debugMode) { - outputChannel.appendLine(`[BASH SHELL] Command: ${bashCmd}`); - } - - const { stdout, stderr } = await execAsync(bashCmd); - if (this.debugMode) { - outputChannel.appendLine(`[BASH SHELL] stdout: ${stdout}`); - outputChannel.appendLine(`[BASH SHELL] stderr: ${stderr || 'none'}`); - } - - this.claudePath = stdout.trim(); + // Try using bash shell (skip on Windows) + if (process.platform !== 'win32') { if (this.debugMode) { - outputChannel.appendLine(`[BASH SHELL] ✓ Found at: ${this.claudePath}`); + outputChannel.appendLine('\n[BASH SHELL] Trying bash -l -c "which claude"...'); } - return this.claudePath; - } catch (error: any) { - if (this.debugMode) { - outputChannel.appendLine(`[BASH SHELL] Failed: ${error.message}`); + try { + const bashCmd = '/bin/bash -l -c "which claude"'; + if (this.debugMode) { + outputChannel.appendLine(`[BASH SHELL] Command: ${bashCmd}`); + } + + const { stdout, stderr } = await execAsync(bashCmd); + if (this.debugMode) { + outputChannel.appendLine(`[BASH SHELL] stdout: ${stdout}`); + outputChannel.appendLine(`[BASH SHELL] stderr: ${stderr || 'none'}`); + } + + this.claudePath = stdout.trim(); + if (this.debugMode) { + outputChannel.appendLine(`[BASH SHELL] ✓ Found at: ${this.claudePath}`); + } + return this.claudePath; + } catch (error: any) { + if (this.debugMode) { + outputChannel.appendLine(`[BASH SHELL] Failed: ${error.message}`); + } } } - // Try zsh shell - if (this.debugMode) { - outputChannel.appendLine('\n[ZSH SHELL] Trying zsh -l -c "which claude"...'); - } - try { - const zshCmd = '/bin/zsh -l -c "which claude"'; + // Try zsh shell (skip on Windows) + if (process.platform !== 'win32') { if (this.debugMode) { - outputChannel.appendLine(`[ZSH SHELL] Command: ${zshCmd}`); + outputChannel.appendLine('\n[ZSH SHELL] Trying zsh -l -c "which claude"...'); } - - const { stdout, stderr } = await execAsync(zshCmd); - if (this.debugMode) { - outputChannel.appendLine(`[ZSH SHELL] stdout: ${stdout}`); - outputChannel.appendLine(`[ZSH SHELL] stderr: ${stderr || 'none'}`); - } - - this.claudePath = stdout.trim(); - if (this.debugMode) { - outputChannel.appendLine(`[ZSH SHELL] ✓ Found at: ${this.claudePath}`); - } - return this.claudePath; - } catch (error: any) { - if (this.debugMode) { - outputChannel.appendLine(`[ZSH SHELL] Failed: ${error.message}`); + try { + const zshCmd = '/bin/zsh -l -c "which claude"'; + if (this.debugMode) { + outputChannel.appendLine(`[ZSH SHELL] Command: ${zshCmd}`); + } + + const { stdout, stderr } = await execAsync(zshCmd); + if (this.debugMode) { + outputChannel.appendLine(`[ZSH SHELL] stdout: ${stdout}`); + outputChannel.appendLine(`[ZSH SHELL] stderr: ${stderr || 'none'}`); + } + + this.claudePath = stdout.trim(); + if (this.debugMode) { + outputChannel.appendLine(`[ZSH SHELL] ✓ Found at: ${this.claudePath}`); + } + return this.claudePath; + } catch (error: any) { + if (this.debugMode) { + outputChannel.appendLine(`[ZSH SHELL] Failed: ${error.message}`); + } } } @@ -279,12 +283,22 @@ class ClaudeCLIExecutor { outputChannel.appendLine(`[EXECUTE] Using path: ${this.claudePath}`); } + // Detect platform + const isWindows = process.platform === 'win32'; + // Use base64 encoding to completely avoid shell escaping issues const base64Prompt = Buffer.from(prompt, 'utf8').toString('base64'); - + // Build command that decodes base64 and pipes to claude - let command = `echo "${base64Prompt}" | base64 -d | ${this.claudePath}`; - + let command; + if (isWindows) { + // PowerShell command for Windows + command = `[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${base64Prompt}')) | ${this.claudePath}`; + } else { + // Bash command for Linux/macOS + command = `echo "${base64Prompt}" | base64 -d | ${this.claudePath}`; + } + // Add all flags command += ' --print'; // Use explicit --print flag command += ' --model sonnet'; // Hardcoded model @@ -304,11 +318,11 @@ class ClaudeCLIExecutor { const execOptions = { timeout: 60000, // 60 seconds timeout - shell: '/bin/bash', + shell: isWindows ? 'powershell.exe' : '/bin/bash', maxBuffer: 1024 * 1024 * 10, // 10MB buffer for large outputs - env: { - ...process.env, - PATH: `/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:${process.env.PATH}:${process.env.HOME}/.nvm/versions/node/*/bin` + env: { + ...process.env, + PATH: isWindows ? process.env.PATH : `/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:${process.env.PATH}:${process.env.HOME}/.nvm/versions/node/*/bin` } }; From 29de8f3a3ae7b2a5d9e9cc4340200114e5dafa56 Mon Sep 17 00:00:00 2001 From: DawidGrzejek Date: Thu, 18 Dec 2025 14:44:03 +0100 Subject: [PATCH 2/3] feat: add custom commit message instructions support - Add customInstructions setting for inline custom rules - Add instructionsFilePath setting to load rules from file - Support both relative and absolute file paths - File instructions take precedence over inline instructions - Update README with configuration examples Addresses #2 --- README.md | 44 +++++++++++++++++++++-- package.json | 10 ++++++ src/extension.ts | 90 ++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 131 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index b66d231..f649194 100644 --- a/README.md +++ b/README.md @@ -46,14 +46,17 @@ That's it. No configuration, no setup wizards, no complexity. ## Extension Settings -This extension keeps it simple with just two optional settings: +This extension provides several optional settings to customize your experience: -* `claude-commit.claudePath`: Custom path to Claude CLI executable (auto-detects by default) -* `claude-commit.debugMode`: Enable debug output for troubleshooting +- `claude-commit.claudePath`: Custom path to Claude CLI executable (auto-detects by default) +- `claude-commit.debugMode`: Enable debug output for troubleshooting +- `claude-commit.customInstructions`: Custom instructions for commit message generation +- `claude-commit.instructionsFilePath`: Path to a file containing custom commit message instructions ## Configuration Examples ### Using a custom Claude path + ```json { "claude-commit.claudePath": "/usr/local/bin/claude" @@ -61,12 +64,47 @@ This extension keeps it simple with just two optional settings: ``` ### Debug mode for troubleshooting + ```json { "claude-commit.debugMode": true } ``` +### Custom instructions (inline) + +Provide custom rules directly in your settings: + +```json +{ + "claude-commit.customInstructions": "Summarize changes into a single sentence suitable for release notes. Focus on user-facing impact rather than technical details." +} +``` + +### Custom instructions (from file) + +Use a file to share commit message conventions across your team: + +```json +{ + "claude-commit.instructionsFilePath": ".vscode/copilot/commit-message-instructions.md" +} +``` + +Then create `.vscode/copilot/commit-message-instructions.md`: + +```markdown +# Commit Message Instructions + +- Summarize changes into a sentence for release notes +- Focus on the "why" rather than the "what" +- Use imperative mood (e.g., "Add feature" not "Added feature") +- Maximum 50 characters for the subject line +- Include ticket number if applicable: [TICKET-123] Subject +``` + +The file path can be relative (from workspace root) or absolute. If both file and inline instructions are provided, the file takes precedence. + ## Troubleshooting ### Claude CLI not found diff --git a/package.json b/package.json index 9442916..0e17711 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,16 @@ "type": "boolean", "default": false, "description": "Enable debug mode to show CLI commands being executed" + }, + "claude-commit.customInstructions": { + "type": "string", + "default": "", + "description": "Custom instructions for commit message generation (e.g., 'Summarize changes into a sentence for release notes')" + }, + "claude-commit.instructionsFilePath": { + "type": "string", + "default": "", + "description": "Path to a file containing custom commit message instructions (e.g., '.vscode/copilot/commit-message-instructions.md')" } } } diff --git a/src/extension.ts b/src/extension.ts index 3bf6eab..f4a43e1 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -3,6 +3,8 @@ import * as vscode from 'vscode'; import { exec } from 'child_process'; import { promisify } from 'util'; +import * as fs from 'fs'; +import * as path from 'path'; const execAsync = promisify(exec); @@ -558,16 +560,69 @@ class CommitMessageGenerator { constructor() { this.config = vscode.workspace.getConfiguration('claude-commit'); this.debugMode = this.config.get('debugMode') || false; - + if (this.debugMode) { outputChannel.appendLine('\n=== CONFIGURATION ==='); outputChannel.appendLine(`claudePath: ${this.config.get('claudePath') || 'not set'}`); outputChannel.appendLine(`debugMode: ${this.debugMode}`); + outputChannel.appendLine(`customInstructions: ${this.config.get('customInstructions') || 'not set'}`); + outputChannel.appendLine(`instructionsFilePath: ${this.config.get('instructionsFilePath') || 'not set'}`); } - + this.cliExecutor = new ClaudeCLIExecutor(this.debugMode); } + private async loadCustomInstructions(workspacePath: string): Promise { + if (this.debugMode) { + outputChannel.appendLine('\n[INSTRUCTIONS] Loading custom instructions...'); + } + + let customInstructions = ''; + + // First, try to load from file + const instructionsFilePath = this.config.get('instructionsFilePath'); + if (instructionsFilePath && instructionsFilePath.trim() !== '') { + try { + // Resolve relative paths from workspace root + const resolvedPath = path.isAbsolute(instructionsFilePath) + ? instructionsFilePath + : path.join(workspacePath, instructionsFilePath); + + if (this.debugMode) { + outputChannel.appendLine(`[INSTRUCTIONS] Reading from file: ${resolvedPath}`); + } + + if (fs.existsSync(resolvedPath)) { + customInstructions = fs.readFileSync(resolvedPath, 'utf8').trim(); + if (this.debugMode) { + outputChannel.appendLine(`[INSTRUCTIONS] Loaded ${customInstructions.length} chars from file`); + } + } else { + if (this.debugMode) { + outputChannel.appendLine(`[INSTRUCTIONS] File not found: ${resolvedPath}`); + } + } + } catch (error: any) { + if (this.debugMode) { + outputChannel.appendLine(`[INSTRUCTIONS] Error reading file: ${error.message}`); + } + } + } + + // If no file instructions, use inline custom instructions + if (!customInstructions) { + const inlineInstructions = this.config.get('customInstructions'); + if (inlineInstructions && inlineInstructions.trim() !== '') { + customInstructions = inlineInstructions.trim(); + if (this.debugMode) { + outputChannel.appendLine(`[INSTRUCTIONS] Using inline instructions: ${customInstructions.length} chars`); + } + } + } + + return customInstructions; + } + async generateCommitMessage(repositoryPath?: string): Promise { if (this.debugMode) { outputChannel.appendLine('\n=== GENERATE COMMIT MESSAGE START ==='); @@ -637,9 +692,28 @@ class CommitMessageGenerator { if (this.debugMode) { outputChannel.appendLine(`\n[PROMPT] Creating prompt for ${fileType}...`); } - - // Build prompt with hardcoded conventional format - const prompt = `Generate a git commit message for the following changes. + + // Load custom instructions + const customInstructions = await this.loadCustomInstructions(cwd || ''); + + // Build prompt with default rules + let rulesSection = `Rules: +- Use conventional commit format +- Keep under 72 characters for the first line +- Be specific and clear +- Common types: feat, fix, docs, style, refactor, test, chore`; + + // If custom instructions exist, use them instead + if (customInstructions) { + rulesSection = `Custom Instructions: +${customInstructions}`; + + if (this.debugMode) { + outputChannel.appendLine(`[PROMPT] Using custom instructions (${customInstructions.length} chars)`); + } + } + + const prompt = `Generate a git commit message for the following changes. IMPORTANT: Return ONLY the commit message text itself. Do not include: - Any explanatory text like "Based on...", "Here's...", or "Here is..." @@ -652,11 +726,7 @@ Just return the raw commit message text that will be used directly in git commit Git diff: ${diff} -Rules: -- Use conventional commit format -- Keep under 72 characters for the first line -- Be specific and clear -- Common types: feat, fix, docs, style, refactor, test, chore +${rulesSection} Remember: Return ONLY the commit message text, nothing else.`; From 549e82b6bfa3c989c9f2b04f775ad239ff1b92ba Mon Sep 17 00:00:00 2001 From: DawidGrzejek Date: Thu, 18 Dec 2025 10:27:42 +0100 Subject: [PATCH 3/3] fix: use temp file for large diffs to avoid command-line length limits When git diffs are very large, the base64-encoded prompt can exceed the command-line argument length limit (~8KB on many systems), causing ENAMETOOLONG errors. This fix: - Detects when prompts exceed 100KB - Writes them to a temporary file instead - Pipes the file content to Claude CLI - Cleans up temp files automatically - Works on all platforms (Linux, macOS, Windows) Fixes issues with large commits that have extensive diffs. --- src/extension.ts | 59 ++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 50 insertions(+), 9 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 3bf6eab..5d02765 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -3,6 +3,9 @@ import * as vscode from 'vscode'; import { exec } from 'child_process'; import { promisify } from 'util'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; const execAsync = promisify(exec); @@ -279,12 +282,37 @@ class ClaudeCLIExecutor { outputChannel.appendLine(`[EXECUTE] Using path: ${this.claudePath}`); } - // Use base64 encoding to completely avoid shell escaping issues - const base64Prompt = Buffer.from(prompt, 'utf8').toString('base64'); - - // Build command that decodes base64 and pipes to claude - let command = `echo "${base64Prompt}" | base64 -d | ${this.claudePath}`; - + // For large prompts, use a temp file to avoid command-line length limits + // This affects all platforms when diffs are very large + let command; + let tempFile: string | null = null; + const maxInlineLength = 100000; // Conservative limit for command-line arguments + + if (prompt.length > maxInlineLength) { + if (this.debugMode) { + outputChannel.appendLine(`[EXECUTE] Prompt too long (${prompt.length} chars), using temp file`); + } + + // Create temp file with the prompt + tempFile = path.join(os.tmpdir(), `claude-prompt-${Date.now()}.txt`); + fs.writeFileSync(tempFile, prompt, 'utf8'); + + // Read from temp file and pipe to claude + command = `cat '${tempFile}' | ${this.claudePath}`; + + if (this.debugMode) { + outputChannel.appendLine(`[EXECUTE] Temp file: ${tempFile}`); + } + } else { + // For shorter prompts, use base64 encoding inline + const base64Prompt = Buffer.from(prompt, 'utf8').toString('base64'); + command = `echo "${base64Prompt}" | base64 -d | ${this.claudePath}`; + + if (this.debugMode) { + outputChannel.appendLine(`[EXECUTE] Using inline base64 (${base64Prompt.length} chars)`); + } + } + // Add all flags command += ' --print'; // Use explicit --print flag command += ' --model sonnet'; // Hardcoded model @@ -292,8 +320,7 @@ class ClaudeCLIExecutor { command += ' --dangerously-skip-permissions'; // Skip permissions check if (this.debugMode) { - outputChannel.appendLine(`\n[EXECUTE] Command structure: echo [base64] | base64 -d | claude [options]`); - outputChannel.appendLine(`[EXECUTE] Base64 length: ${base64Prompt.length} chars`); + outputChannel.appendLine(`\n[EXECUTE] Command structure: ${tempFile ? 'temp file' : 'inline base64'} | claude [options]`); outputChannel.appendLine(`[EXECUTE] Original prompt length: ${prompt.length} chars`); } @@ -381,11 +408,25 @@ class ClaudeCLIExecutor { outputChannel.appendLine(`[EXECUTE] Error code: ${error.code}`); outputChannel.appendLine(`[EXECUTE] Error stack: ${error.stack}`); } - + if (error.code === 'ETIMEDOUT') { throw new Error('Claude CLI timed out after 60 seconds. Please check if Claude is authenticated.'); } throw new Error(`Claude CLI execution failed: ${error.message}`); + } finally { + // Clean up temp file if it was created + if (tempFile) { + try { + fs.unlinkSync(tempFile); + if (this.debugMode) { + outputChannel.appendLine(`[EXECUTE] Cleaned up temp file: ${tempFile}`); + } + } catch (cleanupError: any) { + if (this.debugMode) { + outputChannel.appendLine(`[EXECUTE] Warning: Failed to clean up temp file: ${cleanupError.message}`); + } + } + } } }