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/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 0baca01..fd768ac 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.
@@ -154,6 +149,29 @@ 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. 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
+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/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
new file mode 100644
index 0000000..2588f9c
--- /dev/null
+++ b/vscode-extension/README.md
@@ -0,0 +1,38 @@
+# 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 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 for manual overrides
+- 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
+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
+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..38c902e
--- /dev/null
+++ b/vscode-extension/package.json
@@ -0,0 +1,76 @@
+{
+ "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": [
+ "onStartupFinished",
+ "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.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",
+ "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..221e09e
--- /dev/null
+++ b/vscode-extension/src/extension.ts
@@ -0,0 +1,266 @@
+import * as vscode from 'vscode';
+import { execFile } from 'child_process';
+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 {
+ await execFileAsync(check, [cmd]);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+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';
+ 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) {
+ 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 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 (resolvedApiKey) {
+ env['OPENAI_API_KEY'] = resolvedApiKey;
+ }
+ if (resolvedEndpoint) {
+ env['OPENAI_ENDPOINT'] = resolvedEndpoint;
+ }
+
+ try {
+ const args = ['--dry-run'];
+ if (resolvedModel) {
+ args.push('--model', resolvedModel);
+ }
+ 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;
+ } 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);
+
+ await ensureWriteCommitInstalled(context);
+
+ 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"]
+}