From f4bd7d81355a761575abb707bc1f6c31e7fb5342 Mon Sep 17 00:00:00 2001 From: Patrick Ruddiman <86851465+PatrickRuddiman@users.noreply.github.com> Date: Sat, 28 Jun 2025 15:50:30 -0400 Subject: [PATCH 1/3] chore: add VS Code Marketplace publish workflow --- .github/workflows/publish-extension.yml | 26 ++++++++ .gitignore | 4 ++ README.md | 20 ++++++ vscode-extension/README.md | 29 +++++++++ vscode-extension/package.json | 65 +++++++++++++++++++ vscode-extension/src/extension.ts | 84 +++++++++++++++++++++++++ vscode-extension/tsconfig.json | 13 ++++ 7 files changed, 241 insertions(+) create mode 100644 .github/workflows/publish-extension.yml create mode 100644 vscode-extension/README.md create mode 100644 vscode-extension/package.json create mode 100644 vscode-extension/src/extension.ts create mode 100644 vscode-extension/tsconfig.json diff --git a/.github/workflows/publish-extension.yml b/.github/workflows/publish-extension.yml new file mode 100644 index 0000000..03551be --- /dev/null +++ b/.github/workflows/publish-extension.yml @@ -0,0 +1,26 @@ +name: Publish VS Code Extension + +on: + workflow_dispatch: + push: + tags: + - 'extension-v*' + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v3 + with: + node-version: '18' + - name: Install dependencies + run: | + cd vscode-extension + npm ci + npm run build + - name: Install vsce + run: npm install -g vsce + - name: Publish to Marketplace + run: vsce publish -p ${{ secrets.VSCE_TOKEN }} + working-directory: vscode-extension diff --git a/.gitignore b/.gitignore index 7f52a4b..81061a7 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,7 @@ Thumbs.db # Fabric temporary files fabric_temp_* +vscode-extension/node_modules +vscode-extension/out +vscode-extension/package-lock.json +vscode-extension/*.vsix diff --git a/README.md b/README.md index 0baca01..4049e1a 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,26 @@ dotnet build dotnet run -- --help ``` +## VS Code Extension + +The `vscode-extension` folder contains an extension that lets you run WriteCommit +directly from VS Code. To try it out: + +1. Open the `vscode-extension` folder in VS Code. +2. Run `npm install` and then `npm run build` to compile the extension. +3. Press `F5` to launch an Extension Development Host. + +The extension adds a Source Control panel action that runs `WriteCommit --dry-run` +and inserts the generated message into the commit input box. It installs the +CLI automatically if missing and supports configuring the OpenAI API key and +executable path via settings. + +### Publishing +The workflow `.github/workflows/publish-extension.yml` uses `vsce publish` to +upload the extension to the Visual Studio Code Marketplace when a tag matching +`extension-v*` is pushed. Before triggering the workflow, add a `VSCE_TOKEN` +secret to your repository with a Marketplace personal access token. + ### Contributing 1. Fork the repository 2. Create a feature branch (`git checkout -b feature/amazing-feature`) diff --git a/vscode-extension/README.md b/vscode-extension/README.md new file mode 100644 index 0000000..971de5b --- /dev/null +++ b/vscode-extension/README.md @@ -0,0 +1,29 @@ +# WriteCommit VS Code Extension + +This extension integrates the **WriteCommit** CLI with Visual Studio Code. +It adds an action in the Source Control panel that generates a commit message +using the WriteCommit tool and inserts it into the commit message input box. + +## Features +- Single click commit message generation with OpenAI +- Automatically installs the WriteCommit CLI if not present +- Uses the `--dry-run` option so nothing is committed automatically +- Configurable OpenAI key and executable path +- Shows a spinner in the Source Control panel while generating the message + +## Configuration +- `writecommit.openAIApiKey` โ€“ API key used for generation +- `writecommit.executablePath` โ€“ Path to the WriteCommit executable + +## Installation +When the command is first run, the extension checks for the `WriteCommit` +executable. If it is not found, it will download and install it using the +official installation script for your platform. + + +## Publishing +The extension can be published to the Visual Studio Code Marketplace using the +`vsce` tool. A GitHub Actions workflow is provided in +`.github/workflows/publish-extension.yml` that runs `vsce publish` whenever a tag +matching `extension-v*` is pushed. Set the `VSCE_TOKEN` secret in your +repository with a Personal Access Token that has the `Marketplace` scope. diff --git a/vscode-extension/package.json b/vscode-extension/package.json new file mode 100644 index 0000000..561e902 --- /dev/null +++ b/vscode-extension/package.json @@ -0,0 +1,65 @@ +{ + "name": "writecommit-vscode", + "displayName": "Write Commit", + "version": "0.0.1", + "engines": { + "vscode": "^1.80.0" + }, + "description": "Generate commit messages using WriteCommit CLI", + "main": "./out/extension.js", + "scripts": { + "compile": "tsc -p ./", + "build": "npm run compile", + "test": "npm run compile" + }, + "keywords": [ + "git", + "commit", + "ai" + ], + "categories": [ + "Source Control" + ], + "activationEvents": [ + "onCommand:writecommit.generateMessage" + ], + "contributes": { + "commands": [ + { + "command": "writecommit.generateMessage", + "title": "Generate Commit Message", + "icon": "$(sparkle-filled)" + } + ], + "menus": { + "scm/title": [ + { + "command": "writecommit.generateMessage", + "group": "navigation" + } + ] + }, + "configuration": { + "title": "WriteCommit", + "properties": { + "writecommit.openAIApiKey": { + "type": "string", + "default": "", + "description": "OpenAI API key for WriteCommit" + }, + "writecommit.executablePath": { + "type": "string", + "default": "WriteCommit", + "description": "Path to the WriteCommit executable" + } + } + } + }, + "dependencies": { + "@types/node": "^24.0.7" + }, + "devDependencies": { + "@types/vscode": "^1.101.0", + "typescript": "^5.8.3" + } +} diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts new file mode 100644 index 0000000..34d7d35 --- /dev/null +++ b/vscode-extension/src/extension.ts @@ -0,0 +1,84 @@ +import * as vscode from 'vscode'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; + +const execFileAsync = promisify(execFile); + +async function isExecutableAvailable(cmd: string): Promise { + const check = process.platform === 'win32' ? 'where' : 'which'; + try { + await execFileAsync(check, [cmd]); + return true; + } catch { + return false; + } +} + +async function installWriteCommit() { + const script = process.platform === 'win32' + ? ['-Command', 'iwr https://raw.githubusercontent.com/PatrickRuddiman/Toolkit/main/Tools/Write-Commit/install-web.ps1 -UseBasicParsing | iex'] + : ['-c', 'curl -sSL https://raw.githubusercontent.com/PatrickRuddiman/Toolkit/main/Tools/Write-Commit/install-universal.sh | bash']; + + const shell = process.platform === 'win32' ? 'powershell' : 'bash'; + vscode.window.showInformationMessage('Installing WriteCommit CLI...'); + try { + await execFileAsync(shell, script); + } catch (err: any) { + vscode.window.showErrorMessage(`Failed to install WriteCommit: ${err.message}`); + throw err; + } +} + +async function runWriteCommit(): Promise { + const config = vscode.workspace.getConfiguration('writecommit'); + const executable = config.get('executablePath', 'WriteCommit'); + const apiKey = config.get('openAIApiKey', ''); + + if (!(await isExecutableAvailable(executable))) { + await installWriteCommit(); + } + + const env = { ...process.env }; + if (apiKey) { + env['OPENAI_API_KEY'] = apiKey; + } + + try { + const { stdout } = await execFileAsync(executable, ['--dry-run'], { env }); + const output = stdout.trim(); + const match = output.match(/Generated commit message:\s*([\s\S]*?)(?:\n\s*Dry run mode|$)/); + return match ? match[1].trim() : output; + } catch (err: any) { + vscode.window.showErrorMessage(`WriteCommit failed: ${err.message}`); + throw err; + } +} + +export async function activate(context: vscode.ExtensionContext) { + const gitExt = vscode.extensions.getExtension('vscode.git'); + await gitExt?.activate(); + const gitApi = gitExt?.exports.getAPI(1); + + const disposable = vscode.commands.registerCommand('writecommit.generateMessage', async () => { + await vscode.window.withProgress({ + location: vscode.ProgressLocation.SourceControl, + title: 'Generating commit message...' + }, async () => { + const message = await runWriteCommit(); + if (!gitApi) { + vscode.window.showErrorMessage('Git extension not available'); + return; + } + const repo = gitApi.repositories[0]; + if (!repo) { + vscode.window.showErrorMessage('No git repository found'); + return; + } + repo.inputBox.value = message; + }); + }); + + context.subscriptions.push(disposable); +} + +export function deactivate() {} diff --git a/vscode-extension/tsconfig.json b/vscode-extension/tsconfig.json new file mode 100644 index 0000000..02376bb --- /dev/null +++ b/vscode-extension/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es6", + "outDir": "out", + "lib": ["es6"], + "sourceMap": true, + "rootDir": "src", + "strict": true, + "esModuleInterop": true + }, + "exclude": ["node_modules", "out"] +} From 111f146b408f905a229dbef97170fcc8d049eccd Mon Sep 17 00:00:00 2001 From: Patrick Ruddiman <86851465+PatrickRuddiman@users.noreply.github.com> Date: Mon, 15 Sep 2025 17:38:34 -0400 Subject: [PATCH 2/3] Add endpoint and model settings to VS Code extension --- Constants/FabricPatterns.cs | 15 ------- Models/AppConfiguration.cs | 3 ++ Program.cs | 29 +++++-------- README.md | 37 ++++++++--------- Services/ConfigurationService.cs | 47 +++++++++++++++------ Services/OpenAIService.cs | 68 +++++++++++++++++++++++++++++-- WriteCommit.csproj | 4 ++ demo.ps1 | 8 ++-- install-universal.sh | 41 +++++++++++-------- install-web.ps1 | 4 +- install-web.sh | 53 ++++++++++++++---------- install.sh | 9 +--- vscode-extension/README.md | 4 +- vscode-extension/package.json | 10 +++++ vscode-extension/src/extension.ts | 11 ++++- 15 files changed, 220 insertions(+), 123 deletions(-) delete mode 100644 Constants/FabricPatterns.cs diff --git a/Constants/FabricPatterns.cs b/Constants/FabricPatterns.cs deleted file mode 100644 index 2f9e923..0000000 --- a/Constants/FabricPatterns.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace WriteCommit.Constants -{ - public static class FabricPatterns - { - /// - /// Pattern used for summarizing individual diff chunks - /// - public const string ChunkPattern = "chunk_git_diff"; - - /// - /// Default pattern used for generating commit messages - /// - public const string CommitPattern = "write_commit_message"; - } -} diff --git a/Models/AppConfiguration.cs b/Models/AppConfiguration.cs index 58a195a..b1b853f 100644 --- a/Models/AppConfiguration.cs +++ b/Models/AppConfiguration.cs @@ -10,6 +10,9 @@ public class AppConfiguration [JsonPropertyName("default_model")] public string? DefaultModel { get; set; } + [JsonPropertyName("openai_endpoint")] + public string? OpenAiEndpoint { get; set; } + [JsonPropertyName("default_temperature")] public int? DefaultTemperature { get; set; } diff --git a/Program.cs b/Program.cs index d020c65..14dbd69 100644 --- a/Program.cs +++ b/Program.cs @@ -14,11 +14,6 @@ static async Task Main(string[] args) "Generate commit message without committing" ); var verboseOption = new Option("--verbose", "Show detailed output"); - var patternOption = new Option( - "--pattern", - () => PatternNames.CommitPattern, - "Pattern to use for message generation" - ); var temperatureOption = new Option( "--temperature", () => 1, @@ -31,10 +26,9 @@ static async Task Main(string[] args) () => 0, "Frequency penalty for AI model" ); - var modelOption = new Option( + var modelOption = new Option( "--model", - () => "gpt-4o-mini", - "AI model to use (default: gpt-4o-mini)" + description: "AI model to use (overrides setup)" ); var setupOption = new Option( @@ -46,7 +40,6 @@ static async Task Main(string[] args) { dryRunOption, verboseOption, - patternOption, temperatureOption, topPOption, presenceOption, @@ -59,12 +52,11 @@ static async Task Main(string[] args) async ( bool dryRun, bool verbose, - string pattern, int temperature, int topP, int presence, int frequency, - string model + string? model ) => { try @@ -84,7 +76,6 @@ string model await GenerateCommitMessage( dryRun, verbose, - pattern, temperature, topP, presence, @@ -100,7 +91,6 @@ await GenerateCommitMessage( }, dryRunOption, verboseOption, - patternOption, temperatureOption, topPOption, presenceOption, @@ -114,12 +104,11 @@ await GenerateCommitMessage( static async Task GenerateCommitMessage( bool dryRun, bool verbose, - string pattern, int temperature, int topP, int presence, int frequency, - string model + string? model ) { var gitService = new GitService(); @@ -137,7 +126,10 @@ string model } // Create OpenAI service with the API key - var openAiService = new OpenAIService(apiKey); + var endpoint = await configService.GetOpenAiEndpointAsync() ?? "https://api.openai.com/v1"; + var defaultModel = await configService.GetDefaultModelAsync() ?? "gpt-4o-mini"; + + var openAiService = new OpenAIService(apiKey, endpoint); // Check if we're in a git repository if (!Directory.Exists(".git") && !await gitService.IsInGitRepositoryAsync()) @@ -182,14 +174,15 @@ string model } // Generate commit message using OpenAI with chunking support + var finalModel = string.IsNullOrWhiteSpace(model) ? defaultModel : model; var commitMessage = await openAiService.GenerateCommitMessageAsync( chunks, - pattern, + PatternNames.CommitPattern, temperature, topP, presence, frequency, - model, + finalModel, verbose ); diff --git a/README.md b/README.md index 4049e1a..8857a1f 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A cross-platform .NET tool that generates AI-powered commit messages using OpenA - ๐Ÿค– **AI-powered commit messages** - Generate meaningful commit messages from your staged changes - ๐Ÿ”„ **Cross-platform** - Works on Windows, macOS, and Linux -- ๐ŸŽ›๏ธ **Highly configurable** - Adjust AI parameters and patterns to your preference +- ๐ŸŽ›๏ธ **Highly configurable** - Adjust AI parameters to your preference - ๐Ÿงช **Dry-run mode** - Preview generated messages without committing - ๐Ÿ“ **Verbose output** - Detailed logging for debugging and transparency - โšก **Fast and lightweight** - Direct OpenAI API integration for quick responses @@ -17,7 +17,7 @@ A cross-platform .NET tool that generates AI-powered commit messages using OpenA ### Prerequisites - [.NET 8.0 or later](https://dotnet.microsoft.com/download) -- OpenAI API key (either set as `OPENAI_API_KEY` environment variable or via `--setup`) + - OpenAI API key (optional, required only if your endpoint needs authentication) - Git repository with staged changes ### Installation @@ -77,19 +77,16 @@ WriteCommit ```bash # Preview message without committing -write-commit --dry-run +WriteCommit --dry-run # Detailed output for debugging -write-commit --verbose +WriteCommit --verbose # Custom AI parameters -write-commit --temperature 0.7 --topp 0.9 --pattern custom_pattern - -# Force reinstall all patterns -write-commit --reinstall-patterns +WriteCommit --temperature 0.7 --topp 0.9 # Combine multiple options -write-commit --dry-run --verbose --temperature 0.5 --reinstall-patterns +WriteCommit --dry-run --verbose --temperature 0.5 ``` ## โš™๏ธ Configuration Options @@ -98,18 +95,16 @@ write-commit --dry-run --verbose --temperature 0.5 --reinstall-patterns |--------|---------|-------------| | `--dry-run` | `false` | Generate message without committing | | `--verbose` | `false` | Show detailed output | -| `--pattern` | `write_commit_message` | Pattern to use for message generation | | `--temperature` | `1` | AI creativity level (0-2) | | `--topp` | `1` | Nucleus sampling parameter (0-1) | -| `--model` | `gpt-4o-mini` | OpenAI model to use | +| `--model` | from setup | OpenAI model to use | | `--presence` | `0` | Presence penalty (-2 to 2) | | `--frequency` | `0` | Frequency penalty (-2 to 2) | -| `--reinstall-patterns` | `false` | Force reinstallation of all patterns | -| `--setup` | `false` | Configure OpenAI API key | +| `--setup` | `false` | Configure OpenAI settings | ## ๐Ÿ”ง How It Works -1. **Validates environment** - Checks for git repository and OpenAI API key +1. **Validates environment** - Checks for git repository and OpenAI settings 2. **Analyzes changes** - Processes your staged git diff using semantic chunking 3. **Generates message** - Uses OpenAI API to create meaningful commit message 4. **Commits changes** - Applies the generated message (unless `--dry-run`) @@ -122,22 +117,22 @@ write-commit --dry-run --verbose --temperature 0.5 --reinstall-patterns ```bash # Run the setup wizard -write-commit --setup +WriteCommit --setup ``` -This will prompt you to enter your API key and securely save it to `~/.writecommit/config.json`. +This will prompt you to enter your API key (if needed), API endpoint, and default model, then save them to `~/.writecommit/config.json`. **Option 2: Using Environment Variables** ```bash # Linux/macOS -export OPENAI_API_KEY="your-api-key-here" +export OPENAI_API_KEY="your-api-key-here" # optional # Windows (PowerShell) -$env:OPENAI_API_KEY="your-api-key-here" +$env:OPENAI_API_KEY="your-api-key-here" # optional # Windows (Command Prompt) -set OPENAI_API_KEY=your-api-key-here +set OPENAI_API_KEY=your-api-key-here # optional ``` For persistent configuration, add the export to your shell profile (`~/.bashrc`, `~/.zshrc`, etc.) or Windows environment variables. @@ -165,8 +160,8 @@ directly from VS Code. To try it out: The extension adds a Source Control panel action that runs `WriteCommit --dry-run` and inserts the generated message into the commit input box. It installs the -CLI automatically if missing and supports configuring the OpenAI API key and -executable path via settings. +CLI automatically if missing and supports configuring the OpenAI API key, +endpoint, model, and executable path via settings. ### Publishing The workflow `.github/workflows/publish-extension.yml` uses `vsce publish` to diff --git a/Services/ConfigurationService.cs b/Services/ConfigurationService.cs index 459020c..393ea74 100644 --- a/Services/ConfigurationService.cs +++ b/Services/ConfigurationService.cs @@ -76,6 +76,24 @@ public async Task SaveConfigurationAsync(AppConfiguration config) return config?.OpenAiApiKey; } + /// + /// Gets the configured OpenAI endpoint or null if not set + /// + public async Task GetOpenAiEndpointAsync() + { + var config = await LoadConfigurationAsync(); + return config?.OpenAiEndpoint; + } + + /// + /// Gets the configured default model or null if not set + /// + public async Task GetDefaultModelAsync() + { + var config = await LoadConfigurationAsync(); + return config?.DefaultModel; + } + /// /// Prompts user to enter and save their OpenAI API key /// @@ -87,7 +105,7 @@ public async Task SetupApiKeyAsync(bool verbose = false) Console.WriteLine("Please enter your OpenAI API key."); Console.WriteLine("You can get one from: https://platform.openai.com/api-keys"); Console.WriteLine(); - Console.Write("API Key: "); + Console.Write("API Key (leave blank if not required): "); // Read API key with masked input var apiKey = ReadMaskedInput(); @@ -95,25 +113,30 @@ public async Task SetupApiKeyAsync(bool verbose = false) if (string.IsNullOrWhiteSpace(apiKey)) { - Console.WriteLine("No API key entered. Setup cancelled."); - return false; + apiKey = null; } - // Basic validation - if (!apiKey.StartsWith("sk-")) - { - Console.WriteLine("Invalid API key format. OpenAI API keys should start with 'sk-'."); - return false; - } + // Prompt for endpoint and model + Console.Write($"Endpoint (default: https://api.openai.com/v1): "); + var endpointInput = Console.ReadLine()?.Trim(); + var endpoint = string.IsNullOrWhiteSpace(endpointInput) + ? "https://api.openai.com/v1" + : endpointInput; + + Console.Write($"Default model (default: gpt-4o-mini): "); + var modelInput = Console.ReadLine()?.Trim(); + var model = string.IsNullOrWhiteSpace(modelInput) ? "gpt-4o-mini" : modelInput; // Load existing config or create new one var config = await LoadConfigurationAsync() ?? new AppConfiguration(); config.OpenAiApiKey = apiKey; + config.OpenAiEndpoint = endpoint; + config.DefaultModel = model; // Save configuration await SaveConfigurationAsync(config); - Console.WriteLine($"โœ… API key saved to {_configFilePath}"); + Console.WriteLine($"โœ… Configuration saved to {_configFilePath}"); if (verbose) { @@ -126,7 +149,7 @@ public async Task SetupApiKeyAsync(bool verbose = false) Console.Write("Would you like to test the API key? (y/N): "); var testResponse = Console.ReadLine()?.Trim().ToLowerInvariant(); - if (testResponse == "y" || testResponse == "yes") + if ((testResponse == "y" || testResponse == "yes") && !string.IsNullOrEmpty(apiKey)) { return await TestApiKeyAsync(apiKey, verbose); } @@ -137,7 +160,7 @@ public async Task SetupApiKeyAsync(bool verbose = false) /// /// Tests if the API key is valid by making a simple request /// - private async Task TestApiKeyAsync(string apiKey, bool verbose) + private async Task TestApiKeyAsync(string? apiKey, bool verbose) { Console.WriteLine("Testing API key..."); diff --git a/Services/OpenAIService.cs b/Services/OpenAIService.cs index 56d4b2d..39c4b56 100644 --- a/Services/OpenAIService.cs +++ b/Services/OpenAIService.cs @@ -1,5 +1,6 @@ using System.Text; using OpenAI.Chat; +using System.ClientModel; using WriteCommit.Constants; using WriteCommit.Models; @@ -8,9 +9,11 @@ namespace WriteCommit.Services; public class OpenAIService { private readonly string _apiKey; + private readonly string _endpoint; private readonly string _patternsDirectory; + private const int MaxContextTokens = 128000; - public OpenAIService(string apiKey) + public OpenAIService(string apiKey, string? endpoint = null) { if (string.IsNullOrEmpty(apiKey)) { @@ -18,6 +21,9 @@ public OpenAIService(string apiKey) } _apiKey = apiKey; + _endpoint = string.IsNullOrWhiteSpace(endpoint) + ? "https://api.openai.com/v1" + : endpoint; _patternsDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "patterns"); } @@ -149,7 +155,11 @@ bool verbose } // Create a client for this specific model - var chatClient = new ChatClient(model, _apiKey); + var clientOptions = new OpenAI.OpenAIClientOptions + { + Endpoint = new Uri(_endpoint) + }; + var chatClient = new ChatClient(model, new ApiKeyCredential(_apiKey), clientOptions); // Create the chat messages var messages = new List @@ -227,14 +237,56 @@ bool verbose throw new InvalidOperationException($"Failed to load pattern: {pattern}"); } + var combinedContent = string.Join("\n\n", chunkMessages); + var estimatedTokens = EstimateTokenCount(systemPrompt) + EstimateTokenCount(combinedContent); + + if (estimatedTokens > MaxContextTokens && chunkMessages.Count > 1) + { + if (verbose) + { + Console.WriteLine("Context length exceeded, re-chunking summaries..."); + } + + var groupedSummaries = new List(); + var currentGroup = new List(); + var currentTokens = EstimateTokenCount(systemPrompt); + + foreach (var msg in chunkMessages) + { + var msgTokens = EstimateTokenCount(msg); + if (currentTokens + msgTokens > MaxContextTokens / 2 && currentGroup.Count > 0) + { + var summary = await CombineChunkMessagesAsync(currentGroup, pattern, temperature, topP, presence, frequency, model, verbose); + groupedSummaries.Add(summary); + currentGroup.Clear(); + currentTokens = EstimateTokenCount(systemPrompt); + } + + currentGroup.Add(msg); + currentTokens += msgTokens; + } + + if (currentGroup.Count > 0) + { + var summary = await CombineChunkMessagesAsync(currentGroup, pattern, temperature, topP, presence, frequency, model, verbose); + groupedSummaries.Add(summary); + } + + return await CombineChunkMessagesAsync(groupedSummaries, pattern, temperature, topP, presence, frequency, model, verbose); + } + // Create a client for this specific model - var chatClient = new ChatClient(model, _apiKey); + var clientOptions = new OpenAI.OpenAIClientOptions + { + Endpoint = new Uri(_endpoint) + }; + var chatClient = new ChatClient(model, new ApiKeyCredential(_apiKey), clientOptions); // Create the chat messages var messages = new List { new SystemChatMessage(systemPrompt), - new UserChatMessage(string.Join("\n\n", chunkMessages)), + new UserChatMessage(combinedContent), }; // Create chat completion options @@ -313,4 +365,12 @@ private float ConvertPenalty(int penalty) // OpenAI uses -2 to 2 for penalties return Math.Clamp((float)penalty, -2f, 2f); } + + /// + /// Estimates token count using a rough 4 chars per token heuristic + /// + private int EstimateTokenCount(string text) + { + return Math.Max(1, text.Length / 4); + } } diff --git a/WriteCommit.csproj b/WriteCommit.csproj index 2ee0b89..b714c70 100644 --- a/WriteCommit.csproj +++ b/WriteCommit.csproj @@ -17,6 +17,10 @@ MIT git;commit;ai;openai;cli;tool false + linux-x64;linux-arm64;osx-x64;osx-arm64;win-x64 + true + true + true diff --git a/demo.ps1 b/demo.ps1 index f57f79d..672bdfa 100644 --- a/demo.ps1 +++ b/demo.ps1 @@ -7,7 +7,7 @@ Write-Host "" Write-Host "1. Checking tool installation..." -ForegroundColor Yellow try { - $version = write-commit --version 2>$null + $version = WriteCommit --version 2>$null if ($LASTEXITCODE -eq 0) { Write-Host "โœ… WriteCommit is installed" -ForegroundColor Green } else { @@ -21,7 +21,7 @@ try { Write-Host "" Write-Host "2. Showing help..." -ForegroundColor Yellow -write-commit --help +WriteCommit --help Write-Host "" Write-Host "3. Checking git repository status..." -ForegroundColor Yellow @@ -40,14 +40,14 @@ if ($LASTEXITCODE -ne 0) { Write-Host "4. Running WriteCommit in dry-run mode..." -ForegroundColor Yellow Write-Host " (This will generate a commit message without committing)" -ForegroundColor Gray Write-Host "" - write-commit --dry-run --verbose + WriteCommit --dry-run --verbose } else { Write-Host "โ„น๏ธ No staged changes found" -ForegroundColor Blue Write-Host " To test the tool:" -ForegroundColor Gray Write-Host " 1. Make some changes to files" -ForegroundColor Gray Write-Host " 2. Run: git add ." -ForegroundColor Gray - Write-Host " 3. Run: write-commit --dry-run" -ForegroundColor Gray + Write-Host " 3. Run: WriteCommit --dry-run" -ForegroundColor Gray } } diff --git a/install-universal.sh b/install-universal.sh index 971898f..4085576 100644 --- a/install-universal.sh +++ b/install-universal.sh @@ -1,13 +1,16 @@ #!/bin/bash # Universal web installer for WriteCommit tool -# Usage: curl -sSL https://raw.githubusercontent.com/PatrickRuddiman/Toolkit/main/Tools/Write-Commit/install-universal.sh | bash -# Or with arch override: curl -sSL https://raw.githubusercontent.com/PatrickRuddiman/Toolkit/main/Tools/Write-Commit/install-universal.sh | bash -s -- --arch linux-arm64 +# Usage: curl -sSL https://raw.githubusercontent.com/PatrickRuddiman/WriteCommit/main/install-universal.sh | bash +# Or with arch override: curl -sSL https://raw.githubusercontent.com/PatrickRuddiman/WriteCommit/main/install-universal.sh | bash -s -- --arch linux-arm64 set -e # Configuration -REPO="PatrickRuddiman/Toolkit" -TOOL_NAME="WriteCommit" +REPO="PatrickRuddiman/WriteCommit" +# Binary name inside the archive +BINARY_NAME="WriteCommit" +# Asset prefix is lowercase +TOOL_ASSET="writecommit" INSTALL_DIR="$HOME/.local/bin" # Parse command line arguments @@ -70,7 +73,7 @@ else *) echo "โŒ Unsupported OS: $OS" echo "This script is for Linux and macOS only. For Windows, use:" - echo "iex (irm https://raw.githubusercontent.com/PatrickRuddiman/Toolkit/main/Tools/Write-Commit/install-web.ps1)" + echo "iex (irm https://raw.githubusercontent.com/PatrickRuddiman/WriteCommit/main/install-web.ps1)" exit 1 ;; esac @@ -91,13 +94,17 @@ fi echo "๐Ÿ“ฆ Latest version: $VERSION" # Construct download URL -ASSET_NAME="${TOOL_NAME}-${VERSION}-${RUNTIME}.tar.gz" +ASSET_NAME="${TOOL_ASSET}-${RUNTIME}-${VERSION}.tar.gz" DOWNLOAD_URL="https://github.com/$REPO/releases/download/$VERSION/$ASSET_NAME" echo "โฌ‡๏ธ Downloading $ASSET_NAME..." -# Create temporary directory +# Create temporary directory and ensure cleanup TEMP_DIR=$(mktemp -d) +cleanup() { + rm -rf "$TEMP_DIR" +} +trap cleanup EXIT cd "$TEMP_DIR" # Download the release @@ -121,15 +128,15 @@ if [ ! -d "$INSTALL_DIR" ]; then fi # Install the binary -echo "๐Ÿ“ฅ Installing $TOOL_NAME to $INSTALL_DIR..." -cp "$TOOL_NAME" "$INSTALL_DIR/" -chmod +x "$INSTALL_DIR/$TOOL_NAME" +echo "๐Ÿ“ฅ Installing $BINARY_NAME to $INSTALL_DIR..." +cp "$BINARY_NAME" "$INSTALL_DIR/" +chmod +x "$INSTALL_DIR/$BINARY_NAME" # Cleanup cd - > /dev/null rm -rf "$TEMP_DIR" -echo "โœ… $TOOL_NAME $VERSION ($RUNTIME) installed successfully!" +echo "โœ… $BINARY_NAME $VERSION ($RUNTIME) installed successfully!" echo "" echo "๐Ÿ“ Note: Make sure $INSTALL_DIR is in your PATH." echo " Add this line to your ~/.bashrc or ~/.zshrc:" @@ -137,16 +144,16 @@ echo " export PATH=\"$INSTALL_DIR:\$PATH\"" echo "" echo "๐Ÿš€ Example usage:" echo " git add ." -echo " $TOOL_NAME" -echo " $TOOL_NAME --dry-run" -echo " $TOOL_NAME --verbose" +echo " $BINARY_NAME" +echo " $BINARY_NAME --dry-run" +echo " $BINARY_NAME --verbose" # Check if binary is in PATH -if command -v "$TOOL_NAME" >/dev/null 2>&1; then +if command -v "$BINARY_NAME" >/dev/null 2>&1; then echo "" - echo "๐ŸŽ‰ $TOOL_NAME is ready to use!" + echo "๐ŸŽ‰ $BINARY_NAME is ready to use!" else echo "" - echo "โš ๏ธ $TOOL_NAME is not in your PATH. Restart your shell or run:" + echo "โš ๏ธ $BINARY_NAME is not in your PATH. Restart your shell or run:" echo " export PATH=\"$INSTALL_DIR:\$PATH\"" fi diff --git a/install-web.ps1 b/install-web.ps1 index f0851ba..fa1fa9f 100644 --- a/install-web.ps1 +++ b/install-web.ps1 @@ -1,5 +1,5 @@ # Web installer for WriteCommit tool (Windows) -# Usage: iex (irm https://raw.githubusercontent.com/PatrickRuddiman/Toolkit/main/Tools/Write-Commit/install-web.ps1) +# Usage: iex (irm https://raw.githubusercontent.com/PatrickRuddiman/WriteCommit/main/install-web.ps1) param( [string]$InstallDir = "$env:LOCALAPPDATA\Programs\WriteCommit", @@ -9,7 +9,7 @@ param( $ErrorActionPreference = "Stop" # Configuration -$Repo = "PatrickRuddiman/Toolkit" +$Repo = "PatrickRuddiman/WriteCommit" $ToolName = "writecommit" $Platform = "windows" $Arch = "x64" diff --git a/install-web.sh b/install-web.sh index e3292f6..34a3eba 100644 --- a/install-web.sh +++ b/install-web.sh @@ -1,12 +1,15 @@ #!/bin/bash # Web installer for WriteCommit tool (Linux/macOS) -# Usage: curl -sSL https://raw.githubusercontent.com/PatrickRuddiman/Toolkit/main/Tools/Write-Commit/install-web.sh | bash +# Usage: curl -sSL https://raw.githubusercontent.com/PatrickRuddiman/WriteCommit/main/install-web.sh | bash set -e # Configuration -REPO="PatrickRuddiman/Toolkit" -TOOL_NAME="WriteCommit" +REPO="PatrickRuddiman/WriteCommit" +# Name of the binary within the archive +BINARY_NAME="WriteCommit" +# Prefix for assets +TOOL_ASSET="writecommit" INSTALL_DIR="$HOME/.local/share/WriteCommit" BIN_DIR="$HOME/.local/bin" VERSION="latest" @@ -44,7 +47,7 @@ case $OS in *) echo "โŒ Unsupported OS: $OS" echo "This script is for Linux and macOS only. For Windows, use the PowerShell installer:" - echo "iex (irm https://raw.githubusercontent.com/PatrickRuddiman/Toolkit/main/Tools/Write-Commit/install-web.ps1)" + echo "iex (irm https://raw.githubusercontent.com/PatrickRuddiman/WriteCommit/main/install-web.ps1)" exit 1 ;; esac @@ -81,13 +84,17 @@ fi echo "๐Ÿ“ฆ Version: $VERSION" # Construct download URL with correct naming pattern -ASSET_NAME="${TOOL_NAME}-${PLATFORM}-${ARCH_TAG}-${VERSION}.tar.gz" +ASSET_NAME="${TOOL_ASSET}-${PLATFORM}-${ARCH_TAG}-${VERSION}.tar.gz" DOWNLOAD_URL="https://github.com/$REPO/releases/download/$VERSION/$ASSET_NAME" echo "โฌ‡๏ธ Downloading $ASSET_NAME from $DOWNLOAD_URL..." -# Create temporary directory +# Create temporary directory and ensure cleanup TEMP_DIR=$(mktemp -d) +cleanup() { + rm -rf "$TEMP_DIR" +} +trap cleanup EXIT cd "$TEMP_DIR" # Download the release @@ -120,10 +127,10 @@ fi # Create a wrapper script in the bin directory echo "๐Ÿ“ฅ Creating wrapper script in $BIN_DIR..." -WRAPPER_PATH="$BIN_DIR/$TOOL_NAME" +WRAPPER_PATH="$BIN_DIR/$BINARY_NAME" cat > "$WRAPPER_PATH" << EOF #!/bin/bash -exec "$INSTALL_DIR/$TOOL_NAME" "\$@" +exec "$INSTALL_DIR/$BINARY_NAME" "\$@" EOF chmod +x "$WRAPPER_PATH" @@ -138,9 +145,11 @@ update_shell_config() { if [ -f "$config_file" ]; then if ! grep -q "$BIN_DIR" "$config_file"; then - echo "" >> "$config_file" - echo "# Added by WriteCommit installer" >> "$config_file" - echo "$path_entry" >> "$config_file" + { + echo "" + echo "# Added by WriteCommit installer" + echo "$path_entry" + } >> "$config_file" echo "โœ… Updated $config_file" return 0 else @@ -170,9 +179,11 @@ if command -v fish >/dev/null 2>&1 || [ "$SHELL" = "/usr/bin/fish" ]; then if [ -f "$FISH_CONFIG" ]; then if ! grep -q "$BIN_DIR" "$FISH_CONFIG"; then mkdir -p "$(dirname "$FISH_CONFIG")" - echo "" >> "$FISH_CONFIG" - echo "# Added by WriteCommit installer" >> "$FISH_CONFIG" - echo "fish_add_path $BIN_DIR" >> "$FISH_CONFIG" + { + echo "" + echo "# Added by WriteCommit installer" + echo "fish_add_path $BIN_DIR" + } >> "$FISH_CONFIG" echo "โœ… Updated $FISH_CONFIG" PATH_UPDATED=true else @@ -188,21 +199,21 @@ if [ "$PATH_UPDATED" = false ]; then fi echo "" -echo "โœ… WriteCommit $VERSION installed successfully!" +echo "โœ… $BINARY_NAME $VERSION installed successfully!" echo "" echo "๐Ÿ“‚ Installation directory: $INSTALL_DIR" echo "๐Ÿš€ Example usage:" echo " git add ." -echo " WriteCommit" -echo " WriteCommit --dry-run" -echo " WriteCommit --verbose" +echo " $BINARY_NAME" +echo " $BINARY_NAME --dry-run" +echo " $BINARY_NAME --verbose" # Check if binary is in PATH -if command -v WriteCommit >/dev/null 2>&1; then +if command -v "$BINARY_NAME" >/dev/null 2>&1; then echo "" - echo "๐ŸŽ‰ WriteCommit is ready to use!" + echo "๐ŸŽ‰ $BINARY_NAME is ready to use!" else echo "" - echo "โš ๏ธ You need to restart your terminal or run the following command to use WriteCommit in this session:" + echo "โš ๏ธ You need to restart your terminal or run the following command to use $BINARY_NAME in this session:" echo " export PATH=\"$BIN_DIR:\$PATH\"" fi diff --git a/install.sh b/install.sh index 7052a87..0ad393e 100644 --- a/install.sh +++ b/install.sh @@ -5,9 +5,7 @@ runtime="linux-x64" exeName="WriteCommit" echo "Publishing WriteCommit for $runtime..." -dotnet publish WriteCommit.csproj --configuration Release --runtime $runtime --self-contained true --output "publish/$runtime" - -if [ $? -eq 0 ]; then +if dotnet publish WriteCommit.csproj --configuration Release --runtime "$runtime" --self-contained true --output "publish/$runtime"; then echo "Publish successful!" # Create a directory in user's local bin if it doesn't exist @@ -23,10 +21,7 @@ if [ $? -eq 0 ]; then targetPath="$localBin/$exeName" echo "Installing WriteCommit to $targetPath..." - cp "$sourcePath" "$targetPath" - chmod +x "$targetPath" - - if [ $? -eq 0 ]; then + if cp "$sourcePath" "$targetPath" && chmod +x "$targetPath"; then echo "โœ… WriteCommit installed successfully!" echo "Note: Make sure $localBin is in your PATH." diff --git a/vscode-extension/README.md b/vscode-extension/README.md index 971de5b..c0fa92c 100644 --- a/vscode-extension/README.md +++ b/vscode-extension/README.md @@ -8,11 +8,13 @@ using the WriteCommit tool and inserts it into the commit message input box. - Single click commit message generation with OpenAI - Automatically installs the WriteCommit CLI if not present - Uses the `--dry-run` option so nothing is committed automatically -- Configurable OpenAI key and executable path +- Configurable OpenAI key, endpoint, model, and executable path - Shows a spinner in the Source Control panel while generating the message ## Configuration - `writecommit.openAIApiKey` โ€“ API key used for generation +- `writecommit.openAIEndpoint` โ€“ Custom API endpoint (optional) +- `writecommit.model` โ€“ Model name to use - `writecommit.executablePath` โ€“ Path to the WriteCommit executable ## Installation diff --git a/vscode-extension/package.json b/vscode-extension/package.json index 561e902..49b8b5a 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -47,6 +47,16 @@ "default": "", "description": "OpenAI API key for WriteCommit" }, + "writecommit.openAIEndpoint": { + "type": "string", + "default": "", + "description": "Custom OpenAI API endpoint" + }, + "writecommit.model": { + "type": "string", + "default": "", + "description": "OpenAI model to use for generation" + }, "writecommit.executablePath": { "type": "string", "default": "WriteCommit", diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index 34d7d35..7d24e89 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -33,6 +33,8 @@ async function runWriteCommit(): Promise { const config = vscode.workspace.getConfiguration('writecommit'); const executable = config.get('executablePath', 'WriteCommit'); const apiKey = config.get('openAIApiKey', ''); + const endpoint = config.get('openAIEndpoint', ''); + const model = config.get('model', ''); if (!(await isExecutableAvailable(executable))) { await installWriteCommit(); @@ -42,9 +44,16 @@ async function runWriteCommit(): Promise { if (apiKey) { env['OPENAI_API_KEY'] = apiKey; } + if (endpoint) { + env['OPENAI_ENDPOINT'] = endpoint; + } try { - const { stdout } = await execFileAsync(executable, ['--dry-run'], { env }); + const args = ['--dry-run']; + if (model) { + args.push('--model', model); + } + const { stdout } = await execFileAsync(executable, args, { env }); const output = stdout.trim(); const match = output.match(/Generated commit message:\s*([\s\S]*?)(?:\n\s*Dry run mode|$)/); return match ? match[1].trim() : output; From b00083bb76270014d870c5c724324afc772e902d Mon Sep 17 00:00:00 2001 From: Patrick Ruddiman <86851465+PatrickRuddiman@users.noreply.github.com> Date: Mon, 15 Sep 2025 17:38:39 -0400 Subject: [PATCH 3/3] Enhance VS Code extension auto install and LM integration --- README.md | 9 +- vscode-extension/README.md | 17 ++- vscode-extension/package.json | 1 + vscode-extension/src/extension.ts | 197 ++++++++++++++++++++++++++++-- 4 files changed, 204 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 8857a1f..fd768ac 100644 --- a/README.md +++ b/README.md @@ -159,9 +159,12 @@ directly from VS Code. To try it out: 3. Press `F5` to launch an Extension Development Host. The extension adds a Source Control panel action that runs `WriteCommit --dry-run` -and inserts the generated message into the commit input box. It installs the -CLI automatically if missing and supports configuring the OpenAI API key, -endpoint, model, and executable path via settings. +and inserts the generated message into the commit input box. As soon as the +extension activates it ensures the CLI is installed, reusing the endpoint and +model you have configured through the `vscode-lm` Language Model settings (for +example GitHub Copilot). You can still override those values through the +extension settings, which also let you configure the OpenAI API key and the CLI +path. ### Publishing The workflow `.github/workflows/publish-extension.yml` uses `vsce publish` to diff --git a/vscode-extension/README.md b/vscode-extension/README.md index c0fa92c..2588f9c 100644 --- a/vscode-extension/README.md +++ b/vscode-extension/README.md @@ -6,9 +6,10 @@ using the WriteCommit tool and inserts it into the commit message input box. ## Features - Single click commit message generation with OpenAI -- Automatically installs the WriteCommit CLI if not present +- Automatically installs the WriteCommit CLI when the extension is first activated +- Reuses the Language Model (`vscode-lm`) endpoint and model configured in VS Code (including GitHub Copilot) - Uses the `--dry-run` option so nothing is committed automatically -- Configurable OpenAI key, endpoint, model, and executable path +- Configurable OpenAI key, endpoint, model, and executable path for manual overrides - Shows a spinner in the Source Control panel while generating the message ## Configuration @@ -18,9 +19,15 @@ using the WriteCommit tool and inserts it into the commit message input box. - `writecommit.executablePath` โ€“ Path to the WriteCommit executable ## Installation -When the command is first run, the extension checks for the `WriteCommit` -executable. If it is not found, it will download and install it using the -official installation script for your platform. +As soon as the extension activates (after VS Code finishes starting), it checks +for the `WriteCommit` executable. If it is missing, the extension downloads and +installs it using the official installation script for your platform so the CLI +is ready before you run the command. + +If you have configured an endpoint or model through the `vscode-lm` extension, +the WriteCommit command will automatically reuse those settings. You can still +override them with the `writecommit.*` settings if you need to target a +different model. ## Publishing diff --git a/vscode-extension/package.json b/vscode-extension/package.json index 49b8b5a..38c902e 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -21,6 +21,7 @@ "Source Control" ], "activationEvents": [ + "onStartupFinished", "onCommand:writecommit.generateMessage" ], "contributes": { diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index 7d24e89..221e09e 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -4,6 +4,12 @@ import { promisify } from 'util'; const execFileAsync = promisify(execFile); +interface VsCodeLmConfig { + endpoint?: string; + model?: string; + apiKey?: string; +} + async function isExecutableAvailable(cmd: string): Promise { const check = process.platform === 'win32' ? 'where' : 'which'; try { @@ -14,44 +20,209 @@ async function isExecutableAvailable(cmd: string): Promise { } } -async function installWriteCommit() { +async function installWriteCommit(options: { silent?: boolean } = {}) { + const { silent } = options; const script = process.platform === 'win32' ? ['-Command', 'iwr https://raw.githubusercontent.com/PatrickRuddiman/Toolkit/main/Tools/Write-Commit/install-web.ps1 -UseBasicParsing | iex'] : ['-c', 'curl -sSL https://raw.githubusercontent.com/PatrickRuddiman/Toolkit/main/Tools/Write-Commit/install-universal.sh | bash']; const shell = process.platform === 'win32' ? 'powershell' : 'bash'; - vscode.window.showInformationMessage('Installing WriteCommit CLI...'); try { + if (!silent) { + vscode.window.showInformationMessage('Installing WriteCommit CLI...'); + } await execFileAsync(shell, script); + if (!silent) { + vscode.window.showInformationMessage('WriteCommit CLI installed successfully.'); + } } catch (err: any) { - vscode.window.showErrorMessage(`Failed to install WriteCommit: ${err.message}`); + const message = err?.message ?? String(err); + if (!silent) { + vscode.window.showErrorMessage(`Failed to install WriteCommit: ${message}`); + } else { + console.error('Failed to install WriteCommit silently', message); + } throw err; } } +function applyConfig(target: VsCodeLmConfig, addition: VsCodeLmConfig): void { + if (!addition) { + return; + } + + if (!target.endpoint && addition.endpoint) { + target.endpoint = addition.endpoint; + } + if (!target.model && addition.model) { + target.model = addition.model; + } + if (!target.apiKey && addition.apiKey) { + target.apiKey = addition.apiKey; + } +} + +function extractFromCandidate(candidate: unknown): VsCodeLmConfig { + if (!candidate || typeof candidate !== 'object') { + return {}; + } + + const obj = candidate as Record; + const config: VsCodeLmConfig = {}; + + const endpointKeys = ['endpoint', 'url', 'baseUrl', 'baseURL', 'host']; + for (const key of endpointKeys) { + const value = obj[key]; + if (typeof value === 'string' && value.trim().length > 0) { + config.endpoint = value.trim(); + break; + } + } + + const apiKeyKeys = ['apiKey', 'key', 'token', 'accessToken', 'authToken']; + for (const key of apiKeyKeys) { + const value = obj[key]; + if (typeof value === 'string' && value.trim().length > 0) { + config.apiKey = value.trim(); + break; + } + } + + const modelKeys = ['model', 'defaultModel', 'preferredModel', 'modelId']; + for (const key of modelKeys) { + const value = obj[key]; + if (typeof value === 'string' && value.trim().length > 0) { + config.model = value.trim(); + break; + } + } + + if (!config.model) { + const models = obj['models']; + if (typeof models === 'string' && models.trim().length > 0) { + config.model = models.trim(); + } else if (Array.isArray(models)) { + const firstString = models.find((item) => typeof item === 'string' && item.trim().length > 0) as string | undefined; + if (firstString) { + config.model = firstString.trim(); + } + } else if (models && typeof models === 'object') { + const nested = models as Record; + const defaultKey = nested['default'] ?? nested['primary']; + if (typeof defaultKey === 'string' && defaultKey.trim().length > 0) { + config.model = defaultKey.trim(); + } else if (defaultKey && typeof defaultKey === 'object') { + const nestedConfig = extractFromCandidate(defaultKey); + config.model = config.model ?? nestedConfig.model; + } + } + } + + return config; +} + +function getVsCodeLmConfiguration(): VsCodeLmConfig { + const lmConfig = vscode.workspace.getConfiguration('vscode-lm'); + const result: VsCodeLmConfig = {}; + + const candidateKeys = [ + lmConfig.get('defaultEndpoint'), + lmConfig.get('defaultProvider'), + lmConfig.get('activeEndpoint'), + lmConfig.get('activeProvider'), + ].filter((key): key is string => typeof key === 'string' && key.trim().length > 0); + + const endpointCollections = [ + lmConfig.get>('endpoints'), + lmConfig.get>('providers'), + ]; + + for (const collection of endpointCollections) { + if (!collection || typeof collection !== 'object') { + continue; + } + + const entries = Object.entries(collection); + const preferredEntry = entries.find(([key]) => candidateKeys.includes(key)); + const [, preferredValue] = preferredEntry ?? entries[0] ?? []; + + if (preferredValue) { + applyConfig(result, extractFromCandidate(preferredValue)); + } + } + + const directCandidates = [ + lmConfig.get('endpoint'), + lmConfig.get('provider'), + lmConfig.get('configuration'), + ]; + + for (const candidate of directCandidates) { + applyConfig(result, extractFromCandidate(candidate)); + } + + const fallbackStrings: Array<[key: keyof VsCodeLmConfig, value: string | undefined]> = [ + ['endpoint', lmConfig.get('url')], + ['endpoint', lmConfig.get('endpoint')], + ['model', lmConfig.get('model')], + ['model', lmConfig.get('defaultModel')], + ['apiKey', lmConfig.get('apiKey')], + ['apiKey', lmConfig.get('token')], + ]; + + for (const [key, value] of fallbackStrings) { + if (value && value.trim().length > 0) { + (result as Record)[key] = (result as Record)[key] ?? value.trim(); + } + } + + return result; +} + +async function ensureWriteCommitInstalled(context: vscode.ExtensionContext) { + const config = vscode.workspace.getConfiguration('writecommit'); + const executable = config.get('executablePath', 'WriteCommit'); + if (await isExecutableAvailable(executable)) { + await context.globalState.update('writecommit.cliInstalled', true); + return; + } + + try { + await installWriteCommit({ silent: true }); + await context.globalState.update('writecommit.cliInstalled', true); + } catch { + // Installation errors are surfaced via console but should not block activation. + } +} + async function runWriteCommit(): Promise { const config = vscode.workspace.getConfiguration('writecommit'); const executable = config.get('executablePath', 'WriteCommit'); - const apiKey = config.get('openAIApiKey', ''); - const endpoint = config.get('openAIEndpoint', ''); - const model = config.get('model', ''); + const extensionApiKey = config.get('openAIApiKey', ''); + const extensionEndpoint = config.get('openAIEndpoint', ''); + const extensionModel = config.get('model', ''); + + const vscodeLmConfig = getVsCodeLmConfiguration(); + const resolvedApiKey = extensionApiKey || vscodeLmConfig.apiKey || ''; + const resolvedEndpoint = extensionEndpoint || vscodeLmConfig.endpoint || ''; + const resolvedModel = extensionModel || vscodeLmConfig.model || ''; if (!(await isExecutableAvailable(executable))) { await installWriteCommit(); } const env = { ...process.env }; - if (apiKey) { - env['OPENAI_API_KEY'] = apiKey; + if (resolvedApiKey) { + env['OPENAI_API_KEY'] = resolvedApiKey; } - if (endpoint) { - env['OPENAI_ENDPOINT'] = endpoint; + if (resolvedEndpoint) { + env['OPENAI_ENDPOINT'] = resolvedEndpoint; } try { const args = ['--dry-run']; - if (model) { - args.push('--model', model); + if (resolvedModel) { + args.push('--model', resolvedModel); } const { stdout } = await execFileAsync(executable, args, { env }); const output = stdout.trim(); @@ -68,6 +239,8 @@ export async function activate(context: vscode.ExtensionContext) { await gitExt?.activate(); const gitApi = gitExt?.exports.getAPI(1); + await ensureWriteCommitInstalled(context); + const disposable = vscode.commands.registerCommand('writecommit.generateMessage', async () => { await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl,