From f308401817ce1e1a9f39179ce8ccf55dc04b04bc Mon Sep 17 00:00:00 2001 From: George Marcus Date: Sun, 14 Dec 2025 16:58:58 -0500 Subject: [PATCH 1/3] Integrate OpenRouter support: add configuration, provider, and update CLI tool commands --- Commands/CacheCommand.cs | 36 + Commands/CommandBuilderExtensions.cs | 898 +----------------- Commands/CompletionsCommand.cs | 56 ++ Commands/ConfigCommand.cs | 295 ++++++ Commands/ConflictsCommand.cs | 331 +++++++ Commands/GRPCServeCommand.cs | 44 + Commands/HelpCommand.cs | 52 + Commands/ProviderCommand.cs | 34 + Commands/RunCommand.cs | 144 +++ Configuration/GitAgentConfig.cs | 3 + Configuration/OpenRouterConfig.cs | 21 + Configuration/ProviderConfigs.cs | 3 + GitAgentCli.csproj | 2 +- JsonContext.cs | 14 + Providers/OpenRouterProvider.cs | 249 +++++ Providers/ProviderFactory.cs | 2 + README.md | 91 +- Services/CompletionGenerator.cs | 19 +- Services/ConfigManager.cs | 16 +- .../Configuration/ConfigManagerTests.cs | 95 +- .../Providers/OpenRouterProviderTests.cs | 162 ++++ .../Providers/ProviderFactoryTests.cs | 36 + 22 files changed, 1694 insertions(+), 909 deletions(-) create mode 100644 Commands/CacheCommand.cs create mode 100644 Commands/CompletionsCommand.cs create mode 100644 Commands/ConfigCommand.cs create mode 100644 Commands/ConflictsCommand.cs create mode 100644 Commands/GRPCServeCommand.cs create mode 100644 Commands/HelpCommand.cs create mode 100644 Commands/ProviderCommand.cs create mode 100644 Commands/RunCommand.cs create mode 100644 Configuration/OpenRouterConfig.cs create mode 100644 Providers/OpenRouterProvider.cs create mode 100644 tests/GitAgentCli.Tests/Providers/OpenRouterProviderTests.cs diff --git a/Commands/CacheCommand.cs b/Commands/CacheCommand.cs new file mode 100644 index 0000000..0a46981 --- /dev/null +++ b/Commands/CacheCommand.cs @@ -0,0 +1,36 @@ +using GitAgent.Services; +using Microsoft.Extensions.DependencyInjection; +using System.CommandLine; +using System.CommandLine.Hosting; + +namespace GitAgent.Commands +{ + internal class CacheCommand + { + public static Command BuildCacheCommand() + { + var cacheCmd = new Command("cache", "Manage HTTP response cache"); + + var cacheClearCmd = new Command("clear", "Clear all cached HTTP responses"); + cacheClearCmd.SetHandler(async context => + { + var host = context.GetHost(); + var cachingHandler = host.Services.GetRequiredService(); + cachingHandler.ClearCache(); + await Console.Out.WriteLineAsync("HTTP cache cleared."); + }); + cacheCmd.AddCommand(cacheClearCmd); + + var cachePathCmd = new Command("path", "Show cache directory path"); + cachePathCmd.SetHandler(async context => + { + var host = context.GetHost(); + var cachingHandler = host.Services.GetRequiredService(); + await Console.Out.WriteLineAsync(cachingHandler.CacheDirectory); + }); + cacheCmd.AddCommand(cachePathCmd); + + return cacheCmd; + } + } +} diff --git a/Commands/CommandBuilderExtensions.cs b/Commands/CommandBuilderExtensions.cs index 072712f..2afc357 100644 --- a/Commands/CommandBuilderExtensions.cs +++ b/Commands/CommandBuilderExtensions.cs @@ -1,9 +1,4 @@ using System.CommandLine; -using System.CommandLine.Hosting; -using GitAgent.Configuration; -using GitAgent.Providers; -using GitAgent.Services; -using Microsoft.Extensions.DependencyInjection; namespace GitAgent.Commands; @@ -16,893 +11,16 @@ public static RootCommand BuildRootCommand() Name = "git-agent" }; - root.AddCommand(BuildRunCommand()); - root.AddCommand(BuildConfigCommand()); - root.AddCommand(BuildProvidersCommand()); - root.AddCommand(BuildCacheCommand()); - root.AddCommand(BuildConflictsCommand()); - root.AddCommand(BuildCompletionsCommand()); - root.AddCommand(BuildServeCommand()); - root.AddCommand(BuildHelpCommand()); + root.AddCommand(RunCommand.BuildRunCommand()); + root.AddCommand(ConfigCommand.BuildConfigCommand()); + root.AddCommand(ProvidersCommand.BuildProvidersCommand()); + root.AddCommand(CacheCommand.BuildCacheCommand()); + root.AddCommand(ConflictsCommand.BuildConflictsCommand()); + root.AddCommand(CompletionsCommand.BuildCompletionsCommand()); + root.AddCommand(GRPCServeCommand.BuildServeCommand()); + root.AddCommand(HelpCommand.BuildHelpCommand()); return root; } - - private static Command BuildRunCommand() - { - var runCmd = new Command("run", "Translate and optionally execute a plain English instruction"); - - var instructionArg = new Argument("instruction", "Natural language instruction to translate to git commands"); - var execOption = new Option(["--exec", "-x"], () => false, "Execute the resulting commands"); - var interactiveOption = new Option(["--interactive", "-i"], () => false, "Confirm each step interactively"); - var providerOption = new Option(["--provider", "-p"], "Override the active provider for this run"); - var noCacheOption = new Option(["--no-cache"], () => false, "Skip cache and force a fresh API call"); - - runCmd.AddArgument(instructionArg); - runCmd.AddOption(execOption); - runCmd.AddOption(interactiveOption); - runCmd.AddOption(providerOption); - runCmd.AddOption(noCacheOption); - - runCmd.SetHandler(async context => - { - var instruction = context.ParseResult.GetValueForArgument(instructionArg); - var exec = context.ParseResult.GetValueForOption(execOption); - var interactive = context.ParseResult.GetValueForOption(interactiveOption); - var providerOverride = context.ParseResult.GetValueForOption(providerOption); - var noCache = context.ParseResult.GetValueForOption(noCacheOption); - - var host = context.GetHost(); - var configManager = host.Services.GetRequiredService(); - var providerFactory = host.Services.GetRequiredService(); - var gitInspector = host.Services.GetRequiredService(); - var safetyValidator = host.Services.GetRequiredService(); - var commandExecutor = host.Services.GetRequiredService(); - var cachingHandler = host.Services.GetRequiredService(); - - try - { - if (noCache) - { - cachingHandler.ClearCache(); - } - - var provider = string.IsNullOrWhiteSpace(providerOverride) - ? await providerFactory.CreateProviderAsync() - : await providerFactory.CreateProviderAsync(providerOverride); - - var config = await configManager.LoadAsync(); - var activeProvider = providerOverride ?? config.ActiveProvider; - await Console.Out.WriteLineAsync($"Using provider: {activeProvider}"); - await Console.Out.WriteLineAsync(); - - var ctx = await gitInspector.BuildRepoContextAsync(); - - if (string.IsNullOrWhiteSpace(ctx.CurrentBranch)) - { - await Console.Error.WriteLineAsync("Warning: Not in a git repository or git is not available."); - await Console.Error.WriteLineAsync("Commands will be generated but may not be accurate without repo context."); - await Console.Out.WriteLineAsync(); - } - - await Console.Out.WriteLineAsync("Generating commands..."); - var commands = await provider.GenerateGitCommands(instruction, ctx); - - if (commands.Count == 0) - { - await Console.Out.WriteLineAsync("No git commands were generated for this instruction."); - return; - } - - var validated = safetyValidator.FilterAndAnnotate(commands); - - await Console.Out.WriteLineAsync(); - await Console.Out.WriteLineAsync("Generated commands:"); - await Console.Out.WriteLineAsync(new string('-', 40)); - - foreach (var cmd in validated) - { - var riskColor = cmd.Risk switch - { - "safe" => "\u001b[32m", - "destructive" => "\u001b[31m", - _ => "\u001b[33m" - }; - await Console.Out.WriteLineAsync($" {riskColor}{cmd.CommandText}\u001b[0m"); - if (!string.IsNullOrWhiteSpace(cmd.Reason)) - { - await Console.Out.WriteLineAsync($" ({cmd.Reason})"); - } - } - - await Console.Out.WriteLineAsync(new string('-', 40)); - - if (commands.Count != validated.Count) - { - var filtered = commands.Count - validated.Count; - await Console.Out.WriteLineAsync($"Note: {filtered} command(s) filtered out (not in allowlist)."); - } - - if (exec && validated.Count > 0) - { - await Console.Out.WriteLineAsync(); - await Console.Out.WriteLineAsync("Executing commands..."); - await Console.Out.WriteLineAsync(); - await commandExecutor.ExecuteAsync(validated, interactive); - } - else if (!exec && validated.Count > 0) - { - await Console.Out.WriteLineAsync(); - await Console.Out.WriteLineAsync("Add --exec (-x) to run these commands, or --exec --interactive (-xi) to confirm each."); - } - } - catch (InvalidOperationException ex) - { - await Console.Error.WriteLineAsync($"Configuration error: {ex.Message}"); - context.ExitCode = 1; - } - catch (HttpRequestException ex) - { - await Console.Error.WriteLineAsync($"API error: {ex.Message}"); - context.ExitCode = 1; - } - catch (TimeoutException ex) - { - await Console.Error.WriteLineAsync($"Timeout: {ex.Message}"); - context.ExitCode = 1; - } - catch (Exception ex) - { - await Console.Error.WriteLineAsync($"Error: {ex.Message}"); - context.ExitCode = 1; - } - }); - - return runCmd; - } - - private static Command BuildConfigCommand() - { - var configCmd = new Command("config", "Manage git-agent configuration"); - - configCmd.AddCommand(BuildConfigShowCommand()); - configCmd.AddCommand(BuildConfigSetCommand()); - configCmd.AddCommand(BuildConfigGetCommand()); - configCmd.AddCommand(BuildConfigUseCommand()); - configCmd.AddCommand(BuildConfigPathCommand()); - configCmd.AddCommand(BuildConfigResetCommand()); - - return configCmd; - } - - private static Command BuildConfigShowCommand() - { - var showCmd = new Command("show", "Display current configuration"); - showCmd.SetHandler(async context => - { - var host = context.GetHost(); - var configManager = host.Services.GetRequiredService(); - - var config = await configManager.LoadAsync(); - await Console.Out.WriteLineAsync($"Configuration file: {configManager.ConfigPath}"); - await Console.Out.WriteLineAsync(); - await Console.Out.WriteLineAsync($"Active provider: {config.ActiveProvider}"); - await Console.Out.WriteLineAsync(); - await Console.Out.WriteLineAsync("Provider settings:"); - await Console.Out.WriteLineAsync($" Claude:"); - await Console.Out.WriteLineAsync($" API Key: {ConfigHelpers.MaskApiKey(config.Providers.Claude.ApiKey)}"); - await Console.Out.WriteLineAsync($" Model: {config.Providers.Claude.Model}"); - await Console.Out.WriteLineAsync($" BaseUrl: {config.Providers.Claude.BaseUrl}"); - await Console.Out.WriteLineAsync(); - await Console.Out.WriteLineAsync($" OpenAI:"); - await Console.Out.WriteLineAsync($" API Key: {ConfigHelpers.MaskApiKey(config.Providers.OpenAI.ApiKey)}"); - await Console.Out.WriteLineAsync($" Model: {config.Providers.OpenAI.Model}"); - await Console.Out.WriteLineAsync($" BaseUrl: {config.Providers.OpenAI.BaseUrl}"); - await Console.Out.WriteLineAsync(); - await Console.Out.WriteLineAsync($" Ollama:"); - await Console.Out.WriteLineAsync($" Model: {config.Providers.Ollama.Model}"); - await Console.Out.WriteLineAsync($" BaseUrl: {config.Providers.Ollama.BaseUrl}"); - }); - return showCmd; - } - - private static Command BuildConfigSetCommand() - { - var keyArg = new Argument("key", "Configuration key (e.g., activeProvider, claude.apiKey)"); - var valueArg = new Argument("value", "Value to set"); - var setCmd = new Command("set", "Set a configuration value") { keyArg, valueArg }; - setCmd.SetHandler(async context => - { - var key = context.ParseResult.GetValueForArgument(keyArg); - var value = context.ParseResult.GetValueForArgument(valueArg); - - var host = context.GetHost(); - var configManager = host.Services.GetRequiredService(); - - var config = await configManager.LoadAsync(); - var updated = ConfigHelpers.SetConfigValue(config, key, value); - if (updated) - { - await configManager.SaveAsync(config); - await Console.Out.WriteLineAsync($"Set {key} = {(key.Contains("apiKey", StringComparison.OrdinalIgnoreCase) ? ConfigHelpers.MaskApiKey(value) : value)}"); - } - else - { - await Console.Error.WriteLineAsync($"Unknown configuration key: {key}"); - await Console.Error.WriteLineAsync(); - await ConfigHelpers.PrintAvailableKeysAsync(); - } - }); - return setCmd; - } - - private static Command BuildConfigGetCommand() - { - var keyArg = new Argument("key", "Configuration key to retrieve"); - var getCmd = new Command("get", "Get a configuration value") { keyArg }; - getCmd.SetHandler(async context => - { - var key = context.ParseResult.GetValueForArgument(keyArg); - - var host = context.GetHost(); - var configManager = host.Services.GetRequiredService(); - - var config = await configManager.LoadAsync(); - var value = ConfigHelpers.GetConfigValue(config, key); - if (value != null) - { - var displayValue = key.Contains("apiKey", StringComparison.OrdinalIgnoreCase) - ? ConfigHelpers.MaskApiKey(value) - : value; - await Console.Out.WriteLineAsync(displayValue); - } - else - { - await Console.Error.WriteLineAsync($"Unknown configuration key: {key}"); - await ConfigHelpers.PrintAvailableKeysAsync(); - } - }); - return getCmd; - } - - private static Command BuildConfigUseCommand() - { - var providerArg = new Argument("provider", "Provider to use (claude, openai, ollama, stub)"); - var useCmd = new Command("use", "Set the active provider") { providerArg }; - useCmd.SetHandler(async context => - { - var provider = context.ParseResult.GetValueForArgument(providerArg); - - var host = context.GetHost(); - var configManager = host.Services.GetRequiredService(); - var providerFactory = host.Services.GetRequiredService(); - - var providerLower = provider.ToLowerInvariant(); - if (!providerFactory.AvailableProviders.Contains(providerLower)) - { - await Console.Error.WriteLineAsync($"Unknown provider: {provider}"); - await Console.Error.WriteLineAsync($"Available providers: {string.Join(", ", providerFactory.AvailableProviders)}"); - return; - } - - var config = await configManager.LoadAsync(); - config.ActiveProvider = providerLower; - await configManager.SaveAsync(config); - await Console.Out.WriteLineAsync($"Active provider set to: {providerLower}"); - }); - return useCmd; - } - - private static Command BuildConfigPathCommand() - { - var pathCmd = new Command("path", "Show the configuration file path"); - pathCmd.SetHandler(async context => - { - var host = context.GetHost(); - var configManager = host.Services.GetRequiredService(); - await Console.Out.WriteLineAsync(configManager.ConfigPath); - }); - return pathCmd; - } - - private static Command BuildConfigResetCommand() - { - var resetCmd = new Command("reset", "Reset configuration to defaults"); - resetCmd.SetHandler(async context => - { - var host = context.GetHost(); - var configManager = host.Services.GetRequiredService(); - - await Console.Out.WriteAsync("Reset configuration to defaults? (y/N): "); - var input = Console.ReadLine(); - if (string.Equals(input, "y", StringComparison.OrdinalIgnoreCase)) - { - await configManager.SaveAsync(new GitAgentConfig()); - await Console.Out.WriteLineAsync("Configuration reset to defaults."); - } - else - { - await Console.Out.WriteLineAsync("Cancelled."); - } - }); - return resetCmd; - } - - private static Command BuildProvidersCommand() - { - var providersCmd = new Command("providers", "List available AI providers"); - providersCmd.SetHandler(async context => - { - var host = context.GetHost(); - var configManager = host.Services.GetRequiredService(); - var providerFactory = host.Services.GetRequiredService(); - - var config = await configManager.LoadAsync(); - await Console.Out.WriteLineAsync("Available providers:"); - foreach (var p in providerFactory.AvailableProviders) - { - var marker = p == config.ActiveProvider ? " (active)" : ""; - await Console.Out.WriteLineAsync($" - {p}{marker}"); - } - await Console.Out.WriteLineAsync(); - await Console.Out.WriteLineAsync("Use 'git-agent config use ' to switch providers."); - }); - - return providersCmd; - } - - private static Command BuildCacheCommand() - { - var cacheCmd = new Command("cache", "Manage HTTP response cache"); - - var cacheClearCmd = new Command("clear", "Clear all cached HTTP responses"); - cacheClearCmd.SetHandler(async context => - { - var host = context.GetHost(); - var cachingHandler = host.Services.GetRequiredService(); - cachingHandler.ClearCache(); - await Console.Out.WriteLineAsync("HTTP cache cleared."); - }); - cacheCmd.AddCommand(cacheClearCmd); - - var cachePathCmd = new Command("path", "Show cache directory path"); - cachePathCmd.SetHandler(async context => - { - var host = context.GetHost(); - var cachingHandler = host.Services.GetRequiredService(); - await Console.Out.WriteLineAsync(cachingHandler.CacheDirectory); - }); - cacheCmd.AddCommand(cachePathCmd); - - return cacheCmd; - } - - private static Command BuildConflictsCommand() - { - var conflictsCmd = new Command("conflicts", "Analyze and resolve merge conflicts with AI assistance"); - - var suggestOption = new Option(["--suggest", "-s"], () => false, "Show AI-suggested resolutions"); - var resolveOption = new Option(["--resolve", "-r"], () => false, "Interactively resolve conflicts"); - var applyOption = new Option(["--apply", "-a"], () => false, "Auto-apply AI-suggested resolutions"); - var providerOption = new Option(["--provider", "-p"], "Override the active provider for AI suggestions"); - var fileArg = new Argument("file", () => null, "Specific file to analyze (optional)"); - - conflictsCmd.AddOption(suggestOption); - conflictsCmd.AddOption(resolveOption); - conflictsCmd.AddOption(applyOption); - conflictsCmd.AddOption(providerOption); - conflictsCmd.AddArgument(fileArg); - - conflictsCmd.SetHandler(async context => - { - var suggest = context.ParseResult.GetValueForOption(suggestOption); - var resolve = context.ParseResult.GetValueForOption(resolveOption); - var apply = context.ParseResult.GetValueForOption(applyOption); - var providerOverride = context.ParseResult.GetValueForOption(providerOption); - var file = context.ParseResult.GetValueForArgument(fileArg); - - var host = context.GetHost(); - var gitInspector = host.Services.GetRequiredService(); - var conflictResolver = host.Services.GetRequiredService(); - var providerFactory = host.Services.GetRequiredService(); - - var ctx = await gitInspector.BuildRepoContextAsync(); - - if (ctx.MergeState == Models.MergeState.None) - { - await Console.Out.WriteLineAsync("No merge in progress."); - return; - } - - if (ctx.ConflictedFiles.Count == 0) - { - await Console.Out.WriteLineAsync($"Merge state: {ctx.MergeState}"); - await Console.Out.WriteLineAsync("No conflicts detected. You can continue with:"); - await Console.Out.WriteLineAsync($" git {GetContinueCommand(ctx.MergeState)}"); - return; - } - - var filesToAnalyze = ctx.ConflictedFiles; - if (!string.IsNullOrEmpty(file)) - { - filesToAnalyze = ctx.ConflictedFiles.Where(f => f.FilePath.Contains(file)).ToList(); - if (filesToAnalyze.Count == 0) - { - await Console.Error.WriteLineAsync($"No conflicts found in files matching: {file}"); - return; - } - } - - var analysis = await conflictResolver.AnalyzeConflictsAsync(ctx); - - await Console.Out.WriteLineAsync($"\u001b[33mMerge State:\u001b[0m {ctx.MergeState}"); - await Console.Out.WriteLineAsync($"\u001b[33mTotal Conflicts:\u001b[0m {analysis.TotalConflicts} in {analysis.ConflictedFileCount} file(s)"); - await Console.Out.WriteLineAsync(); - - foreach (var fileAnalysis in analysis.Files) - { - await Console.Out.WriteLineAsync($"\u001b[36m{fileAnalysis.FilePath}\u001b[0m ({fileAnalysis.ConflictCount} conflict(s))"); - - foreach (var section in fileAnalysis.Sections) - { - var typeColor = section.ConflictType switch - { - Services.ConflictType.SameChange => "\u001b[32m", - Services.ConflictType.AdjacentChanges => "\u001b[33m", - _ => "\u001b[31m" - }; - await Console.Out.WriteLineAsync($" Lines {section.StartLine}-{section.EndLine}: {typeColor}{section.ConflictType}\u001b[0m"); - await Console.Out.WriteLineAsync($" {section.Description}"); - await Console.Out.WriteLineAsync($" Ours: {section.OursLabel} | Theirs: {section.TheirsLabel}"); - } - await Console.Out.WriteLineAsync(); - } - - if (suggest || apply) - { - // Get provider for AI suggestions - Providers.IModelProvider? provider = null; - try - { - provider = string.IsNullOrWhiteSpace(providerOverride) - ? await providerFactory.CreateProviderAsync() - : await providerFactory.CreateProviderAsync(providerOverride); - - if (suggest) - { - await Console.Out.WriteLineAsync($"Using provider: {providerOverride ?? "default"}"); - } - } - catch (Exception ex) - { - await Console.Error.WriteLineAsync($"Warning: Could not initialize AI provider: {ex.Message}"); - await Console.Error.WriteLineAsync("Falling back to heuristic suggestions."); - } - - await Console.Out.WriteLineAsync("\u001b[33mGenerating AI Resolutions...\u001b[0m"); - var resolutions = await conflictResolver.SuggestResolutionsAsync(ctx, provider); - - if (suggest) - { - await Console.Out.WriteLineAsync(new string('-', 40)); - var grouped = resolutions.GroupBy(r => r.FilePath); - - foreach (var group in grouped) - { - await Console.Out.WriteLineAsync($"\u001b[36m{group.Key}\u001b[0m"); - var sectionGroups = group.GroupBy(r => (r.Section.StartLine, r.Section.EndLine)); - - foreach (var sectionGroup in sectionGroups) - { - await Console.Out.WriteLineAsync($" Lines {sectionGroup.Key.StartLine}-{sectionGroup.Key.EndLine}:"); - int optionNum = 1; - foreach (var resolution in sectionGroup) - { - var strategyColor = resolution.Strategy switch - { - Services.ResolutionStrategy.AiSuggested => "\u001b[35m", - Services.ResolutionStrategy.AcceptOurs => "\u001b[32m", - Services.ResolutionStrategy.AcceptTheirs => "\u001b[34m", - _ => "\u001b[33m" - }; - await Console.Out.WriteLineAsync($" {optionNum}. {strategyColor}[{resolution.Strategy}]\u001b[0m {resolution.Description}"); - optionNum++; - } - } - await Console.Out.WriteLineAsync(); - } - } - - if (apply) - { - await Console.Out.WriteLineAsync(); - await Console.Out.WriteLineAsync("\u001b[33mApplying AI Resolutions...\u001b[0m"); - await Console.Out.WriteLineAsync(new string('-', 40)); - - // Get only AI-suggested resolutions - var aiResolutions = resolutions - .Where(r => r.Strategy == Services.ResolutionStrategy.AiSuggested) - .ToList(); - - if (aiResolutions.Count == 0) - { - await Console.Out.WriteLineAsync("No AI resolutions available to apply."); - } - else - { - var groupedByFile = aiResolutions.GroupBy(r => r.FilePath); - int appliedCount = 0; - int failedCount = 0; - - foreach (var fileGroup in groupedByFile) - { - await Console.Out.WriteAsync($" {fileGroup.Key}: "); - var fileResolutions = fileGroup.ToList(); - - var success = await conflictResolver.ApplyAllResolutionsAsync(fileResolutions, fileGroup.Key); - if (success) - { - await Console.Out.WriteLineAsync($"\u001b[32m{fileResolutions.Count} conflict(s) resolved\u001b[0m"); - appliedCount += fileResolutions.Count; - } - else - { - await Console.Out.WriteLineAsync("\u001b[31mFailed to apply\u001b[0m"); - failedCount += fileResolutions.Count; - } - } - - await Console.Out.WriteLineAsync(); - await Console.Out.WriteLineAsync($"Applied: {appliedCount}, Failed: {failedCount}"); - - if (appliedCount > 0) - { - await Console.Out.WriteLineAsync(); - await Console.Out.WriteLineAsync("Next steps:"); - await Console.Out.WriteLineAsync(" 1. Review the resolved files"); - await Console.Out.WriteLineAsync(" 2. git add "); - await Console.Out.WriteLineAsync($" 3. git {GetContinueCommand(ctx.MergeState)}"); - } - } - } - } - - if (resolve) - { - await Console.Out.WriteLineAsync("\u001b[33mInteractive Resolution Mode\u001b[0m"); - await Console.Out.WriteLineAsync("For each conflict, choose a resolution strategy:"); - await Console.Out.WriteLineAsync(); - - var resolvedFiles = new List(); - - foreach (var conflictFile in filesToAnalyze) - { - await Console.Out.WriteLineAsync($"\u001b[36m{conflictFile.FilePath}\u001b[0m"); - var fileResolved = false; - - foreach (var section in conflictFile.Sections) - { - await Console.Out.WriteLineAsync($"\n Conflict at lines {section.StartLine}-{section.EndLine}:"); - await Console.Out.WriteLineAsync($" \u001b[32mOurs ({section.OursLabel}):\u001b[0m"); - PrintIndented(section.OursContent, " "); - await Console.Out.WriteLineAsync($" \u001b[34mTheirs ({section.TheirsLabel}):\u001b[0m"); - PrintIndented(section.TheirsContent, " "); - - await Console.Out.WriteLineAsync(); - await Console.Out.WriteLineAsync(" Options:"); - await Console.Out.WriteLineAsync(" 1. Accept ours"); - await Console.Out.WriteLineAsync(" 2. Accept theirs"); - await Console.Out.WriteLineAsync(" 3. Combine (ours + theirs)"); - await Console.Out.WriteLineAsync(" 4. Skip this conflict"); - - await Console.Out.WriteAsync(" Choose [1-4]: "); - var input = Console.ReadLine()?.Trim(); - - string? resolvedContent = input switch - { - "1" => section.OursContent, - "2" => section.TheirsContent, - "3" => section.OursContent + "\n" + section.TheirsContent, - _ => null - }; - - if (resolvedContent != null) - { - var resolution = new Services.ConflictResolution - { - FilePath = conflictFile.FilePath, - Section = section, - Strategy = Services.ResolutionStrategy.Manual, - Description = "Manual resolution", - ResolvedContent = resolvedContent - }; - - var success = await conflictResolver.ApplyResolutionAsync(resolution); - if (success) - { - await Console.Out.WriteLineAsync($" \u001b[32mResolution applied.\u001b[0m"); - fileResolved = true; - } - else - { - await Console.Out.WriteLineAsync($" \u001b[31mFailed to apply resolution.\u001b[0m"); - } - } - else - { - await Console.Out.WriteLineAsync($" \u001b[33mSkipped.\u001b[0m"); - } - } - - if (fileResolved) - { - resolvedFiles.Add(conflictFile.FilePath); - } - } - - await Console.Out.WriteLineAsync(); - if (resolvedFiles.Count > 0) - { - await Console.Out.WriteLineAsync("Resolved files:"); - foreach (var resolvedFile in resolvedFiles) - { - await Console.Out.WriteLineAsync($" {resolvedFile}"); - } - await Console.Out.WriteLineAsync(); - await Console.Out.WriteLineAsync("Next steps:"); - await Console.Out.WriteLineAsync($" git add {string.Join(" ", resolvedFiles.Select(f => $"\"{f}\""))}"); - await Console.Out.WriteLineAsync($" git {GetContinueCommand(ctx.MergeState)}"); - } - else - { - await Console.Out.WriteLineAsync("No conflicts were resolved."); - } - } - else if (!suggest && !apply) - { - await Console.Out.WriteLineAsync("Use --suggest (-s) to see AI-suggested resolutions."); - await Console.Out.WriteLineAsync("Use --apply (-a) to auto-apply AI resolutions."); - await Console.Out.WriteLineAsync("Use --resolve (-r) to interactively resolve conflicts."); - } - }); - - return conflictsCmd; - } - - private static string GetContinueCommand(Models.MergeState state) => state switch - { - Models.MergeState.Merging => "merge --continue", - Models.MergeState.Rebasing => "rebase --continue", - Models.MergeState.CherryPicking => "cherry-pick --continue", - Models.MergeState.Reverting => "revert --continue", - _ => "commit" - }; - - private static void PrintIndented(string content, string indent) - { - if (string.IsNullOrEmpty(content)) - { - Console.WriteLine($"{indent}(empty)"); - return; - } - foreach (var line in content.Split('\n').Take(5)) - { - Console.WriteLine($"{indent}{line}"); - } - var lineCount = content.Split('\n').Length; - if (lineCount > 5) - { - Console.WriteLine($"{indent}... ({lineCount - 5} more lines)"); - } - } - - private static Command BuildCompletionsCommand() - { - var completionsCmd = new Command("completions", "Generate shell completion scripts"); - - var shellArg = new Argument("shell", "Shell type (bash, zsh, powershell, fish)"); - completionsCmd.AddArgument(shellArg); - - completionsCmd.SetHandler(async context => - { - var shell = context.ParseResult.GetValueForArgument(shellArg); - var host = context.GetHost(); - var generator = host.Services.GetRequiredService(); - - var script = shell.ToLowerInvariant() switch - { - "bash" => generator.GenerateBashCompletion(), - "zsh" => generator.GenerateZshCompletion(), - "powershell" or "pwsh" => generator.GeneratePowerShellCompletion(), - "fish" => generator.GenerateFishCompletion(), - _ => null - }; - - if (script == null) - { - await Console.Error.WriteLineAsync($"Unknown shell: {shell}"); - await Console.Error.WriteLineAsync("Supported shells: bash, zsh, powershell, fish"); - context.ExitCode = 1; - return; - } - - await Console.Out.WriteLineAsync(script); - await Console.Error.WriteLineAsync(); - await Console.Error.WriteLineAsync($"# To install, add the above to your shell configuration:"); - await Console.Error.WriteLineAsync(shell.ToLowerInvariant() switch - { - "bash" => "# Add to ~/.bashrc or ~/.bash_completion", - "zsh" => "# Add to ~/.zshrc or save to a file in $fpath", - "powershell" or "pwsh" => "# Add to your PowerShell profile ($PROFILE)", - "fish" => "# Save to ~/.config/fish/completions/git-agent.fish", - _ => "" - }); - }); - - return completionsCmd; - } - - private static Command BuildServeCommand() - { - var serveCmd = new Command("serve", "Start JSON-RPC server for IDE integration"); - - var portOption = new Option(["--port", "-p"], () => 9123, "Port to listen on"); - serveCmd.AddOption(portOption); - - serveCmd.SetHandler(async context => - { - var port = context.ParseResult.GetValueForOption(portOption); - var host = context.GetHost(); - var server = host.Services.GetRequiredService(); - - var cts = new CancellationTokenSource(); - Console.CancelKeyPress += (_, e) => - { - e.Cancel = true; - cts.Cancel(); - }; - - try - { - await server.StartAsync(port, cts.Token); - } - catch (OperationCanceledException) - { - await Console.Out.WriteLineAsync("\nServer stopped."); - } - }); - - return serveCmd; - } - - private static Command BuildHelpCommand() - { - var helpCmd = new Command("help", "Show help and list all available commands"); - helpCmd.SetHandler(async () => - { - await Console.Out.WriteLineAsync("git-agent: Translate natural language to git commands using AI"); - await Console.Out.WriteLineAsync(); - await Console.Out.WriteLineAsync("Usage: git-agent [command] [options]"); - await Console.Out.WriteLineAsync(); - await Console.Out.WriteLineAsync("Commands:"); - await Console.Out.WriteLineAsync(" run Translate and optionally execute a plain English instruction"); - await Console.Out.WriteLineAsync(" config Manage git-agent configuration"); - await Console.Out.WriteLineAsync(" config show Display current configuration"); - await Console.Out.WriteLineAsync(" config set Set a configuration value"); - await Console.Out.WriteLineAsync(" config get Get a configuration value"); - await Console.Out.WriteLineAsync(" config use Set the active provider"); - await Console.Out.WriteLineAsync(" config path Show the configuration file path"); - await Console.Out.WriteLineAsync(" config reset Reset configuration to defaults"); - await Console.Out.WriteLineAsync(" providers List available AI providers"); - await Console.Out.WriteLineAsync(" cache Manage HTTP response cache"); - await Console.Out.WriteLineAsync(" cache clear Clear all cached HTTP responses"); - await Console.Out.WriteLineAsync(" cache path Show cache directory path"); - await Console.Out.WriteLineAsync(" conflicts Analyze and resolve merge conflicts"); - await Console.Out.WriteLineAsync(" conflicts -s Show AI-suggested resolutions"); - await Console.Out.WriteLineAsync(" conflicts -a Auto-apply AI-suggested resolutions"); - await Console.Out.WriteLineAsync(" conflicts -r Interactively resolve conflicts"); - await Console.Out.WriteLineAsync(" completions Generate shell completion scripts (bash, zsh, powershell, fish)"); - await Console.Out.WriteLineAsync(" serve Start JSON-RPC server for IDE integration"); - await Console.Out.WriteLineAsync(" help [command] Show help for a command"); - await Console.Out.WriteLineAsync(); - await Console.Out.WriteLineAsync("Options:"); - await Console.Out.WriteLineAsync(" --version Show version information"); - await Console.Out.WriteLineAsync(" -?, -h, --help Show help and usage information"); - await Console.Out.WriteLineAsync(); - await Console.Out.WriteLineAsync("Examples:"); - await Console.Out.WriteLineAsync(" git-agent run \"commit all changes\""); - await Console.Out.WriteLineAsync(" git-agent run \"push to origin\" --exec"); - await Console.Out.WriteLineAsync(" git-agent run \"merge feature into main\" -xi"); - await Console.Out.WriteLineAsync(" git-agent config set claude.apiKey sk-ant-xxx"); - await Console.Out.WriteLineAsync(" git-agent config use openai"); - }); - - return helpCmd; - } } -internal static class ConfigHelpers -{ - public static string MaskApiKey(string apiKey) - { - if (string.IsNullOrWhiteSpace(apiKey)) - return "(not set)"; - - if (apiKey.Length <= 8) - return "****"; - - return apiKey[..4] + "****" + apiKey[^4..]; - } - - public static bool SetConfigValue(GitAgentConfig config, string key, string value) - { - switch (key.ToLowerInvariant()) - { - case "activeprovider": - case "provider": - config.ActiveProvider = value.ToLowerInvariant(); - return true; - - case "claude.apikey": - config.Providers.Claude.ApiKey = value; - return true; - case "claude.model": - config.Providers.Claude.Model = value; - return true; - case "claude.baseurl": - config.Providers.Claude.BaseUrl = value; - return true; - - case "openai.apikey": - config.Providers.OpenAI.ApiKey = value; - return true; - case "openai.model": - config.Providers.OpenAI.Model = value; - return true; - case "openai.baseurl": - config.Providers.OpenAI.BaseUrl = value; - return true; - - case "ollama.model": - config.Providers.Ollama.Model = value; - return true; - case "ollama.baseurl": - config.Providers.Ollama.BaseUrl = value; - return true; - - default: - return false; - } - } - - public static string? GetConfigValue(GitAgentConfig config, string key) - { - return key.ToLowerInvariant() switch - { - "activeprovider" or "provider" => config.ActiveProvider, - "claude.apikey" => config.Providers.Claude.ApiKey, - "claude.model" => config.Providers.Claude.Model, - "claude.baseurl" => config.Providers.Claude.BaseUrl, - "openai.apikey" => config.Providers.OpenAI.ApiKey, - "openai.model" => config.Providers.OpenAI.Model, - "openai.baseurl" => config.Providers.OpenAI.BaseUrl, - "ollama.model" => config.Providers.Ollama.Model, - "ollama.baseurl" => config.Providers.Ollama.BaseUrl, - _ => null - }; - } - - public static async Task PrintAvailableKeysAsync() - { - await Console.Error.WriteLineAsync("Available keys:"); - await Console.Error.WriteLineAsync(" activeProvider - Active provider (claude, openai, ollama, stub)"); - await Console.Error.WriteLineAsync(" claude.apiKey - Claude API key"); - await Console.Error.WriteLineAsync(" claude.model - Claude model name"); - await Console.Error.WriteLineAsync(" claude.baseUrl - Claude API base URL"); - await Console.Error.WriteLineAsync(" openai.apiKey - OpenAI API key"); - await Console.Error.WriteLineAsync(" openai.model - OpenAI model name"); - await Console.Error.WriteLineAsync(" openai.baseUrl - OpenAI API base URL"); - await Console.Error.WriteLineAsync(" ollama.model - Ollama model name"); - await Console.Error.WriteLineAsync(" ollama.baseUrl - Ollama API base URL"); - } -} diff --git a/Commands/CompletionsCommand.cs b/Commands/CompletionsCommand.cs new file mode 100644 index 0000000..d8f3070 --- /dev/null +++ b/Commands/CompletionsCommand.cs @@ -0,0 +1,56 @@ +using GitAgent.Services; +using Microsoft.Extensions.DependencyInjection; +using System.CommandLine; +using System.CommandLine.Hosting; + +namespace GitAgent.Commands +{ + internal class CompletionsCommand + { + public static Command BuildCompletionsCommand() + { + var completionsCmd = new Command("completions", "Generate shell completion scripts"); + + var shellArg = new Argument("shell", "Shell type (bash, zsh, powershell, fish)"); + completionsCmd.AddArgument(shellArg); + + completionsCmd.SetHandler(async context => + { + var shell = context.ParseResult.GetValueForArgument(shellArg); + var host = context.GetHost(); + var generator = host.Services.GetRequiredService(); + + var script = shell.ToLowerInvariant() switch + { + "bash" => generator.GenerateBashCompletion(), + "zsh" => generator.GenerateZshCompletion(), + "powershell" or "pwsh" => generator.GeneratePowerShellCompletion(), + "fish" => generator.GenerateFishCompletion(), + _ => null + }; + + if (script == null) + { + await Console.Error.WriteLineAsync($"Unknown shell: {shell}"); + await Console.Error.WriteLineAsync("Supported shells: bash, zsh, powershell, fish"); + context.ExitCode = 1; + return; + } + + await Console.Out.WriteLineAsync(script); + await Console.Error.WriteLineAsync(); + await Console.Error.WriteLineAsync($"# To install, add the above to your shell configuration:"); + await Console.Error.WriteLineAsync(shell.ToLowerInvariant() switch + { + "bash" => "# Add to ~/.bashrc or ~/.bash_completion", + "zsh" => "# Add to ~/.zshrc or save to a file in $fpath", + "powershell" or "pwsh" => "# Add to your PowerShell profile ($PROFILE)", + "fish" => "# Save to ~/.config/fish/completions/git-agent.fish", + _ => "" + }); + }); + + return completionsCmd; + } + } +} diff --git a/Commands/ConfigCommand.cs b/Commands/ConfigCommand.cs new file mode 100644 index 0000000..5ed7418 --- /dev/null +++ b/Commands/ConfigCommand.cs @@ -0,0 +1,295 @@ +using GitAgent.Configuration; +using GitAgent.Providers; +using GitAgent.Services; +using System.CommandLine; +using System.CommandLine.Hosting; +using Microsoft.Extensions.DependencyInjection; + +namespace GitAgent.Commands +{ + internal class ConfigCommand + { + public static Command BuildConfigCommand() + { + var configCmd = new Command("config", "Manage git-agent configuration"); + + configCmd.AddCommand(BuildConfigShowCommand()); + configCmd.AddCommand(BuildConfigSetCommand()); + configCmd.AddCommand(BuildConfigGetCommand()); + configCmd.AddCommand(BuildConfigUseCommand()); + configCmd.AddCommand(BuildConfigPathCommand()); + configCmd.AddCommand(BuildConfigResetCommand()); + + return configCmd; + } + + private static Command BuildConfigShowCommand() + { + var showCmd = new Command("show", "Display current configuration"); + showCmd.SetHandler(async context => + { + var host = context.GetHost(); + var configManager = host.Services.GetRequiredService(); + + var config = await configManager.LoadAsync(); + await Console.Out.WriteLineAsync($"Configuration file: {configManager.ConfigPath}"); + await Console.Out.WriteLineAsync(); + await Console.Out.WriteLineAsync($"Active provider: {config.ActiveProvider}"); + await Console.Out.WriteLineAsync(); + await Console.Out.WriteLineAsync("Provider settings:"); + await Console.Out.WriteLineAsync($" Claude:"); + await Console.Out.WriteLineAsync($" API Key: {ConfigHelpers.MaskApiKey(config.Providers.Claude.ApiKey)}"); + await Console.Out.WriteLineAsync($" Model: {config.Providers.Claude.Model}"); + await Console.Out.WriteLineAsync($" BaseUrl: {config.Providers.Claude.BaseUrl}"); + await Console.Out.WriteLineAsync(); + await Console.Out.WriteLineAsync($" OpenAI:"); + await Console.Out.WriteLineAsync($" API Key: {ConfigHelpers.MaskApiKey(config.Providers.OpenAI.ApiKey)}"); + await Console.Out.WriteLineAsync($" Model: {config.Providers.OpenAI.Model}"); + await Console.Out.WriteLineAsync($" BaseUrl: {config.Providers.OpenAI.BaseUrl}"); + await Console.Out.WriteLineAsync(); + await Console.Out.WriteLineAsync($" OpenRouter:"); + await Console.Out.WriteLineAsync($" API Key: {ConfigHelpers.MaskApiKey(config.Providers.OpenRouter.ApiKey)}"); + await Console.Out.WriteLineAsync($" Model: {config.Providers.OpenRouter.Model}"); + await Console.Out.WriteLineAsync($" BaseUrl: {config.Providers.OpenRouter.BaseUrl}"); + await Console.Out.WriteLineAsync($" Site Name: {config.Providers.OpenRouter.SiteName}"); + await Console.Out.WriteLineAsync($" Site Url: {config.Providers.OpenRouter.SiteUrl}"); + await Console.Out.WriteLineAsync(); + await Console.Out.WriteLineAsync($" Ollama:"); + await Console.Out.WriteLineAsync($" Model: {config.Providers.Ollama.Model}"); + await Console.Out.WriteLineAsync($" BaseUrl: {config.Providers.Ollama.BaseUrl}"); + }); + return showCmd; + } + + private static Command BuildConfigSetCommand() + { + var keyArg = new Argument("key", "Configuration key (e.g., activeProvider, claude.apiKey)"); + var valueArg = new Argument("value", "Value to set"); + var setCmd = new Command("set", "Set a configuration value") { keyArg, valueArg }; + setCmd.SetHandler(async context => + { + var key = context.ParseResult.GetValueForArgument(keyArg); + var value = context.ParseResult.GetValueForArgument(valueArg); + + var host = context.GetHost(); + var configManager = host.Services.GetRequiredService(); + + var config = await configManager.LoadAsync(); + var updated = ConfigHelpers.SetConfigValue(config, key, value); + if (updated) + { + await configManager.SaveAsync(config); + await Console.Out.WriteLineAsync($"Set {key} = {(key.Contains("apiKey", StringComparison.OrdinalIgnoreCase) ? ConfigHelpers.MaskApiKey(value) : value)}"); + } + else + { + await Console.Error.WriteLineAsync($"Unknown configuration key: {key}"); + await Console.Error.WriteLineAsync(); + await ConfigHelpers.PrintAvailableKeysAsync(); + } + }); + return setCmd; + } + + private static Command BuildConfigGetCommand() + { + var keyArg = new Argument("key", "Configuration key to retrieve"); + var getCmd = new Command("get", "Get a configuration value") { keyArg }; + getCmd.SetHandler(async context => + { + var key = context.ParseResult.GetValueForArgument(keyArg); + + var host = context.GetHost(); + var configManager = host.Services.GetRequiredService(); + + var config = await configManager.LoadAsync(); + var value = ConfigHelpers.GetConfigValue(config, key); + if (value != null) + { + var displayValue = key.Contains("apiKey", StringComparison.OrdinalIgnoreCase) + ? ConfigHelpers.MaskApiKey(value) + : value; + await Console.Out.WriteLineAsync(displayValue); + } + else + { + await Console.Error.WriteLineAsync($"Unknown configuration key: {key}"); + await ConfigHelpers.PrintAvailableKeysAsync(); + } + }); + return getCmd; + } + + private static Command BuildConfigUseCommand() + { + var providerArg = new Argument("provider", "Provider to use (claude, openai, openrouter ,ollama, stub)"); + var useCmd = new Command("use", "Set the active provider") { providerArg }; + useCmd.SetHandler(async context => + { + var provider = context.ParseResult.GetValueForArgument(providerArg); + + var host = context.GetHost(); + var configManager = host.Services.GetRequiredService(); + var providerFactory = host.Services.GetRequiredService(); + + var providerLower = provider.ToLowerInvariant(); + if (!providerFactory.AvailableProviders.Contains(providerLower)) + { + await Console.Error.WriteLineAsync($"Unknown provider: {provider}"); + await Console.Error.WriteLineAsync($"Available providers: {string.Join(", ", providerFactory.AvailableProviders)}"); + return; + } + + var config = await configManager.LoadAsync(); + config.ActiveProvider = providerLower; + await configManager.SaveAsync(config); + await Console.Out.WriteLineAsync($"Active provider set to: {providerLower}"); + }); + return useCmd; + } + + private static Command BuildConfigPathCommand() + { + var pathCmd = new Command("path", "Show the configuration file path"); + pathCmd.SetHandler(async context => + { + var host = context.GetHost(); + var configManager = host.Services.GetRequiredService(); + await Console.Out.WriteLineAsync(configManager.ConfigPath); + }); + return pathCmd; + } + + private static Command BuildConfigResetCommand() + { + var resetCmd = new Command("reset", "Reset configuration to defaults"); + resetCmd.SetHandler(async context => + { + var host = context.GetHost(); + var configManager = host.Services.GetRequiredService(); + + await Console.Out.WriteAsync("Reset configuration to defaults? (y/N): "); + var input = Console.ReadLine(); + if (string.Equals(input, "y", StringComparison.OrdinalIgnoreCase)) + { + await configManager.SaveAsync(new GitAgentConfig()); + await Console.Out.WriteLineAsync("Configuration reset to defaults."); + } + else + { + await Console.Out.WriteLineAsync("Cancelled."); + } + }); + return resetCmd; + } + } +} + +internal static class ConfigHelpers +{ + public static string MaskApiKey(string apiKey) + { + if (string.IsNullOrWhiteSpace(apiKey)) + return "(not set)"; + + if (apiKey.Length <= 8) + return "****"; + + return apiKey[..4] + "****" + apiKey[^4..]; + } + + public static bool SetConfigValue(GitAgentConfig config, string key, string value) + { + switch (key.ToLowerInvariant()) + { + case "activeprovider": + case "provider": + config.ActiveProvider = value.ToLowerInvariant(); + return true; + case "claude.apikey": + config.Providers.Claude.ApiKey = value; + return true; + case "claude.model": + config.Providers.Claude.Model = value; + return true; + case "claude.baseurl": + config.Providers.Claude.BaseUrl = value; + return true; + case "openai.apikey": + config.Providers.OpenAI.ApiKey = value; + return true; + case "openai.model": + config.Providers.OpenAI.Model = value; + return true; + case "openai.baseurl": + config.Providers.OpenAI.BaseUrl = value; + return true; + case "openrouter.apikey": + config.Providers.OpenRouter.ApiKey = value; + return true; + case "openrouter.model": + config.Providers.OpenRouter.Model = value; + return true; + case "openrouter.baseurl": + config.Providers.OpenRouter.BaseUrl = value; + return true; + case "openrouter.sitename": + config.Providers.OpenRouter.SiteName = value; + return true; + case "openrouter.siteurl": + config.Providers.OpenRouter.SiteUrl = value; + return true; + case "ollama.model": + config.Providers.Ollama.Model = value; + return true; + case "ollama.baseurl": + config.Providers.Ollama.BaseUrl = value; + return true; + + default: + return false; + } + } + + public static string? GetConfigValue(GitAgentConfig config, string key) + { + return key.ToLowerInvariant() switch + { + "activeprovider" or "provider" => config.ActiveProvider, + "claude.apikey" => config.Providers.Claude.ApiKey, + "claude.model" => config.Providers.Claude.Model, + "claude.baseurl" => config.Providers.Claude.BaseUrl, + "openai.apikey" => config.Providers.OpenAI.ApiKey, + "openai.model" => config.Providers.OpenAI.Model, + "openai.baseurl" => config.Providers.OpenAI.BaseUrl, + "openrouter.apikey" => config.Providers.OpenRouter.ApiKey, + "openrouter.model" => config.Providers.OpenRouter.Model, + "openrouter.baseurl" => config.Providers.OpenRouter.BaseUrl, + "openrouter.sitename" => config.Providers.OpenRouter.SiteName, + "openrouter.siteurl" => config.Providers.OpenRouter.SiteUrl, + "ollama.model" => config.Providers.Ollama.Model, + "ollama.baseurl" => config.Providers.Ollama.BaseUrl, + _ => null + }; + } + + public static async Task PrintAvailableKeysAsync() + { + await Console.Error.WriteLineAsync("Available keys:"); + await Console.Error.WriteLineAsync(" activeProvider - Active provider (claude, openai, openrouter, ollama, stub)"); + await Console.Error.WriteLineAsync(" claude.apiKey - Claude API key"); + await Console.Error.WriteLineAsync(" claude.model - Claude model name"); + await Console.Error.WriteLineAsync(" claude.baseUrl - Claude API base URL"); + await Console.Error.WriteLineAsync(" openai.apiKey - OpenAI API key"); + await Console.Error.WriteLineAsync(" openai.model - OpenAI model name"); + await Console.Error.WriteLineAsync(" openai.baseUrl - OpenAI API base URL"); + await Console.Error.WriteLineAsync(" openrouter.apiKey - OpenRouter API key"); + await Console.Error.WriteLineAsync(" openrouter.model - OpenRouter model name"); + await Console.Error.WriteLineAsync(" openrouter.baseUrl - OpenRouter API base URL"); + await Console.Error.WriteLineAsync(" openrouter.sitename - OpenRouter API Site Name"); + await Console.Error.WriteLineAsync(" openrouter.siteurl - OpenRouter API Site Url"); + await Console.Error.WriteLineAsync(" ollama.model - Ollama model name"); + await Console.Error.WriteLineAsync(" ollama.baseUrl - Ollama API base URL"); + } +} + diff --git a/Commands/ConflictsCommand.cs b/Commands/ConflictsCommand.cs new file mode 100644 index 0000000..6d0ae7f --- /dev/null +++ b/Commands/ConflictsCommand.cs @@ -0,0 +1,331 @@ +using GitAgent.Providers; +using GitAgent.Services; +using Microsoft.Extensions.DependencyInjection; +using System.CommandLine; +using System.CommandLine.Hosting; + + +namespace GitAgent.Commands +{ + internal class ConflictsCommand + { + public static Command BuildConflictsCommand() + { + var conflictsCmd = new Command("conflicts", "Analyze and resolve merge conflicts with AI assistance"); + + var suggestOption = new Option(["--suggest", "-s"], () => false, "Show AI-suggested resolutions"); + var resolveOption = new Option(["--resolve", "-r"], () => false, "Interactively resolve conflicts"); + var applyOption = new Option(["--apply", "-a"], () => false, "Auto-apply AI-suggested resolutions"); + var providerOption = new Option(["--provider", "-p"], "Override the active provider for AI suggestions"); + var fileArg = new Argument("file", () => null, "Specific file to analyze (optional)"); + + conflictsCmd.AddOption(suggestOption); + conflictsCmd.AddOption(resolveOption); + conflictsCmd.AddOption(applyOption); + conflictsCmd.AddOption(providerOption); + conflictsCmd.AddArgument(fileArg); + + conflictsCmd.SetHandler(async context => + { + var suggest = context.ParseResult.GetValueForOption(suggestOption); + var resolve = context.ParseResult.GetValueForOption(resolveOption); + var apply = context.ParseResult.GetValueForOption(applyOption); + var providerOverride = context.ParseResult.GetValueForOption(providerOption); + var file = context.ParseResult.GetValueForArgument(fileArg); + + var host = context.GetHost(); + var gitInspector = host.Services.GetRequiredService(); + var conflictResolver = host.Services.GetRequiredService(); + var providerFactory = host.Services.GetRequiredService(); + + var ctx = await gitInspector.BuildRepoContextAsync(); + + if (ctx.MergeState == Models.MergeState.None) + { + await Console.Out.WriteLineAsync("No merge in progress."); + return; + } + + if (ctx.ConflictedFiles.Count == 0) + { + await Console.Out.WriteLineAsync($"Merge state: {ctx.MergeState}"); + await Console.Out.WriteLineAsync("No conflicts detected. You can continue with:"); + await Console.Out.WriteLineAsync($" git {GetContinueCommand(ctx.MergeState)}"); + return; + } + + var filesToAnalyze = ctx.ConflictedFiles; + if (!string.IsNullOrEmpty(file)) + { + filesToAnalyze = ctx.ConflictedFiles.Where(f => f.FilePath.Contains(file)).ToList(); + if (filesToAnalyze.Count == 0) + { + await Console.Error.WriteLineAsync($"No conflicts found in files matching: {file}"); + return; + } + } + + var analysis = await conflictResolver.AnalyzeConflictsAsync(ctx); + + await Console.Out.WriteLineAsync($"\u001b[33mMerge State:\u001b[0m {ctx.MergeState}"); + await Console.Out.WriteLineAsync($"\u001b[33mTotal Conflicts:\u001b[0m {analysis.TotalConflicts} in {analysis.ConflictedFileCount} file(s)"); + await Console.Out.WriteLineAsync(); + + foreach (var fileAnalysis in analysis.Files) + { + await Console.Out.WriteLineAsync($"\u001b[36m{fileAnalysis.FilePath}\u001b[0m ({fileAnalysis.ConflictCount} conflict(s))"); + + foreach (var section in fileAnalysis.Sections) + { + var typeColor = section.ConflictType switch + { + Services.ConflictType.SameChange => "\u001b[32m", + Services.ConflictType.AdjacentChanges => "\u001b[33m", + _ => "\u001b[31m" + }; + await Console.Out.WriteLineAsync($" Lines {section.StartLine}-{section.EndLine}: {typeColor}{section.ConflictType}\u001b[0m"); + await Console.Out.WriteLineAsync($" {section.Description}"); + await Console.Out.WriteLineAsync($" Ours: {section.OursLabel} | Theirs: {section.TheirsLabel}"); + } + await Console.Out.WriteLineAsync(); + } + + if (suggest || apply) + { + // Get provider for AI suggestions + Providers.IModelProvider? provider = null; + try + { + provider = string.IsNullOrWhiteSpace(providerOverride) + ? await providerFactory.CreateProviderAsync() + : await providerFactory.CreateProviderAsync(providerOverride); + + if (suggest) + { + await Console.Out.WriteLineAsync($"Using provider: {providerOverride ?? "default"}"); + } + } + catch (Exception ex) + { + await Console.Error.WriteLineAsync($"Warning: Could not initialize AI provider: {ex.Message}"); + await Console.Error.WriteLineAsync("Falling back to heuristic suggestions."); + } + + await Console.Out.WriteLineAsync("\u001b[33mGenerating AI Resolutions...\u001b[0m"); + var resolutions = await conflictResolver.SuggestResolutionsAsync(ctx, provider); + + if (suggest) + { + await Console.Out.WriteLineAsync(new string('-', 40)); + var grouped = resolutions.GroupBy(r => r.FilePath); + + foreach (var group in grouped) + { + await Console.Out.WriteLineAsync($"\u001b[36m{group.Key}\u001b[0m"); + var sectionGroups = group.GroupBy(r => (r.Section.StartLine, r.Section.EndLine)); + + foreach (var sectionGroup in sectionGroups) + { + await Console.Out.WriteLineAsync($" Lines {sectionGroup.Key.StartLine}-{sectionGroup.Key.EndLine}:"); + int optionNum = 1; + foreach (var resolution in sectionGroup) + { + var strategyColor = resolution.Strategy switch + { + Services.ResolutionStrategy.AiSuggested => "\u001b[35m", + Services.ResolutionStrategy.AcceptOurs => "\u001b[32m", + Services.ResolutionStrategy.AcceptTheirs => "\u001b[34m", + _ => "\u001b[33m" + }; + await Console.Out.WriteLineAsync($" {optionNum}. {strategyColor}[{resolution.Strategy}]\u001b[0m {resolution.Description}"); + optionNum++; + } + } + await Console.Out.WriteLineAsync(); + } + } + + if (apply) + { + await Console.Out.WriteLineAsync(); + await Console.Out.WriteLineAsync("\u001b[33mApplying AI Resolutions...\u001b[0m"); + await Console.Out.WriteLineAsync(new string('-', 40)); + + // Get only AI-suggested resolutions + var aiResolutions = resolutions + .Where(r => r.Strategy == Services.ResolutionStrategy.AiSuggested) + .ToList(); + + if (aiResolutions.Count == 0) + { + await Console.Out.WriteLineAsync("No AI resolutions available to apply."); + } + else + { + var groupedByFile = aiResolutions.GroupBy(r => r.FilePath); + int appliedCount = 0; + int failedCount = 0; + + foreach (var fileGroup in groupedByFile) + { + await Console.Out.WriteAsync($" {fileGroup.Key}: "); + var fileResolutions = fileGroup.ToList(); + + var success = await conflictResolver.ApplyAllResolutionsAsync(fileResolutions, fileGroup.Key); + if (success) + { + await Console.Out.WriteLineAsync($"\u001b[32m{fileResolutions.Count} conflict(s) resolved\u001b[0m"); + appliedCount += fileResolutions.Count; + } + else + { + await Console.Out.WriteLineAsync("\u001b[31mFailed to apply\u001b[0m"); + failedCount += fileResolutions.Count; + } + } + + await Console.Out.WriteLineAsync(); + await Console.Out.WriteLineAsync($"Applied: {appliedCount}, Failed: {failedCount}"); + + if (appliedCount > 0) + { + await Console.Out.WriteLineAsync(); + await Console.Out.WriteLineAsync("Next steps:"); + await Console.Out.WriteLineAsync(" 1. Review the resolved files"); + await Console.Out.WriteLineAsync(" 2. git add "); + await Console.Out.WriteLineAsync($" 3. git {GetContinueCommand(ctx.MergeState)}"); + } + } + } + } + + if (resolve) + { + await Console.Out.WriteLineAsync("\u001b[33mInteractive Resolution Mode\u001b[0m"); + await Console.Out.WriteLineAsync("For each conflict, choose a resolution strategy:"); + await Console.Out.WriteLineAsync(); + + var resolvedFiles = new List(); + + foreach (var conflictFile in filesToAnalyze) + { + await Console.Out.WriteLineAsync($"\u001b[36m{conflictFile.FilePath}\u001b[0m"); + var fileResolved = false; + + foreach (var section in conflictFile.Sections) + { + await Console.Out.WriteLineAsync($"\n Conflict at lines {section.StartLine}-{section.EndLine}:"); + await Console.Out.WriteLineAsync($" \u001b[32mOurs ({section.OursLabel}):\u001b[0m"); + PrintIndented(section.OursContent, " "); + await Console.Out.WriteLineAsync($" \u001b[34mTheirs ({section.TheirsLabel}):\u001b[0m"); + PrintIndented(section.TheirsContent, " "); + + await Console.Out.WriteLineAsync(); + await Console.Out.WriteLineAsync(" Options:"); + await Console.Out.WriteLineAsync(" 1. Accept ours"); + await Console.Out.WriteLineAsync(" 2. Accept theirs"); + await Console.Out.WriteLineAsync(" 3. Combine (ours + theirs)"); + await Console.Out.WriteLineAsync(" 4. Skip this conflict"); + + await Console.Out.WriteAsync(" Choose [1-4]: "); + var input = Console.ReadLine()?.Trim(); + + string? resolvedContent = input switch + { + "1" => section.OursContent, + "2" => section.TheirsContent, + "3" => section.OursContent + "\n" + section.TheirsContent, + _ => null + }; + + if (resolvedContent != null) + { + var resolution = new Services.ConflictResolution + { + FilePath = conflictFile.FilePath, + Section = section, + Strategy = Services.ResolutionStrategy.Manual, + Description = "Manual resolution", + ResolvedContent = resolvedContent + }; + + var success = await conflictResolver.ApplyResolutionAsync(resolution); + if (success) + { + await Console.Out.WriteLineAsync($" \u001b[32mResolution applied.\u001b[0m"); + fileResolved = true; + } + else + { + await Console.Out.WriteLineAsync($" \u001b[31mFailed to apply resolution.\u001b[0m"); + } + } + else + { + await Console.Out.WriteLineAsync($" \u001b[33mSkipped.\u001b[0m"); + } + } + + if (fileResolved) + { + resolvedFiles.Add(conflictFile.FilePath); + } + } + + await Console.Out.WriteLineAsync(); + if (resolvedFiles.Count > 0) + { + await Console.Out.WriteLineAsync("Resolved files:"); + foreach (var resolvedFile in resolvedFiles) + { + await Console.Out.WriteLineAsync($" {resolvedFile}"); + } + await Console.Out.WriteLineAsync(); + await Console.Out.WriteLineAsync("Next steps:"); + await Console.Out.WriteLineAsync($" git add {string.Join(" ", resolvedFiles.Select(f => $"\"{f}\""))}"); + await Console.Out.WriteLineAsync($" git {GetContinueCommand(ctx.MergeState)}"); + } + else + { + await Console.Out.WriteLineAsync("No conflicts were resolved."); + } + } + else if (!suggest && !apply) + { + await Console.Out.WriteLineAsync("Use --suggest (-s) to see AI-suggested resolutions."); + await Console.Out.WriteLineAsync("Use --apply (-a) to auto-apply AI resolutions."); + await Console.Out.WriteLineAsync("Use --resolve (-r) to interactively resolve conflicts."); + } + }); + + return conflictsCmd; + } + + private static string GetContinueCommand(Models.MergeState state) => state switch + { + Models.MergeState.Merging => "merge --continue", + Models.MergeState.Rebasing => "rebase --continue", + Models.MergeState.CherryPicking => "cherry-pick --continue", + Models.MergeState.Reverting => "revert --continue", + _ => "commit" + }; + + private static void PrintIndented(string content, string indent) + { + if (string.IsNullOrEmpty(content)) + { + Console.WriteLine($"{indent}(empty)"); + return; + } + foreach (var line in content.Split('\n').Take(5)) + { + Console.WriteLine($"{indent}{line}"); + } + var lineCount = content.Split('\n').Length; + if (lineCount > 5) + { + Console.WriteLine($"{indent}... ({lineCount - 5} more lines)"); + } + } + } +} \ No newline at end of file diff --git a/Commands/GRPCServeCommand.cs b/Commands/GRPCServeCommand.cs new file mode 100644 index 0000000..0843880 --- /dev/null +++ b/Commands/GRPCServeCommand.cs @@ -0,0 +1,44 @@ +using GitAgent.Services; +using Microsoft.Extensions.DependencyInjection; +using System.CommandLine; +using System.CommandLine.Hosting; + + +namespace GitAgent.Commands +{ + internal class GRPCServeCommand + { + public static Command BuildServeCommand() + { + var serveCmd = new Command("serve", "Start JSON-RPC server for IDE integration"); + + var portOption = new Option(["--port", "-p"], () => 9123, "Port to listen on"); + serveCmd.AddOption(portOption); + + serveCmd.SetHandler(async context => + { + var port = context.ParseResult.GetValueForOption(portOption); + var host = context.GetHost(); + var server = host.Services.GetRequiredService(); + + var cts = new CancellationTokenSource(); + Console.CancelKeyPress += (_, e) => + { + e.Cancel = true; + cts.Cancel(); + }; + + try + { + await server.StartAsync(port, cts.Token); + } + catch (OperationCanceledException) + { + await Console.Out.WriteLineAsync("\nServer stopped."); + } + }); + + return serveCmd; + } + } +} diff --git a/Commands/HelpCommand.cs b/Commands/HelpCommand.cs new file mode 100644 index 0000000..e0ab71c --- /dev/null +++ b/Commands/HelpCommand.cs @@ -0,0 +1,52 @@ +using System.CommandLine; + +namespace GitAgent.Commands +{ + internal class HelpCommand + { + public static Command BuildHelpCommand() + { + var helpCmd = new Command("help", "Show help and list all available commands"); + helpCmd.SetHandler(async () => + { + await Console.Out.WriteLineAsync("git-agent: Translate natural language to git commands using AI"); + await Console.Out.WriteLineAsync(); + await Console.Out.WriteLineAsync("Usage: git-agent [command] [options]"); + await Console.Out.WriteLineAsync(); + await Console.Out.WriteLineAsync("Commands:"); + await Console.Out.WriteLineAsync(" run Translate and optionally execute a plain English instruction"); + await Console.Out.WriteLineAsync(" config Manage git-agent configuration"); + await Console.Out.WriteLineAsync(" config show Display current configuration"); + await Console.Out.WriteLineAsync(" config set Set a configuration value"); + await Console.Out.WriteLineAsync(" config get Get a configuration value"); + await Console.Out.WriteLineAsync(" config use Set the active provider"); + await Console.Out.WriteLineAsync(" config path Show the configuration file path"); + await Console.Out.WriteLineAsync(" config reset Reset configuration to defaults"); + await Console.Out.WriteLineAsync(" providers List available AI providers"); + await Console.Out.WriteLineAsync(" cache Manage HTTP response cache"); + await Console.Out.WriteLineAsync(" cache clear Clear all cached HTTP responses"); + await Console.Out.WriteLineAsync(" cache path Show cache directory path"); + await Console.Out.WriteLineAsync(" conflicts Analyze and resolve merge conflicts"); + await Console.Out.WriteLineAsync(" conflicts -s Show AI-suggested resolutions"); + await Console.Out.WriteLineAsync(" conflicts -a Auto-apply AI-suggested resolutions"); + await Console.Out.WriteLineAsync(" conflicts -r Interactively resolve conflicts"); + await Console.Out.WriteLineAsync(" completions Generate shell completion scripts (bash, zsh, powershell, fish)"); + await Console.Out.WriteLineAsync(" serve Start JSON-RPC server for IDE integration"); + await Console.Out.WriteLineAsync(" help [command] Show help for a command"); + await Console.Out.WriteLineAsync(); + await Console.Out.WriteLineAsync("Options:"); + await Console.Out.WriteLineAsync(" --version Show version information"); + await Console.Out.WriteLineAsync(" -?, -h, --help Show help and usage information"); + await Console.Out.WriteLineAsync(); + await Console.Out.WriteLineAsync("Examples:"); + await Console.Out.WriteLineAsync(" git-agent run \"commit all changes\""); + await Console.Out.WriteLineAsync(" git-agent run \"push to origin\" --exec"); + await Console.Out.WriteLineAsync(" git-agent run \"merge feature into main\" -xi"); + await Console.Out.WriteLineAsync(" git-agent config set claude.apiKey sk-ant-xxx"); + await Console.Out.WriteLineAsync(" git-agent config use openai"); + }); + + return helpCmd; + } + } +} diff --git a/Commands/ProviderCommand.cs b/Commands/ProviderCommand.cs new file mode 100644 index 0000000..ce14c37 --- /dev/null +++ b/Commands/ProviderCommand.cs @@ -0,0 +1,34 @@ +using GitAgent.Providers; +using GitAgent.Services; +using Microsoft.Extensions.DependencyInjection; +using System.CommandLine; +using System.CommandLine.Hosting; + +namespace GitAgent.Commands +{ + internal class ProvidersCommand + { + public static Command BuildProvidersCommand() + { + var providersCmd = new Command("providers", "List available AI providers"); + providersCmd.SetHandler(async context => + { + var host = context.GetHost(); + var configManager = host.Services.GetRequiredService(); + var providerFactory = host.Services.GetRequiredService(); + + var config = await configManager.LoadAsync(); + await Console.Out.WriteLineAsync("Available providers:"); + foreach (var p in providerFactory.AvailableProviders) + { + var marker = p == config.ActiveProvider ? " (active)" : ""; + await Console.Out.WriteLineAsync($" - {p}{marker}"); + } + await Console.Out.WriteLineAsync(); + await Console.Out.WriteLineAsync("Use 'git-agent config use ' to switch providers."); + }); + + return providersCmd; + } + } +} diff --git a/Commands/RunCommand.cs b/Commands/RunCommand.cs new file mode 100644 index 0000000..417e0c9 --- /dev/null +++ b/Commands/RunCommand.cs @@ -0,0 +1,144 @@ +using System.CommandLine; +using System.CommandLine.Hosting; +using GitAgent.Providers; +using GitAgent.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace GitAgent.Commands +{ + internal class RunCommand + { + public static Command BuildRunCommand() + { + var runCmd = new Command("run", "Translate and optionally execute a plain English instruction"); + + var instructionArg = new Argument("instruction", "Natural language instruction to translate to git commands"); + var execOption = new Option(["--exec", "-x"], () => false, "Execute the resulting commands"); + var interactiveOption = new Option(["--interactive", "-i"], () => false, "Confirm each step interactively"); + var providerOption = new Option(["--provider", "-p"], "Override the active provider for this run"); + var noCacheOption = new Option(["--no-cache"], () => false, "Skip cache and force a fresh API call"); + + runCmd.AddArgument(instructionArg); + runCmd.AddOption(execOption); + runCmd.AddOption(interactiveOption); + runCmd.AddOption(providerOption); + runCmd.AddOption(noCacheOption); + + runCmd.SetHandler(async context => + { + var instruction = context.ParseResult.GetValueForArgument(instructionArg); + var exec = context.ParseResult.GetValueForOption(execOption); + var interactive = context.ParseResult.GetValueForOption(interactiveOption); + var providerOverride = context.ParseResult.GetValueForOption(providerOption); + var noCache = context.ParseResult.GetValueForOption(noCacheOption); + + var host = context.GetHost(); + var configManager = host.Services.GetRequiredService(); + var providerFactory = host.Services.GetRequiredService(); + var gitInspector = host.Services.GetRequiredService(); + var safetyValidator = host.Services.GetRequiredService(); + var commandExecutor = host.Services.GetRequiredService(); + var cachingHandler = host.Services.GetRequiredService(); + + try + { + if (noCache) + { + cachingHandler.ClearCache(); + } + + var provider = string.IsNullOrWhiteSpace(providerOverride) + ? await providerFactory.CreateProviderAsync() + : await providerFactory.CreateProviderAsync(providerOverride); + + var config = await configManager.LoadAsync(); + var activeProvider = providerOverride ?? config.ActiveProvider; + await Console.Out.WriteLineAsync($"Using provider: {activeProvider}"); + await Console.Out.WriteLineAsync(); + + var ctx = await gitInspector.BuildRepoContextAsync(); + + if (string.IsNullOrWhiteSpace(ctx.CurrentBranch)) + { + await Console.Error.WriteLineAsync("Warning: Not in a git repository or git is not available."); + await Console.Error.WriteLineAsync("Commands will be generated but may not be accurate without repo context."); + await Console.Out.WriteLineAsync(); + } + + await Console.Out.WriteLineAsync("Generating commands..."); + var commands = await provider.GenerateGitCommands(instruction, ctx); + + if (commands.Count == 0) + { + await Console.Out.WriteLineAsync("No git commands were generated for this instruction."); + return; + } + + var validated = safetyValidator.FilterAndAnnotate(commands); + + await Console.Out.WriteLineAsync(); + await Console.Out.WriteLineAsync("Generated commands:"); + await Console.Out.WriteLineAsync(new string('-', 40)); + + foreach (var cmd in validated) + { + var riskColor = cmd.Risk switch + { + "safe" => "\u001b[32m", + "destructive" => "\u001b[31m", + _ => "\u001b[33m" + }; + await Console.Out.WriteLineAsync($" {riskColor}{cmd.CommandText}\u001b[0m"); + if (!string.IsNullOrWhiteSpace(cmd.Reason)) + { + await Console.Out.WriteLineAsync($" ({cmd.Reason})"); + } + } + + await Console.Out.WriteLineAsync(new string('-', 40)); + + if (commands.Count != validated.Count) + { + var filtered = commands.Count - validated.Count; + await Console.Out.WriteLineAsync($"Note: {filtered} command(s) filtered out (not in allowlist)."); + } + + if (exec && validated.Count > 0) + { + await Console.Out.WriteLineAsync(); + await Console.Out.WriteLineAsync("Executing commands..."); + await Console.Out.WriteLineAsync(); + await commandExecutor.ExecuteAsync(validated, interactive); + } + else if (!exec && validated.Count > 0) + { + await Console.Out.WriteLineAsync(); + await Console.Out.WriteLineAsync("Add --exec (-x) to run these commands, or --exec --interactive (-xi) to confirm each."); + } + } + catch (InvalidOperationException ex) + { + await Console.Error.WriteLineAsync($"Configuration error: {ex.Message}"); + context.ExitCode = 1; + } + catch (HttpRequestException ex) + { + await Console.Error.WriteLineAsync($"API error: {ex.Message}"); + context.ExitCode = 1; + } + catch (TimeoutException ex) + { + await Console.Error.WriteLineAsync($"Timeout: {ex.Message}"); + context.ExitCode = 1; + } + catch (Exception ex) + { + await Console.Error.WriteLineAsync($"Error: {ex.Message}"); + context.ExitCode = 1; + } + }); + + return runCmd; + } + } +} diff --git a/Configuration/GitAgentConfig.cs b/Configuration/GitAgentConfig.cs index da836aa..c74b5d9 100644 --- a/Configuration/GitAgentConfig.cs +++ b/Configuration/GitAgentConfig.cs @@ -4,6 +4,9 @@ namespace GitAgent.Configuration; public class GitAgentConfig { + [JsonPropertyName("configVersion")] + public int ConfigVersion { get; set; } = 1; + [JsonPropertyName("activeProvider")] public string ActiveProvider { get; set; } = "stub"; diff --git a/Configuration/OpenRouterConfig.cs b/Configuration/OpenRouterConfig.cs new file mode 100644 index 0000000..9ed632a --- /dev/null +++ b/Configuration/OpenRouterConfig.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace GitAgent.Configuration; + +public class OpenRouterConfig +{ + [JsonPropertyName("apiKey")] + public string ApiKey { get; set; } = ""; + + [JsonPropertyName("model")] + public string Model { get; set; } = "openai/gpt-4o"; + + [JsonPropertyName("baseUrl")] + public string BaseUrl { get; set; } = "https://openrouter.ai"; + + [JsonPropertyName("siteName")] + public string SiteName { get; set; } = "GitAgent"; + + [JsonPropertyName("siteUrl")] + public string SiteUrl { get; set; } = ""; +} diff --git a/Configuration/ProviderConfigs.cs b/Configuration/ProviderConfigs.cs index e5a0419..e657fa3 100644 --- a/Configuration/ProviderConfigs.cs +++ b/Configuration/ProviderConfigs.cs @@ -12,4 +12,7 @@ public class ProviderConfigs [JsonPropertyName("ollama")] public OllamaConfig Ollama { get; set; } = new(); + + [JsonPropertyName("openrouter")] + public OpenRouterConfig OpenRouter { get; set; } = new(); } diff --git a/GitAgentCli.csproj b/GitAgentCli.csproj index 24ea8fe..84c877d 100644 --- a/GitAgentCli.csproj +++ b/GitAgentCli.csproj @@ -13,7 +13,7 @@ true git-agent GitAgent - 1.0.0 + 1.0.6 George Marcus Translate natural language to git commands using AI diff --git a/JsonContext.cs b/JsonContext.cs index 28abe4a..d483f31 100644 --- a/JsonContext.cs +++ b/JsonContext.cs @@ -14,6 +14,7 @@ namespace GitAgent; [JsonSerializable(typeof(ClaudeConfig))] [JsonSerializable(typeof(OpenAIConfig))] [JsonSerializable(typeof(OllamaConfig))] +[JsonSerializable(typeof(OpenRouterConfig))] internal partial class ConfigJsonContext : JsonSerializerContext; [JsonSourceGenerationOptions(WriteIndented = true)] @@ -52,6 +53,19 @@ internal partial class OpenAIJsonContext : JsonSerializerContext; [JsonSerializable(typeof(OllamaResponse))] internal partial class OllamaJsonContext : JsonSerializerContext; +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(OpenRouterRequest))] +[JsonSerializable(typeof(OpenRouterResponse))] +[JsonSerializable(typeof(GitToolInput))] +[JsonSerializable(typeof(ConflictToolInput))] +[JsonSerializable(typeof(JsonSchema))] +[JsonSerializable(typeof(JsonSchemaProperty))] +[JsonSerializable(typeof(JsonSchemaItems))] +[JsonSerializable(typeof(Dictionary))] +internal partial class OpenRouterJsonContext : JsonSerializerContext; + [JsonSerializable(typeof(JsonRpcRequest))] [JsonSerializable(typeof(JsonRpcResponse))] [JsonSerializable(typeof(JsonRpcErrorResponse))] diff --git a/Providers/OpenRouterProvider.cs b/Providers/OpenRouterProvider.cs new file mode 100644 index 0000000..2ec2cbb --- /dev/null +++ b/Providers/OpenRouterProvider.cs @@ -0,0 +1,249 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using GitAgent.Configuration; +using GitAgent.Models; +using GitAgent.Services; + +namespace GitAgent.Providers; + +public class OpenRouterProvider : IModelProvider +{ + private readonly OpenRouterConfig _config; + private readonly IPromptBuilder _promptBuilder; + private readonly HttpClient _httpClient; + + public OpenRouterProvider(OpenRouterConfig config, IPromptBuilder promptBuilder, CachingHttpHandler cachingHandler) + { + _config = config; + _promptBuilder = promptBuilder; + _httpClient = new HttpClient(cachingHandler, disposeHandler: false) + { + BaseAddress = new Uri(_config.BaseUrl), + Timeout = TimeSpan.FromSeconds(60) + }; + } + + public async Task> GenerateGitCommands(string instruction, RepoContext context) + { + EnsureApiKeyConfigured(); + + var userPrompt = _promptBuilder.BuildCommandUserPrompt(instruction, context); + var request = BuildRequest(GitTools.GitCommandSystemPrompt, userPrompt, GitTools.ToolName, GitTools.ToolDescription, GitTools.GetInputSchema(), 1024); + + var result = await SendRequestAsync(request); + LogUsage(result); + + var toolInput = ExtractToolInput(result, OpenRouterJsonContext.Default.GitToolInput); + if (toolInput?.Commands != null) + { + return toolInput.Commands.Select(c => c.ToGeneratedCommand()).ToList(); + } + + if (!string.IsNullOrEmpty(result?.Choices?.FirstOrDefault()?.Message?.Content)) + { + Console.Error.WriteLine("Warning: Model returned text instead of tool call"); + } + + return []; + } + + public async Task GenerateConflictResolution(ConflictSection conflict, string filePath, string fileExtension) + { + EnsureApiKeyConfigured(); + + var userPrompt = _promptBuilder.BuildConflictUserPrompt(conflict, filePath, fileExtension); + var request = BuildRequest(GitTools.ConflictSystemPrompt, userPrompt, GitTools.ConflictToolName, GitTools.ConflictToolDescription, GitTools.GetConflictInputSchema(), 4096); + + var result = await SendRequestAsync(request); + + var toolInput = ExtractToolInput(result, OpenRouterJsonContext.Default.ConflictToolInput); + if (toolInput != null) + { + return new ConflictResolutionResult + { + ResolvedContent = toolInput.ResolvedContent, + Explanation = toolInput.Explanation, + Confidence = ParseConfidence(toolInput.Confidence) + }; + } + + return new ConflictResolutionResult + { + ResolvedContent = conflict.OursContent, + Explanation = "Failed to generate AI resolution, defaulting to 'ours'", + Confidence = ResolutionConfidence.Low + }; + } + + private void EnsureApiKeyConfigured() + { + if (string.IsNullOrWhiteSpace(_config.ApiKey)) + { + throw new InvalidOperationException("OpenRouter API key not configured. Run: git-agent config set openrouter.apiKey "); + } + } + + private OpenRouterRequest BuildRequest(string systemPrompt, string userPrompt, string toolName, string toolDescription, JsonSchema parameters, int maxTokens) => new() + { + Model = _config.Model, + MaxTokens = maxTokens, + Messages = + [ + new OpenRouterMessage { Role = "system", Content = systemPrompt }, + new OpenRouterMessage { Role = "user", Content = userPrompt } + ], + Tools = + [ + new OpenRouterTool + { + Type = "function", + Function = new OpenRouterFunction { Name = toolName, Description = toolDescription, Parameters = parameters } + } + ], + ToolChoice = new OpenRouterToolChoice { Type = "function", Function = new OpenRouterFunctionName { Name = toolName } } + }; + + private async Task SendRequestAsync(OpenRouterRequest request) + { + var json = JsonSerializer.Serialize(request, OpenRouterJsonContext.Default.OpenRouterRequest); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + _httpClient.DefaultRequestHeaders.Clear(); + _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _config.ApiKey); + + if (!string.IsNullOrWhiteSpace(_config.SiteUrl)) + { + _httpClient.DefaultRequestHeaders.Add("HTTP-Referer", _config.SiteUrl); + } + + if (!string.IsNullOrWhiteSpace(_config.SiteName)) + { + _httpClient.DefaultRequestHeaders.Add("X-Title", _config.SiteName); + } + + try + { + var response = await _httpClient.PostAsync("/api/v1/chat/completions", content); + var responseJson = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException($"OpenRouter API error ({response.StatusCode}): {responseJson}"); + } + + return JsonSerializer.Deserialize(responseJson, OpenRouterJsonContext.Default.OpenRouterResponse); + } + catch (TaskCanceledException) + { + throw new TimeoutException("OpenRouter API request timed out after 60 seconds."); + } + } + + private static void LogUsage(OpenRouterResponse? result) + { + if (result?.Usage == null) return; + + var totalTokens = result.Usage.TotalTokens; + if (totalTokens > 0) + { + Console.WriteLine($"(tokens used: {result.Usage.PromptTokens} prompt + {result.Usage.CompletionTokens} completion = {totalTokens} total)"); + } + } + + private static T? ExtractToolInput(OpenRouterResponse? result, JsonTypeInfo typeInfo) + { + var arguments = result?.Choices?.FirstOrDefault()?.Message?.ToolCalls?.FirstOrDefault()?.Function?.Arguments; + if (arguments == null) return default; + + return JsonSerializer.Deserialize(arguments, typeInfo); + } + + private static ResolutionConfidence ParseConfidence(string confidence) => confidence.ToLowerInvariant() switch + { + "high" => ResolutionConfidence.High, + "medium" => ResolutionConfidence.Medium, + _ => ResolutionConfidence.Low + }; +} + +#region OpenRouter API Models + +internal class OpenRouterRequest +{ + public string Model { get; set; } = ""; + public int MaxTokens { get; set; } + public List Messages { get; set; } = []; + public List? Tools { get; set; } + public OpenRouterToolChoice? ToolChoice { get; set; } +} + +internal class OpenRouterMessage +{ + public string Role { get; set; } = ""; + public string Content { get; set; } = ""; +} + +internal class OpenRouterTool +{ + public string Type { get; set; } = "function"; + public OpenRouterFunction? Function { get; set; } +} + +internal class OpenRouterFunction +{ + public string Name { get; set; } = ""; + public string Description { get; set; } = ""; + public JsonSchema? Parameters { get; set; } +} + +internal class OpenRouterToolChoice +{ + public string Type { get; set; } = "function"; + public OpenRouterFunctionName? Function { get; set; } +} + +internal class OpenRouterFunctionName +{ + public string Name { get; set; } = ""; +} + +internal class OpenRouterResponse +{ + public List? Choices { get; set; } + public OpenRouterUsage? Usage { get; set; } +} + +internal class OpenRouterChoice +{ + public OpenRouterResponseMessage? Message { get; set; } +} + +internal class OpenRouterResponseMessage +{ + public string? Content { get; set; } + public List? ToolCalls { get; set; } +} + +internal class OpenRouterToolCall +{ + public string? Id { get; set; } + public string? Type { get; set; } + public OpenRouterFunctionCall? Function { get; set; } +} + +internal class OpenRouterFunctionCall +{ + public string? Name { get; set; } + public string? Arguments { get; set; } +} + +internal class OpenRouterUsage +{ + public int PromptTokens { get; set; } + public int CompletionTokens { get; set; } + public int TotalTokens { get; set; } +} + +#endregion diff --git a/Providers/ProviderFactory.cs b/Providers/ProviderFactory.cs index 3e3984f..47766b2 100644 --- a/Providers/ProviderFactory.cs +++ b/Providers/ProviderFactory.cs @@ -22,6 +22,7 @@ public class ProviderFactory : IProviderFactory [ "claude", "openai", + "openrouter", "ollama", "stub" ]; @@ -48,6 +49,7 @@ public async Task CreateProviderAsync(string providerName) { "claude" => new ClaudeProvider(config.Providers.Claude, _promptBuilder, HttpCacheHandler), "openai" => new OpenAIProvider(config.Providers.OpenAI, _promptBuilder, HttpCacheHandler), + "openrouter" => new OpenRouterProvider(config.Providers.OpenRouter, _promptBuilder, HttpCacheHandler), "ollama" => new OllamaProvider(config.Providers.Ollama, new OllamaPromptBuilder(), _responseParser), "stub" => new StubProvider(), _ => throw new ArgumentException($"Unknown provider: '{providerName}'. Available: {string.Join(", ", AvailableProviders)}") diff --git a/README.md b/README.md index 1648f7e..38e27a2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # git-agent -A .NET CLI tool that translates natural language instructions into git commands using AI providers (Claude, OpenAI, Ollama). +A .NET CLI tool that translates natural language instructions into git commands using AI providers (Claude, OpenAI, OpenRouter, Ollama). ## Installation @@ -181,13 +181,18 @@ git-agent config set **Available keys:** | Key | Description | |-----|-------------| -| `activeProvider` | Active provider (claude, openai, ollama, stub) | +| `activeProvider` | Active provider (claude, openai, openrouter, ollama, stub) | | `claude.apiKey` | Claude API key | | `claude.model` | Claude model name (default: claude-sonnet-4-20250514) | | `claude.baseUrl` | Claude API base URL | | `openai.apiKey` | OpenAI API key | | `openai.model` | OpenAI model name (default: gpt-4o) | | `openai.baseUrl` | OpenAI API base URL | +| `openrouter.apiKey` | OpenRouter API key | +| `openrouter.model` | OpenRouter model name (default: openai/gpt-4o) | +| `openrouter.baseUrl` | OpenRouter API base URL | +| `openrouter.siteName` | App name for OpenRouter attribution (default: GitAgent) | +| `openrouter.siteUrl` | Site URL for OpenRouter attribution | | `ollama.model` | Ollama model name (default: llama3.2) | | `ollama.baseUrl` | Ollama API base URL (default: http://localhost:11434) | @@ -195,6 +200,8 @@ git-agent config set ```bash git-agent config set claude.apiKey sk-ant-xxxxx git-agent config set openai.model gpt-4-turbo +git-agent config set openrouter.apiKey sk-or-xxxxx +git-agent config set openrouter.model anthropic/claude-3-opus git-agent config set ollama.baseUrl http://192.168.1.100:11434 ``` @@ -214,11 +221,12 @@ Set the active provider. git-agent config use ``` -**Available providers:** `claude`, `openai`, `ollama`, `stub` +**Available providers:** `claude`, `openai`, `openrouter`, `ollama`, `stub` ```bash git-agent config use claude git-agent config use openai +git-agent config use openrouter git-agent config use ollama ``` @@ -253,6 +261,7 @@ git-agent providers Available providers: - claude (active) - openai + - openrouter - ollama - stub @@ -551,6 +560,13 @@ Configuration is stored in `~/.git-agent/config.json`: "model": "gpt-4o", "baseUrl": "https://api.openai.com" }, + "openrouter": { + "apiKey": "sk-or-...", + "model": "openai/gpt-4o", + "baseUrl": "https://openrouter.ai", + "siteName": "GitAgent", + "siteUrl": "" + }, "ollama": { "model": "llama3.2", "baseUrl": "http://localhost:11434" @@ -559,6 +575,73 @@ Configuration is stored in `~/.git-agent/config.json`: } ``` +## Using OpenRouter + +[OpenRouter](https://openrouter.ai) is a unified API that provides access to 500+ AI models from multiple providers (OpenAI, Anthropic, Google, Meta, Mistral, and more) with a single API key. + +### Benefits + +- **Access to many models**: Use GPT-4, Claude, Gemini, Llama, Mistral, and hundreds more with one API key +- **Cost optimization**: OpenRouter automatically routes to the cheapest provider for each model +- **Fallback support**: Automatic failover if a provider is unavailable +- **OpenAI-compatible**: Uses the standard OpenAI API format + +### Setup + +1. **Get an API key** from [openrouter.ai/keys](https://openrouter.ai/keys) + +2. **Configure git-agent:** + ```bash + git-agent config set openrouter.apiKey sk-or-xxxxx + git-agent config use openrouter + ``` + +3. **Choose a model** (optional, default is `openai/gpt-4o`): + ```bash + # Use Claude via OpenRouter + git-agent config set openrouter.model anthropic/claude-3-opus + + # Use GPT-4 Turbo + git-agent config set openrouter.model openai/gpt-4-turbo + + # Use Mistral Large + git-agent config set openrouter.model mistralai/mistral-large + + # Use Llama 3.1 405B + git-agent config set openrouter.model meta-llama/llama-3.1-405b-instruct + ``` + +4. **Use git-agent:** + ```bash + git-agent run "show me the last 5 commits" + git-agent run "commit all changes" -x + ``` + +### Popular Models + +| Model | ID | +|-------|-----| +| GPT-4o | `openai/gpt-4o` | +| GPT-4 Turbo | `openai/gpt-4-turbo` | +| Claude 3 Opus | `anthropic/claude-3-opus` | +| Claude 3.5 Sonnet | `anthropic/claude-3.5-sonnet` | +| Gemini Pro 1.5 | `google/gemini-pro-1.5` | +| Mistral Large | `mistralai/mistral-large` | +| Llama 3.1 405B | `meta-llama/llama-3.1-405b-instruct` | + +See the full model list at [openrouter.ai/models](https://openrouter.ai/models). + +### App Attribution (Optional) + +OpenRouter supports app attribution headers for leaderboard rankings: + +```bash +git-agent config set openrouter.siteName "MyApp" +git-agent config set openrouter.siteUrl "https://myapp.example.com" +``` + +--- + ## Using Ollama for Local LLM [Ollama](https://ollama.ai) allows you to run LLMs locally without any API keys or cloud dependencies. @@ -668,6 +751,7 @@ Cache status is displayed when available: │ ├── ProviderConfigs.cs # Provider config container │ ├── ClaudeConfig.cs # Claude provider settings │ ├── OpenAIConfig.cs # OpenAI provider settings +│ ├── OpenRouterConfig.cs # OpenRouter provider settings │ └── OllamaConfig.cs # Ollama provider settings ├── Models/ │ ├── GeneratedCommand.cs # Generated command with risk level @@ -678,6 +762,7 @@ Cache status is displayed when available: │ ├── ProviderFactory.cs # Provider factory with DI │ ├── ClaudeProvider.cs # Anthropic Claude implementation │ ├── OpenAIProvider.cs # OpenAI implementation +│ ├── OpenRouterProvider.cs # OpenRouter multi-model implementation │ ├── OllamaProvider.cs # Ollama local LLM implementation │ └── StubProvider.cs # Testing stub provider ├── Services/ diff --git a/Services/CompletionGenerator.cs b/Services/CompletionGenerator.cs index 335b9a3..baaefb4 100644 --- a/Services/CompletionGenerator.cs +++ b/Services/CompletionGenerator.cs @@ -65,15 +65,15 @@ local cur prev opts commands return 0 ;; use|--provider|-p) - COMPREPLY=( $(compgen -W "claude openai ollama stub" -- ${cur}) ) + COMPREPLY=( $(compgen -W "claude openai openrouter ollama stub" -- ${cur}) ) return 0 ;; set) - COMPREPLY=( $(compgen -W "activeProvider claude.apiKey claude.model claude.baseUrl openai.apiKey openai.model openai.baseUrl ollama.model ollama.baseUrl" -- ${cur}) ) + COMPREPLY=( $(compgen -W "activeProvider claude.apiKey claude.model claude.baseUrl openai.apiKey openai.model openai.baseUrl openrouter.apiKey openrouter.model openrouter.baseUrl openrouter.siteName openrouter.siteUrl ollama.model ollama.baseUrl" -- ${cur}) ) return 0 ;; get) - COMPREPLY=( $(compgen -W "activeProvider claude.apiKey claude.model claude.baseUrl openai.apiKey openai.model openai.baseUrl ollama.model ollama.baseUrl" -- ${cur}) ) + COMPREPLY=( $(compgen -W "activeProvider claude.apiKey claude.model claude.baseUrl openai.apiKey openai.model openai.baseUrl openrouter.apiKey openrouter.model openrouter.baseUrl openrouter.siteName openrouter.siteUrl ollama.model ollama.baseUrl" -- ${cur}) ) return 0 ;; *) @@ -122,7 +122,7 @@ public string GenerateZshCompletion() 'clear:Clear all cached HTTP responses' 'path:Show cache directory path' ) - providers=(claude openai ollama stub) + providers=(claude openai openrouter ollama stub) _arguments -C \ '1: :->command' \ @@ -190,9 +190,10 @@ public string GeneratePowerShellCompletion() 'serve' = @('--port', '-p') } - $providers = @('claude', 'openai', 'ollama', 'stub') + $providers = @('claude', 'openai', 'openrouter', 'ollama', 'stub') $configKeys = @('activeProvider', 'claude.apiKey', 'claude.model', 'claude.baseUrl', 'openai.apiKey', 'openai.model', 'openai.baseUrl', + 'openrouter.apiKey', 'openrouter.model', 'openrouter.baseUrl', 'openrouter.siteName', 'openrouter.siteUrl', 'ollama.model', 'ollama.baseUrl') $elements = $commandAst.CommandElements @@ -259,7 +260,7 @@ public string GenerateFishCompletion() # run command options complete -c git-agent -n "__fish_seen_subcommand_from run" -s x -l exec -d "Execute the resulting commands" complete -c git-agent -n "__fish_seen_subcommand_from run" -s i -l interactive -d "Confirm each step" - complete -c git-agent -n "__fish_seen_subcommand_from run" -s p -l provider -d "Override the active provider" -xa "claude openai ollama stub" + complete -c git-agent -n "__fish_seen_subcommand_from run" -s p -l provider -d "Override the active provider" -xa "claude openai openrouter ollama stub" complete -c git-agent -n "__fish_seen_subcommand_from run" -l no-cache -d "Skip cache" # config subcommands @@ -271,10 +272,10 @@ public string GenerateFishCompletion() complete -c git-agent -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from show set get use path reset" -a "reset" -d "Reset to defaults" # config use providers - complete -c git-agent -n "__fish_seen_subcommand_from use" -a "claude openai ollama stub" -d "Provider" + complete -c git-agent -n "__fish_seen_subcommand_from use" -a "claude openai openrouter ollama stub" -d "Provider" # config set/get keys - complete -c git-agent -n "__fish_seen_subcommand_from set get" -a "activeProvider claude.apiKey claude.model claude.baseUrl openai.apiKey openai.model openai.baseUrl ollama.model ollama.baseUrl" + complete -c git-agent -n "__fish_seen_subcommand_from set get" -a "activeProvider claude.apiKey claude.model claude.baseUrl openai.apiKey openai.model openai.baseUrl openrouter.apiKey openrouter.model openrouter.baseUrl openrouter.siteName openrouter.siteUrl ollama.model ollama.baseUrl" # cache subcommands complete -c git-agent -n "__fish_seen_subcommand_from cache; and not __fish_seen_subcommand_from clear path" -a "clear" -d "Clear all cached responses" @@ -284,7 +285,7 @@ public string GenerateFishCompletion() complete -c git-agent -n "__fish_seen_subcommand_from conflicts" -s s -l suggest -d "Show AI-suggested resolutions" complete -c git-agent -n "__fish_seen_subcommand_from conflicts" -s r -l resolve -d "Interactively resolve conflicts" complete -c git-agent -n "__fish_seen_subcommand_from conflicts" -s a -l apply -d "Auto-apply AI-suggested resolutions" - complete -c git-agent -n "__fish_seen_subcommand_from conflicts" -s p -l provider -d "Override the active provider" -xa "claude openai ollama stub" + complete -c git-agent -n "__fish_seen_subcommand_from conflicts" -s p -l provider -d "Override the active provider" -xa "claude openai openrouter ollama stub" # completions shells complete -c git-agent -n "__fish_seen_subcommand_from completions" -a "bash zsh powershell fish" -d "Shell type" diff --git a/Services/ConfigManager.cs b/Services/ConfigManager.cs index 0b74d72..9292654 100644 --- a/Services/ConfigManager.cs +++ b/Services/ConfigManager.cs @@ -12,6 +12,8 @@ public interface IConfigManager public class ConfigManager : IConfigManager { + private const int CurrentConfigVersion = 2; + public string ConfigPath { get; } public ConfigManager() @@ -30,7 +32,7 @@ public async Task LoadAsync() { if (!File.Exists(ConfigPath)) { - var defaultConfig = new GitAgentConfig(); + var defaultConfig = new GitAgentConfig { ConfigVersion = CurrentConfigVersion }; await SaveAsync(defaultConfig); return defaultConfig; } @@ -38,13 +40,21 @@ public async Task LoadAsync() try { var json = await File.ReadAllTextAsync(ConfigPath); - return JsonSerializer.Deserialize(json, ConfigJsonContext.Default.GitAgentConfig) ?? new GitAgentConfig(); + var config = JsonSerializer.Deserialize(json, ConfigJsonContext.Default.GitAgentConfig) ?? new GitAgentConfig(); + + if (config.ConfigVersion < CurrentConfigVersion) + { + config.ConfigVersion = CurrentConfigVersion; + await SaveAsync(config); + } + + return config; } catch (Exception ex) { await Console.Error.WriteLineAsync($"Warning: Failed to load config from {ConfigPath}: {ex.Message}"); await Console.Error.WriteLineAsync("Using default configuration."); - return new GitAgentConfig(); + return new GitAgentConfig {ConfigVersion = CurrentConfigVersion }; } } diff --git a/tests/GitAgentCli.Tests/Configuration/ConfigManagerTests.cs b/tests/GitAgentCli.Tests/Configuration/ConfigManagerTests.cs index 8c259dc..7d5e3fd 100644 --- a/tests/GitAgentCli.Tests/Configuration/ConfigManagerTests.cs +++ b/tests/GitAgentCli.Tests/Configuration/ConfigManagerTests.cs @@ -152,10 +152,91 @@ public async Task SaveAsync_CreatesIndentedJson() json.Should().Contain("\n"); json.Should().Contain(" "); } + + [Fact] + public async Task LoadAsync_WithOldConfigVersion_MigratesAndSaves() + { + var oldConfig = """ + { + "activeProvider": "claude", + "providers": { + "claude": { + "apiKey": "test-key", + "model": "claude-3-opus", + "baseUrl": "https://api.anthropic.com" + }, + "openai": { + "apiKey": "", + "model": "gpt-4o", + "baseUrl": "https://api.openai.com" + }, + "ollama": { + "model": "llama3.2", + "baseUrl": "http://localhost:11434" + } + } + } + """; + await File.WriteAllTextAsync(_testConfigPath, oldConfig); + var manager = CreateManager(); + + var config = await manager.LoadAsync(); + + config.ActiveProvider.Should().Be("claude"); + config.Providers.Claude.ApiKey.Should().Be("test-key"); + + config.Providers.OpenRouter.Should().NotBeNull(); + config.Providers.OpenRouter.Model.Should().Be("openai/gpt-4o"); + config.Providers.OpenRouter.BaseUrl.Should().Be("https://openrouter.ai"); + + config.ConfigVersion.Should().BeGreaterThanOrEqualTo(2); + } + + [Fact] + public async Task LoadAsync_PreservesExistingProviderSettings_DuringMigration() + { + var oldConfig = """ + { + "activeProvider": "openai", + "providers": { + "claude": { + "apiKey": "my-claude-key", + "model": "claude-3-5-sonnet", + "baseUrl": "https://custom-claude.com" + }, + "openai": { + "apiKey": "my-openai-key", + "model": "gpt-4-turbo", + "baseUrl": "https://custom-openai.com" + }, + "ollama": { + "model": "mistral", + "baseUrl": "http://192.168.1.100:11434" + } + } + } + """; + await File.WriteAllTextAsync(_testConfigPath, oldConfig); + var manager = CreateManager(); + + var config = await manager.LoadAsync(); + + config.ActiveProvider.Should().Be("openai"); + config.Providers.Claude.ApiKey.Should().Be("my-claude-key"); + config.Providers.Claude.Model.Should().Be("claude-3-5-sonnet"); + config.Providers.Claude.BaseUrl.Should().Be("https://custom-claude.com"); + config.Providers.OpenAI.ApiKey.Should().Be("my-openai-key"); + config.Providers.OpenAI.Model.Should().Be("gpt-4-turbo"); + config.Providers.OpenAI.BaseUrl.Should().Be("https://custom-openai.com"); + config.Providers.Ollama.Model.Should().Be("mistral"); + config.Providers.Ollama.BaseUrl.Should().Be("http://192.168.1.100:11434"); + } } internal class TestableConfigManager : IConfigManager { + private const int CurrentConfigVersion = 2; + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, @@ -178,7 +259,7 @@ public async Task LoadAsync() { if (!File.Exists(ConfigPath)) { - var defaultConfig = new GitAgentConfig(); + var defaultConfig = new GitAgentConfig { ConfigVersion = CurrentConfigVersion }; await SaveAsync(defaultConfig); return defaultConfig; } @@ -186,11 +267,19 @@ public async Task LoadAsync() try { var json = await File.ReadAllTextAsync(ConfigPath); - return JsonSerializer.Deserialize(json, JsonOptions) ?? new GitAgentConfig(); + var config = JsonSerializer.Deserialize(json, JsonOptions) ?? new GitAgentConfig(); + + if (config.ConfigVersion < CurrentConfigVersion) + { + config.ConfigVersion = CurrentConfigVersion; + await SaveAsync(config); + } + + return config; } catch { - return new GitAgentConfig(); + return new GitAgentConfig { ConfigVersion = CurrentConfigVersion }; } } diff --git a/tests/GitAgentCli.Tests/Providers/OpenRouterProviderTests.cs b/tests/GitAgentCli.Tests/Providers/OpenRouterProviderTests.cs new file mode 100644 index 0000000..a94868c --- /dev/null +++ b/tests/GitAgentCli.Tests/Providers/OpenRouterProviderTests.cs @@ -0,0 +1,162 @@ +using FluentAssertions; +using GitAgent.Configuration; +using GitAgent.Models; +using GitAgent.Providers; +using GitAgent.Services; +using NSubstitute; + +namespace GitAgentCli.Tests.Providers; + +public class OpenRouterProviderTests +{ + private readonly OpenRouterConfig _config; + private readonly IPromptBuilder _promptBuilder; + + public OpenRouterProviderTests() + { + _config = new OpenRouterConfig + { + ApiKey = "test-api-key", + Model = "openai/gpt-4o", + BaseUrl = "https://openrouter.ai", + SiteName = "TestApp", + SiteUrl = "https://test.example.com" + }; + + _promptBuilder = Substitute.For(); + + _promptBuilder.BuildCommandUserPrompt(Arg.Any(), Arg.Any()).Returns("Test prompt"); + _promptBuilder.BuildConflictUserPrompt(Arg.Any(), Arg.Any(), Arg.Any()).Returns("Test conflict prompt"); + } + + [Fact] + public void Constructor_WithValidConfig_CreatesInstance() + { + var cachingHandler = new CachingHttpHandler(); + + var provider = new OpenRouterProvider(_config, _promptBuilder, cachingHandler); + + provider.Should().NotBeNull(); + } + + [Fact] + public async Task GenerateGitCommands_WithoutApiKey_ThrowsInvalidOperationException() + { + var configWithoutKey = new OpenRouterConfig { ApiKey = "" }; + var cachingHandler = new CachingHttpHandler(); + var provider = new OpenRouterProvider(configWithoutKey, _promptBuilder, cachingHandler); + + var act = async () => await provider.GenerateGitCommands("test instruction", new RepoContext()); + + await act.Should().ThrowAsync().WithMessage("*OpenRouter API key not configured*"); + } + + [Fact] + public async Task GenerateGitCommands_WithWhitespaceApiKey_ThrowsInvalidOperationException() + { + var configWithWhitespace = new OpenRouterConfig { ApiKey = " " }; + var cachingHandler = new CachingHttpHandler(); + var provider = new OpenRouterProvider(configWithWhitespace, _promptBuilder, cachingHandler); + + var act = async () => await provider.GenerateGitCommands("test instruction", new RepoContext()); + + await act.Should().ThrowAsync().WithMessage("*OpenRouter API key not configured*"); + } + + [Fact] + public async Task GenerateConflictResolution_WithoutApiKey_ThrowsInvalidOperationException() + { + var configWithoutKey = new OpenRouterConfig { ApiKey = "" }; + var cachingHandler = new CachingHttpHandler(); + var provider = new OpenRouterProvider(configWithoutKey, _promptBuilder, cachingHandler); + var conflict = new ConflictSection { OursContent = "ours", TheirsContent = "theirs" }; + + var act = async () => await provider.GenerateConflictResolution(conflict, "test.cs", ".cs"); + + await act.Should().ThrowAsync().WithMessage("*OpenRouter API key not configured*"); + } + + [Fact] + public async Task GenerateGitCommands_WithNullApiKey_ThrowsInvalidOperationException() + { + var configWithNull = new OpenRouterConfig { ApiKey = null! }; + var cachingHandler = new CachingHttpHandler(); + var provider = new OpenRouterProvider(configWithNull, _promptBuilder, cachingHandler); + + var act = async () => await provider.GenerateGitCommands("test instruction", new RepoContext()); + + await act.Should().ThrowAsync().WithMessage("*OpenRouter API key not configured*"); + } +} + +public class OpenRouterConfigTests +{ + [Fact] + public void DefaultModel_IsOpenAIGpt4o() + { + var config = new OpenRouterConfig(); + + config.Model.Should().Be("openai/gpt-4o"); + } + + [Fact] + public void DefaultBaseUrl_IsOpenRouterAi() + { + var config = new OpenRouterConfig(); + + config.BaseUrl.Should().Be("https://openrouter.ai"); + } + + [Fact] + public void DefaultSiteName_IsGitAgent() + { + var config = new OpenRouterConfig(); + + config.SiteName.Should().Be("GitAgent"); + } + + [Fact] + public void DefaultSiteUrl_IsEmpty() + { + var config = new OpenRouterConfig(); + + config.SiteUrl.Should().BeEmpty(); + } + + [Fact] + public void DefaultApiKey_IsEmpty() + { + var config = new OpenRouterConfig(); + + config.ApiKey.Should().BeEmpty(); + } + + [Fact] + public void CanSetCustomModel() + { + var config = new OpenRouterConfig { Model = "anthropic/claude-3-opus" }; + + config.Model.Should().Be("anthropic/claude-3-opus"); + } + + [Fact] + public void CanSetCustomBaseUrl() + { + var config = new OpenRouterConfig { BaseUrl = "https://custom.api.com" }; + + config.BaseUrl.Should().Be("https://custom.api.com"); + } + + [Fact] + public void CanSetSiteMetadata() + { + var config = new OpenRouterConfig + { + SiteName = "MyApp", + SiteUrl = "https://myapp.example.com" + }; + + config.SiteName.Should().Be("MyApp"); + config.SiteUrl.Should().Be("https://myapp.example.com"); + } +} diff --git a/tests/GitAgentCli.Tests/Providers/ProviderFactoryTests.cs b/tests/GitAgentCli.Tests/Providers/ProviderFactoryTests.cs index e919fd7..d5f2889 100644 --- a/tests/GitAgentCli.Tests/Providers/ProviderFactoryTests.cs +++ b/tests/GitAgentCli.Tests/Providers/ProviderFactoryTests.cs @@ -30,6 +30,7 @@ public void AvailableProviders_ContainsExpectedProviders() { _factory.AvailableProviders.Should().Contain("claude"); _factory.AvailableProviders.Should().Contain("openai"); + _factory.AvailableProviders.Should().Contain("openrouter"); _factory.AvailableProviders.Should().Contain("ollama"); _factory.AvailableProviders.Should().Contain("stub"); } @@ -47,6 +48,7 @@ public async Task CreateProviderAsync_WithNoArgument_UsesActiveProviderFromConfi [Theory] [InlineData("claude", typeof(ClaudeProvider))] [InlineData("openai", typeof(OpenAIProvider))] + [InlineData("openrouter", typeof(OpenRouterProvider))] [InlineData("ollama", typeof(OllamaProvider))] [InlineData("stub", typeof(StubProvider))] public async Task CreateProviderAsync_WithValidName_ReturnsCorrectType(string name, Type expectedType) @@ -60,6 +62,8 @@ public async Task CreateProviderAsync_WithValidName_ReturnsCorrectType(string na [InlineData("Claude")] [InlineData("OPENAI")] [InlineData("OpenAI")] + [InlineData("OPENROUTER")] + [InlineData("OpenRouter")] [InlineData("OLLAMA")] [InlineData("Ollama")] [InlineData("STUB")] @@ -89,6 +93,7 @@ public async Task CreateProviderAsync_WithUnknownProvider_IncludesAvailableProvi var exception = await act.Should().ThrowAsync(); exception.Which.Message.Should().Contain("claude"); exception.Which.Message.Should().Contain("openai"); + exception.Which.Message.Should().Contain("openrouter"); exception.Which.Message.Should().Contain("ollama"); exception.Which.Message.Should().Contain("stub"); } @@ -135,4 +140,35 @@ public async Task CreateProviderAsync_WithOpenAI_UsesStandardPromptBuilder() var provider = await _factory.CreateProviderAsync("openai"); provider.Should().BeOfType(); } + + [Fact] + public async Task CreateProviderAsync_WithOpenRouter_UsesStandardPromptBuilder() + { + var provider = await _factory.CreateProviderAsync("openrouter"); + provider.Should().BeOfType(); + } + + [Fact] + public async Task CreateProviderAsync_WithOpenRouterConfig_UsesConfigValues() + { + var config = new GitAgentConfig + { + Providers = new ProviderConfigs + { + OpenRouter = new OpenRouterConfig + { + ApiKey = "test-openrouter-key", + Model = "anthropic/claude-3-opus", + BaseUrl = "https://custom-openrouter.example.com", + SiteName = "CustomApp", + SiteUrl = "https://custom.example.com" + } + } + }; + _configManager.LoadAsync().Returns(config); + + var provider = await _factory.CreateProviderAsync("openrouter"); + + provider.Should().BeOfType(); + } } From 1085675297fde1242d44e0c9135bac6ed7f03164 Mon Sep 17 00:00:00 2001 From: George Marcus Date: Sun, 14 Dec 2025 17:26:31 -0500 Subject: [PATCH 2/3] Refactor Program.cs to enhance service configuration and logging setup; update FolderProfile.pubxml for publishing settings --- Program.cs | 39 +++++++++++-------- .../PublishProfiles/FolderProfile.pubxml | 6 +++ 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/Program.cs b/Program.cs index 887ef8c..38013e3 100644 --- a/Program.cs +++ b/Program.cs @@ -6,25 +6,32 @@ using GitAgent.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; var builder = new CommandLineBuilder(CommandBuilderExtensions.BuildRootCommand()) - .UseHost(_ => Host.CreateDefaultBuilder(args), hostBuilder => - { - hostBuilder.ConfigureServices((_, services) => + .UseHost( + _ => Host.CreateDefaultBuilder(args) + .ConfigureLogging(logging => + { + logging.ClearProviders(); + }), + hostBuilder => { - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - }); - }) + hostBuilder.ConfigureServices((_, services) => + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + }); + }) .UseDefaults(); return await builder.Build().InvokeAsync(args); diff --git a/Properties/PublishProfiles/FolderProfile.pubxml b/Properties/PublishProfiles/FolderProfile.pubxml index 8534b0c..1b96066 100644 --- a/Properties/PublishProfiles/FolderProfile.pubxml +++ b/Properties/PublishProfiles/FolderProfile.pubxml @@ -7,5 +7,11 @@ D:\Private\C#Git\Release FileSystem <_TargetId>Folder + net10.0 + win-x64 + true + true + false + true \ No newline at end of file From 9bfa9216110eae4e01a7bc4e1068d3e7d94521d3 Mon Sep 17 00:00:00 2001 From: George Marcus Date: Sun, 14 Dec 2025 17:28:04 -0500 Subject: [PATCH 3/3] Update publish directory path in FolderProfile.pubxml to reflect correct project structure --- Properties/PublishProfiles/FolderProfile.pubxml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Properties/PublishProfiles/FolderProfile.pubxml b/Properties/PublishProfiles/FolderProfile.pubxml index 1b96066..99dedd4 100644 --- a/Properties/PublishProfiles/FolderProfile.pubxml +++ b/Properties/PublishProfiles/FolderProfile.pubxml @@ -4,7 +4,7 @@ Release Any CPU - D:\Private\C#Git\Release + D:\Private\git-agent\Release FileSystem <_TargetId>Folder net10.0