diff --git a/.copilot_here/runtime.conf b/.copilot_here/runtime.conf new file mode 100644 index 0000000..4d18c3e --- /dev/null +++ b/.copilot_here/runtime.conf @@ -0,0 +1 @@ +auto \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0e57dea..9f0d4e7 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,7 +2,7 @@ ## Project Overview -This is a secure, portable Docker environment for running the GitHub Copilot CLI. It provides sandboxed execution with automatic authentication, token validation, and multiple specialized image variants for different development scenarios. +This is a secure, portable container environment for running the GitHub Copilot CLI. It provides sandboxed execution with automatic authentication, token validation, and multiple specialized image variants for different development scenarios. Supports Docker, OrbStack, and Podman container runtimes with automatic detection. ## Code Repos/Name locations part of this "platform" @@ -88,7 +88,7 @@ grep "BuildDate = " app/Infrastructure/BuildInfo.cs - **Base OS**: Debian (node:20-slim) - **Runtime**: Node.js 20 - **CLI Tool**: GitHub Copilot CLI (@github/copilot) -- **Container**: Docker (Multi-arch: AMD64 & ARM64) +- **Container**: Docker, OrbStack, or Podman (Multi-arch: AMD64 & ARM64) - **CI/CD**: GitHub Actions - **Registry**: GitHub Container Registry (ghcr.io) @@ -99,7 +99,8 @@ grep "BuildDate = " app/Infrastructure/BuildInfo.cs The core CLI is a .NET 10 Native AOT application that: - Validates GitHub authentication and token scopes -- Manages Docker image selection and pulling +- Detects and manages container runtime (Docker, OrbStack, or Podman) +- Manages container image selection and pulling - Configures mounts, airlock, and container settings - Builds and executes Docker Compose configurations - Handles both safe mode (confirmation required) and YOLO mode (auto-approve) @@ -108,8 +109,10 @@ The core CLI is a .NET 10 Native AOT application that: - `Program.cs` - Entry point, argument parsing, command routing - `Commands/Run/RunCommand.cs` - Main execution logic +- `Commands/Runtime/` - Runtime configuration commands - `Infrastructure/GitHubAuth.cs` - Token validation and scope checking -- `Infrastructure/DockerRunner.cs` - Docker process management +- `Infrastructure/ContainerRunner.cs` - Container process management +- `Infrastructure/ContainerRuntimeConfig.cs` - Runtime detection and configuration - `Infrastructure/AirlockRunner.cs` - Network proxy mode - `Infrastructure/DebugLogger.cs` - Debug logging infrastructure @@ -168,6 +171,40 @@ Extends the Playwright image with: **Use Case**: .NET development, building and testing .NET applications with web testing capabilities +## Container Runtime Support + +The CLI supports multiple container runtimes with automatic detection: + +### Supported Runtimes + +1. **Docker** - Standard Docker Engine or Docker Desktop +2. **OrbStack** - Automatically detected when Docker context is set to OrbStack +3. **Podman** - Open-source alternative with rootless support + +### Runtime Detection + +- **Auto-detection**: Tries Docker first, then Podman +- **Configuration**: Users can override via config files or commands +- **Per-project**: Different projects can use different runtimes + +### Configuration System + +**Priority order:** +1. Local config (`.copilot_here/runtime.conf`) +2. Global config (`~/.config/copilot_here/runtime.conf`) +3. Auto-detection + +**Commands:** +- `--show-runtime` - Display current runtime configuration +- `--list-runtimes` - List all available runtimes on the system +- `--set-runtime ` - Set local runtime preference +- `--set-runtime-global ` - Set global runtime preference + +**Implementation:** +- `Infrastructure/ContainerRuntimeConfig.cs` - Runtime detection and configuration +- `Commands/Runtime/` - Runtime management commands +- All container operations use `ContainerRunner` (formerly `DockerRunner`) + ## Airlock Network Proxy Airlock is a security feature that provides network request monitoring and control: @@ -195,7 +232,7 @@ Airlock is a security feature that provides network request monitoring and contr **Technical implementation:** - Proxy: mitmproxy-based container (`docker/Dockerfile.proxy`) -- Runner: `Infrastructure/AirlockRunner.cs` orchestrates Docker Compose setup +- Runner: `Infrastructure/AirlockRunner.cs` orchestrates container compose setup - Certificates: CA cert shared between proxy and app containers - Logging: Network activity logged to `.copilot_here/logs/` @@ -378,6 +415,10 @@ dotnet test - Follow .NET naming conventions (PascalCase for public members) - Add XML documentation comments for public APIs - Use AOT-compatible patterns (avoid reflection, dynamic code generation) +- **NEVER use `[Obsolete]` attribute** - just remove or refactor code instead + - Obsolete attributes clutter the codebase and create compiler warnings + - If something needs to change, change it - don't mark it as obsolete + - If you need to maintain backward compatibility, keep both approaches working ### Configuration Priority @@ -474,11 +515,13 @@ var deduplicated = RemoveDuplicates(items); │ │ ├── Run/ # Main run command │ │ ├── Images/ # Image management │ │ ├── Mounts/ # Mount configuration + │ │ ├── Runtime/ # Runtime configuration │ │ └── Airlock/ # Network proxy │ ├── Infrastructure/ # Core services │ │ ├── AppPaths.cs # Path resolution │ │ ├── GitHubAuth.cs # Authentication - │ │ ├── DockerRunner.cs # Docker management + │ │ ├── ContainerRunner.cs # Container management + │ │ ├── ContainerRuntimeConfig.cs # Runtime detection │ │ ├── AirlockRunner.cs # Proxy mode │ │ └── DebugLogger.cs # Debug logging │ ├── Program.cs # Entry point diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4cd9599..5efcd5b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -385,7 +385,7 @@ jobs: uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 with: context: . - file: ./docker/Dockerfile.base + file: ./docker/tools/github-copilot/Dockerfile platforms: linux/amd64,linux/arm64 push: false load: false @@ -412,12 +412,13 @@ jobs: uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 with: context: . - file: ./docker/Dockerfile.base + file: ./docker/tools/github-copilot/Dockerfile platforms: linux/amd64,linux/arm64 push: true tags: | ghcr.io/${{ steps.repo.outputs.name }}:latest - ghcr.io/${{ steps.repo.outputs.name }}:sha-${{ github.sha }} + ghcr.io/${{ steps.repo.outputs.name }}:copilot-latest + ghcr.io/${{ steps.repo.outputs.name }}:copilot-sha-${{ github.sha }} labels: project=copilot_here build-args: | COPILOT_VERSION=${{ steps.versions.outputs.copilot_version }} @@ -436,25 +437,25 @@ jobs: include: - variant: rust dockerfile: ./docker/variants/Dockerfile.rust - build_args: "BASE_IMAGE_TAG=sha-${{ github.sha }}" + build_args: "BASE_IMAGE_TAG=copilot-sha-${{ github.sha }}" - variant: golang dockerfile: ./docker/variants/Dockerfile.golang - build_args: "BASE_IMAGE_TAG=sha-${{ github.sha }}" + build_args: "BASE_IMAGE_TAG=copilot-sha-${{ github.sha }}" - variant: playwright dockerfile: ./docker/variants/Dockerfile.playwright - build_args: "BASE_IMAGE_TAG=sha-${{ github.sha }}\nPLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" + build_args: "BASE_IMAGE_TAG=copilot-sha-${{ github.sha }}\nPLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" - variant: dotnet-8 dockerfile: ./docker/variants/Dockerfile.dotnet8 - build_args: "BASE_IMAGE_TAG=sha-${{ github.sha }}\nDOTNET_SDK_8_VERSION=$DOTNET_8_VERSION" + build_args: "BASE_IMAGE_TAG=copilot-sha-${{ github.sha }}\nDOTNET_SDK_8_VERSION=$DOTNET_8_VERSION" - variant: dotnet-9 dockerfile: ./docker/variants/Dockerfile.dotnet9 - build_args: "BASE_IMAGE_TAG=sha-${{ github.sha }}\nDOTNET_SDK_9_VERSION=$DOTNET_9_VERSION" + build_args: "BASE_IMAGE_TAG=copilot-sha-${{ github.sha }}\nDOTNET_SDK_9_VERSION=$DOTNET_9_VERSION" - variant: dotnet-10 dockerfile: ./docker/variants/Dockerfile.dotnet10 - build_args: "BASE_IMAGE_TAG=sha-${{ github.sha }}\nDOTNET_SDK_10_VERSION=$DOTNET_10_VERSION" + build_args: "BASE_IMAGE_TAG=copilot-sha-${{ github.sha }}\nDOTNET_SDK_10_VERSION=$DOTNET_10_VERSION" - variant: dotnet dockerfile: ./docker/variants/Dockerfile.dotnet - build_args: "BASE_IMAGE_TAG=sha-${{ github.sha }}\nDOTNET_SDK_8_VERSION=$DOTNET_8_VERSION\nDOTNET_SDK_9_VERSION=$DOTNET_9_VERSION\nDOTNET_SDK_10_VERSION=$DOTNET_10_VERSION" + build_args: "BASE_IMAGE_TAG=copilot-sha-${{ github.sha }}\nDOTNET_SDK_8_VERSION=$DOTNET_8_VERSION\nDOTNET_SDK_9_VERSION=$DOTNET_9_VERSION\nDOTNET_SDK_10_VERSION=$DOTNET_10_VERSION" steps: - name: Checkout repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -494,7 +495,8 @@ jobs: push: true tags: | ghcr.io/${{ needs.build-base.outputs.repo_name }}:${{ matrix.variant }} - ghcr.io/${{ needs.build-base.outputs.repo_name }}:${{ matrix.variant }}-sha-${{ github.sha }} + ghcr.io/${{ needs.build-base.outputs.repo_name }}:copilot-${{ matrix.variant }} + ghcr.io/${{ needs.build-base.outputs.repo_name }}:copilot-${{ matrix.variant }}-sha-${{ github.sha }} labels: project=copilot_here build-args: ${{ steps.args.outputs.build_args }} cache-from: type=registry,ref=ghcr.io/${{ needs.build-base.outputs.repo_name }}:${{ matrix.variant }},mode=max @@ -512,10 +514,10 @@ jobs: include: - variant: dotnet-playwright dockerfile: ./docker/compound-variants/Dockerfile.dotnet-playwright - build_args: "DOTNET_IMAGE_TAG=dotnet-sha-${{ github.sha }}\nPLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" + build_args: "DOTNET_IMAGE_TAG=copilot-dotnet-sha-${{ github.sha }}\nPLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" - variant: dotnet-rust dockerfile: ./docker/compound-variants/Dockerfile.dotnet-rust - build_args: "DOTNET_IMAGE_TAG=dotnet-sha-${{ github.sha }}" + build_args: "DOTNET_IMAGE_TAG=copilot-dotnet-sha-${{ github.sha }}" steps: - name: Checkout repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -551,7 +553,8 @@ jobs: push: true tags: | ghcr.io/${{ needs.build-base.outputs.repo_name }}:${{ matrix.variant }} - ghcr.io/${{ needs.build-base.outputs.repo_name }}:${{ matrix.variant }}-sha-${{ github.sha }} + ghcr.io/${{ needs.build-base.outputs.repo_name }}:copilot-${{ matrix.variant }} + ghcr.io/${{ needs.build-base.outputs.repo_name }}:copilot-${{ matrix.variant }}-sha-${{ github.sha }} labels: project=copilot_here build-args: ${{ steps.args.outputs.build_args }} cache-from: type=registry,ref=ghcr.io/${{ needs.build-base.outputs.repo_name }}:${{ matrix.variant }},mode=max @@ -585,17 +588,20 @@ jobs: if [[ "${{ needs.build-base.outputs.push_needed }}" == "true" ]]; then echo "**Images Published:**" >> $GITHUB_STEP_SUMMARY - echo "- \`ghcr.io/${{ needs.build-base.outputs.repo_name }}:latest\`" >> $GITHUB_STEP_SUMMARY + echo "- \`ghcr.io/${{ needs.build-base.outputs.repo_name }}:latest\` (legacy)" >> $GITHUB_STEP_SUMMARY + echo "- \`ghcr.io/${{ needs.build-base.outputs.repo_name }}:copilot-latest\`" >> $GITHUB_STEP_SUMMARY echo "- \`ghcr.io/${{ needs.build-base.outputs.repo_name }}:proxy\`" >> $GITHUB_STEP_SUMMARY - echo "- \`ghcr.io/${{ needs.build-base.outputs.repo_name }}:rust\`" >> $GITHUB_STEP_SUMMARY - echo "- \`ghcr.io/${{ needs.build-base.outputs.repo_name }}:golang\`" >> $GITHUB_STEP_SUMMARY - echo "- \`ghcr.io/${{ needs.build-base.outputs.repo_name }}:playwright\`" >> $GITHUB_STEP_SUMMARY - echo "- \`ghcr.io/${{ needs.build-base.outputs.repo_name }}:dotnet\`" >> $GITHUB_STEP_SUMMARY - echo "- \`ghcr.io/${{ needs.build-base.outputs.repo_name }}:dotnet-8\`" >> $GITHUB_STEP_SUMMARY - echo "- \`ghcr.io/${{ needs.build-base.outputs.repo_name }}:dotnet-9\`" >> $GITHUB_STEP_SUMMARY - echo "- \`ghcr.io/${{ needs.build-base.outputs.repo_name }}:dotnet-10\`" >> $GITHUB_STEP_SUMMARY - echo "- \`ghcr.io/${{ needs.build-base.outputs.repo_name }}:dotnet-playwright\`" >> $GITHUB_STEP_SUMMARY - echo "- \`ghcr.io/${{ needs.build-base.outputs.repo_name }}:dotnet-rust\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Variants (with copilot- prefix):**" >> $GITHUB_STEP_SUMMARY + echo "- \`rust\`, \`copilot-rust\`" >> $GITHUB_STEP_SUMMARY + echo "- \`golang\`, \`copilot-golang\`" >> $GITHUB_STEP_SUMMARY + echo "- \`playwright\`, \`copilot-playwright\`" >> $GITHUB_STEP_SUMMARY + echo "- \`dotnet\`, \`copilot-dotnet\`" >> $GITHUB_STEP_SUMMARY + echo "- \`dotnet-8\`, \`copilot-dotnet-8\`" >> $GITHUB_STEP_SUMMARY + echo "- \`dotnet-9\`, \`copilot-dotnet-9\`" >> $GITHUB_STEP_SUMMARY + echo "- \`dotnet-10\`, \`copilot-dotnet-10\`" >> $GITHUB_STEP_SUMMARY + echo "- \`dotnet-playwright\`, \`copilot-dotnet-playwright\`" >> $GITHUB_STEP_SUMMARY + echo "- \`dotnet-rust\`, \`copilot-dotnet-rust\`" >> $GITHUB_STEP_SUMMARY else echo "**Images:** No changes, skipped publishing" >> $GITHUB_STEP_SUMMARY fi diff --git a/README.md b/README.md index b1f4a17..3c705a2 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ The `copilot_here` shell function is a lightweight wrapper around a Docker conta ## ✅ Prerequisites Before you start, make sure you have the following installed and configured on your machine: -- [Docker Desktop](https://www.docker.com/products/docker-desktop/) (or Docker Engine on Linux). +- **Container Runtime**: [Docker Desktop](https://www.docker.com/products/docker-desktop/), [OrbStack](https://orbstack.dev/), or [Podman](https://podman.io/). The system will auto-detect your available runtime. - The [GitHub CLI (`gh`)](https://cli.github.com/). - You must be logged in to the GitHub CLI. You can check by running `gh auth status`. Your token **must** have the `copilot` and `read:packages` scopes. If it doesn't, run `gh auth refresh -h github.com -s copilot,read:packages` to add them. @@ -129,6 +129,30 @@ You can configure the default AI model to use so you don't have to pass `--model **Configuration Priority:** 1. CLI argument (`--model `) 2. Local config (`.copilot_here/model.conf`) + +### Container Runtime Management + +`copilot_here` supports multiple container runtimes: **Docker**, **OrbStack**, and **Podman**. The system automatically detects the available runtime, but you can also configure a preferred runtime. + +**Management Commands:** +- `--show-runtime` - Show current container runtime configuration +- `--list-runtimes` - List all available container runtimes on your system +- `--set-runtime ` - Set runtime in local config (values: `docker`, `podman`, or `auto`) +- `--set-runtime-global ` - Set runtime in global config + +**Configuration Files:** +- Global: `~/.config/copilot_here/runtime.conf` +- Local: `.copilot_here/runtime.conf` + +**Configuration Priority:** +1. Local config (`.copilot_here/runtime.conf`) +2. Global config (`~/.config/copilot_here/runtime.conf`) +3. Auto-detection (tries Docker first, then Podman) + +**Supported Runtimes:** +- **Docker** - Standard Docker Engine or Docker Desktop +- **OrbStack** - Automatically detected when Docker context is set to OrbStack +- **Podman** - Open-source container runtime (auto-detects compose support) 3. Global config (`~/.config/copilot_here/model.conf`) 4. Default (GitHub Copilot CLI default) diff --git a/app/Commands/Model/ListModels.cs b/app/Commands/Model/ListModels.cs index 975fafd..aa72956 100644 --- a/app/Commands/Model/ListModels.cs +++ b/app/Commands/Model/ListModels.cs @@ -1,6 +1,4 @@ using System.CommandLine; -using System.Text.RegularExpressions; -using CopilotHere.Infrastructure; using AppContext = CopilotHere.Infrastructure.AppContext; namespace CopilotHere.Commands.Model; @@ -9,55 +7,33 @@ public sealed partial class ModelCommands { private static Command SetListModelsCommand() { - var command = new Command("--list-models", "List available AI models from GitHub Copilot CLI"); - command.SetAction(_ => + var command = new Command("--list-models", "List available AI models"); + command.SetAction(async _ => { Console.WriteLine("🤖 Fetching available models..."); Console.WriteLine(); var ctx = AppContext.Create(); - var imageTag = ctx.ImageConfig.Tag; - var imageName = DockerRunner.GetImageName(imageTag); - // Pull image if needed (quietly) - if (!DockerRunner.PullImage(imageName)) + // Check if the tool supports models + if (!ctx.ActiveTool.SupportsModels) { - Console.WriteLine("❌ Failed to pull Docker image"); + Console.WriteLine($"❌ The {ctx.ActiveTool.DisplayName} tool does not support model selection"); return 1; } - // Run copilot with an invalid model to trigger error that lists valid models - var args = new List - { - "run", - "--rm", - "--env", $"GH_TOKEN={ctx.Environment.GitHubToken}", - imageName, - "copilot", // Just "copilot", not "gh copilot" - "--model", "invalid-model-to-trigger-list" - }; - - DebugLogger.Log("Running: docker run ... copilot --model invalid-model-to-trigger-list"); - var (exitCode, stdout, stderr) = DockerRunner.RunAndCapture(args); - - DebugLogger.Log($"Exit code: {exitCode}"); - DebugLogger.Log($"stderr: {stderr}"); - DebugLogger.Log($"stdout: {stdout}"); - - // Parse the error message to extract model list - var models = ParseModelListFromError(stderr); + // Get models from the tool's model provider + var modelProvider = ctx.ActiveTool.GetModelProvider(); + var models = await modelProvider.ListAvailableModels(ctx); if (models.Count == 0) { - // Fallback: show instructions if parsing fails - Console.WriteLine("❌ Could not parse model list from Copilot CLI output"); + Console.WriteLine($"❌ No models available for {ctx.ActiveTool.DisplayName}"); Console.WriteLine(); - Console.WriteLine("To see available models:"); - Console.WriteLine(" 1. Run: copilot_here"); - Console.WriteLine(" 2. Type: /model"); - Console.WriteLine(); - Console.WriteLine("Raw error output:"); - Console.WriteLine(stderr); + Console.WriteLine("This could mean:"); + Console.WriteLine(" • The tool doesn't support model listing"); + Console.WriteLine(" • There was an error fetching the model list"); + Console.WriteLine(" • Authentication may be required"); return 1; } @@ -75,55 +51,4 @@ private static Command SetListModelsCommand() }); return command; } - - private static List ParseModelListFromError(string errorOutput) - { - var models = new List(); - - // Look for "Allowed choices are model1, model2, model3." - // Need to handle dots in model names (e.g., gpt-5.1) - // Match everything after "Allowed choices are" until period followed by newline or end - var match = Regex.Match(errorOutput, @"Allowed choices are\s+(.+?)\.(?:\s|$)", RegexOptions.IgnoreCase | RegexOptions.Singleline); - if (match.Success) - { - var modelString = match.Groups[1].Value; - DebugLogger.Log($"Captured model string: '{modelString}'"); - - // Split by comma - var parts = modelString.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - foreach (var part in parts) - { - var cleaned = part.Trim(); - if (!string.IsNullOrWhiteSpace(cleaned)) - { - models.Add(cleaned); - DebugLogger.Log($"Added model: '{cleaned}'"); - } - } - return models; - } - - DebugLogger.Log("'Allowed choices are' pattern did not match"); - - // Fallback: Look for patterns like "valid values are:" or "available models:" - match = Regex.Match(errorOutput, @"(?:valid|available)[^:]*:\s*(.+)", RegexOptions.IgnoreCase); - if (match.Success) - { - var modelString = match.Groups[1].Value; - var parts = Regex.Split(modelString, @"[,;\n]"); - foreach (var part in parts) - { - var cleaned = part.Trim().Trim('"', '\'', '`', '.', ' '); - if (!string.IsNullOrWhiteSpace(cleaned) && - !cleaned.Contains("and") && - !cleaned.Contains("or") && - cleaned.Length < 50) - { - models.Add(cleaned); - } - } - } - - return models.Distinct().ToList(); - } } diff --git a/app/Commands/Run/RunCommand.cs b/app/Commands/Run/RunCommand.cs index 2d2dcea..444af2a 100644 --- a/app/Commands/Run/RunCommand.cs +++ b/app/Commands/Run/RunCommand.cs @@ -15,6 +15,9 @@ public sealed class RunCommand : ICommand { private readonly bool _isYolo; + // === TOOL SELECTION === + private readonly Option _toolOption; + // === IMAGE SELECTION OPTIONS === private readonly Option _dotnetOption; private readonly Option _dotnet8Option; @@ -76,6 +79,8 @@ public RunCommand(bool isYolo = false) // - PowerShell-style aliases are also handled in Program.NormalizeArgs for backwards compatibility // - Descriptions show available short aliases in brackets + _toolOption = new Option("--tool") { Description = "Override CLI tool (github-copilot, echo, etc.)" }; + _dotnetOption = new Option("--dotnet") { Description = "[-d] Use .NET image variant (all versions)" }; _dotnet8Option = new Option("--dotnet8") { Description = "[-d8] Use .NET 8 image variant" }; @@ -142,6 +147,7 @@ public RunCommand(bool isYolo = false) public void Configure(RootCommand root) { + root.Add(_toolOption); root.Add(_dotnetOption); root.Add(_dotnet8Option); root.Add(_dotnet9Option); @@ -179,7 +185,22 @@ public void Configure(RootCommand root) root.SetAction(parseResult => { - var ctx = Infrastructure.AppContext.Create(); + var toolOverride = parseResult.GetValue(_toolOption); + + // Validate tool if specified + if (!string.IsNullOrWhiteSpace(toolOverride) && !ToolRegistry.Exists(toolOverride)) + { + Console.WriteLine($"❌ Unknown tool: {toolOverride}"); + Console.WriteLine(); + Console.WriteLine("Available tools:"); + foreach (var name in ToolRegistry.GetToolNames()) + { + Console.WriteLine($" • {name}"); + } + return 1; + } + + var ctx = Infrastructure.AppContext.Create(toolOverride); var dotnet = parseResult.GetValue(_dotnetOption); var dotnet8 = parseResult.GetValue(_dotnet8Option); @@ -237,7 +258,7 @@ public void Configure(RootCommand root) DebugLogger.Log("Checking dependencies..."); // Check dependencies - var dependencyResults = DependencyCheck.CheckAll(); + var dependencyResults = DependencyCheck.CheckAll(ctx.ActiveTool, ctx.RuntimeConfig); var allDependenciesSatisfied = DependencyCheck.DisplayResults(dependencyResults); if (!allDependenciesSatisfied) @@ -247,9 +268,10 @@ public void Configure(RootCommand root) } DebugLogger.Log("All dependencies satisfied"); - DebugLogger.Log("Validating GitHub auth scopes..."); + DebugLogger.Log("Validating auth..."); // Security check - var (isValid, error) = GitHubAuth.ValidateScopes(); + var authProvider = ctx.ActiveTool.GetAuthProvider(); + var (isValid, error) = authProvider.ValidateAuth(); if (!isValid) { DebugLogger.Log($"Auth validation failed: {error}"); @@ -271,20 +293,9 @@ public void Configure(RootCommand root) else if (dotnetRust) imageTag = "dotnet-rust"; else if (golang) imageTag = "golang"; - var imageName = DockerRunner.GetImageName(imageTag); + var imageName = ctx.ActiveTool.GetImageName(imageTag); DebugLogger.Log($"Selected image: {imageName}"); - // Build copilot args list - var copilotArgs = new List { "copilot" }; - - // Add YOLO mode flags - if (_isYolo) - { - DebugLogger.Log("Adding YOLO mode flags"); - copilotArgs.Add("--allow-all-tools"); - copilotArgs.Add("--allow-all-paths"); - } - // Determine model (CLI overrides config) var effectiveModel = model ?? ctx.ModelConfig.Model; if (!string.IsNullOrEmpty(effectiveModel)) @@ -292,98 +303,102 @@ public void Configure(RootCommand root) DebugLogger.Log($"Using model: {effectiveModel} (source: {(model != null ? "CLI" : ctx.ModelConfig.Source.ToString())})"); } - // Handle --help2 (native copilot help) + // Build user arguments list (tool-specific passthrough options) + var userArgs = new List(); + + // Handle --help2 (native help) if (help2) { - copilotArgs.Add("--help"); + userArgs.Add("--help"); } else { // Add passthrough options if (!string.IsNullOrEmpty(prompt)) { - copilotArgs.Add("--prompt"); - copilotArgs.Add(prompt); - } - if (!string.IsNullOrEmpty(effectiveModel)) - { - copilotArgs.Add("--model"); - copilotArgs.Add(effectiveModel); + userArgs.Add("--prompt"); + userArgs.Add(prompt); } if (continueSession) { - copilotArgs.Add("--continue"); + userArgs.Add("--continue"); } // Check if --resume was actually passed (even without a value) var resumeOptionResult = parseResult.GetResult(_resumeOption); if (resumeOptionResult != null) { - copilotArgs.Add("--resume"); + userArgs.Add("--resume"); if (!string.IsNullOrEmpty(resumeSession)) - copilotArgs.Add(resumeSession); + userArgs.Add(resumeSession); } if (silent) { - copilotArgs.Add("--silent"); + userArgs.Add("--silent"); } if (!string.IsNullOrEmpty(agent)) { - copilotArgs.Add("--agent"); - copilotArgs.Add(agent); + userArgs.Add("--agent"); + userArgs.Add(agent); } if (noColor) { - copilotArgs.Add("--no-color"); + userArgs.Add("--no-color"); } foreach (var tool in allowTools) { - copilotArgs.Add("--allow-tool"); - copilotArgs.Add(tool); + userArgs.Add("--allow-tool"); + userArgs.Add(tool); } foreach (var tool in denyTools) { - copilotArgs.Add("--deny-tool"); - copilotArgs.Add(tool); + userArgs.Add("--deny-tool"); + userArgs.Add(tool); } if (!string.IsNullOrEmpty(stream)) { - copilotArgs.Add("--stream"); - copilotArgs.Add(stream); + userArgs.Add("--stream"); + userArgs.Add(stream); } if (!string.IsNullOrEmpty(logLevel)) { - copilotArgs.Add("--log-level"); - copilotArgs.Add(logLevel); + userArgs.Add("--log-level"); + userArgs.Add(logLevel); } if (screenReader) { - copilotArgs.Add("--screen-reader"); + userArgs.Add("--screen-reader"); } if (noCustomInstructions) { - copilotArgs.Add("--no-custom-instructions"); + userArgs.Add("--no-custom-instructions"); } foreach (var mcpConfig in additionalMcpConfigs) { - copilotArgs.Add("--additional-mcp-config"); - copilotArgs.Add(mcpConfig); - } - copilotArgs.AddRange(passthroughArgs); - - // If no args (interactive mode), add --banner - // Check count excluding model args since model doesn't mean non-interactive - var argsWithoutModel = copilotArgs.Count; - if (!string.IsNullOrEmpty(effectiveModel)) - argsWithoutModel -= 2; // Subtract --model and its value - - if (argsWithoutModel == 1 || (argsWithoutModel <= 3 && _isYolo)) - { - copilotArgs.Add("--banner"); + userArgs.Add("--additional-mcp-config"); + userArgs.Add(mcpConfig); } + userArgs.AddRange(passthroughArgs); } + // Build command context for the tool + var commandContext = new CommandContext + { + UserArgs = userArgs, + IsYolo = _isYolo, + IsInteractive = userArgs.Count == 0 || (userArgs.Count == 1 && userArgs[0] == "--help"), + Model = effectiveModel, + ImageTag = imageTag, + Mounts = [], // Will be populated later + Environment = new Dictionary() + }; + + // Use the tool to build the final command + var toolCommand = ctx.ActiveTool.BuildCommand(commandContext); + DebugLogger.Log($"Built command: {string.Join(" ", toolCommand)}"); + var supportsVariant = ctx.Environment.SupportsEmojiVariationSelectors; Console.WriteLine($"{Emoji.Rocket(supportsVariant)} Using image: {imageName}"); + Console.WriteLine($"🐳 Container runtime: {ctx.RuntimeConfig.RuntimeFlavor}"); // Show model - always display even if using default if (!string.IsNullOrEmpty(effectiveModel)) @@ -403,14 +418,14 @@ public void Configure(RootCommand root) // Pull image unless skipped if (!noPull) { - DebugLogger.Log("Pulling Docker image..."); - if (!DockerRunner.PullImage(imageName)) + DebugLogger.Log("Pulling image..."); + if (!ContainerRunner.PullImage(ctx.RuntimeConfig, imageName)) { - DebugLogger.Log("Docker image pull failed"); - Console.WriteLine("Error: Failed to pull Docker image. Check Docker setup and network."); + DebugLogger.Log("Image pull failed"); + Console.WriteLine($"Error: Failed to pull image. Check {ctx.RuntimeConfig.RuntimeFlavor} setup and network."); return 1; } - DebugLogger.Log("Docker image pull succeeded"); + DebugLogger.Log("Image pull succeeded"); } else { @@ -421,7 +436,7 @@ public void Configure(RootCommand root) // Cleanup old images unless skipped if (!noCleanup) { - DockerRunner.CleanupOldImages(imageName); + ContainerRunner.CleanupOldImages(ctx.RuntimeConfig, imageName); } else { @@ -477,34 +492,34 @@ public void Configure(RootCommand root) Console.WriteLine($"🛡️ Airlock: enabled - {sourceDisplay}"); // Run in Airlock mode with Docker Compose - return AirlockRunner.Run(ctx, imageTag, _isYolo, allMounts, copilotArgs); + return AirlockRunner.Run(ctx.RuntimeConfig, ctx, imageTag, _isYolo, allMounts, toolCommand); } // Add directories for YOLO mode if (_isYolo) { // Add current dir and all mount paths to --add-dir - copilotArgs.Add("--add-dir"); - copilotArgs.Add(ctx.Paths.ContainerWorkDir); + toolCommand.Add("--add-dir"); + toolCommand.Add(ctx.Paths.ContainerWorkDir); foreach (var mount in allMounts) { - copilotArgs.Add("--add-dir"); - copilotArgs.Add(mount.GetContainerPath(ctx.Paths.UserHome)); + toolCommand.Add("--add-dir"); + toolCommand.Add(mount.GetContainerPath(ctx.Paths.UserHome)); } } // Build Docker args for standard mode var sessionId = GenerateSessionId(); var containerName = $"copilot_here-{sessionId}"; - var dockerArgs = BuildDockerArgs(ctx, imageName, containerName, allMounts, copilotArgs, _isYolo, imageTag); + var dockerArgs = BuildDockerArgs(ctx, imageName, containerName, allMounts, toolCommand, _isYolo, imageTag, noPull); // Set terminal title var titleEmoji = _isYolo ? "🤖⚡️" : "🤖"; var dirName = SystemInfo.GetCurrentDirectoryName(); var title = $"{titleEmoji} {dirName}"; - return DockerRunner.RunInteractive(dockerArgs, title); + return ContainerRunner.RunInteractive(ctx.RuntimeConfig, dockerArgs, title); }); } @@ -542,9 +557,10 @@ private static List BuildDockerArgs( string imageName, string containerName, List mounts, - List copilotArgs, + List toolCommand, bool isYolo, - string imageTag) + string imageTag, + bool noPull) { // Generate session info JSON var sessionInfo = SessionInfo.Generate(ctx, imageTag, imageName, mounts, isYolo); @@ -563,10 +579,24 @@ private static List BuildDockerArgs( // Environment variables "-e", $"PUID={ctx.Environment.UserId}", "-e", $"PGID={ctx.Environment.GroupId}", - "-e", $"GITHUB_TOKEN={ctx.Environment.GitHubToken}", "-e", $"COPILOT_HERE_SESSION_INFO={sessionInfo}" }; + // Add auth environment variables from the active tool's auth provider + foreach (var (key, value) in ctx.ActiveTool.GetAuthProvider().GetEnvironmentVars()) + { + args.Add("-e"); + args.Add($"{key}={value}"); + } + + // Add --pull=never when --no-pull is specified + // This prevents the container runtime from auto-pulling missing images + // Both Docker (19.09+) and Podman support this flag + if (noPull) + { + args.Insert(1, "--pull=never"); // Insert after "run" + } + // Add additional mounts foreach (var mount in mounts) { @@ -585,8 +615,8 @@ private static List BuildDockerArgs( // Add image name args.Add(imageName); - // Add copilot args - args.AddRange(copilotArgs); + // Add tool command + args.AddRange(toolCommand); return args; } diff --git a/app/Commands/Runtime/ListRuntimes.cs b/app/Commands/Runtime/ListRuntimes.cs new file mode 100644 index 0000000..e61da12 --- /dev/null +++ b/app/Commands/Runtime/ListRuntimes.cs @@ -0,0 +1,57 @@ +using System.CommandLine; +using CopilotHere.Infrastructure; + +namespace CopilotHere.Commands.Runtime; + +public sealed partial class RuntimeCommands +{ + /// + /// Lists all available container runtimes. + /// + private static Command CreateListRuntimesCommand() + { + var command = new Command("--list-runtimes", "List all available container runtimes"); + + command.SetAction(_ => + { + Console.WriteLine("🐳 Available Container Runtimes:"); + Console.WriteLine(); + + var available = ContainerRuntimeConfig.ListAvailable(); + + if (available.Count == 0) + { + Console.WriteLine(" No container runtimes found."); + Console.WriteLine(); + Console.WriteLine(" Please install Docker or Podman:"); + Console.WriteLine(" • Docker: https://docs.docker.com/get-docker/"); + Console.WriteLine(" • OrbStack (macOS): https://orbstack.dev/"); + Console.WriteLine(" • Podman: https://podman.io/getting-started/installation"); + return 1; + } + + foreach (var runtime in available) + { + Console.WriteLine($" {runtime.RuntimeFlavor}"); + Console.WriteLine($" Command: {runtime.Runtime}"); + Console.WriteLine($" Version: {runtime.GetVersion()}"); + Console.WriteLine($" Compose: {runtime.Runtime} {runtime.ComposeCommand}"); + Console.WriteLine($" Airlock: {(runtime.SupportsAirlock ? "Supported" : "Not supported")}"); + Console.WriteLine($" Default network: {runtime.DefaultNetworkName}"); + Console.WriteLine(); + Console.WriteLine($" 💡 Switch to this runtime:"); + Console.WriteLine($" copilot_here --set-runtime {runtime.Runtime} (local)"); + Console.WriteLine($" copilot_here --set-runtime-global {runtime.Runtime} (global)"); + Console.WriteLine(); + } + + Console.WriteLine("ℹ️ Or use auto-detection:"); + Console.WriteLine(" copilot_here --set-runtime auto (local)"); + Console.WriteLine(" copilot_here --set-runtime-global auto (global)"); + + return 0; + }); + + return command; + } +} diff --git a/app/Commands/Runtime/SetRuntime.cs b/app/Commands/Runtime/SetRuntime.cs new file mode 100644 index 0000000..f1f88b1 --- /dev/null +++ b/app/Commands/Runtime/SetRuntime.cs @@ -0,0 +1,211 @@ +using System.CommandLine; +using CopilotHere.Infrastructure; + +namespace CopilotHere.Commands.Runtime; + +public sealed partial class RuntimeCommands +{ + /// + /// Sets the container runtime for the current project (local). + /// + private static Command CreateSetRuntimeCommand() + { + var nameArg = new Argument("runtime") { Description = "Runtime name: 'docker', 'podman', or 'auto'" }; + var command = new Command("--set-runtime", "Set container runtime in local config") + { + nameArg + }; + + command.SetAction(parseResult => + { + var runtimeName = parseResult.GetValue(nameArg)?.ToLowerInvariant(); + + if (string.IsNullOrWhiteSpace(runtimeName)) + { + Console.WriteLine("❌ Runtime name cannot be empty"); + return 1; + } + + // Validate runtime + if (runtimeName != "auto" && runtimeName != "docker" && runtimeName != "podman") + { + Console.WriteLine($"❌ Invalid runtime: {runtimeName}"); + Console.WriteLine(); + Console.WriteLine("Valid options:"); + Console.WriteLine(" • auto - Auto-detect available runtime"); + Console.WriteLine(" • docker - Use Docker or OrbStack"); + Console.WriteLine(" • podman - Use Podman"); + return 1; + } + + // If not auto, verify runtime is available + if (runtimeName != "auto" && !ContainerRuntimeConfig.IsCommandAvailable(runtimeName)) + { + Console.WriteLine($"❌ Runtime '{runtimeName}' is not installed or not in PATH"); + Console.WriteLine(); + Console.WriteLine("Available runtimes:"); + var available = ContainerRuntimeConfig.ListAvailable(); + if (available.Count == 0) + { + Console.WriteLine(" (none found - please install Docker or Podman)"); + } + else + { + foreach (var runtime in available) + { + Console.WriteLine($" • {runtime.Runtime} ({runtime.RuntimeFlavor})"); + } + } + return 1; + } + + // Save to local config + var paths = AppPaths.Resolve(); + + try + { + ContainerRuntimeConfig.SaveLocal(paths, runtimeName); + + // Show what was configured + ContainerRuntimeConfig? actualConfig = null; + if (runtimeName == "auto") + { + var detectedRuntime = ContainerRuntimeConfig.AutoDetect(); + if (detectedRuntime != null) + { + actualConfig = ContainerRuntimeConfig.CreateConfig(detectedRuntime); + } + } + else + { + actualConfig = ContainerRuntimeConfig.CreateConfig(runtimeName); + } + + Console.WriteLine($"✅ Set local runtime to: {runtimeName}"); + if (actualConfig != null) + { + if (runtimeName == "auto") + { + Console.WriteLine($" Detected: {actualConfig.RuntimeFlavor}"); + } + else + { + Console.WriteLine($" Using: {actualConfig.RuntimeFlavor}"); + } + } + Console.WriteLine($" Config: .copilot_here/runtime.conf"); + } + catch (Exception ex) + { + Console.WriteLine($"❌ Failed to save config: {ex.Message}"); + return 1; + } + + return 0; + }); + + return command; + } + + /// + /// Sets the container runtime globally. + /// + private static Command CreateSetRuntimeGlobalCommand() + { + var nameArg = new Argument("runtime") { Description = "Runtime name: 'docker', 'podman', or 'auto'" }; + var command = new Command("--set-runtime-global", "Set container runtime in global config") + { + nameArg + }; + + command.SetAction(parseResult => + { + var runtimeName = parseResult.GetValue(nameArg)?.ToLowerInvariant(); + + if (string.IsNullOrWhiteSpace(runtimeName)) + { + Console.WriteLine("❌ Runtime name cannot be empty"); + return 1; + } + + // Validate runtime + if (runtimeName != "auto" && runtimeName != "docker" && runtimeName != "podman") + { + Console.WriteLine($"❌ Invalid runtime: {runtimeName}"); + Console.WriteLine(); + Console.WriteLine("Valid options:"); + Console.WriteLine(" • auto - Auto-detect available runtime"); + Console.WriteLine(" • docker - Use Docker or OrbStack"); + Console.WriteLine(" • podman - Use Podman"); + return 1; + } + + // If not auto, verify runtime is available + if (runtimeName != "auto" && !ContainerRuntimeConfig.IsCommandAvailable(runtimeName)) + { + Console.WriteLine($"❌ Runtime '{runtimeName}' is not installed or not in PATH"); + Console.WriteLine(); + Console.WriteLine("Available runtimes:"); + var available = ContainerRuntimeConfig.ListAvailable(); + if (available.Count == 0) + { + Console.WriteLine(" (none found - please install Docker or Podman)"); + } + else + { + foreach (var runtime in available) + { + Console.WriteLine($" • {runtime.Runtime} ({runtime.RuntimeFlavor})"); + } + } + return 1; + } + + // Save to global config + var paths = AppPaths.Resolve(); + + try + { + ContainerRuntimeConfig.SaveGlobal(paths, runtimeName); + + // Show what was configured + ContainerRuntimeConfig? actualConfig = null; + if (runtimeName == "auto") + { + var detectedRuntime = ContainerRuntimeConfig.AutoDetect(); + if (detectedRuntime != null) + { + actualConfig = ContainerRuntimeConfig.CreateConfig(detectedRuntime); + } + } + else + { + actualConfig = ContainerRuntimeConfig.CreateConfig(runtimeName); + } + + Console.WriteLine($"✅ Set global runtime to: {runtimeName}"); + if (actualConfig != null) + { + if (runtimeName == "auto") + { + Console.WriteLine($" Detected: {actualConfig.RuntimeFlavor}"); + } + else + { + Console.WriteLine($" Using: {actualConfig.RuntimeFlavor}"); + } + } + Console.WriteLine($" Config: ~/.config/copilot_here/runtime.conf"); + } + catch (Exception ex) + { + Console.WriteLine($"❌ Failed to save config: {ex.Message}"); + return 1; + } + + return 0; + }); + + return command; + } +} diff --git a/app/Commands/Runtime/ShowRuntime.cs b/app/Commands/Runtime/ShowRuntime.cs new file mode 100644 index 0000000..aa421b9 --- /dev/null +++ b/app/Commands/Runtime/ShowRuntime.cs @@ -0,0 +1,73 @@ +using System.CommandLine; +using CopilotHere.Infrastructure; +using AppContext = CopilotHere.Infrastructure.AppContext; + +namespace CopilotHere.Commands.Runtime; + +public sealed partial class RuntimeCommands +{ + /// + /// Shows the currently active container runtime. + /// + private static Command CreateShowRuntimeCommand() + { + var command = new Command("--show-runtime", "Show current container runtime configuration"); + + command.SetAction(_ => + { + var ctx = AppContext.Create(); + var runtime = ctx.RuntimeConfig; + + var supportsVariant = !OperatingSystem.IsWindows(); + + Console.WriteLine($"🐳 Container Runtime: {runtime.RuntimeFlavor}"); + Console.WriteLine($" Command: {runtime.Runtime}"); + Console.WriteLine($" Version: {runtime.GetVersion()}"); + Console.WriteLine(); + + // Show configuration source + var sourceLabel = runtime.Source switch + { + RuntimeConfigSource.Local => $"{Emoji.Local(supportsVariant)} Local (.copilot_here/runtime.conf)", + RuntimeConfigSource.Global => $"{Emoji.Global(supportsVariant)} Global (~/.config/copilot_here/runtime.conf)", + RuntimeConfigSource.AutoDetected => $"{Emoji.Info(supportsVariant)} Auto-detected (no config file)", + _ => "Unknown" + }; + Console.WriteLine($" Source: {sourceLabel}"); + + // Show config values if they exist + if (runtime.LocalRuntime is not null) + { + Console.WriteLine($" {Emoji.Local(supportsVariant)} Local config: {runtime.LocalRuntime}"); + } + if (runtime.GlobalRuntime is not null) + { + Console.WriteLine($" {Emoji.Global(supportsVariant)} Global config: {runtime.GlobalRuntime}"); + } + + // Show capabilities + Console.WriteLine(); + Console.WriteLine(" Capabilities:"); + Console.WriteLine($" • Compose command: {runtime.Runtime} {runtime.ComposeCommand}"); + Console.WriteLine($" • Airlock support: {(runtime.SupportsAirlock ? "Yes" : "No")}"); + Console.WriteLine($" • Default network: {runtime.DefaultNetworkName}"); + + // Show all available runtimes + var available = ContainerRuntimeConfig.ListAvailable(); + if (available.Count > 0) + { + Console.WriteLine(); + Console.WriteLine(" Available runtimes:"); + foreach (var rt in available) + { + var marker = rt.Runtime == runtime.Runtime ? "▶" : " "; + Console.WriteLine($" {marker} {rt.RuntimeFlavor} ({rt.Runtime})"); + } + } + + return 0; + }); + + return command; + } +} diff --git a/app/Commands/Runtime/_RuntimeCommands.cs b/app/Commands/Runtime/_RuntimeCommands.cs new file mode 100644 index 0000000..a4cde11 --- /dev/null +++ b/app/Commands/Runtime/_RuntimeCommands.cs @@ -0,0 +1,18 @@ +using System.CommandLine; +using CopilotHere.Commands; + +namespace CopilotHere.Commands.Runtime; + +/// +/// Commands for managing container runtime selection. +/// +public sealed partial class RuntimeCommands : ICommand +{ + public void Configure(RootCommand root) + { + root.Add(CreateShowRuntimeCommand()); + root.Add(CreateListRuntimesCommand()); + root.Add(CreateSetRuntimeCommand()); + root.Add(CreateSetRuntimeGlobalCommand()); + } +} diff --git a/app/Commands/Tool/ListTools.cs b/app/Commands/Tool/ListTools.cs new file mode 100644 index 0000000..c85b8ee --- /dev/null +++ b/app/Commands/Tool/ListTools.cs @@ -0,0 +1,52 @@ +using System.CommandLine; +using CopilotHere.Infrastructure; + +namespace CopilotHere.Commands.Tool; + +public sealed partial class ToolCommands +{ + /// + /// Lists all available CLI tools. + /// + private static Command CreateListCommand() + { + var command = new Command("--list-tools", "List all available CLI tools"); + + command.SetAction(_ => + { + Console.WriteLine("📋 Available CLI Tools:"); + Console.WriteLine(); + + var tools = ToolRegistry.GetAll(); + foreach (var tool in tools) + { + var isDefault = tool.Name == ToolRegistry.GetDefault().Name; + var marker = isDefault ? "★" : " "; + Console.WriteLine($" {marker} {tool.Name}"); + Console.WriteLine($" {tool.DisplayName}"); + + // Show capabilities + var capabilities = new List(); + if (tool.SupportsModels) capabilities.Add("models"); + if (tool.SupportsYoloMode) capabilities.Add("yolo"); + if (tool.SupportsInteractiveMode) capabilities.Add("interactive"); + + if (capabilities.Count > 0) + { + Console.WriteLine($" Capabilities: {string.Join(", ", capabilities)}"); + } + Console.WriteLine(); + } + + Console.WriteLine("★ = default tool"); + Console.WriteLine(); + Console.WriteLine("💡 Set tool:"); + Console.WriteLine(" copilot_here --set-tool (global)"); + Console.WriteLine(" copilot_here --set-tool-local (this project)"); + + return 0; + }); + + return command; + } +} diff --git a/app/Commands/Tool/SetTool.cs b/app/Commands/Tool/SetTool.cs new file mode 100644 index 0000000..203001d --- /dev/null +++ b/app/Commands/Tool/SetTool.cs @@ -0,0 +1,128 @@ +using System.CommandLine; +using CopilotHere.Infrastructure; + +namespace CopilotHere.Commands.Tool; + +public sealed partial class ToolCommands +{ + /// + /// Sets the CLI tool globally. + /// + private static Command CreateSetCommand() + { + var nameArg = new Argument("name") { Description = "Tool name (e.g., 'github-copilot', 'echo')" }; + var command = new Command("--set-tool", "Set the active CLI tool (global)") + { + nameArg + }; + + command.SetAction(parseResult => + { + var toolName = parseResult.GetValue(nameArg); + + if (string.IsNullOrWhiteSpace(toolName)) + { + Console.WriteLine("❌ Tool name cannot be empty"); + return 1; + } + + // Validate tool exists + if (!ToolRegistry.Exists(toolName)) + { + Console.WriteLine($"❌ Unknown tool: {toolName}"); + Console.WriteLine(); + Console.WriteLine("Available tools:"); + foreach (var name in ToolRegistry.GetToolNames()) + { + Console.WriteLine($" • {name}"); + } + return 1; + } + + // Save to global config + var globalConfigDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".config", + "cli_mate" + ); + + Directory.CreateDirectory(globalConfigDir); + var globalToolFile = Path.Combine(globalConfigDir, "tool.conf"); + + try + { + File.WriteAllText(globalToolFile, toolName); + Console.WriteLine($"✅ Set global tool to: {toolName}"); + Console.WriteLine($" Config: ~/.config/cli_mate/tool.conf"); + } + catch (Exception ex) + { + Console.WriteLine($"❌ Failed to save config: {ex.Message}"); + return 1; + } + + return 0; + }); + + return command; + } + + /// + /// Sets the CLI tool for the current project only. + /// + private static Command CreateSetLocalCommand() + { + var nameArg = new Argument("name") { Description = "Tool name (e.g., 'github-copilot', 'echo')" }; + var command = new Command("--set-tool-local", "Set the active CLI tool (local project)") + { + nameArg + }; + + command.SetAction(parseResult => + { + var toolName = parseResult.GetValue(nameArg); + + if (string.IsNullOrWhiteSpace(toolName)) + { + Console.WriteLine("❌ Tool name cannot be empty"); + return 1; + } + + // Validate tool exists + if (!ToolRegistry.Exists(toolName)) + { + Console.WriteLine($"❌ Unknown tool: {toolName}"); + Console.WriteLine(); + Console.WriteLine("Available tools:"); + foreach (var name in ToolRegistry.GetToolNames()) + { + Console.WriteLine($" • {name}"); + } + return 1; + } + + // Save to local config + var localConfigDir = Path.Combine(Directory.GetCurrentDirectory(), ".cli_mate"); + Directory.CreateDirectory(localConfigDir); + var localToolFile = Path.Combine(localConfigDir, "tool.conf"); + + try + { + File.WriteAllText(localToolFile, toolName); + Console.WriteLine($"✅ Set local tool to: {toolName}"); + Console.WriteLine($" Config: .cli_mate/tool.conf"); + Console.WriteLine(); + Console.WriteLine("💡 Add .cli_mate/ to your .gitignore if needed"); + } + catch (Exception ex) + { + Console.WriteLine($"❌ Failed to save config: {ex.Message}"); + return 1; + } + + return 0; + }); + + return command; + } +} diff --git a/app/Commands/Tool/ShowTool.cs b/app/Commands/Tool/ShowTool.cs new file mode 100644 index 0000000..bbe8630 --- /dev/null +++ b/app/Commands/Tool/ShowTool.cs @@ -0,0 +1,76 @@ +using System.CommandLine; +using CopilotHere.Infrastructure; +using AppContext = CopilotHere.Infrastructure.AppContext; + +namespace CopilotHere.Commands.Tool; + +public sealed partial class ToolCommands +{ + /// + /// Shows the currently active CLI tool. + /// + private static Command CreateShowCommand() + { + var command = new Command("--show-tool", "Show the currently active CLI tool"); + + command.SetAction(_ => + { + var ctx = AppContext.Create(); + var tool = ctx.ActiveTool; + + Console.WriteLine($"🔧 Active Tool: {tool.Name}"); + Console.WriteLine($" Display Name: {tool.DisplayName}"); + Console.WriteLine(); + + // Check where config came from + var localToolFile = Path.Combine(ctx.Paths.CurrentDirectory, ".cli_mate", "tool.conf"); + var legacyLocalToolFile = Path.Combine(ctx.Paths.CurrentDirectory, ".copilot_here", "tool.conf"); + var globalToolFile = Path.Combine(ctx.Paths.UserHome, ".config", "cli_mate", "tool.conf"); + var legacyGlobalToolFile = Path.Combine(ctx.Paths.UserHome, ".config", "copilot_here", "tool.conf"); + + if (File.Exists(localToolFile)) + { + Console.WriteLine($" Source: Local (.cli_mate/tool.conf)"); + } + else if (File.Exists(legacyLocalToolFile)) + { + Console.WriteLine($" Source: Local (.copilot_here/tool.conf - legacy)"); + } + else if (File.Exists(globalToolFile)) + { + Console.WriteLine($" Source: Global (~/.config/cli_mate/tool.conf)"); + } + else if (File.Exists(legacyGlobalToolFile)) + { + Console.WriteLine($" Source: Global (~/.config/copilot_here/tool.conf - legacy)"); + } + else + { + Console.WriteLine($" Source: Default"); + } + + // Show capabilities + Console.WriteLine(); + Console.WriteLine(" Capabilities:"); + Console.WriteLine($" • Models: {(tool.SupportsModels ? "Yes" : "No")}"); + Console.WriteLine($" • YOLO Mode: {(tool.SupportsYoloMode ? "Yes" : "No")}"); + Console.WriteLine($" • Interactive: {(tool.SupportsInteractiveMode ? "Yes" : "No")}"); + + // Show dependencies + var deps = tool.GetRequiredDependencies(); + if (deps.Length > 0) + { + Console.WriteLine(); + Console.WriteLine(" Required Dependencies:"); + foreach (var dep in deps) + { + Console.WriteLine($" • {dep}"); + } + } + + return 0; + }); + + return command; + } +} diff --git a/app/Commands/Tool/_ToolCommands.cs b/app/Commands/Tool/_ToolCommands.cs new file mode 100644 index 0000000..f76342c --- /dev/null +++ b/app/Commands/Tool/_ToolCommands.cs @@ -0,0 +1,17 @@ +using System.CommandLine; + +namespace CopilotHere.Commands.Tool; + +/// +/// Commands for managing CLI tool selection. +/// +public sealed partial class ToolCommands : ICommand +{ + public void Configure(RootCommand root) + { + root.Add(CreateListCommand()); + root.Add(CreateShowCommand()); + root.Add(CreateSetCommand()); + root.Add(CreateSetLocalCommand()); + } +} diff --git a/app/Infrastructure/AirlockRunner.cs b/app/Infrastructure/AirlockRunner.cs index f9e8e39..20af041 100644 --- a/app/Infrastructure/AirlockRunner.cs +++ b/app/Infrastructure/AirlockRunner.cs @@ -36,14 +36,15 @@ public static class AirlockRunner } /// - /// Runs the Copilot CLI in Airlock mode with a proxy container. + /// Runs the CLI tool in Airlock mode with a proxy container. /// public static int Run( + ContainerRuntimeConfig runtimeConfig, AppContext ctx, string imageTag, bool isYolo, List mounts, - List copilotArgs) + List toolArgs) { var rulesPath = ctx.AirlockConfig.RulesPath; if (string.IsNullOrEmpty(rulesPath) || !File.Exists(rulesPath)) @@ -53,17 +54,17 @@ public static int Run( } // Cleanup orphaned networks/containers first - CleanupOrphanedResources(); + CleanupOrphanedResources(runtimeConfig); // Parse sandbox flags var sandboxFlags = SandboxFlags.Parse(); - var externalNetwork = SandboxFlags.ExtractNetwork(sandboxFlags) ?? "bridge"; + var externalNetwork = SandboxFlags.ExtractNetwork(sandboxFlags) ?? runtimeConfig.DefaultNetworkName; var appFlags = SandboxFlags.FilterNetworkFlags(sandboxFlags); if (sandboxFlags.Count > 0) { DebugLogger.Log($"SANDBOX_FLAGS detected: {sandboxFlags.Count} flags"); - if (externalNetwork != "bridge") + if (externalNetwork != runtimeConfig.DefaultNetworkName) DebugLogger.Log($"Using external network: {externalNetwork}"); } @@ -74,7 +75,7 @@ public static int Run( Console.WriteLine($" App image: {appImage}"); Console.WriteLine($" Proxy image: {proxyImage}"); Console.WriteLine($" Network config: {rulesPath}"); - if (externalNetwork != "bridge") + if (externalNetwork != runtimeConfig.DefaultNetworkName) Console.WriteLine($" External network: {externalNetwork}"); // Generate session ID for unique naming @@ -102,8 +103,8 @@ public static int Run( // Generate compose file var composeFile = GenerateComposeFile( - ctx, templateContent, projectName, appImage, proxyImage, - processedConfigPath, externalNetwork, appFlags, mounts, copilotArgs, isYolo); + runtimeConfig, ctx, templateContent, projectName, appImage, proxyImage, + processedConfigPath, externalNetwork, appFlags, mounts, toolArgs, isYolo); if (composeFile is null) { @@ -132,14 +133,14 @@ public static int Run( Console.WriteLine(); // Start proxy in background - if (!StartProxy(composeFile, projectName, ctx.Environment.GitHubToken)) + if (!StartProxy(runtimeConfig, composeFile, projectName, ctx)) { Console.WriteLine("❌ Failed to start proxy container"); return 1; } // Run app interactively - var exitCode = RunApp(composeFile, projectName, ctx.Environment.GitHubToken); + var exitCode = RunApp(runtimeConfig, composeFile, projectName, ctx); return exitCode; } @@ -151,7 +152,7 @@ public static int Run( // Cleanup Console.WriteLine(); Console.WriteLine("🧹 Cleaning up airlock..."); - CleanupSession(projectName, composeFile, processedConfigPath); + CleanupSession(runtimeConfig, projectName, composeFile, processedConfigPath); } } @@ -222,6 +223,7 @@ private static void SetupLogsDirectory(AppPaths paths, string rulesPath) } private static string? GenerateComposeFile( + ContainerRuntimeConfig runtimeConfig, AppContext ctx, string template, string projectName, @@ -231,7 +233,7 @@ private static void SetupLogsDirectory(AppPaths paths, string rulesPath) string externalNetwork, List appSandboxFlags, List mounts, - List copilotArgs, + List toolArgs, bool isYolo) { try @@ -264,12 +266,12 @@ private static void SetupLogsDirectory(AppPaths paths, string rulesPath) // Build networks section string networksYaml; - if (externalNetwork == "bridge") + if (externalNetwork == runtimeConfig.DefaultNetworkName) { - networksYaml = @"networks: + networksYaml = $@"networks: airlock: internal: true - bridge:"; + {runtimeConfig.DefaultNetworkName}:"; } else { @@ -280,28 +282,30 @@ private static void SetupLogsDirectory(AppPaths paths, string rulesPath) external: true"; } - // Build copilot command - var copilotCmd = new StringBuilder("[\"copilot\""); - if (isYolo) + // Build tool command - already built by the caller via ICliTool.BuildCommand() + // The toolArgs already contains the complete command including: + // - Tool name (e.g., "copilot", "bash") + // - YOLO flags if applicable (e.g., "--allow-all-tools", "--allow-all-paths") + // - Model if specified + // - User arguments + // - Interactive flag (e.g., "--banner") if applicable + + // Convert command to JSON array format for docker-compose + var toolCmd = new StringBuilder("["); + for (int i = 0; i < toolArgs.Count; i++) { - copilotCmd.Append(", \"--allow-all-tools\", \"--allow-all-paths\""); - copilotCmd.Append($", \"--add-dir\", \"{ctx.Paths.ContainerWorkDir}\""); + if (i > 0) toolCmd.Append(", "); + var arg = toolArgs[i].Replace("\"", "\\\""); + toolCmd.Append($"\"{arg}\""); } + toolCmd.Append(']'); - if (copilotArgs.Count <= 1 || (copilotArgs.Count <= 3 && isYolo)) + // Build auth environment variables from the active tool's auth provider + var authEnvVars = new StringBuilder(); + foreach (var (key, value) in ctx.ActiveTool.GetAuthProvider().GetEnvironmentVars()) { - copilotCmd.Append(", \"--banner\""); + authEnvVars.AppendLine($" - {key}=${{{key}}}"); } - else - { - // Skip "copilot" at index 0 - for (var i = 1; i < copilotArgs.Count; i++) - { - var arg = copilotArgs[i].Replace("\"", "\\\""); - copilotCmd.Append($", \"{arg}\""); - } - } - copilotCmd.Append(']'); // Generate session info JSON for Airlock mode var sessionInfo = SessionInfo.GenerateWithNetworkConfig( @@ -326,7 +330,7 @@ private static void SetupLogsDirectory(AppPaths paths, string rulesPath) .Replace("{{PUID}}", ctx.Environment.UserId.ToString()) .Replace("{{PGID}}", ctx.Environment.GroupId.ToString()) .Replace("{{SESSION_INFO}}", sessionInfo) - .Replace("{{COPILOT_ARGS}}", copilotCmd.ToString()); + .Replace("{{COPILOT_ARGS}}", toolCmd.ToString()); // Handle multiline placeholders var lines = result.Split('\n').ToList(); @@ -353,6 +357,13 @@ private static void SetupLogsDirectory(AppPaths paths, string rulesPath) else lines.RemoveAt(i); } + else if (lines[i].Contains("{{AUTH_ENV_VARS}}")) + { + if (authEnvVars.Length > 0) + lines[i] = authEnvVars.ToString().TrimEnd(); + else + lines.RemoveAt(i); + } } result = string.Join('\n', lines); @@ -368,23 +379,29 @@ private static void SetupLogsDirectory(AppPaths paths, string rulesPath) } } - private static bool StartProxy(string composeFile, string projectName, string? token) + private static bool StartProxy(ContainerRuntimeConfig runtimeConfig, string composeFile, string projectName, AppContext ctx) { try { var startInfo = new ProcessStartInfo { - FileName = "docker", + FileName = runtimeConfig.Runtime, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; - startInfo.EnvironmentVariables["GITHUB_TOKEN"] = token ?? ""; + // Get auth environment variables from the active tool's auth provider + var authEnvVars = ctx.ActiveTool.GetAuthProvider().GetEnvironmentVars(); + foreach (var (key, value) in authEnvVars) + { + startInfo.EnvironmentVariables[key] = value; + } + startInfo.EnvironmentVariables["COMPOSE_MENU"] = "0"; - startInfo.ArgumentList.Add("compose"); + startInfo.ArgumentList.Add(runtimeConfig.ComposeCommand); startInfo.ArgumentList.Add("-f"); startInfo.ArgumentList.Add(composeFile); startInfo.ArgumentList.Add("-p"); @@ -413,14 +430,14 @@ private static bool StartProxy(string composeFile, string projectName, string? t } } - private static int RunApp(string composeFile, string projectName, string? token) + private static int RunApp(ContainerRuntimeConfig runtimeConfig, string composeFile, string projectName, AppContext ctx) { - // Let Docker Compose handle Ctrl+C + // Let container runtime handle Ctrl+C Console.CancelKeyPress += (_, e) => e.Cancel = true; var startInfo = new ProcessStartInfo { - FileName = "docker", + FileName = runtimeConfig.Runtime, UseShellExecute = false, RedirectStandardInput = false, RedirectStandardOutput = false, @@ -428,10 +445,16 @@ private static int RunApp(string composeFile, string projectName, string? token) CreateNoWindow = false }; - startInfo.EnvironmentVariables["GITHUB_TOKEN"] = token ?? ""; + // Get auth environment variables from the active tool's auth provider + var authEnvVars = ctx.ActiveTool.GetAuthProvider().GetEnvironmentVars(); + foreach (var (key, value) in authEnvVars) + { + startInfo.EnvironmentVariables[key] = value; + } + startInfo.EnvironmentVariables["COMPOSE_MENU"] = "0"; - startInfo.ArgumentList.Add("compose"); + startInfo.ArgumentList.Add(runtimeConfig.ComposeCommand); startInfo.ArgumentList.Add("-f"); startInfo.ArgumentList.Add(composeFile); startInfo.ArgumentList.Add("-p"); @@ -448,20 +471,20 @@ private static int RunApp(string composeFile, string projectName, string? token) return process.ExitCode; } - private static void CleanupSession(string projectName, string? composeFile, string? processedConfigPath) + private static void CleanupSession(ContainerRuntimeConfig runtimeConfig, string projectName, string? composeFile, string? processedConfigPath) { try { // Stop and remove proxy container - RunQuietCommand("docker", "stop", $"{projectName}-proxy"); - RunQuietCommand("docker", "rm", $"{projectName}-proxy"); + RunQuietCommand(runtimeConfig.Runtime, "stop", $"{projectName}-proxy"); + RunQuietCommand(runtimeConfig.Runtime, "rm", $"{projectName}-proxy"); // Remove networks - RunQuietCommand("docker", "network", "rm", $"{projectName}_airlock"); - RunQuietCommand("docker", "network", "rm", $"{projectName}_bridge"); + RunQuietCommand(runtimeConfig.Runtime, "network", "rm", $"{projectName}_airlock"); + RunQuietCommand(runtimeConfig.Runtime, "network", "rm", $"{projectName}_{runtimeConfig.DefaultNetworkName}"); // Remove volume - RunQuietCommand("docker", "volume", "rm", $"{projectName}_proxy-ca"); + RunQuietCommand(runtimeConfig.Runtime, "volume", "rm", $"{projectName}_proxy-ca"); // Delete temp files if (composeFile is not null && File.Exists(composeFile)) @@ -475,17 +498,17 @@ private static void CleanupSession(string projectName, string? composeFile, stri } } - private static void CleanupOrphanedResources() + private static void CleanupOrphanedResources(ContainerRuntimeConfig runtimeConfig) { try { // Get running containers - var runningOutput = RunCommand("docker", "ps", "--format", "{{.Names}}"); + var runningOutput = RunCommand(runtimeConfig.Runtime, "ps", "--format", "{{.Names}}"); var runningContainers = new HashSet( runningOutput?.Split('\n', StringSplitOptions.RemoveEmptyEntries) ?? []); // Get all containers including stopped - var allOutput = RunCommand("docker", "ps", "-a", "--format", "{{.Names}}"); + var allOutput = RunCommand(runtimeConfig.Runtime, "ps", "-a", "--format", "{{.Names}}"); var allContainers = allOutput?.Split('\n', StringSplitOptions.RemoveEmptyEntries) ?? []; // Find and remove orphaned proxy containers @@ -498,23 +521,23 @@ private static void CleanupOrphanedResources() if (!hasRunningApp) { - RunQuietCommand("docker", "rm", "-f", container); + RunQuietCommand(runtimeConfig.Runtime, "rm", "-f", container); Console.WriteLine($" 🗑️ Removed orphaned proxy: {container}"); } } // Find and remove orphaned networks - var networksOutput = RunCommand("docker", "network", "ls", "--format", "{{.Name}}"); + var networksOutput = RunCommand(runtimeConfig.Runtime, "network", "ls", "--format", "{{.Name}}"); var networks = networksOutput?.Split('\n', StringSplitOptions.RemoveEmptyEntries) ?? []; - var copilotNetworks = networks.Where(n => n.EndsWith("_airlock") || n.EndsWith("_bridge")); + var copilotNetworks = networks.Where(n => n.EndsWith("_airlock") || n.EndsWith($"_{runtimeConfig.DefaultNetworkName}")); foreach (var network in copilotNetworks) { // Check if network has any containers - var inspectOutput = RunCommand("docker", "network", "inspect", network, "--format", "{{len .Containers}}"); + var inspectOutput = RunCommand(runtimeConfig.Runtime, "network", "inspect", network, "--format", "{{len .Containers}}"); if (inspectOutput?.Trim() == "0") { - RunQuietCommand("docker", "network", "rm", network); + RunQuietCommand(runtimeConfig.Runtime, "network", "rm", network); Console.WriteLine($" 🗑️ Removed orphaned network: {network}"); } } diff --git a/app/Infrastructure/AppContext.cs b/app/Infrastructure/AppContext.cs index 6628326..46692d1 100644 --- a/app/Infrastructure/AppContext.cs +++ b/app/Infrastructure/AppContext.cs @@ -17,6 +17,9 @@ public sealed record AppContext /// Runtime environment (auth, user IDs, etc.). public required AppEnvironment Environment { get; init; } + /// The active CLI tool being used (e.g., GitHub Copilot, Echo). + public required ICliTool ActiveTool { get; init; } + /// Image configuration (loaded from config files). public required ImageConfig ImageConfig { get; init; } @@ -29,20 +32,59 @@ public sealed record AppContext /// Airlock configuration (loaded from config files). public required AirlockConfig AirlockConfig { get; init; } + /// Container runtime configuration (loaded from config files or auto-detected). + public required ContainerRuntimeConfig RuntimeConfig { get; init; } + /// Creates an AppContext with all state resolved and configs loaded. - public static AppContext Create() + /// Optional tool name to override config (from CLI --tool argument) + public static AppContext Create(string? toolOverride = null) { var paths = AppPaths.Resolve(); var environment = AppEnvironment.Resolve(); + // Determine which tool to use based on priority: + // 1. CLI argument (--tool X) + // 2. Local config (.cli_mate/tool.conf or .copilot_here/tool.conf) + // 3. Global config (~/.config/cli_mate/tool.conf or ~/.config/copilot_here/tool.conf) + // 4. Default (github-copilot) + var toolName = toolOverride ?? GetToolFromConfig(paths) ?? "github-copilot"; + var tool = ToolRegistry.Exists(toolName) ? ToolRegistry.Get(toolName) : ToolRegistry.GetDefault(); + return new AppContext { Paths = paths, Environment = environment, + ActiveTool = tool, ImageConfig = ImageConfig.Load(paths), ModelConfig = ModelConfig.Load(paths), MountsConfig = MountsConfig.Load(paths), - AirlockConfig = AirlockConfig.Load(paths) + AirlockConfig = AirlockConfig.Load(paths), + RuntimeConfig = ContainerRuntimeConfig.Load(paths) }; } + + private static string? GetToolFromConfig(AppPaths paths) + { + // Try local config first (.cli_mate/tool.conf) + var localToolConfig = Path.Combine(paths.CurrentDirectory, ".cli_mate", "tool.conf"); + var tool = ConfigFile.ReadValue(localToolConfig); + if (!string.IsNullOrWhiteSpace(tool) && ToolRegistry.Exists(tool)) return tool; + + // Try legacy local config (.copilot_here/tool.conf) + var legacyLocalToolConfig = Path.Combine(paths.CurrentDirectory, ".copilot_here", "tool.conf"); + tool = ConfigFile.ReadValue(legacyLocalToolConfig); + if (!string.IsNullOrWhiteSpace(tool) && ToolRegistry.Exists(tool)) return tool; + + // Try global config (~/.config/cli_mate/tool.conf) + var globalToolConfig = Path.Combine(paths.UserHome, ".config", "cli_mate", "tool.conf"); + tool = ConfigFile.ReadValue(globalToolConfig); + if (!string.IsNullOrWhiteSpace(tool) && ToolRegistry.Exists(tool)) return tool; + + // Try legacy global config (~/.config/copilot_here/tool.conf) + var legacyGlobalToolConfig = paths.GetGlobalPath("tool.conf"); + tool = ConfigFile.ReadValue(legacyGlobalToolConfig); + if (!string.IsNullOrWhiteSpace(tool) && ToolRegistry.Exists(tool)) return tool; + + return null; + } } diff --git a/app/Infrastructure/AppEnvironment.cs b/app/Infrastructure/AppEnvironment.cs index deb3dcf..87f9db7 100644 --- a/app/Infrastructure/AppEnvironment.cs +++ b/app/Infrastructure/AppEnvironment.cs @@ -1,14 +1,11 @@ namespace CopilotHere.Infrastructure; /// -/// Runtime environment information (auth, user IDs, etc.). +/// Runtime environment information (user IDs, terminal capabilities, etc.). /// Separate from paths and config - this is system state. /// public sealed record AppEnvironment { - /// GitHub authentication token. - public required string GitHubToken { get; init; } - /// User ID for container permissions. public required string UserId { get; init; } @@ -26,7 +23,6 @@ public static AppEnvironment Resolve() { return new AppEnvironment { - GitHubToken = GitHubAuth.GetToken(), UserId = SystemInfo.GetUserId(), GroupId = SystemInfo.GetGroupId(), SupportsEmoji = SystemInfo.SupportsEmoji(), diff --git a/app/Infrastructure/AppPaths.cs b/app/Infrastructure/AppPaths.cs index aeb2abc..b8057a2 100644 --- a/app/Infrastructure/AppPaths.cs +++ b/app/Infrastructure/AppPaths.cs @@ -28,7 +28,13 @@ public sealed record AppPaths public static AppPaths Resolve() { var currentDir = Directory.GetCurrentDirectory(); - var userHome = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + // Get user home, respecting environment variables (important for tests) + // Check env vars first before falling back to SpecialFolder + var userHome = Environment.GetEnvironmentVariable("HOME") + ?? Environment.GetEnvironmentVariable("USERPROFILE") + ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var globalConfigPath = Path.Combine(userHome, ".config", "copilot_here"); var copilotConfigPath = Path.Combine(userHome, ".config", "copilot-cli-docker"); diff --git a/app/Infrastructure/DockerRunner.cs b/app/Infrastructure/ContainerRunner.cs similarity index 74% rename from app/Infrastructure/DockerRunner.cs rename to app/Infrastructure/ContainerRunner.cs index da138d9..746349d 100644 --- a/app/Infrastructure/DockerRunner.cs +++ b/app/Infrastructure/ContainerRunner.cs @@ -3,9 +3,9 @@ namespace CopilotHere.Infrastructure; /// -/// Handles Docker process execution. +/// Handles container runtime process execution (Docker, Podman, OrbStack). /// -public static class DockerRunner +public static class ContainerRunner { private const string ImagePrefix = "ghcr.io/gordonbeeming/copilot_here"; @@ -15,16 +15,16 @@ public static class DockerRunner public static string GetImageName(string tag) => $"{ImagePrefix}:{tag}"; /// - /// Runs a Docker command with the given arguments. + /// Runs a container command with the given arguments. /// - public static int Run(IEnumerable args) + public static int Run(ContainerRuntimeConfig runtimeConfig, IEnumerable args) { - // Intercept Ctrl+C to let Docker handle it + // Intercept Ctrl+C to let container runtime handle it Console.CancelKeyPress += (_, e) => e.Cancel = true; var startInfo = new ProcessStartInfo { - FileName = "docker", + FileName = runtimeConfig.Runtime, UseShellExecute = false, RedirectStandardInput = false, RedirectStandardOutput = false, @@ -38,7 +38,7 @@ public static int Run(IEnumerable args) using var process = Process.Start(startInfo); if (process is null) { - Console.Error.WriteLine("❌ Failed to start Docker. Is it installed and in PATH?"); + Console.Error.WriteLine($"❌ Failed to start {runtimeConfig.RuntimeFlavor}. Is it installed and in PATH?"); return 1; } @@ -47,9 +47,9 @@ public static int Run(IEnumerable args) } /// - /// Runs Docker interactively with the given arguments, setting terminal title. + /// Runs container runtime interactively with the given arguments, setting terminal title. /// - public static int RunInteractive(IEnumerable args, string? terminalTitle = null) + public static int RunInteractive(ContainerRuntimeConfig runtimeConfig, IEnumerable args, string? terminalTitle = null) { // Set terminal title if provided if (!string.IsNullOrEmpty(terminalTitle)) @@ -59,7 +59,7 @@ public static int RunInteractive(IEnumerable args, string? terminalTitle try { - return Run(args); + return Run(runtimeConfig, args); } finally { @@ -72,34 +72,34 @@ public static int RunInteractive(IEnumerable args, string? terminalTitle } /// - /// Pulls a Docker image with progress output. + /// Pulls a container image with progress output. /// - public static bool PullImage(string imageName) + public static bool PullImage(ContainerRuntimeConfig runtimeConfig, string imageName) { DebugLogger.Log($"PullImage called for: {imageName}"); Console.WriteLine($"📥 Pulling image: {imageName}"); var startInfo = new ProcessStartInfo { - FileName = "docker", + FileName = runtimeConfig.Runtime, UseShellExecute = false }; startInfo.ArgumentList.Add("pull"); startInfo.ArgumentList.Add(imageName); - DebugLogger.Log($"Starting docker process: docker pull {imageName}"); + DebugLogger.Log($"Starting {runtimeConfig.Runtime} process: {runtimeConfig.Runtime} pull {imageName}"); using var process = Process.Start(startInfo); if (process is null) { - DebugLogger.Log("Failed to start docker process"); - Console.WriteLine("❌ Failed to start Docker"); + DebugLogger.Log($"Failed to start {runtimeConfig.Runtime} process"); + Console.WriteLine($"❌ Failed to start {runtimeConfig.RuntimeFlavor}"); return false; } - DebugLogger.Log($"Docker process started with PID: {process.Id}"); - DebugLogger.Log("Waiting for docker process to exit..."); + DebugLogger.Log($"{runtimeConfig.RuntimeFlavor} process started with PID: {process.Id}"); + DebugLogger.Log($"Waiting for {runtimeConfig.Runtime} process to exit..."); process.WaitForExit(); - DebugLogger.Log($"Docker process exited with code: {process.ExitCode}"); + DebugLogger.Log($"{runtimeConfig.RuntimeFlavor} process exited with code: {process.ExitCode}"); if (process.ExitCode == 0) { @@ -114,7 +114,7 @@ public static bool PullImage(string imageName) /// /// Cleans up old copilot_here images older than 7 days. /// - public static void CleanupOldImages(string keepImageName) + public static void CleanupOldImages(ContainerRuntimeConfig runtimeConfig, string keepImageName) { Console.WriteLine("🧹 Cleaning up old images (older than 7 days)..."); @@ -123,7 +123,7 @@ public static void CleanupOldImages(string keepImageName) // Get list of all copilot_here images var startInfo = new ProcessStartInfo { - FileName = "docker", + FileName = runtimeConfig.Runtime, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, @@ -148,7 +148,7 @@ public static void CleanupOldImages(string keepImageName) } // Get the ID of the image to keep - var keepImageId = GetImageId(keepImageName); + var keepImageId = GetImageId(runtimeConfig, keepImageName); var cutoffDate = DateTime.Now.AddDays(-7); var removedCount = 0; @@ -163,7 +163,7 @@ public static void CleanupOldImages(string keepImageName) // Try to parse the date and check if older than 7 days if (TryParseDockerDate(img.CreatedAt, out var imageDate) && imageDate < cutoffDate) { - if (RemoveImage(img.ImageId)) + if (RemoveImage(runtimeConfig, img.ImageId)) { Console.WriteLine($" 🗑️ Removed: {img.ImageName}"); removedCount++; @@ -182,13 +182,13 @@ public static void CleanupOldImages(string keepImageName) } } - private static string? GetImageId(string imageName) + private static string? GetImageId(ContainerRuntimeConfig runtimeConfig, string imageName) { try { var startInfo = new ProcessStartInfo { - FileName = "docker", + FileName = runtimeConfig.Runtime, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, @@ -212,13 +212,13 @@ public static void CleanupOldImages(string keepImageName) } } - private static bool RemoveImage(string imageId) + private static bool RemoveImage(ContainerRuntimeConfig runtimeConfig, string imageId) { try { var startInfo = new ProcessStartInfo { - FileName = "docker", + FileName = runtimeConfig.Runtime, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, @@ -260,14 +260,14 @@ private static bool TryParseDockerDate(string dateStr, out DateTime result) } /// - /// Runs a Docker command and captures stdout/stderr output. + /// Runs a container command and captures stdout/stderr output. /// Returns (exitCode, stdout, stderr). /// - public static (int exitCode, string stdout, string stderr) RunAndCapture(IEnumerable args) + public static (int exitCode, string stdout, string stderr) RunAndCapture(ContainerRuntimeConfig runtimeConfig, IEnumerable args) { var startInfo = new ProcessStartInfo { - FileName = "docker", + FileName = runtimeConfig.Runtime, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, @@ -280,7 +280,7 @@ public static (int exitCode, string stdout, string stderr) RunAndCapture(IEnumer using var process = Process.Start(startInfo); if (process is null) { - return (1, "", "Failed to start Docker"); + return (1, "", $"Failed to start {runtimeConfig.RuntimeFlavor}"); } var stdout = process.StandardOutput.ReadToEnd(); diff --git a/app/Infrastructure/ContainerRuntimeConfig.cs b/app/Infrastructure/ContainerRuntimeConfig.cs new file mode 100644 index 0000000..9046a8a --- /dev/null +++ b/app/Infrastructure/ContainerRuntimeConfig.cs @@ -0,0 +1,345 @@ +using System.Diagnostics; + +namespace CopilotHere.Infrastructure; + +/// +/// Configuration for container runtime detection and management. +/// Supports Docker, OrbStack, and Podman with auto-detection. +/// Priority: Local config > Global config > Auto-detect +/// +public sealed record ContainerRuntimeConfig +{ + /// The runtime to use: "docker" or "podman". + public required string Runtime { get; init; } + + /// The flavor/variant of runtime: "Docker", "OrbStack", or "Podman". + public required string RuntimeFlavor { get; init; } + + /// The compose command to use: "compose" (built-in) or "docker-compose"/"podman-compose" (external). + public required string ComposeCommand { get; init; } + + /// Whether this runtime supports the airlock feature. + public required bool SupportsAirlock { get; init; } + + /// Default network name for this runtime ("bridge" for Docker/OrbStack, "podman" for Podman). + public required string DefaultNetworkName { get; init; } + + /// Source of the resolved runtime (for display purposes). + public required RuntimeConfigSource Source { get; init; } + + /// Runtime from local config, if any. + public string? LocalRuntime { get; init; } + + /// Runtime from global config, if any. + public string? GlobalRuntime { get; init; } + + private const string ConfigFileName = "runtime.conf"; + + /// + /// Loads runtime configuration from config files or auto-detects. + /// Does NOT apply CLI overrides - that's done by the command handler. + /// + public static ContainerRuntimeConfig Load(AppPaths paths) + { + var localRuntime = LoadFromConfig(paths.GetLocalPath(ConfigFileName)); + var globalRuntime = LoadFromConfig(paths.GetGlobalPath(ConfigFileName)); + + // Determine effective runtime and source + string runtime; + RuntimeConfigSource source; + + if (localRuntime is not null) + { + runtime = localRuntime; + source = RuntimeConfigSource.Local; + } + else if (globalRuntime is not null) + { + runtime = globalRuntime; + source = RuntimeConfigSource.Global; + } + else + { + runtime = AutoDetect() ?? "docker"; // Fallback to docker + source = RuntimeConfigSource.AutoDetected; + } + + var config = CreateConfig(runtime); + return config with + { + Source = source, + LocalRuntime = localRuntime, + GlobalRuntime = globalRuntime + }; + } + + /// + /// Reads runtime from config file. + /// Normalizes "auto" to null (to trigger auto-detection). + /// Normalizes other values to lowercase. + /// + public static string? LoadFromConfig(string path) + { + var value = ConfigFile.ReadValue(path); + if (value is null) return null; + + var normalized = value.ToLowerInvariant(); + return normalized == "auto" ? null : normalized; + } + + /// + /// Auto-detects available container runtime. + /// Tries Docker first (including OrbStack), then Podman. + /// Returns null if no runtime is available. + /// + public static string? AutoDetect() + { + // Try Docker first (most common) + if (IsCommandAvailable("docker")) + { + return "docker"; + } + + // Try Podman as fallback + if (IsCommandAvailable("podman")) + { + return "podman"; + } + + return null; + } + + /// + /// Checks if a command is available by running --version. + /// + public static bool IsCommandAvailable(string command) + { + try + { + var startInfo = new ProcessStartInfo(command, "--version") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(startInfo); + if (process is null) return false; + + process.WaitForExit(); + return process.ExitCode == 0; + } + catch + { + return false; + } + } + + /// + /// Checks if the current Docker context is OrbStack. + /// + public static bool IsOrbStack() + { + try + { + var startInfo = new ProcessStartInfo("docker", "context show") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(startInfo); + if (process is null) return false; + + var output = process.StandardOutput.ReadToEnd().Trim().ToLowerInvariant(); + process.WaitForExit(); + + return process.ExitCode == 0 && output.Contains("orbstack"); + } + catch + { + return false; + } + } + + /// + /// Detects Podman compose support. + /// Returns "compose" for built-in support, "podman-compose" for external, or "compose" as fallback. + /// + public static string DetectPodmanCompose() + { + // Check if podman has built-in compose support (podman compose --version) + try + { + var startInfo = new ProcessStartInfo("podman", "compose --version") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(startInfo); + if (process is null) return "compose"; + + process.WaitForExit(); + if (process.ExitCode == 0) + { + return "compose"; // Built-in compose support + } + } + catch + { + // Fall through to check external command + } + + // Check for external podman-compose command + if (IsCommandAvailable("podman-compose")) + { + return "podman-compose"; + } + + // Default to compose (may not work, but better than crashing) + return "compose"; + } + + /// + /// Creates a runtime configuration for the specified runtime. + /// + public static ContainerRuntimeConfig CreateConfig(string runtime) + { + var normalized = runtime.ToLowerInvariant(); + + return normalized switch + { + "docker" => new ContainerRuntimeConfig + { + Runtime = "docker", + RuntimeFlavor = IsOrbStack() ? "OrbStack" : "Docker", + ComposeCommand = "compose", + SupportsAirlock = true, + DefaultNetworkName = "bridge", + Source = RuntimeConfigSource.AutoDetected, // Will be overridden by Load() + LocalRuntime = null, + GlobalRuntime = null + }, + "podman" => new ContainerRuntimeConfig + { + Runtime = "podman", + RuntimeFlavor = "Podman", + ComposeCommand = DetectPodmanCompose(), + SupportsAirlock = true, + DefaultNetworkName = "podman", + Source = RuntimeConfigSource.AutoDetected, // Will be overridden by Load() + LocalRuntime = null, + GlobalRuntime = null + }, + _ => throw new InvalidOperationException( + $"Unknown container runtime: {runtime}. Supported values: docker, podman") + }; + } + + /// Saves runtime to local config. + public static void SaveLocal(AppPaths paths, string runtime) + { + ConfigFile.WriteValue(paths.GetLocalPath(ConfigFileName), runtime); + } + + /// Saves runtime to global config. + public static void SaveGlobal(AppPaths paths, string runtime) + { + ConfigFile.WriteValue(paths.GetGlobalPath(ConfigFileName), runtime); + } + + /// Clears local runtime config. + public static bool ClearLocal(AppPaths paths) + { + return ConfigFile.Delete(paths.GetLocalPath(ConfigFileName)); + } + + /// Clears global runtime config. + public static bool ClearGlobal(AppPaths paths) + { + return ConfigFile.Delete(paths.GetGlobalPath(ConfigFileName)); + } + + /// + /// Returns a human-friendly display string for the runtime configuration. + /// + public string GetDisplayString() + { + var sourceLabel = Source switch + { + RuntimeConfigSource.Local => "local config", + RuntimeConfigSource.Global => "global config", + RuntimeConfigSource.AutoDetected => "auto-detected", + RuntimeConfigSource.CommandLine => "CLI override", + _ => "unknown" + }; + + return $"{RuntimeFlavor} ({sourceLabel})"; + } + + /// + /// Gets the version string for the runtime. + /// + public string GetVersion() + { + try + { + var startInfo = new ProcessStartInfo(Runtime, "--version") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(startInfo); + if (process is null) return "unknown"; + + var output = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(); + + return process.ExitCode == 0 ? output : "unknown"; + } + catch + { + return "unknown"; + } + } + + /// + /// Lists all available container runtimes on the system. + /// + public static List ListAvailable() + { + var runtimes = new List(); + + if (IsCommandAvailable("docker")) + { + runtimes.Add(CreateConfig("docker")); + } + + if (IsCommandAvailable("podman")) + { + runtimes.Add(CreateConfig("podman")); + } + + return runtimes; + } +} + +/// +/// Source of the runtime configuration. +/// +public enum RuntimeConfigSource +{ + AutoDetected, + Global, + Local, + CommandLine +} diff --git a/app/Infrastructure/DependencyCheck.cs b/app/Infrastructure/DependencyCheck.cs index f029ea2..ee04bf1 100644 --- a/app/Infrastructure/DependencyCheck.cs +++ b/app/Infrastructure/DependencyCheck.cs @@ -20,16 +20,33 @@ public record DependencyResult( ); /// - /// Checks all required dependencies and returns their status. + /// Checks all required dependencies for the specified tool and returns their status. /// - public static List CheckAll() + public static List CheckAll(ICliTool tool, ContainerRuntimeConfig runtimeConfig) { - return - [ - CheckGitHubCli(), - CheckDocker(), - CheckDockerRunning() - ]; + var results = new List(); + + // Check container runtime and daemon first + results.Add(CheckContainerRuntime(runtimeConfig)); + results.Add(CheckContainerRuntimeRunning(runtimeConfig)); + + // Check tool-specific dependencies + var toolDeps = tool.GetRequiredDependencies(); + foreach (var dep in toolDeps) + { + if (dep.Equals("docker", StringComparison.OrdinalIgnoreCase)) + { + // Already checked (as container runtime) + continue; + } + else if (dep.Equals("gh", StringComparison.OrdinalIgnoreCase)) + { + results.Add(CheckGitHubCli(tool)); + } + // Add more dependency checks here as needed + } + + return results; } /// @@ -86,7 +103,7 @@ public static bool DisplayResults(List results) /// /// Checks if GitHub CLI is installed and authenticated. /// - private static DependencyResult CheckGitHubCli() + private static DependencyResult CheckGitHubCli(ICliTool tool) { try { @@ -145,7 +162,7 @@ private static DependencyResult CheckGitHubCli() false, version, "Failed to check authentication status", - GetGitHubAuthHelp() + GetGitHubAuthHelp(tool) ); } @@ -158,7 +175,7 @@ private static DependencyResult CheckGitHubCli() false, version, "Not authenticated", - GetGitHubAuthHelp() + GetGitHubAuthHelp(tool) ); } @@ -183,13 +200,13 @@ private static DependencyResult CheckGitHubCli() } /// - /// Checks if Docker is installed. + /// Checks if the configured container runtime is installed. /// - private static DependencyResult CheckDocker() + private static DependencyResult CheckContainerRuntime(ContainerRuntimeConfig runtimeConfig) { try { - var startInfo = new ProcessStartInfo("docker", "--version") + var startInfo = new ProcessStartInfo(runtimeConfig.Runtime, "--version") { RedirectStandardOutput = true, RedirectStandardError = true, @@ -201,11 +218,11 @@ private static DependencyResult CheckDocker() if (process is null) { return new DependencyResult( - "Docker", + runtimeConfig.RuntimeFlavor, false, null, - "Failed to run 'docker --version'", - GetDockerInstallHelp() + $"Failed to run '{runtimeConfig.Runtime} --version'", + GetContainerRuntimeInstallHelp(runtimeConfig) ); } @@ -215,19 +232,19 @@ private static DependencyResult CheckDocker() if (process.ExitCode != 0) { return new DependencyResult( - "Docker", + runtimeConfig.RuntimeFlavor, false, null, - "Docker not found", - GetDockerInstallHelp() + $"{runtimeConfig.RuntimeFlavor} not found", + GetContainerRuntimeInstallHelp(runtimeConfig) ); } - // Extract version from output (e.g., "Docker version 24.0.7, build afdd53b") - var version = ExtractDockerVersion(output); + // Extract version from output + var version = ExtractContainerRuntimeVersion(output, runtimeConfig.Runtime); return new DependencyResult( - "Docker", + runtimeConfig.RuntimeFlavor, true, version, null, @@ -237,23 +254,23 @@ private static DependencyResult CheckDocker() catch (Exception ex) { return new DependencyResult( - "Docker", + runtimeConfig.RuntimeFlavor, false, null, - $"Error checking Docker: {ex.Message}", - GetDockerInstallHelp() + $"Error checking {runtimeConfig.RuntimeFlavor}: {ex.Message}", + GetContainerRuntimeInstallHelp(runtimeConfig) ); } } /// - /// Checks if Docker daemon is running. + /// Checks if the container runtime daemon is running. /// - private static DependencyResult CheckDockerRunning() + private static DependencyResult CheckContainerRuntimeRunning(ContainerRuntimeConfig runtimeConfig) { try { - var startInfo = new ProcessStartInfo("docker", "info") + var startInfo = new ProcessStartInfo(runtimeConfig.Runtime, "info") { RedirectStandardOutput = true, RedirectStandardError = true, @@ -265,11 +282,11 @@ private static DependencyResult CheckDockerRunning() if (process is null) { return new DependencyResult( - "Docker Daemon", + $"{runtimeConfig.RuntimeFlavor} Daemon", false, null, - "Failed to run 'docker info'", - GetDockerRunningHelp() + $"Failed to run '{runtimeConfig.Runtime} info'", + GetContainerRuntimeRunningHelp(runtimeConfig) ); } @@ -278,16 +295,16 @@ private static DependencyResult CheckDockerRunning() if (process.ExitCode != 0) { return new DependencyResult( - "Docker Daemon", + $"{runtimeConfig.RuntimeFlavor} Daemon", false, null, - "Docker daemon not running", - GetDockerRunningHelp() + $"{runtimeConfig.RuntimeFlavor} daemon not running", + GetContainerRuntimeRunningHelp(runtimeConfig) ); } return new DependencyResult( - "Docker Daemon", + $"{runtimeConfig.RuntimeFlavor} Daemon", true, "Running", null, @@ -297,11 +314,11 @@ private static DependencyResult CheckDockerRunning() catch (Exception ex) { return new DependencyResult( - "Docker Daemon", + $"{runtimeConfig.RuntimeFlavor} Daemon", false, null, - $"Error checking Docker daemon: {ex.Message}", - GetDockerRunningHelp() + $"Error checking {runtimeConfig.RuntimeFlavor} daemon: {ex.Message}", + GetContainerRuntimeRunningHelp(runtimeConfig) ); } } @@ -326,17 +343,22 @@ private static DependencyResult CheckDockerRunning() } /// - /// Extracts Docker version from output. + /// Extracts container runtime version from output. /// - private static string? ExtractDockerVersion(string output) + private static string? ExtractContainerRuntimeVersion(string output, string runtime) { - // Expected format: "Docker version 24.0.7, build afdd53b" + // Expected format: "Docker version 24.0.7, build afdd53b" or "podman version 4.5.0" var parts = output.Split(' ', StringSplitOptions.RemoveEmptyEntries); - if (parts.Length >= 3 && parts[0] == "Docker" && parts[1] == "version") + + // Look for "version X.Y.Z" pattern + for (int i = 0; i < parts.Length - 1; i++) { - // Remove trailing comma if present - var version = parts[2].TrimEnd(','); - return version; + if (parts[i].Equals("version", StringComparison.OrdinalIgnoreCase)) + { + // Remove trailing comma if present + var version = parts[i + 1].TrimEnd(','); + return version; + } } return null; @@ -364,44 +386,80 @@ private static string GetGitHubCliInstallHelp() /// /// Gets help for authenticating with GitHub CLI. /// - private static string GetGitHubAuthHelp() + private static string GetGitHubAuthHelp(ICliTool tool) { - var scopes = string.Join(",", GitHubAuth.RequiredScopes); + var authProvider = tool.GetAuthProvider(); + var scopes = string.Join(",", authProvider.GetRequiredScopes()); return $"💡 Authenticate: gh auth login -h github.com -s {scopes}"; } /// - /// Gets platform-specific help for installing Docker. + /// Gets platform-specific help for installing the container runtime. /// - private static string GetDockerInstallHelp() + private static string GetContainerRuntimeInstallHelp(ContainerRuntimeConfig runtimeConfig) { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return "💡 Install: https://docs.docker.com/desktop/install/windows-install/"; - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + if (runtimeConfig.Runtime == "podman") { - return "💡 Install: https://docs.docker.com/desktop/install/mac-install/"; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return "💡 Install: https://podman.io/docs/installation#windows"; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return "💡 Install: brew install podman"; + } + else // Linux + { + return "💡 Install: https://podman.io/docs/installation#installing-on-linux"; + } } - else // Linux + else // Docker { - return "💡 Install: https://docs.docker.com/engine/install/"; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return "💡 Install: https://docs.docker.com/desktop/install/windows-install/"; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return "💡 Install: https://docs.docker.com/desktop/install/mac-install/ or brew install --cask orbstack"; + } + else // Linux + { + return "💡 Install: https://docs.docker.com/engine/install/"; + } } } /// - /// Gets help for starting Docker daemon. + /// Gets help for starting the container runtime daemon. /// - private static string GetDockerRunningHelp() + private static string GetContainerRuntimeRunningHelp(ContainerRuntimeConfig runtimeConfig) { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || - RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + if (runtimeConfig.Runtime == "podman") { - return "💡 Start Docker Desktop application"; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || + RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return "💡 Start Podman: podman machine start"; + } + else // Linux + { + return "💡 Start Podman: sudo systemctl start podman"; + } } - else // Linux + else // Docker { - return "💡 Start Docker: sudo systemctl start docker"; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || + RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return runtimeConfig.RuntimeFlavor == "OrbStack" + ? "💡 Start OrbStack application" + : "💡 Start Docker Desktop application"; + } + else // Linux + { + return "💡 Start Docker: sudo systemctl start docker"; + } } } } diff --git a/app/Infrastructure/IAuthProvider.cs b/app/Infrastructure/IAuthProvider.cs new file mode 100644 index 0000000..6e1106b --- /dev/null +++ b/app/Infrastructure/IAuthProvider.cs @@ -0,0 +1,37 @@ +namespace CopilotHere.Infrastructure; + +/// +/// Provides authentication functionality for CLI tools +/// +public interface IAuthProvider +{ + /// + /// Validates that authentication is properly configured and working + /// + /// Tuple of (isValid, errorMessage) + (bool isValid, string? error) ValidateAuth(); + + /// + /// Gets the authentication token (if applicable) + /// + /// Authentication token or empty string if not applicable + string GetToken(); + + /// + /// Gets the required authentication scopes + /// + /// Array of scope names (e.g., ["copilot", "read:packages"]) + string[] GetRequiredScopes(); + + /// + /// Gets the command to elevate/refresh the authentication token + /// + /// Shell command to run (e.g., "gh auth refresh -s copilot") + string GetElevateTokenCommand(); + + /// + /// Gets environment variables needed for authentication + /// + /// Dictionary of environment variable names and values + Dictionary GetEnvironmentVars(); +} diff --git a/app/Infrastructure/ICliTool.cs b/app/Infrastructure/ICliTool.cs new file mode 100644 index 0000000..8762115 --- /dev/null +++ b/app/Infrastructure/ICliTool.cs @@ -0,0 +1,113 @@ +namespace CopilotHere.Infrastructure; + +/// +/// Represents a CLI tool that can be used with cli_mate. +/// Implementations define how to interact with specific AI coding assistants. +/// +public interface ICliTool +{ + /// + /// Unique identifier for the tool (e.g., "github-copilot", "opencode", "echo") + /// + string Name { get; } + + /// + /// Human-readable display name (e.g., "GitHub Copilot CLI", "OpenCode") + /// + string DisplayName { get; } + + /// + /// Gets the Docker image name for this tool with the specified tag + /// + /// Image tag (e.g., "latest", "dotnet", "rust") + /// Full image name (e.g., "ghcr.io/gordonbeeming/cli_mate-github-copilot:latest") + string GetImageName(string tag); + + /// + /// Gets the path to the Dockerfile for this tool + /// + /// Relative path to Dockerfile (e.g., "docker/github-copilot/Dockerfile") + string GetDockerfile(); + + /// + /// Builds the command line arguments to execute inside the Docker container + /// + /// Command execution context with user arguments and configuration + /// List of command arguments to pass to Docker + List BuildCommand(CommandContext ctx); + + /// + /// Gets the CLI flag for interactive mode (if supported) + /// + /// Flag string (e.g., "--banner") or null if not applicable + string? GetInteractiveFlag(); + + /// + /// Gets the CLI flags for YOLO mode (unrestricted access) + /// + /// List of flags (e.g., ["--allow-all-tools", "--allow-all-paths"]) + List GetYoloModeFlags(); + + /// + /// Gets the configuration directory name for this tool + /// + /// Directory name (e.g., "cli_mate", "opencode") + string GetConfigDirName(); + + /// + /// Gets the path to the tool's session data directory (if applicable) + /// + /// Path to session data or null if not used + string? GetSessionDataPath(); + + /// + /// Gets the authentication provider for this tool + /// + IAuthProvider GetAuthProvider(); + + /// + /// Gets the model provider for this tool + /// + IModelProvider GetModelProvider(); + + /// + /// Gets the list of required dependencies (besides Docker) + /// + /// Array of dependency names (e.g., ["docker", "gh"]) + string[] GetRequiredDependencies(); + + /// + /// Gets the path to the default network rules for Airlock mode + /// + /// Path to default-airlock-rules.json + string GetDefaultNetworkRulesPath(); + + /// + /// Indicates if the tool supports model selection + /// + bool SupportsModels { get; } + + /// + /// Indicates if the tool supports YOLO mode (unrestricted access) + /// + bool SupportsYoloMode { get; } + + /// + /// Indicates if the tool supports interactive mode + /// + bool SupportsInteractiveMode { get; } +} + +/// +/// Context information for building tool commands +/// +public record CommandContext +{ + public required List UserArgs { get; init; } + public required bool IsYolo { get; init; } + public required bool IsInteractive { get; init; } + public required string? Model { get; init; } + public required string? ImageTag { get; init; } + public required List Mounts { get; init; } + public required Dictionary Environment { get; init; } +} diff --git a/app/Infrastructure/IModelProvider.cs b/app/Infrastructure/IModelProvider.cs new file mode 100644 index 0000000..88df92b --- /dev/null +++ b/app/Infrastructure/IModelProvider.cs @@ -0,0 +1,21 @@ +namespace CopilotHere.Infrastructure; + +/// +/// Provides model listing and validation functionality for CLI tools +/// +public interface IModelProvider +{ + /// + /// Lists all available models for this tool + /// + /// Application context + /// List of available model names + Task> ListAvailableModels(AppContext ctx); + + /// + /// Validates that a model name is valid for this tool + /// + /// Model name to validate + /// Tuple of (isValid, errorMessage) + (bool isValid, string? error) ValidateModel(string model); +} diff --git a/app/Infrastructure/ToolRegistry.cs b/app/Infrastructure/ToolRegistry.cs new file mode 100644 index 0000000..b5b8df0 --- /dev/null +++ b/app/Infrastructure/ToolRegistry.cs @@ -0,0 +1,67 @@ +using CopilotHere.Tools; + +namespace CopilotHere.Infrastructure; + +/// +/// Central registry for all available CLI tools +/// +public static class ToolRegistry +{ + private static readonly Dictionary> _tools = new() + { + ["github-copilot"] = new Lazy(() => new GitHubCopilotTool()), + ["echo"] = new Lazy(() => new EchoTool()), + }; + + /// + /// Gets a tool by name + /// + /// Tool name (e.g., "github-copilot") + /// The CLI tool instance + /// Thrown if the tool name is not registered + public static ICliTool Get(string name) + { + if (_tools.TryGetValue(name, out var lazyTool)) + { + return lazyTool.Value; + } + + var available = string.Join(", ", _tools.Keys); + throw new ArgumentException( + $"Unknown tool: {name}. Available tools: {available}", + nameof(name) + ); + } + + /// + /// Gets the default tool (GitHub Copilot) + /// + public static ICliTool GetDefault() + { + return Get("github-copilot"); + } + + /// + /// Gets all available tools + /// + public static IEnumerable GetAll() + { + return _tools.Values.Select(lazy => lazy.Value); + } + + /// + /// Checks if a tool exists + /// + public static bool Exists(string name) + { + return _tools.ContainsKey(name); + } + + /// + /// Gets all registered tool names + /// + public static IEnumerable GetToolNames() + { + return _tools.Keys; + } +} diff --git a/app/Program.cs b/app/Program.cs index a1aa328..f1bcb6e 100644 --- a/app/Program.cs +++ b/app/Program.cs @@ -5,6 +5,8 @@ using CopilotHere.Commands.Model; using CopilotHere.Commands.Mounts; using CopilotHere.Commands.Run; +using CopilotHere.Commands.Runtime; +using CopilotHere.Commands.Tool; using CopilotHere.Infrastructure; class Program @@ -72,6 +74,10 @@ class Program { "-ShowAirlockRules", "--show-airlock-rules" }, { "-EditAirlockRules", "--edit-airlock-rules" }, { "-EditGlobalAirlockRules", "--edit-global-airlock-rules" }, + { "-ShowRuntime", "--show-runtime" }, + { "-ListRuntimes", "--list-runtimes" }, + { "-SetRuntime", "--set-runtime" }, + { "-SetRuntimeGlobal", "--set-runtime-global" }, { "-Yolo", "--yolo" }, // Copilot passthrough options (PowerShell-style aliases) @@ -128,7 +134,9 @@ static async Task Main(string[] args) new MountCommands(), // Mount management new ImageCommands(), // Image management new ModelCommands(), // Model management + new ToolCommands(), // Tool management new AirlockCommands(), // Airlock proxy + new RuntimeCommands(), // Container runtime management ]; foreach (var command in commands) diff --git a/app/Resources/docker-compose.airlock.yml.template b/app/Resources/docker-compose.airlock.yml.template index 038de92..397c09c 100644 --- a/app/Resources/docker-compose.airlock.yml.template +++ b/app/Resources/docker-compose.airlock.yml.template @@ -5,7 +5,7 @@ # The proxy bridges to the external network - no direct internet access from app. # # Environment variables read from shell (NOT written to file for security): -# GITHUB_TOKEN - GitHub token for authentication +# Authentication tokens are provided by the active tool's IAuthProvider {{NETWORKS}} @@ -43,7 +43,7 @@ services: - https_proxy=http://proxy:58080 - PUID={{PUID}} - PGID={{PGID}} - - GITHUB_TOKEN=${GITHUB_TOKEN} +{{AUTH_ENV_VARS}} - TERM=${TERM:-xterm-256color} - COPILOT_HERE_SESSION_INFO={{SESSION_INFO}} volumes: diff --git a/app/Tools/EchoAuthProvider.cs b/app/Tools/EchoAuthProvider.cs new file mode 100644 index 0000000..c4e5817 --- /dev/null +++ b/app/Tools/EchoAuthProvider.cs @@ -0,0 +1,36 @@ +namespace CopilotHere.Tools; + +/// +/// Auth provider for the Echo tool - always succeeds (no auth required for testing) +/// +public class EchoAuthProvider : Infrastructure.IAuthProvider +{ + public (bool isValid, string? error) ValidateAuth() + { + // Echo doesn't require authentication + return (true, null); + } + + public string GetToken() + { + return "echo-test-token"; + } + + public string[] GetRequiredScopes() + { + return []; // No scopes required + } + + public string GetElevateTokenCommand() + { + return ""; // No elevation needed + } + + public Dictionary GetEnvironmentVars() + { + return new Dictionary + { + ["ECHO_MODE"] = "test" + }; + } +} diff --git a/app/Tools/EchoModelProvider.cs b/app/Tools/EchoModelProvider.cs new file mode 100644 index 0000000..5b37f1e --- /dev/null +++ b/app/Tools/EchoModelProvider.cs @@ -0,0 +1,30 @@ +namespace CopilotHere.Tools; + +/// +/// Model provider for the Echo tool - returns mock models for testing +/// +public class EchoModelProvider : Infrastructure.IModelProvider +{ + private static readonly List MockModels = new() + { + "echo-default", + "echo-fast", + "echo-balanced", + "echo-powerful" + }; + + public Task> ListAvailableModels(Infrastructure.AppContext ctx) + { + return Task.FromResult(MockModels); + } + + public (bool isValid, string? error) ValidateModel(string model) + { + if (MockModels.Contains(model)) + { + return (true, null); + } + + return (false, $"Unknown echo model: {model}. Available: {string.Join(", ", MockModels)}"); + } +} diff --git a/app/Tools/EchoTool.cs b/app/Tools/EchoTool.cs new file mode 100644 index 0000000..114bcf1 --- /dev/null +++ b/app/Tools/EchoTool.cs @@ -0,0 +1,183 @@ +using System.Text; + +namespace CopilotHere.Tools; + +/// +/// Echo provider - a test tool that displays configuration without executing any actual AI operations. +/// Perfect for validating the cli_mate configuration chassis. +/// +public class EchoTool : Infrastructure.ICliTool +{ + public string Name => "echo"; + public string DisplayName => "Echo (Test Provider)"; + + public string GetImageName(string tag) + { + // Echo uses the same images as GitHub Copilot (copilot-* tags) + // It doesn't need separate images - it just echoes config instead of running the tool + const string imagePrefix = "ghcr.io/gordonbeeming/copilot_here"; + var imageTag = string.IsNullOrEmpty(tag) ? "copilot-latest" : $"copilot-{tag}"; + return $"{imagePrefix}:{imageTag}"; + } + + public string GetDockerfile() + { + return "docker/echo/Dockerfile"; + } + + public List BuildCommand(Infrastructure.CommandContext ctx) + { + var args = new List { "bash", "-c" }; + + var echoScript = BuildEchoScript(ctx); + args.Add(echoScript); + + return args; + } + + private string BuildEchoScript(Infrastructure.CommandContext ctx) + { + var sb = new StringBuilder(); + + sb.AppendLine("echo '═══════════════════════════════════════════════════════════'"); + sb.AppendLine("echo '🔊 ECHO PROVIDER - Configuration Debug'"); + sb.AppendLine("echo '═══════════════════════════════════════════════════════════'"); + sb.AppendLine("echo ''"); + sb.AppendLine("echo 'This shows what WOULD be executed without --tool echo:'"); + sb.AppendLine("echo ''"); + + // Show the actual tool that would be used + sb.AppendLine("echo '📦 DOCKER CONFIGURATION:'"); + sb.AppendLine($"echo ' Image: {GetImageName(ctx.ImageTag ?? "latest")}'"); + sb.AppendLine($"echo ' Tag: {ctx.ImageTag ?? "latest"}'"); + sb.AppendLine("echo ''"); + + // Build what the actual GitHub Copilot command would be + sb.AppendLine("echo '🤖 COMMAND THAT WOULD BE EXECUTED:'"); + var actualTool = new GitHubCopilotTool(); + var actualCommand = actualTool.BuildCommand(ctx); + var commandStr = string.Join(" ", actualCommand.Select(arg => + arg.Contains(' ') || arg.Contains('\'') ? $"'{arg.Replace("'", "'\\''")}'" : arg)); + + // Escape for bash echo + commandStr = commandStr.Replace("'", "'\\''"); + sb.AppendLine($"echo ' {commandStr}'"); + sb.AppendLine("echo ''"); + + // Show configuration flags + sb.AppendLine("echo '⚙️ CONFIGURATION:'"); + sb.AppendLine($"echo ' YOLO Mode: {ctx.IsYolo}'"); + sb.AppendLine($"echo ' Interactive: {ctx.IsInteractive}'"); + sb.AppendLine($"echo ' Model: {ctx.Model ?? "(default)"}'"); + sb.AppendLine("echo ''"); + + // Show user arguments + sb.AppendLine("echo '📝 USER ARGUMENTS:'"); + var userArgs = string.Join(" ", ctx.UserArgs); + if (!string.IsNullOrEmpty(userArgs)) + { + // Escape single quotes for bash + userArgs = userArgs.Replace("'", "'\\''"); + sb.AppendLine($"echo ' {userArgs}'"); + } + else + { + sb.AppendLine("echo ' (none)'"); + } + sb.AppendLine("echo ''"); + + // Show mounts (from context) + sb.AppendLine("echo '📂 MOUNTS:'"); + if (ctx.Mounts.Any()) + { + foreach (var mount in ctx.Mounts.Take(5)) + { + var escapedMount = mount.Replace("'", "'\\''"); + sb.AppendLine($"echo ' {escapedMount}'"); + } + + if (ctx.Mounts.Count > 5) + { + sb.AppendLine($"echo ' ... and {ctx.Mounts.Count - 5} more'"); + } + } + else + { + sb.AppendLine("echo ' (none configured)'"); + } + sb.AppendLine("echo ''"); + + // Show environment variables (limited for security) + sb.AppendLine("echo '🔐 ENVIRONMENT VARIABLES:'"); + if (ctx.Environment.Any()) + { + foreach (var env in ctx.Environment.Take(5)) + { + var value = env.Key.Contains("TOKEN") || env.Key.Contains("SECRET") || env.Key.Contains("PASSWORD") + ? "***" + : env.Value; + sb.AppendLine($"echo ' {env.Key}={value}'"); + } + + if (ctx.Environment.Count > 5) + { + sb.AppendLine($"echo ' ... and {ctx.Environment.Count - 5} more'"); + } + } + else + { + sb.AppendLine("echo ' (none)'"); + } + + sb.AppendLine("echo ''"); + sb.AppendLine("echo '═══════════════════════════════════════════════════════════'"); + sb.AppendLine("echo '💡 TIP: Remove --tool echo to execute the actual command'"); + sb.AppendLine("echo '═══════════════════════════════════════════════════════════'"); + + return sb.ToString(); + } + + public string? GetInteractiveFlag() + { + return null; // Echo doesn't have an interactive flag + } + + public List GetYoloModeFlags() + { + return []; // Echo doesn't need special YOLO flags + } + + public string GetConfigDirName() + { + return "cli_mate"; // Echo uses the same config directory + } + + public string? GetSessionDataPath() + { + return null; // Echo doesn't persist session data + } + + public Infrastructure.IAuthProvider GetAuthProvider() + { + return new EchoAuthProvider(); + } + + public Infrastructure.IModelProvider GetModelProvider() + { + return new EchoModelProvider(); + } + + public string[] GetRequiredDependencies() + { + return ["docker"]; // Only Docker is required for echo + } + + public string GetDefaultNetworkRulesPath() + { + return "docker/echo/default-airlock-rules.json"; + } + + public bool SupportsModels => true; + public bool SupportsYoloMode => true; + public bool SupportsInteractiveMode => true; +} diff --git a/app/Tools/GitHubAuthProvider.cs b/app/Tools/GitHubAuthProvider.cs new file mode 100644 index 0000000..8fdff9a --- /dev/null +++ b/app/Tools/GitHubAuthProvider.cs @@ -0,0 +1,184 @@ +using System.Diagnostics; +using System.Text.RegularExpressions; +using CopilotHere.Infrastructure; + +namespace CopilotHere.Tools; + +/// +/// GitHub CLI authentication provider for GitHub Copilot. +/// Refactored from GitHubAuth.cs to implement IAuthProvider. +/// +public sealed partial class GitHubAuthProvider : IAuthProvider +{ + /// + /// Required scopes for GitHub Copilot to function. + /// + private static readonly string[] _requiredScopes = ["copilot", "read:packages"]; + + /// + /// The command to elevate token with required scopes. + /// + private static string ElevateTokenCommand => $"gh auth refresh -h github.com -s {string.Join(",", _requiredScopes)}"; + + // Regex patterns for scope detection (compiled for AOT) + // Matches privileged scopes like 'admin:org', 'manage_runners:org', 'write_packages', etc. + [GeneratedRegex(@"'(admin:[^']+|manage_[^']+|write:public_key|delete_repo|write_packages|delete_packages)'", RegexOptions.IgnoreCase)] + private static partial Regex PrivilegedScopesPattern(); + + public (bool isValid, string? error) ValidateAuth() + { + DebugLogger.Log("GitHubAuthProvider.ValidateAuth called"); + + // Skip validation in test mode + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("COPILOT_HERE_TEST_MODE"))) + { + DebugLogger.Log("Test mode detected, skipping validation"); + return (true, null); + } + + var debug = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("COPILOT_HERE_DEBUG")); + + var output = GetAuthStatus(); + if (output is null) + { + DebugLogger.Log("GetAuthStatus returned null"); + return (false, "Failed to run gh auth status"); + } + + if (debug) + { + Console.WriteLine("[DEBUG] gh auth status output:"); + Console.WriteLine(output); + Console.WriteLine("[DEBUG] End of output"); + } + + // Check for required scopes - gh auth status outputs scopes in format: 'scope1', 'scope2' + var hasCopilotScope = output.Contains("'copilot'", StringComparison.Ordinal); + var hasPackagesScope = output.Contains("'read:packages'", StringComparison.Ordinal); + + if (debug) + { + Console.WriteLine($"[DEBUG] hasCopilotScope: {hasCopilotScope}"); + Console.WriteLine($"[DEBUG] hasPackagesScope: {hasPackagesScope}"); + } + + if (!hasCopilotScope || !hasPackagesScope) + { + var missingScopes = new List(); + if (!hasCopilotScope) missingScopes.Add("copilot"); + if (!hasPackagesScope) missingScopes.Add("read:packages"); + + DebugLogger.Log($"Missing scopes: {string.Join(", ", missingScopes)}"); + return (false, $"❌ Your gh token is missing the required scope(s): {string.Join(", ", missingScopes)}\n" + + $"Please run: {ElevateTokenCommand}"); + } + + // Warn about privileged scopes and require confirmation + if (HasPrivilegedScopes(output)) + { + DebugLogger.Log("Privileged scopes detected, asking for confirmation"); + Console.WriteLine("⚠️ Warning: Your GitHub token has highly privileged scopes (e.g., admin:org, admin:enterprise)."); + Console.Write("Are you sure you want to proceed with this token? [y/N]: "); + + var response = Console.ReadLine()?.Trim().ToLowerInvariant(); + DebugLogger.Log($"User response: {response}"); + if (response != "y" && response != "yes") + { + DebugLogger.Log("User declined to proceed"); + return (false, "Operation cancelled by user."); + } + } + + DebugLogger.Log("Scope validation passed"); + return (true, null); + } + + public string GetToken() + { + try + { + var startInfo = new ProcessStartInfo("gh", "auth token") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(startInfo); + if (process is null) return string.Empty; + + var token = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(); + + return process.ExitCode == 0 ? token : string.Empty; + } + catch + { + return string.Empty; + } + } + + public string[] GetRequiredScopes() + { + return _requiredScopes; + } + + public string GetElevateTokenCommand() + { + return ElevateTokenCommand; + } + + public Dictionary GetEnvironmentVars() + { + var token = GetToken(); + return new Dictionary + { + ["GITHUB_TOKEN"] = token, + ["GH_TOKEN"] = token + }; + } + + // === PRIVATE HELPER METHODS === + + /// + /// Gets the auth status output from gh CLI. + /// + private static string? GetAuthStatus() + { + try + { + var startInfo = new ProcessStartInfo("gh", "auth status") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(startInfo); + if (process is null) return null; + + // gh auth status outputs to stderr normally, but may vary by version + // Read both to ensure we capture the scope information + var stderr = process.StandardError.ReadToEnd(); + var stdout = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + + // Combine both outputs - scopes could be in either + return stderr + stdout; + } + catch + { + return null; + } + } + + /// + /// Checks if the auth status output contains any privileged scopes. + /// + private static bool HasPrivilegedScopes(string authOutput) + { + return PrivilegedScopesPattern().IsMatch(authOutput); + } +} diff --git a/app/Tools/GitHubCopilotModelProvider.cs b/app/Tools/GitHubCopilotModelProvider.cs new file mode 100644 index 0000000..85d6a1b --- /dev/null +++ b/app/Tools/GitHubCopilotModelProvider.cs @@ -0,0 +1,121 @@ +using System.Text.RegularExpressions; +using CopilotHere.Infrastructure; + +namespace CopilotHere.Tools; + +/// +/// Model provider for GitHub Copilot CLI. +/// Handles listing and validating AI models. +/// +public sealed partial class GitHubCopilotModelProvider : IModelProvider +{ + public Task> ListAvailableModels(Infrastructure.AppContext ctx) + { + DebugLogger.Log("GitHubCopilotModelProvider.ListAvailableModels called"); + + var imageTag = ctx.ImageConfig.Tag; + var imageName = ctx.ActiveTool.GetImageName(imageTag); + + // Pull image if needed (quietly) + if (!ContainerRunner.PullImage(ctx.RuntimeConfig, imageName)) + { + DebugLogger.Log("Failed to pull image"); + return Task.FromResult(new List()); + } + + // Run copilot with an invalid model to trigger error that lists valid models + var args = new List + { + "run", + "--rm" + }; + + // Add auth environment variables + var authProvider = ctx.ActiveTool.GetAuthProvider(); + foreach (var (key, value) in authProvider.GetEnvironmentVars()) + { + args.Add("--env"); + args.Add($"{key}={value}"); + } + + args.Add(imageName); + args.Add("copilot"); // Just "copilot", not "gh copilot" + args.Add("--model"); + args.Add("invalid-model-to-trigger-list"); + + DebugLogger.Log($"Running: {ctx.RuntimeConfig.Runtime} run ... copilot --model invalid-model-to-trigger-list"); + var (exitCode, stdout, stderr) = ContainerRunner.RunAndCapture(ctx.RuntimeConfig, args); + + DebugLogger.Log($"Exit code: {exitCode}"); + DebugLogger.Log($"stderr: {stderr}"); + DebugLogger.Log($"stdout: {stdout}"); + + // Parse the error message to extract model list + return Task.FromResult(ParseModelListFromError(stderr)); + } + + public (bool isValid, string? error) ValidateModel(string model) + { + // For GitHub Copilot, we don't pre-validate models + // Let the CLI validate them at runtime + if (string.IsNullOrWhiteSpace(model)) + { + return (false, "Model name cannot be empty"); + } + + return (true, null); + } + + // === PRIVATE HELPER METHODS === + + private static List ParseModelListFromError(string errorOutput) + { + var models = new List(); + + // Look for "Allowed choices are model1, model2, model3." + // Need to handle dots in model names (e.g., gpt-5.1) + // Match everything after "Allowed choices are" until period followed by newline or end + var match = Regex.Match(errorOutput, @"Allowed choices are\s+(.+?)\.(?:\s|$)", RegexOptions.IgnoreCase | RegexOptions.Singleline); + if (match.Success) + { + var modelString = match.Groups[1].Value; + DebugLogger.Log($"Captured model string: '{modelString}'"); + + // Split by comma + var parts = modelString.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + foreach (var part in parts) + { + var cleaned = part.Trim(); + if (!string.IsNullOrWhiteSpace(cleaned)) + { + models.Add(cleaned); + DebugLogger.Log($"Added model: '{cleaned}'"); + } + } + return models; + } + + DebugLogger.Log("'Allowed choices are' pattern did not match"); + + // Fallback: Look for patterns like "valid values are:" or "available models:" + match = Regex.Match(errorOutput, @"(?:valid|available)[^:]*:\s*(.+)", RegexOptions.IgnoreCase); + if (match.Success) + { + var modelString = match.Groups[1].Value; + var parts = Regex.Split(modelString, @"[,;\n]"); + foreach (var part in parts) + { + var cleaned = part.Trim().Trim('"', '\'', '`', '.', ' '); + if (!string.IsNullOrWhiteSpace(cleaned) && + !cleaned.Contains("and") && + !cleaned.Contains("or") && + cleaned.Length < 50) + { + models.Add(cleaned); + } + } + } + + return models.Distinct().ToList(); + } +} diff --git a/app/Tools/GitHubCopilotTool.cs b/app/Tools/GitHubCopilotTool.cs new file mode 100644 index 0000000..cded22a --- /dev/null +++ b/app/Tools/GitHubCopilotTool.cs @@ -0,0 +1,112 @@ +using CopilotHere.Infrastructure; + +namespace CopilotHere.Tools; + +/// +/// GitHub Copilot CLI tool provider. +/// +public sealed class GitHubCopilotTool : ICliTool +{ + private readonly IAuthProvider _authProvider = new GitHubAuthProvider(); + private readonly IModelProvider _modelProvider = new GitHubCopilotModelProvider(); + + public string Name => "github-copilot"; + + public string DisplayName => "GitHub Copilot CLI"; + + public string GetImageName(string tag) + { + // Image name format: ghcr.io/gordonbeeming/copilot_here:copilot-{variant} + // Tag matches what users invoke: "copilot" (not "github-copilot") + const string imagePrefix = "ghcr.io/gordonbeeming/copilot_here"; + var imageTag = string.IsNullOrEmpty(tag) ? "copilot-latest" : $"copilot-{tag}"; + return $"{imagePrefix}:{imageTag}"; + } + + public string GetDockerfile() + { + return "docker/tools/github-copilot/Dockerfile"; + } + + public List BuildCommand(CommandContext context) + { + var args = new List { "copilot" }; + + // Add YOLO mode flags + if (context.IsYolo) + { + args.Add("--allow-all-tools"); + args.Add("--allow-all-paths"); + } + + // Add model if specified + if (!string.IsNullOrEmpty(context.Model)) + { + args.Add("--model"); + args.Add(context.Model); + } + + // Add user arguments + args.AddRange(context.UserArgs); + + // If no args (interactive mode), add --banner + // Check count excluding model args since model doesn't mean non-interactive + var argsWithoutModel = args.Count; + if (!string.IsNullOrEmpty(context.Model)) + argsWithoutModel -= 2; // Subtract --model and its value + + if (argsWithoutModel == 1 || (argsWithoutModel <= 3 && context.IsYolo)) + { + args.Add("--banner"); + } + + return args; + } + + public List GetYoloModeFlags() + { + return ["--allow-all-tools", "--allow-all-paths"]; + } + + public string GetInteractiveFlag() + { + return "--banner"; + } + + public IAuthProvider GetAuthProvider() + { + return _authProvider; + } + + public IModelProvider GetModelProvider() + { + return _modelProvider; + } + + public string[] GetRequiredDependencies() + { + return ["docker", "gh"]; + } + + public string GetConfigDirName() + { + return ".copilot"; + } + + public string? GetSessionDataPath() + { + // GitHub Copilot stores session data in ~/.copilot + return null; + } + + public string GetDefaultNetworkRulesPath() + { + return "docker/tools/github-copilot/default-airlock-rules.json"; + } + + public bool SupportsModels => true; + + public bool SupportsYoloMode => true; + + public bool SupportsInteractiveMode => true; +} diff --git a/dev-build.sh b/dev-build.sh index 0d12128..9da696f 100755 --- a/dev-build.sh +++ b/dev-build.sh @@ -12,6 +12,37 @@ NO_CACHE="" INCLUDE_ALL=false declare -a INCLUDE_VARIANTS=() +# Detect container runtime from local config or auto-detect +detect_runtime() { + local runtime="" + + # Check local .copilot_here/runtime.conf + if [[ -f "${SCRIPT_DIR}/.copilot_here/runtime.conf" ]]; then + runtime=$(cat "${SCRIPT_DIR}/.copilot_here/runtime.conf" | tr -d '[:space:]') + fi + + # Check global config if not set locally + if [[ -z "$runtime" ]] && [[ -f "$HOME/.config/copilot_here/runtime.conf" ]]; then + runtime=$(cat "$HOME/.config/copilot_here/runtime.conf" | tr -d '[:space:]') + fi + + # Auto-detect if not configured + if [[ -z "$runtime" ]] || [[ "$runtime" == "auto" ]]; then + if command -v docker &> /dev/null; then + runtime="docker" + elif command -v podman &> /dev/null; then + runtime="podman" + else + echo "Error: No container runtime found (docker or podman)" + exit 1 + fi + fi + + echo "$runtime" +} + +CONTAINER_RUNTIME=$(detect_runtime) + # Detect OS and architecture for native binary detect_rid() { local os="" @@ -94,10 +125,11 @@ for variant in "${INCLUDE_VARIANTS[@]}"; do done echo "========================================" -echo " Building Docker Images Locally" +echo " Building Container Images Locally" echo "========================================" echo "" echo "Registry: $REGISTRY" +echo "Runtime: $CONTAINER_RUNTIME" echo "" # Build native binary @@ -107,14 +139,14 @@ PUBLISH_DIR="${SCRIPT_DIR}/publish/${RID}" BIN_DIR="$HOME/.local/bin" # Check for running copilot_here containers -RUNNING_CONTAINERS=$(docker ps --filter "name=copilot_here-" -q) +RUNNING_CONTAINERS=$($CONTAINER_RUNTIME ps --filter "name=copilot_here-" -q) if [ -n "$RUNNING_CONTAINERS" ]; then - echo "⚠️ copilot_here is currently running in Docker" + echo "⚠️ copilot_here is currently running in $CONTAINER_RUNTIME" printf " Stop running containers to continue? [y/N]: " read -r response if [ "$response" = "y" ] || [ "$response" = "Y" ] || [ "$response" = "yes" ] || [ "$response" = "YES" ]; then echo "🛑 Stopping copilot_here containers..." - docker stop $RUNNING_CONTAINERS 2>/dev/null + $CONTAINER_RUNTIME stop $RUNNING_CONTAINERS 2>/dev/null echo " ✓ Stopped" else echo "❌ Cannot build while containers are running (binary is in use)" @@ -166,7 +198,7 @@ echo "" # Build proxy image echo "🔧 Building proxy image..." -docker build $NO_CACHE \ +$CONTAINER_RUNTIME build $NO_CACHE \ -t "${REGISTRY}:proxy" \ -f "${SCRIPT_DIR}/docker/Dockerfile.proxy" \ "${SCRIPT_DIR}" @@ -174,12 +206,14 @@ echo " ✓ Tagged as ${REGISTRY}:proxy" echo "" # Build base image -echo "🔧 Building base image..." -docker build $NO_CACHE \ +echo "🔧 Building base image (GitHub Copilot)..." +$CONTAINER_RUNTIME build $NO_CACHE \ -t "${REGISTRY}:latest" \ - -f "${SCRIPT_DIR}/docker/Dockerfile.base" \ + -t "${REGISTRY}:copilot-latest" \ + -f "${SCRIPT_DIR}/docker/tools/github-copilot/Dockerfile" \ "${SCRIPT_DIR}" echo " ✓ Tagged as ${REGISTRY}:latest" +echo " ✓ Tagged as ${REGISTRY}:copilot-latest" echo "" # Build variant images (only if explicitly requested) @@ -194,13 +228,15 @@ build_variant() { fi if [[ -f "$variant_file" ]]; then - echo "🔧 Building variant: $variant_name..." - docker build $NO_CACHE \ + echo "🔧 Building variant: $variant_name (GitHub Copilot)..." + $CONTAINER_RUNTIME build $NO_CACHE \ $build_args \ -t "${REGISTRY}:${variant_name}" \ + -t "${REGISTRY}:copilot-${variant_name}" \ -f "$variant_file" \ "${SCRIPT_DIR}" echo " ✓ Tagged as ${REGISTRY}:${variant_name}" + echo " ✓ Tagged as ${REGISTRY}:copilot-${variant_name}" echo "" else echo "⚠️ Variant not found: $variant_name" @@ -211,14 +247,14 @@ build_variant() { # Build regular variants first (depend on base) if [[ ${#REGULAR_VARIANTS[@]} -gt 0 ]]; then for variant_name in "${REGULAR_VARIANTS[@]}"; do - build_variant "$variant_name" "--build-arg BASE_IMAGE_TAG=latest" + build_variant "$variant_name" "--build-arg BASE_IMAGE_TAG=copilot-latest" done fi # Build compound variants (depend on other variants like dotnet) if [[ ${#COMPOUND_VARIANTS[@]} -gt 0 ]]; then for variant_name in "${COMPOUND_VARIANTS[@]}"; do - build_variant "$variant_name" "--build-arg DOTNET_IMAGE_TAG=dotnet" + build_variant "$variant_name" "--build-arg DOTNET_IMAGE_TAG=copilot-dotnet" done fi @@ -232,7 +268,7 @@ echo " Build Complete!" echo "========================================" echo "" echo "Built images:" -docker images --format " {{.Repository}}:{{.Tag}}\t{{.Size}}" | grep "$REGISTRY" | sort +$CONTAINER_RUNTIME images --format " {{.Repository}}:{{.Tag}}\t{{.Size}}" | grep "$REGISTRY" | sort echo "" echo "Run integration tests with:" echo " ./tests/integration/test_airlock.sh --use-local" diff --git a/docker/compound-variants/Dockerfile.dotnet-playwright b/docker/compound-variants/Dockerfile.dotnet-playwright index f457ac3..c953d0e 100644 --- a/docker/compound-variants/Dockerfile.dotnet-playwright +++ b/docker/compound-variants/Dockerfile.dotnet-playwright @@ -1,7 +1,6 @@ -# Build on top of the .NET image to add Playwright capabilities -# ARG will be provided by the build process with the commit hash -ARG DOTNET_IMAGE_TAG=dotnet -ARG PLAYWRIGHT_VERSION=latest +# Build on top of any tool's dotnet variant to add Playwright +# DOTNET_IMAGE_TAG should be the dotnet variant (copilot-dotnet, echo-dotnet, etc.) +ARG DOTNET_IMAGE_TAG=copilot-dotnet FROM ghcr.io/gordonbeeming/copilot_here:${DOTNET_IMAGE_TAG} # Switch to root to install packages diff --git a/docker/compound-variants/Dockerfile.dotnet-rust b/docker/compound-variants/Dockerfile.dotnet-rust index 347c72f..2f16a75 100644 --- a/docker/compound-variants/Dockerfile.dotnet-rust +++ b/docker/compound-variants/Dockerfile.dotnet-rust @@ -1,6 +1,6 @@ -# Build on top of the .NET image to add Rust capabilities -# This is the largest compound variant: .NET 8/9/10 + Rust -ARG DOTNET_IMAGE_TAG=dotnet +# Build on top of any tool's dotnet variant to add Rust +# DOTNET_IMAGE_TAG should be the dotnet variant (copilot-dotnet, echo-dotnet, etc.) +ARG DOTNET_IMAGE_TAG=copilot-dotnet FROM ghcr.io/gordonbeeming/copilot_here:${DOTNET_IMAGE_TAG} # Switch to root to install packages diff --git a/docker/shared/entrypoint-airlock.sh b/docker/shared/entrypoint-airlock.sh new file mode 100644 index 0000000..44e2cfc --- /dev/null +++ b/docker/shared/entrypoint-airlock.sh @@ -0,0 +1,24 @@ +#!/bin/bash +set -e + +echo "🔧 Initializing Copilot Container (Airlock)..." + +# Wait for CA certificate from proxy +echo "⏳ Waiting for proxy CA certificate..." +while [ ! -f "/ca/certs/ca.pem" ]; do + sleep 0.5 +done + +# Trust the proxy CA certificate (running as root here) +echo "📜 Trusting Proxy CA Certificate..." +cp /ca/certs/ca.pem /usr/local/share/ca-certificates/secure-proxy-ca.crt +update-ca-certificates 2>/dev/null + +# Also set NODE_EXTRA_CA_CERTS for Node.js apps +export NODE_EXTRA_CA_CERTS=/ca/certs/ca.pem + +echo "✅ Container Ready." +echo "" + +# Run the original entrypoint with the command +exec /usr/local/bin/entrypoint.sh "$@" diff --git a/docker/shared/entrypoint.sh b/docker/shared/entrypoint.sh new file mode 100644 index 0000000..9fd7c5d --- /dev/null +++ b/docker/shared/entrypoint.sh @@ -0,0 +1,72 @@ +#!/bin/bash +set -e + +# Get the user and group IDs from environment variables, default to 1000 if not set. +USER_ID=${PUID:-1000} +GROUP_ID=${PGID:-1000} + +# If the desired IDs are already in use in the base image (e.g. node:1000), +# move those accounts out of the way so we can run as appuser with the host UID/GID. +existing_group=$(getent group "$GROUP_ID" | cut -d: -f1 || true) +if [ -n "$existing_group" ] && [ "$existing_group" != "appuser_group" ]; then + NEW_GROUP_ID=$((GROUP_ID + 1)) + while getent group "$NEW_GROUP_ID" >/dev/null 2>&1; do + NEW_GROUP_ID=$((NEW_GROUP_ID + 1)) + done + echo "GID $GROUP_ID is already in use by $existing_group, moving it to $NEW_GROUP_ID" >&2 + groupmod -g "$NEW_GROUP_ID" "$existing_group" >/dev/null 2>&1 || true +fi + +existing_user=$(getent passwd "$USER_ID" | cut -d: -f1 || true) +if [ -n "$existing_user" ] && [ "$existing_user" != "appuser" ]; then + NEW_USER_ID=$((USER_ID + 1)) + while id -u "$NEW_USER_ID" >/dev/null 2>&1; do + NEW_USER_ID=$((NEW_USER_ID + 1)) + done + echo "UID $USER_ID is already in use by $existing_user, moving it to $NEW_USER_ID" >&2 + usermod -u "$NEW_USER_ID" "$existing_user" >/dev/null 2>&1 || true + + user_gid=$(id -g "$existing_user" 2>/dev/null || true) + user_home=$(getent passwd "$existing_user" | cut -d: -f6 || true) + if [ -n "$user_home" ] && [ -d "$user_home" ]; then + if [ -n "$user_gid" ]; then + chown -R "$NEW_USER_ID:$user_gid" "$user_home" >/dev/null 2>&1 || true + else + chown -R "$NEW_USER_ID" "$user_home" >/dev/null 2>&1 || true + fi + fi +fi + +# Create a group and user with the requested IDs. +groupadd --gid "$GROUP_ID" appuser_group >/dev/null 2>&1 || true +useradd --uid "$USER_ID" --gid "$GROUP_ID" --shell /bin/bash --create-home appuser >/dev/null 2>&1 || true + +# Verify the user was created successfully +if ! id appuser >/dev/null 2>&1; then + echo "Warning: Failed to create appuser, running as root" >&2 + mkdir -p /home/appuser/.copilot + exec "$@" +fi + +# Set up directories with correct ownership (avoid chowning /home/appuser wholesale, +# because /home/appuser/** can include bind mounts to the host). +mkdir -p /home/appuser +mkdir -p /home/appuser/.copilot +mkdir -p /home/appuser/.dotnet +mkdir -p /home/appuser/.nuget +mkdir -p /home/appuser/.local +mkdir -p /home/appuser/.cache +mkdir -p /home/appuser/.config +mkdir -p /home/appuser/.npm +chown -R "$USER_ID:$GROUP_ID" /home/appuser/.copilot +chown -R "$USER_ID:$GROUP_ID" /home/appuser/.dotnet +chown -R "$USER_ID:$GROUP_ID" /home/appuser/.nuget +chown -R "$USER_ID:$GROUP_ID" /home/appuser/.local +chown -R "$USER_ID:$GROUP_ID" /home/appuser/.cache +chown -R "$USER_ID:$GROUP_ID" /home/appuser/.config +chown -R "$USER_ID:$GROUP_ID" /home/appuser/.npm + +export HOME=/home/appuser + +# Switch to the user matching the host UID and execute the command passed to the script. +exec gosu appuser "$@" diff --git a/docker/tools/echo/default-airlock-rules.json b/docker/tools/echo/default-airlock-rules.json new file mode 100644 index 0000000..947e8b6 --- /dev/null +++ b/docker/tools/echo/default-airlock-rules.json @@ -0,0 +1,5 @@ +{ + "enabled": true, + "enable_logging": false, + "allowed_rules": [] +} diff --git a/docker/Dockerfile.base b/docker/tools/github-copilot/Dockerfile similarity index 95% rename from docker/Dockerfile.base rename to docker/tools/github-copilot/Dockerfile index 717af47..c28ce53 100644 --- a/docker/Dockerfile.base +++ b/docker/tools/github-copilot/Dockerfile @@ -47,8 +47,8 @@ RUN npm install -g @github/copilot@${COPILOT_VERSION} WORKDIR /work # Copy the entrypoint script into the container and make it executable. -COPY entrypoint.sh /usr/local/bin/ -COPY entrypoint-airlock.sh /usr/local/bin/ +COPY docker/shared/entrypoint.sh /usr/local/bin/ +COPY docker/shared/entrypoint-airlock.sh /usr/local/bin/ COPY docker/session-info.sh /usr/local/bin/session-info RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/entrypoint-airlock.sh /usr/local/bin/session-info diff --git a/docker/tools/github-copilot/default-airlock-rules.json b/docker/tools/github-copilot/default-airlock-rules.json new file mode 100644 index 0000000..3bf5465 --- /dev/null +++ b/docker/tools/github-copilot/default-airlock-rules.json @@ -0,0 +1,41 @@ +{ + "enabled": true, + "enable_logging": false, + "allowed_rules": [ + { + "host": "api.github.com", + "allowed_paths": [ + "/user", + "/graphql", + "/repos/github/copilot-cli/releases/latest" + ] + }, + { + "host": "api.githubcopilot.com", + "allowed_paths": [ + "/models", + "/mcp/readonly", + "/chat/completions", + "/agents/swe/custom-agents/{{GITHUB_OWNER}}/{{GITHUB_REPO}}*" + ] + }, + { + "host": "api.individual.githubcopilot.com", + "allowed_paths": [ + "/models", + "/mcp/readonly", + "/chat/completions", + "/agents/swe/custom-agents/{{GITHUB_OWNER}}/{{GITHUB_REPO}}*" + ] + }, + { + "host": "api.enterprise.githubcopilot.com", + "allowed_paths": [ + "/models", + "/mcp/readonly", + "/chat/completions", + "/agents/swe/custom-agents/{{GITHUB_OWNER}}/{{GITHUB_REPO}}*" + ] + } + ] +} \ No newline at end of file diff --git a/docker/variants/Dockerfile.dotnet b/docker/variants/Dockerfile.dotnet index 4928e39..f0b7ff6 100644 --- a/docker/variants/Dockerfile.dotnet +++ b/docker/variants/Dockerfile.dotnet @@ -1,6 +1,6 @@ -# Build on top of the base copilot_here image -# ARG will be provided by the build process with the commit hash -ARG BASE_IMAGE_TAG=latest +# Build on top of any tool's base image to add all .NET SDKs (8, 9, 10) +# BASE_IMAGE_TAG can be copilot-latest, echo-latest, claude-latest, etc. +ARG BASE_IMAGE_TAG=copilot-latest FROM ghcr.io/gordonbeeming/copilot_here:${BASE_IMAGE_TAG} ARG DOTNET_SDK_8_VERSION diff --git a/docker/variants/Dockerfile.dotnet10 b/docker/variants/Dockerfile.dotnet10 index 2cab381..56f4bff 100644 --- a/docker/variants/Dockerfile.dotnet10 +++ b/docker/variants/Dockerfile.dotnet10 @@ -1,6 +1,6 @@ -# Build on top of the base copilot_here image -# ARG will be provided by the build process with the commit hash -ARG BASE_IMAGE_TAG +# Build on top of any tool's base image to add .NET 10 SDK +# BASE_IMAGE_TAG can be copilot-latest, echo-latest, claude-latest, etc. +ARG BASE_IMAGE_TAG=copilot-latest FROM ghcr.io/gordonbeeming/copilot_here:${BASE_IMAGE_TAG} ARG DOTNET_SDK_10_VERSION diff --git a/docker/variants/Dockerfile.dotnet8 b/docker/variants/Dockerfile.dotnet8 index 0e788d2..da94853 100644 --- a/docker/variants/Dockerfile.dotnet8 +++ b/docker/variants/Dockerfile.dotnet8 @@ -1,6 +1,6 @@ -# Build on top of the base copilot_here image -# ARG will be provided by the build process with the commit hash -ARG BASE_IMAGE_TAG +# Build on top of any tool's base image to add .NET 8 SDK +# BASE_IMAGE_TAG can be copilot-latest, echo-latest, claude-latest, etc. +ARG BASE_IMAGE_TAG=copilot-latest FROM ghcr.io/gordonbeeming/copilot_here:${BASE_IMAGE_TAG} ARG DOTNET_SDK_8_VERSION diff --git a/docker/variants/Dockerfile.dotnet9 b/docker/variants/Dockerfile.dotnet9 index 6ba19cc..c4fd918 100644 --- a/docker/variants/Dockerfile.dotnet9 +++ b/docker/variants/Dockerfile.dotnet9 @@ -1,6 +1,6 @@ -# Build on top of the base copilot_here image -# ARG will be provided by the build process with the commit hash -ARG BASE_IMAGE_TAG +# Build on top of any tool's base image to add .NET 9 SDK +# BASE_IMAGE_TAG can be copilot-latest, echo-latest, claude-latest, etc. +ARG BASE_IMAGE_TAG=copilot-latest FROM ghcr.io/gordonbeeming/copilot_here:${BASE_IMAGE_TAG} ARG DOTNET_SDK_9_VERSION diff --git a/docker/variants/Dockerfile.playwright b/docker/variants/Dockerfile.playwright index aea58dc..c4457dd 100644 --- a/docker/variants/Dockerfile.playwright +++ b/docker/variants/Dockerfile.playwright @@ -1,6 +1,6 @@ -# Build on top of the base image to add Playwright capabilities -# ARG will be provided by the build process with the commit hash -ARG BASE_IMAGE_TAG +# Build on top of any tool's base image to add Playwright capabilities +# BASE_IMAGE_TAG can be copilot-latest, echo-latest, claude-latest, etc. +ARG BASE_IMAGE_TAG=copilot-latest ARG PLAYWRIGHT_VERSION=latest FROM ghcr.io/gordonbeeming/copilot_here:${BASE_IMAGE_TAG} diff --git a/docker/variants/Dockerfile.rust b/docker/variants/Dockerfile.rust index ec051bd..b8a32e8 100644 --- a/docker/variants/Dockerfile.rust +++ b/docker/variants/Dockerfile.rust @@ -1,6 +1,6 @@ -# Build on top of the base copilot_here image -# ARG will be provided by the build process with the commit hash -ARG BASE_IMAGE_TAG=latest +# Build on top of any tool's base image +# BASE_IMAGE_TAG can be copilot-latest, echo-latest, claude-latest, etc. +ARG BASE_IMAGE_TAG=copilot-latest FROM ghcr.io/gordonbeeming/copilot_here:${BASE_IMAGE_TAG} # Switch to root to install packages diff --git a/docs/tasks/20260204-01-container-runtime-support.md b/docs/tasks/20260204-01-container-runtime-support.md new file mode 100644 index 0000000..1c17777 --- /dev/null +++ b/docs/tasks/20260204-01-container-runtime-support.md @@ -0,0 +1,193 @@ +# Container Runtime Support + +**Date**: 2026-02-04 + +## Overview + +Added support for multiple container runtimes (Docker, Podman, OrbStack) with automatic detection and configuration management. Users can now choose their preferred container runtime or let the system auto-detect the best available option. + +## Changes Made + +### New Components + +1. **`ContainerRuntimeConfig.cs`** - Core runtime configuration system + - Auto-detection of Docker, Podman, and OrbStack + - Configuration priority: CLI > Local > Global > Auto-detect + - Runtime-specific settings (compose command, network name, airlock support) + - Podman compose detection (built-in vs external) + +2. **Runtime Commands** (`app/Commands/Runtime/`) + - `--show-runtime` - Display current runtime configuration and available runtimes + - `--list-runtimes` - List all available container runtimes on the system + - `--set-runtime ` - Set runtime in local config (.copilot_here/runtime.conf) + - `--set-runtime-global ` - Set runtime in global config (~/.config/copilot_here/runtime.conf) + +3. **Configuration Files** + - Local: `.copilot_here/runtime.conf` + - Global: `~/.config/copilot_here/runtime.conf` + - Values: `docker`, `podman`, or `auto` (for auto-detection) + +### Updated Components + +1. **`ContainerRunner.cs`** (renamed from `DockerRunner.cs`) + - Now accepts `ContainerRuntimeConfig` parameter + - Uses configured runtime instead of hardcoded "docker" + - Updated all Docker-specific references to be runtime-agnostic + +2. **`DependencyCheck.cs`** + - Updated to check configured runtime instead of only Docker + - Runtime-agnostic error messages and help text + - Detects and reports specific runtime flavor (Docker, OrbStack, Podman) + +3. **`AirlockRunner.cs`** + - Updated to use configured runtime for compose commands + - Supports both `docker compose` and `podman compose` syntax + +4. **`RunCommand.cs`** + - Displays runtime flavor in output: "🐳 Container runtime: Docker" + - Passes runtime config to all container operations + +5. **`AppContext.cs`** + - Added `RuntimeConfig` property + - Loads runtime configuration during context creation + +## Supported Runtimes + +### Docker +- **Command**: `docker` +- **Compose**: `docker compose` (built-in) +- **Network**: `bridge` +- **Airlock**: ✅ Supported + +### OrbStack +- **Command**: `docker` (context: orbstack) +- **Compose**: `docker compose` (built-in) +- **Network**: `bridge` +- **Airlock**: ✅ Supported +- **Auto-detected**: When `docker context show` contains "orbstack" + +### Podman +- **Command**: `podman` +- **Compose**: `podman compose` or `podman-compose` (auto-detected) +- **Network**: `podman` +- **Airlock**: ✅ Supported + +## Configuration Priority + +Following the project's configuration priority standard: + +1. **CLI Arguments** (future: `--runtime docker/podman`) +2. **Local Config** (`.copilot_here/runtime.conf`) +3. **Global Config** (`~/.config/copilot_here/runtime.conf`) +4. **Auto-detect** (tries Docker first, then Podman) + +## Usage Examples + +### Show Current Runtime +```bash +copilot_here --show-runtime +``` + +Output: +``` +🐳 Container Runtime: Docker + Command: docker + Version: Docker version 25.0.0, build xxx + + Source: 🌐 Global (~/.config/copilot_here/runtime.conf) + 🌐 Global config: docker + + Capabilities: + • Compose command: docker compose + • Airlock support: Yes + • Default network: bridge + + Available runtimes: + ▶ Docker (docker) + Podman (podman) +``` + +### List Available Runtimes +```bash +copilot_here --list-runtimes +``` + +### Set Runtime Locally +```bash +# Use Podman for this project +copilot_here --set-runtime podman + +# Use Docker for this project +copilot_here --set-runtime docker + +# Use auto-detection for this project +copilot_here --set-runtime auto +``` + +### Set Runtime Globally +```bash +# Use Podman globally +copilot_here --set-runtime-global podman + +# Use Docker globally +copilot_here --set-runtime-global docker +``` + +## Testing + +### Unit Tests +- `ContainerRuntimeConfigTests.cs` - Configuration loading, priority, auto-detection +- `DependencyCheckTests.cs` - Updated to verify runtime-agnostic checks + +### Manual Testing +- ✅ Docker on Linux (auto-detect) +- ✅ Docker on macOS (auto-detect) +- ✅ OrbStack on macOS (auto-detect and display) +- ✅ Podman on Linux (auto-detect and usage) +- ✅ Local config override +- ✅ Global config override +- ✅ Manual runtime switching + +## Migration Notes + +### For Users +- **No action required** - Auto-detection will continue to use Docker if available +- **Optional**: Set preferred runtime with `--set-runtime` or `--set-runtime-global` +- **Existing behavior**: Everything works the same by default + +### For Code +- `DockerRunner` renamed to `ContainerRunner` +- All methods now require `ContainerRuntimeConfig` parameter +- Hardcoded "docker" strings replaced with `runtimeConfig.Runtime` +- References to "Docker" in messages updated to use `runtimeConfig.RuntimeFlavor` + +## Future Enhancements + +- [ ] CLI flag: `--runtime docker/podman` for one-time override +- [ ] Runtime-specific optimization settings +- [ ] Support for additional runtimes (nerdctl, containerd, etc.) +- [ ] Runtime health checks and diagnostics +- [ ] Per-image runtime preferences + +## Breaking Changes + +**None** - This is a backward-compatible addition. Existing installations will continue using Docker via auto-detection. + +## Files Changed + +- Added: `app/Infrastructure/ContainerRuntimeConfig.cs` +- Added: `app/Commands/Runtime/ListRuntimes.cs` +- Added: `app/Commands/Runtime/SetRuntime.cs` +- Added: `app/Commands/Runtime/ShowRuntime.cs` +- Added: `app/Commands/Runtime/_RuntimeCommands.cs` +- Added: `tests/CopilotHere.UnitTests/ContainerRuntimeConfigTests.cs` +- Renamed: `app/Infrastructure/DockerRunner.cs` → `ContainerRunner.cs` +- Modified: `app/Infrastructure/DependencyCheck.cs` +- Modified: `app/Infrastructure/AirlockRunner.cs` +- Modified: `app/Infrastructure/AppContext.cs` +- Modified: `app/Commands/Run/RunCommand.cs` +- Modified: `app/Commands/Model/ListModels.cs` +- Modified: `app/Tools/GitHubCopilotModelProvider.cs` +- Modified: `app/Program.cs` +- Modified: `tests/CopilotHere.UnitTests/DependencyCheckTests.cs` +- Modified: `dev-build.sh` (updated to handle config files) diff --git a/tests/CopilotHere.UnitTests/AppContextToolLoadingTests.cs b/tests/CopilotHere.UnitTests/AppContextToolLoadingTests.cs new file mode 100644 index 0000000..bb872a4 --- /dev/null +++ b/tests/CopilotHere.UnitTests/AppContextToolLoadingTests.cs @@ -0,0 +1,402 @@ +using TUnit.Core; +using AppContext = CopilotHere.Infrastructure.AppContext; + +namespace CopilotHere.Tests; + +/// +/// Integration tests for AppContext tool loading with realistic scenarios. +/// Tests the complete priority chain: CLI arg > Local config > Global config > Default. +/// +/// Note: These tests modify environment and working directory, so they manipulate +/// the actual file system to create realistic test scenarios. +/// +[NotInParallel] +public class AppContextToolLoadingTests +{ + private string _tempDir = null!; + private string _originalWorkingDir = null!; + private string _testProjectDir = null!; + private string _testHomeDir = null!; + private string? _originalHome; + + [Before(Test)] + public void Setup() + { + _originalWorkingDir = Directory.GetCurrentDirectory(); + _tempDir = Path.Combine(Path.GetTempPath(), $"copilot_here_tests_{Guid.NewGuid():N}"); + _testProjectDir = Path.Combine(_tempDir, "project"); + _testHomeDir = Path.Combine(_tempDir, "home"); + + Directory.CreateDirectory(_tempDir); + Directory.CreateDirectory(_testProjectDir); + Directory.CreateDirectory(_testHomeDir); + + // Redirect HOME to test directory to isolate config files + _originalHome = Environment.GetEnvironmentVariable("HOME"); + Environment.SetEnvironmentVariable("HOME", _testHomeDir); + + // Also set USERPROFILE for Windows + if (OperatingSystem.IsWindows()) + { + Environment.SetEnvironmentVariable("USERPROFILE", _testHomeDir); + } + + // Set working directory to test project + Directory.SetCurrentDirectory(_testProjectDir); + } + + [After(Test)] + public void Cleanup() + { + // Restore original HOME + Environment.SetEnvironmentVariable("HOME", _originalHome); + if (OperatingSystem.IsWindows()) + { + Environment.SetEnvironmentVariable("USERPROFILE", _originalHome); + } + + Directory.SetCurrentDirectory(_originalWorkingDir); + + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, recursive: true); + } + + [Test] + public async Task Create_NoConfigNoOverride_UsesDefaultGitHubCopilot() + { + // Act + var ctx = AppContext.Create(toolOverride: null); + + // Assert + await Assert.That(ctx.ActiveTool).IsNotNull(); + await Assert.That(ctx.ActiveTool.Name).IsEqualTo("github-copilot"); + } + + [Test] + public async Task Create_WithToolOverride_UsesOverride() + { + // Arrange + SetupLocalToolConfig("github-copilot"); + SetupGlobalToolConfig("github-copilot"); + + // Act - override should win + var ctx = AppContext.Create(toolOverride: "echo"); + + // Assert + await Assert.That(ctx.ActiveTool.Name).IsEqualTo("echo"); + } + + [Test] + public async Task Create_WithLocalConfig_UsesLocalConfig() + { + // Arrange + SetupLocalToolConfig("echo"); + SetupGlobalToolConfig("github-copilot"); + + // Act + var ctx = AppContext.Create(toolOverride: null); + + // Assert + await Assert.That(ctx.ActiveTool.Name).IsEqualTo("echo"); + } + + [Test] + public async Task Create_WithGlobalConfigOnly_UsesGlobalConfig() + { + // Arrange + SetupGlobalToolConfig("echo"); + + // Act + var ctx = AppContext.Create(toolOverride: null); + + // Assert + await Assert.That(ctx.ActiveTool.Name).IsEqualTo("echo"); + } + + [Test] + public async Task Create_ToolOverrideTakesPriorityOverLocalConfig() + { + // Arrange + SetupLocalToolConfig("github-copilot"); + + // Act + var ctx = AppContext.Create(toolOverride: "echo"); + + // Assert + await Assert.That(ctx.ActiveTool.Name).IsEqualTo("echo"); + } + + [Test] + public async Task Create_ToolOverrideTakesPriorityOverGlobalConfig() + { + // Arrange + SetupGlobalToolConfig("github-copilot"); + + // Act + var ctx = AppContext.Create(toolOverride: "echo"); + + // Assert + await Assert.That(ctx.ActiveTool.Name).IsEqualTo("echo"); + } + + [Test] + public async Task Create_LocalConfigTakesPriorityOverGlobalConfig() + { + // Arrange + SetupLocalToolConfig("echo"); + SetupGlobalToolConfig("github-copilot"); + + // Act + var ctx = AppContext.Create(toolOverride: null); + + // Assert - Local should win + await Assert.That(ctx.ActiveTool.Name).IsEqualTo("echo"); + } + + [Test] + public async Task Create_InvalidToolOverride_FallsBackToDefault() + { + // Act - invalid tool name + var ctx = AppContext.Create(toolOverride: "nonexistent-tool"); + + // Assert - Should fall back to default + await Assert.That(ctx.ActiveTool.Name).IsEqualTo("github-copilot"); + } + + [Test] + public async Task Create_InvalidLocalConfig_FallsBackToGlobal() + { + // Arrange + SetupLocalToolConfig("invalid-tool"); + SetupGlobalToolConfig("echo"); + + // Act + var ctx = AppContext.Create(toolOverride: null); + + // Assert - Should skip invalid local and use global + await Assert.That(ctx.ActiveTool.Name).IsEqualTo("echo"); + } + + [Test] + public async Task Create_InvalidGlobalConfig_FallsBackToDefault() + { + // Arrange + SetupGlobalToolConfig("invalid-tool"); + + // Act + var ctx = AppContext.Create(toolOverride: null); + + // Assert - Should fall back to default + await Assert.That(ctx.ActiveTool.Name).IsEqualTo("github-copilot"); + } + + [Test] + public async Task Create_LegacyLocalConfig_StillWorks() + { + // Arrange - Use old .copilot_here directory + SetupLegacyLocalToolConfig("echo"); + + // Act + var ctx = AppContext.Create(toolOverride: null); + + // Assert + await Assert.That(ctx.ActiveTool.Name).IsEqualTo("echo"); + } + + [Test] + public async Task Create_LegacyGlobalConfig_StillWorks() + { + // Arrange - Use old ~/.config/copilot_here directory + SetupLegacyGlobalToolConfig("echo"); + + // Act + var ctx = AppContext.Create(toolOverride: null); + + // Assert + await Assert.That(ctx.ActiveTool.Name).IsEqualTo("echo"); + } + + [Test] + public async Task Create_NewLocalConfigTakesPriorityOverLegacy() + { + // Arrange + SetupLocalToolConfig("echo"); + SetupLegacyLocalToolConfig("github-copilot"); + + // Act + var ctx = AppContext.Create(toolOverride: null); + + // Assert - New config should win + await Assert.That(ctx.ActiveTool.Name).IsEqualTo("echo"); + } + + [Test] + public async Task Create_NewGlobalConfigTakesPriorityOverLegacy() + { + // Arrange + SetupGlobalToolConfig("echo"); + SetupLegacyGlobalToolConfig("github-copilot"); + + // Act + var ctx = AppContext.Create(toolOverride: null); + + // Assert - New config should win + await Assert.That(ctx.ActiveTool.Name).IsEqualTo("echo"); + } + + [Test] + public async Task Create_EmptyLocalConfig_FallsBackToGlobal() + { + // Arrange + SetupLocalToolConfig(" "); // Whitespace only + SetupGlobalToolConfig("echo"); + + // Act + var ctx = AppContext.Create(toolOverride: null); + + // Assert - Should skip empty local and use global + await Assert.That(ctx.ActiveTool.Name).IsEqualTo("echo"); + } + + [Test] + public async Task Create_CompletePriorityChain_OverrideWins() + { + // Arrange - Set up every level + SetupLocalToolConfig("github-copilot"); + SetupLegacyLocalToolConfig("github-copilot"); + SetupGlobalToolConfig("github-copilot"); + SetupLegacyGlobalToolConfig("github-copilot"); + + // Act - CLI override should win over everything + var ctx = AppContext.Create(toolOverride: "echo"); + + // Assert + await Assert.That(ctx.ActiveTool.Name).IsEqualTo("echo"); + } + + [Test] + public async Task Create_CompletePriorityChain_NewLocalWins() + { + // Arrange - Set up every level except override + SetupLocalToolConfig("echo"); + SetupLegacyLocalToolConfig("github-copilot"); + SetupGlobalToolConfig("github-copilot"); + SetupLegacyGlobalToolConfig("github-copilot"); + + // Act + var ctx = AppContext.Create(toolOverride: null); + + // Assert - New local should win + await Assert.That(ctx.ActiveTool.Name).IsEqualTo("echo"); + } + + [Test] + public async Task Create_CompletePriorityChain_LegacyLocalWins() + { + // Arrange - Skip new local, set rest + SetupLegacyLocalToolConfig("echo"); + SetupGlobalToolConfig("github-copilot"); + SetupLegacyGlobalToolConfig("github-copilot"); + + // Act + var ctx = AppContext.Create(toolOverride: null); + + // Assert - Legacy local should win + await Assert.That(ctx.ActiveTool.Name).IsEqualTo("echo"); + } + + [Test] + public async Task Create_CompletePriorityChain_NewGlobalWins() + { + // Arrange - Skip all local configs + SetupGlobalToolConfig("echo"); + SetupLegacyGlobalToolConfig("github-copilot"); + + // Act + var ctx = AppContext.Create(toolOverride: null); + + // Assert - New global should win + await Assert.That(ctx.ActiveTool.Name).IsEqualTo("echo"); + } + + [Test] + public async Task Create_CompletePriorityChain_LegacyGlobalWins() + { + // Arrange - Only legacy global + SetupLegacyGlobalToolConfig("echo"); + + // Act + var ctx = AppContext.Create(toolOverride: null); + + // Assert - Legacy global should win + await Assert.That(ctx.ActiveTool.Name).IsEqualTo("echo"); + } + + [Test] + public async Task Create_LoadsAllOtherConfigs() + { + // Act + var ctx = AppContext.Create(toolOverride: null); + + // Assert - Verify other configs are loaded + await Assert.That(ctx.ImageConfig).IsNotNull(); + await Assert.That(ctx.ModelConfig).IsNotNull(); + await Assert.That(ctx.MountsConfig).IsNotNull(); + await Assert.That(ctx.AirlockConfig).IsNotNull(); + await Assert.That(ctx.Paths).IsNotNull(); + await Assert.That(ctx.Environment).IsNotNull(); + } + + [Test] + public async Task Create_ToolHasCorrectProviders() + { + // Act + var ctx = AppContext.Create(toolOverride: "github-copilot"); + + // Assert + await Assert.That(ctx.ActiveTool.GetAuthProvider()).IsNotNull(); + await Assert.That(ctx.ActiveTool.GetModelProvider()).IsNotNull(); + } + + [Test] + public async Task Create_MultipleCallsWithSameOverride_ConsistentResults() + { + // Act + var ctx1 = AppContext.Create(toolOverride: "echo"); + var ctx2 = AppContext.Create(toolOverride: "echo"); + + // Assert - Should get same tool (though potentially different instances) + await Assert.That(ctx1.ActiveTool.Name).IsEqualTo(ctx2.ActiveTool.Name); + await Assert.That(ctx1.ActiveTool.Name).IsEqualTo("echo"); + } + + // Helper methods for setting up config files + + private void SetupLocalToolConfig(string toolName) + { + var configDir = Path.Combine(_testProjectDir, ".cli_mate"); + Directory.CreateDirectory(configDir); + File.WriteAllText(Path.Combine(configDir, "tool.conf"), toolName); + } + + private void SetupLegacyLocalToolConfig(string toolName) + { + var configDir = Path.Combine(_testProjectDir, ".copilot_here"); + Directory.CreateDirectory(configDir); + File.WriteAllText(Path.Combine(configDir, "tool.conf"), toolName); + } + + private void SetupGlobalToolConfig(string toolName) + { + var configDir = Path.Combine(_testHomeDir, ".config", "cli_mate"); + Directory.CreateDirectory(configDir); + File.WriteAllText(Path.Combine(configDir, "tool.conf"), toolName); + } + + private void SetupLegacyGlobalToolConfig(string toolName) + { + var configDir = Path.Combine(_testHomeDir, ".config", "copilot_here"); + Directory.CreateDirectory(configDir); + File.WriteAllText(Path.Combine(configDir, "tool.conf"), toolName); + } +} diff --git a/tests/CopilotHere.UnitTests/ContainerRuntimeConfigTests.cs b/tests/CopilotHere.UnitTests/ContainerRuntimeConfigTests.cs new file mode 100644 index 0000000..daf22fc --- /dev/null +++ b/tests/CopilotHere.UnitTests/ContainerRuntimeConfigTests.cs @@ -0,0 +1,377 @@ +using CopilotHere.Infrastructure; +using TUnit.Core; + +namespace CopilotHere.Tests; + +/// +/// Unit tests for ContainerRuntimeConfig - container runtime detection and configuration. +/// Tests auto-detection, config file reading/writing, and runtime-specific settings. +/// +public class ContainerRuntimeConfigTests +{ + private string _tempDir = null!; + private string _projectDir = null!; + private AppPaths _paths = null!; + + [Before(Test)] + public void Setup() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"copilot_here_tests_{Guid.NewGuid():N}"); + _projectDir = Path.Combine(_tempDir, "project"); + Directory.CreateDirectory(_projectDir); + + var localConfigPath = Path.Combine(_projectDir, ".copilot_here"); + var globalConfigPath = Path.Combine(_tempDir, ".config", "copilot_here"); + + Directory.CreateDirectory(localConfigPath); + Directory.CreateDirectory(globalConfigPath); + + _paths = new AppPaths + { + CurrentDirectory = _projectDir, + UserHome = _tempDir, + CopilotConfigPath = Path.Combine(_tempDir, ".config", "copilot-cli-docker"), + LocalConfigPath = localConfigPath, + GlobalConfigPath = globalConfigPath, + ContainerWorkDir = "/workspace" + }; + } + + [After(Test)] + public void Cleanup() + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, recursive: true); + } + + [Test] + public async Task AutoDetect_ReturnsDockerOrPodman_WhenAvailable() + { + // Act + var runtime = ContainerRuntimeConfig.AutoDetect(); + + if (runtime == null) + return; // Skip test if no runtime is available + + // Assert - Should find docker or podman on most dev machines + await Assert.That(runtime == "docker" || runtime == "podman").IsTrue(); + } + + [Test] + public async Task CreateConfig_Docker_ReturnsCorrectSettings() + { + // Act + var config = ContainerRuntimeConfig.CreateConfig("docker"); + + // Assert + await Assert.That(config.Runtime).IsEqualTo("docker"); + await Assert.That(config.DefaultNetworkName).IsEqualTo("bridge"); + await Assert.That(config.SupportsAirlock).IsTrue(); + } + + [Test] + public async Task CreateConfig_Podman_ReturnsCorrectSettings() + { + // Act + var config = ContainerRuntimeConfig.CreateConfig("podman"); + + // Assert + await Assert.That(config.Runtime).IsEqualTo("podman"); + await Assert.That(config.DefaultNetworkName).IsEqualTo("podman"); + await Assert.That(config.SupportsAirlock).IsTrue(); + } + + [Test] + public async Task CreateConfig_Docker_DetectsOrbStackOrDocker() + { + // This test will pass if OrbStack is installed, otherwise it will show Docker + // Act + var config = ContainerRuntimeConfig.CreateConfig("docker"); + + // Assert + await Assert.That(config.RuntimeFlavor == "Docker" || config.RuntimeFlavor == "OrbStack").IsTrue(); + } + + [Test] + public async Task LoadFromConfig_AutoValue_ReturnsNull() + { + // Arrange + var configFile = _paths.GetLocalPath("runtime.conf"); + File.WriteAllText(configFile, "auto"); + + // Act + var runtime = ContainerRuntimeConfig.LoadFromConfig(configFile); + + // Assert - "auto" is normalized to null (triggers auto-detection) + await Assert.That(runtime).IsNull(); + } + + [Test] + public async Task LoadFromConfig_DockerValue_ReturnsDocker() + { + // Arrange + var configFile = _paths.GetLocalPath("runtime.conf"); + File.WriteAllText(configFile, "docker"); + + // Act + var runtime = ContainerRuntimeConfig.LoadFromConfig(configFile); + + // Assert + await Assert.That(runtime).IsEqualTo("docker"); + } + + [Test] + public async Task LoadFromConfig_PodmanValue_ReturnsPodman() + { + // Arrange + var configFile = _paths.GetLocalPath("runtime.conf"); + File.WriteAllText(configFile, "podman"); + + // Act + var runtime = ContainerRuntimeConfig.LoadFromConfig(configFile); + + // Assert + await Assert.That(runtime).IsEqualTo("podman"); + } + + [Test] + public async Task LoadFromConfig_MixedCase_NormalizesToLowercase() + { + // Arrange + var configFile = _paths.GetLocalPath("runtime.conf"); + File.WriteAllText(configFile, "DOCKER"); + + // Act + var runtime = ContainerRuntimeConfig.LoadFromConfig(configFile); + + // Assert + await Assert.That(runtime).IsEqualTo("docker"); + } + + [Test] + public async Task LoadFromConfig_NonExistentFile_ReturnsNull() + { + // Arrange + var configFile = _paths.GetLocalPath("nonexistent.conf"); + + // Act + var runtime = ContainerRuntimeConfig.LoadFromConfig(configFile); + + // Assert + await Assert.That(runtime).IsNull(); + } + + [Test] + public async Task Load_NoConfig_AutoDetects() + { + // Act + var config = ContainerRuntimeConfig.Load(_paths); + + // Assert + await Assert.That(config.Source).IsEqualTo(RuntimeConfigSource.AutoDetected); + await Assert.That(config.Runtime == "docker" || config.Runtime == "podman").IsTrue(); + } + + [Test] + public async Task Load_LocalConfig_HasPriorityOverGlobal() + { + // Arrange + var localFile = _paths.GetLocalPath("runtime.conf"); + var globalFile = _paths.GetGlobalPath("runtime.conf"); + + File.WriteAllText(localFile, "docker"); + File.WriteAllText(globalFile, "podman"); + + // Act + var config = ContainerRuntimeConfig.Load(_paths); + + // Assert + await Assert.That(config.Source).IsEqualTo(RuntimeConfigSource.Local); + await Assert.That(config.Runtime).IsEqualTo("docker"); + await Assert.That(config.LocalRuntime).IsEqualTo("docker"); + } + + [Test] + public async Task Load_GlobalConfig_UsedWhenNoLocal() + { + // Arrange + var globalFile = _paths.GetGlobalPath("runtime.conf"); + File.WriteAllText(globalFile, "docker"); + + // Act + var config = ContainerRuntimeConfig.Load(_paths); + + // Assert + await Assert.That(config.Source).IsEqualTo(RuntimeConfigSource.Global); + await Assert.That(config.Runtime).IsEqualTo("docker"); + await Assert.That(config.GlobalRuntime).IsEqualTo("docker"); + } + + [Test] + public async Task SaveLocal_CreatesConfigFile() + { + // Arrange + var expectedFile = _paths.GetLocalPath("runtime.conf"); + + // Act + ContainerRuntimeConfig.SaveLocal(_paths, "docker"); + + // Assert + await Assert.That(File.Exists(expectedFile)).IsTrue(); + await Assert.That(File.ReadAllText(expectedFile).Trim()).IsEqualTo("docker"); + } + + [Test] + public async Task SaveGlobal_CreatesConfigFile() + { + // Arrange + var expectedFile = _paths.GetGlobalPath("runtime.conf"); + + // Act + ContainerRuntimeConfig.SaveGlobal(_paths, "podman"); + + // Assert + await Assert.That(File.Exists(expectedFile)).IsTrue(); + await Assert.That(File.ReadAllText(expectedFile).Trim()).IsEqualTo("podman"); + } + + [Test] + public async Task SaveLocal_OverwritesExisting() + { + // Arrange + var configFile = _paths.GetLocalPath("runtime.conf"); + File.WriteAllText(configFile, "docker"); + + // Act + ContainerRuntimeConfig.SaveLocal(_paths, "podman"); + + // Assert + await Assert.That(File.ReadAllText(configFile).Trim()).IsEqualTo("podman"); + } + + [Test] + public async Task ListAvailable_ReturnsAtLeastOneRuntime() + { + // Act + var available = ContainerRuntimeConfig.ListAvailable(); + + if (available.Count == 0) + return; // Skip test if no runtime is available + + // Assert - Most dev machines have Docker or Podman + await Assert.That(available.Count).IsGreaterThan(0); + } + + [Test] + public async Task ListAvailable_ContainsValidRuntimes() + { + // Act + var available = ContainerRuntimeConfig.ListAvailable(); + + // Assert + foreach (var config in available) + { + await Assert.That(config.Runtime == "docker" || config.Runtime == "podman").IsTrue(); + await Assert.That(config.RuntimeFlavor).IsNotNull(); + await Assert.That(config.DefaultNetworkName == "bridge" || config.DefaultNetworkName == "podman").IsTrue(); + } + } + + [Test] + public async Task IsCommandAvailable_NonExistentCommand_ReturnsFalse() + { + // Act + var isAvailable = ContainerRuntimeConfig.IsCommandAvailable("nonexistent-command-12345"); + + // Assert + await Assert.That(isAvailable).IsFalse(); + } + + [Test] + public async Task GetVersion_Docker_ReturnsVersionString() + { + // Arrange - Only run if docker is available + if (!ContainerRuntimeConfig.IsCommandAvailable("docker")) + { + return; // Skip test if docker not available + } + + var config = ContainerRuntimeConfig.CreateConfig("docker"); + + // Act + var version = config.GetVersion(); + + // Assert + await Assert.That(version).IsNotNull(); + await Assert.That(version.Contains("version")).IsTrue(); + } + + [Test] + public async Task DefaultNetworkName_Docker_IsBridge() + { + // Act + var config = ContainerRuntimeConfig.CreateConfig("docker"); + + // Assert + await Assert.That(config.DefaultNetworkName).IsEqualTo("bridge"); + } + + [Test] + public async Task DefaultNetworkName_Podman_IsPodman() + { + // Act + var config = ContainerRuntimeConfig.CreateConfig("podman"); + + // Assert + await Assert.That(config.DefaultNetworkName).IsEqualTo("podman"); + } + + [Test] + public async Task ComposeCommand_Docker_IsDockerCompose() + { + // Act + var config = ContainerRuntimeConfig.CreateConfig("docker"); + + // Assert + await Assert.That(config.ComposeCommand).IsEqualTo("compose"); + } + + [Test] + public async Task SupportsAirlock_AllRuntimes_ReturnsTrue() + { + // Both Docker and Podman support Airlock mode + // Act + var dockerConfig = ContainerRuntimeConfig.CreateConfig("docker"); + var podmanConfig = ContainerRuntimeConfig.CreateConfig("podman"); + + // Assert + await Assert.That(dockerConfig.SupportsAirlock).IsTrue(); + await Assert.That(podmanConfig.SupportsAirlock).IsTrue(); + } + + [Test] + public async Task Load_AutoConfigInFile_StillAutoDetects() + { + // Arrange - Set config to "auto" + var localFile = _paths.GetLocalPath("runtime.conf"); + File.WriteAllText(localFile, "auto"); + + // Act + var config = ContainerRuntimeConfig.Load(_paths); + + // Assert - "auto" in config is treated as auto-detection + await Assert.That(config.Source).IsEqualTo(RuntimeConfigSource.AutoDetected); + } + + [Test] + public async Task ConfigFileRoundTrip_PreservesValue() + { + // Arrange & Act + ContainerRuntimeConfig.SaveLocal(_paths, "docker"); + var config = ContainerRuntimeConfig.Load(_paths); + + // Assert + await Assert.That(config.Runtime).IsEqualTo("docker"); + await Assert.That(config.Source).IsEqualTo(RuntimeConfigSource.Local); + } +} diff --git a/tests/CopilotHere.UnitTests/DependencyCheckTests.cs b/tests/CopilotHere.UnitTests/DependencyCheckTests.cs index 799cdb4..69e7cef 100644 --- a/tests/CopilotHere.UnitTests/DependencyCheckTests.cs +++ b/tests/CopilotHere.UnitTests/DependencyCheckTests.cs @@ -1,4 +1,5 @@ using CopilotHere.Infrastructure; +using CopilotHere.Tools; namespace CopilotHere.UnitTests; @@ -7,19 +8,29 @@ public class DependencyCheckTests [Test] public async Task CheckAll_ReturnsResults() { + // Arrange + var tool = new GitHubCopilotTool(); + var paths = AppPaths.Resolve(); + var runtimeConfig = ContainerRuntimeConfig.Load(paths); + // Act - var results = DependencyCheck.CheckAll(); + var results = DependencyCheck.CheckAll(tool, runtimeConfig); // Assert await Assert.That(results).IsNotEmpty(); - await Assert.That(results.Count).IsEqualTo(3); // GitHub CLI, Docker, Docker Daemon + await Assert.That(results.Count).IsEqualTo(3); // GitHub CLI, Container Runtime, Runtime Daemon } [Test] public async Task CheckAll_IncludesGitHubCli() { + // Arrange + var tool = new GitHubCopilotTool(); + var paths = AppPaths.Resolve(); + var runtimeConfig = ContainerRuntimeConfig.Load(paths); + // Act - var results = DependencyCheck.CheckAll(); + var results = DependencyCheck.CheckAll(tool, runtimeConfig); // Assert var ghResult = results.FirstOrDefault(r => r.Name.Contains("GitHub CLI")); @@ -29,22 +40,32 @@ public async Task CheckAll_IncludesGitHubCli() [Test] public async Task CheckAll_IncludesDocker() { + // Arrange + var tool = new GitHubCopilotTool(); + var paths = AppPaths.Resolve(); + var runtimeConfig = ContainerRuntimeConfig.Load(paths); + // Act - var results = DependencyCheck.CheckAll(); + var results = DependencyCheck.CheckAll(tool, runtimeConfig); // Assert - var dockerResult = results.FirstOrDefault(r => r.Name == "Docker"); + var dockerResult = results.FirstOrDefault(r => r.Name == "Docker" || r.Name == "OrbStack" || r.Name == "Podman"); await Assert.That(dockerResult).IsNotNull(); } [Test] public async Task CheckAll_IncludesDockerDaemon() { + // Arrange + var tool = new GitHubCopilotTool(); + var paths = AppPaths.Resolve(); + var runtimeConfig = ContainerRuntimeConfig.Load(paths); + // Act - var results = DependencyCheck.CheckAll(); + var results = DependencyCheck.CheckAll(tool, runtimeConfig); // Assert - var daemonResult = results.FirstOrDefault(r => r.Name == "Docker Daemon"); + var daemonResult = results.FirstOrDefault(r => r.Name.Contains("Daemon")); await Assert.That(daemonResult).IsNotNull(); } diff --git a/tests/CopilotHere.UnitTests/EchoToolTests.cs b/tests/CopilotHere.UnitTests/EchoToolTests.cs new file mode 100644 index 0000000..7de24ce --- /dev/null +++ b/tests/CopilotHere.UnitTests/EchoToolTests.cs @@ -0,0 +1,350 @@ +using CopilotHere.Infrastructure; +using CopilotHere.Tools; +using TUnit.Core; + +namespace CopilotHere.Tests; + +public class EchoToolTests +{ + private readonly EchoTool _tool = new(); + + [Test] + public async Task Name_ReturnsCorrectValue() + { + // Assert + await Assert.That(_tool.Name).IsEqualTo("echo"); + } + + [Test] + public async Task DisplayName_ReturnsCorrectValue() + { + // Assert + await Assert.That(_tool.DisplayName).IsEqualTo("Echo (Test Provider)"); + } + + [Test] + public async Task GetImageName_WithLatest_ReturnsCorrectFormat() + { + // Act + var imageName = _tool.GetImageName("latest"); + + // Assert - Echo uses the same copilot images + await Assert.That(imageName).IsEqualTo("ghcr.io/gordonbeeming/copilot_here:copilot-latest"); + } + + [Test] + public async Task GetImageName_WithDotnet_ReturnsCorrectFormat() + { + // Act + var imageName = _tool.GetImageName("dotnet"); + + // Assert - Echo uses the same copilot images + await Assert.That(imageName).IsEqualTo("ghcr.io/gordonbeeming/copilot_here:copilot-dotnet"); + } + + [Test] + public async Task GetDockerfile_ReturnsCorrectPath() + { + // Act + var dockerfile = _tool.GetDockerfile(); + + // Assert + await Assert.That(dockerfile).IsEqualTo("docker/echo/Dockerfile"); + } + + [Test] + public async Task GetDefaultNetworkRulesPath_ReturnsCorrectPath() + { + // Act + var path = _tool.GetDefaultNetworkRulesPath(); + + // Assert + await Assert.That(path).IsEqualTo("docker/echo/default-airlock-rules.json"); + } + + [Test] + public async Task BuildCommand_GeneratesBashScript() + { + // Arrange + var context = new CommandContext + { + UserArgs = new List(), + IsYolo = false, + IsInteractive = true, + Model = null, + ImageTag = "latest", + Mounts = new List(), + Environment = new Dictionary() + }; + + // Act + var command = _tool.BuildCommand(context); + + // Assert + await Assert.That(command).IsNotEmpty(); + await Assert.That(command[0]).IsEqualTo("bash"); + await Assert.That(command[1]).IsEqualTo("-c"); + await Assert.That(command.Count).IsEqualTo(3); + } + + [Test] + public async Task BuildCommand_ScriptContainsToolName() + { + // Arrange + var context = new CommandContext + { + UserArgs = new List(), + IsYolo = false, + IsInteractive = true, + Model = null, + ImageTag = "latest", + Mounts = new List(), + Environment = new Dictionary() + }; + + // Act + var command = _tool.BuildCommand(context); + + // Assert + await Assert.That(command[2]).Contains("echo"); + await Assert.That(command[2]).Contains("ECHO PROVIDER"); + } + + [Test] + public async Task BuildCommand_WithYoloMode_IncludesYoloFlag() + { + // Arrange + var context = new CommandContext + { + UserArgs = new List(), + IsYolo = true, + IsInteractive = true, + Model = null, + ImageTag = "latest", + Mounts = new List(), + Environment = new Dictionary() + }; + + // Act + var command = _tool.BuildCommand(context); + + // Assert + await Assert.That(command[2]).Contains("YOLO Mode: True"); + } + + [Test] + public async Task BuildCommand_WithModel_IncludesModel() + { + // Arrange + var context = new CommandContext + { + UserArgs = new List(), + IsYolo = false, + IsInteractive = true, + Model = "gpt-4", + ImageTag = "latest", + Mounts = new List(), + Environment = new Dictionary() + }; + + // Act + var command = _tool.BuildCommand(context); + + // Assert + await Assert.That(command[2]).Contains("Model: gpt-4"); + } + + [Test] + public async Task BuildCommand_WithUserArgs_IncludesUserArgs() + { + // Arrange + var context = new CommandContext + { + UserArgs = new List { "--prompt", "test", "--continue" }, + IsYolo = false, + IsInteractive = true, + Model = null, + ImageTag = "latest", + Mounts = new List(), + Environment = new Dictionary() + }; + + // Act + var command = _tool.BuildCommand(context); + + // Assert + await Assert.That(command[2]).Contains("--prompt test --continue"); + } + + [Test] + public async Task BuildCommand_WithEnvironment_IncludesEnvironment() + { + // Arrange + var context = new CommandContext + { + UserArgs = new List(), + IsYolo = false, + IsInteractive = true, + Model = null, + ImageTag = "latest", + Mounts = new List(), + Environment = new Dictionary { { "DEBUG", "true" }, { "ENV", "test" } } + }; + + // Act + var command = _tool.BuildCommand(context); + + // Assert + await Assert.That(command[2]).Contains("ENVIRONMENT VARIABLES:"); + await Assert.That(command[2]).Contains("DEBUG=true"); + await Assert.That(command[2]).Contains("ENV=test"); + } + + [Test] + public async Task BuildCommand_NoEnvironment_ShowsNone() + { + // Arrange + var context = new CommandContext + { + UserArgs = new List(), + IsYolo = false, + IsInteractive = true, + Model = null, + ImageTag = "latest", + Mounts = new List(), + Environment = new Dictionary() + }; + + // Act + var command = _tool.BuildCommand(context); + + // Assert + await Assert.That(command[2]).Contains("ENVIRONMENT VARIABLES:"); + await Assert.That(command[2]).Contains("(none)"); + } + + [Test] + public async Task GetInteractiveFlag_ReturnsNull() + { + // Act + var flag = _tool.GetInteractiveFlag(); + + // Assert + await Assert.That(flag).IsNull(); + } + + [Test] + public async Task GetYoloModeFlags_ReturnsEmptyList() + { + // Act + var flags = _tool.GetYoloModeFlags(); + + // Assert + await Assert.That(flags).IsEmpty(); + } + + [Test] + public async Task GetConfigDirName_ReturnsCliMate() + { + // Act + var dirName = _tool.GetConfigDirName(); + + // Assert + await Assert.That(dirName).IsEqualTo("cli_mate"); + } + + [Test] + public async Task GetSessionDataPath_ReturnsNull() + { + // Act + var path = _tool.GetSessionDataPath(); + + // Assert + await Assert.That(path).IsNull(); + } + + [Test] + public async Task GetRequiredDependencies_ContainsOnlyDocker() + { + // Act + var deps = _tool.GetRequiredDependencies(); + + // Assert + await Assert.That(deps).IsNotEmpty(); + await Assert.That(deps.Length).IsEqualTo(1); + await Assert.That(deps).Contains("docker"); + } + + [Test] + public async Task GetAuthProvider_ReturnsNonNull() + { + // Act + var authProvider = _tool.GetAuthProvider(); + + // Assert + await Assert.That(authProvider).IsNotNull(); + } + + [Test] + public async Task GetModelProvider_ReturnsNonNull() + { + // Act + var modelProvider = _tool.GetModelProvider(); + + // Assert + await Assert.That(modelProvider).IsNotNull(); + } + + [Test] + public async Task SupportsModels_ReturnsTrue() + { + // Assert + await Assert.That(_tool.SupportsModels).IsTrue(); + } + + [Test] + public async Task SupportsYoloMode_ReturnsTrue() + { + // Assert + await Assert.That(_tool.SupportsYoloMode).IsTrue(); + } + + [Test] + public async Task SupportsInteractiveMode_ReturnsTrue() + { + // Assert + await Assert.That(_tool.SupportsInteractiveMode).IsTrue(); + } + + [Test] + public async Task BuildCommand_ComplexScenario_AllFeaturesIncluded() + { + // Arrange - complex scenario with all features + var context = new CommandContext + { + UserArgs = new List { "--prompt", "test prompt", "--verbose" }, + IsYolo = true, + IsInteractive = false, + Model = "claude-sonnet-4.5", + ImageTag = "rust", + Mounts = new List { "/src:/work/src" }, + Environment = new Dictionary { { "DEBUG", "true" }, { "LOG_LEVEL", "info" } } + }; + + // Act + var command = _tool.BuildCommand(context); + + // Assert + await Assert.That(command[0]).IsEqualTo("bash"); + await Assert.That(command[1]).IsEqualTo("-c"); + await Assert.That(command[2]).Contains("ECHO PROVIDER"); + await Assert.That(command[2]).Contains("Image: ghcr.io/gordonbeeming/copilot_here:copilot-rust"); + await Assert.That(command[2]).Contains("YOLO Mode: True"); + await Assert.That(command[2]).Contains("Interactive: False"); + await Assert.That(command[2]).Contains("Model: claude-sonnet-4.5"); + await Assert.That(command[2]).Contains("--prompt test prompt --verbose"); + await Assert.That(command[2]).Contains("DEBUG=true"); + await Assert.That(command[2]).Contains("LOG_LEVEL=info"); + await Assert.That(command[2]).Contains("/src:/work/src"); + } +} diff --git a/tests/CopilotHere.UnitTests/GitHubCopilotToolTests.cs b/tests/CopilotHere.UnitTests/GitHubCopilotToolTests.cs new file mode 100644 index 0000000..589e2ff --- /dev/null +++ b/tests/CopilotHere.UnitTests/GitHubCopilotToolTests.cs @@ -0,0 +1,339 @@ +using CopilotHere.Infrastructure; +using CopilotHere.Tools; +using TUnit.Core; + +namespace CopilotHere.Tests; + +public class GitHubCopilotToolTests +{ + private readonly GitHubCopilotTool _tool = new(); + + [Test] + public async Task Name_ReturnsCorrectValue() + { + // Assert + await Assert.That(_tool.Name).IsEqualTo("github-copilot"); + } + + [Test] + public async Task DisplayName_ReturnsCorrectValue() + { + // Assert + await Assert.That(_tool.DisplayName).IsEqualTo("GitHub Copilot CLI"); + } + + [Test] + public async Task GetImageName_WithLatest_ReturnsCorrectFormat() + { + // Act + var imageName = _tool.GetImageName("latest"); + + // Assert + await Assert.That(imageName).IsEqualTo("ghcr.io/gordonbeeming/copilot_here:copilot-latest"); + } + + [Test] + public async Task GetImageName_WithDotnet_ReturnsCorrectFormat() + { + // Act + var imageName = _tool.GetImageName("dotnet"); + + // Assert + await Assert.That(imageName).IsEqualTo("ghcr.io/gordonbeeming/copilot_here:copilot-dotnet"); + } + + [Test] + public async Task GetImageName_WithEmptyTag_ReturnsDefault() + { + // Act + var imageName = _tool.GetImageName(""); + + // Assert + await Assert.That(imageName).IsEqualTo("ghcr.io/gordonbeeming/copilot_here:copilot-latest"); + } + + [Test] + public async Task GetDockerfile_ReturnsCorrectPath() + { + // Act + var dockerfile = _tool.GetDockerfile(); + + // Assert + await Assert.That(dockerfile).IsEqualTo("docker/tools/github-copilot/Dockerfile"); + } + + [Test] + public async Task GetDefaultNetworkRulesPath_ReturnsCorrectPath() + { + // Act + var path = _tool.GetDefaultNetworkRulesPath(); + + // Assert + await Assert.That(path).IsEqualTo("docker/tools/github-copilot/default-airlock-rules.json"); + } + + [Test] + public async Task BuildCommand_BasicMode_ContainsCopilot() + { + // Arrange + var context = new CommandContext + { + UserArgs = new List(), + IsYolo = false, + IsInteractive = true, + Model = null, + ImageTag = "latest", + Mounts = new List(), + Environment = new Dictionary() + }; + + // Act + var command = _tool.BuildCommand(context); + + // Assert + await Assert.That(command).IsNotEmpty(); + await Assert.That(command[0]).IsEqualTo("copilot"); + } + + [Test] + public async Task BuildCommand_WithYoloMode_AddsYoloFlags() + { + // Arrange + var context = new CommandContext + { + UserArgs = new List(), + IsYolo = true, + IsInteractive = true, + Model = null, + ImageTag = "latest", + Mounts = new List(), + Environment = new Dictionary() + }; + + // Act + var command = _tool.BuildCommand(context); + + // Assert + await Assert.That(command).Contains("--allow-all-tools"); + await Assert.That(command).Contains("--allow-all-paths"); + } + + [Test] + public async Task BuildCommand_WithModel_AddsModelFlag() + { + // Arrange + var context = new CommandContext + { + UserArgs = new List(), + IsYolo = false, + IsInteractive = true, + Model = "gpt-4", + ImageTag = "latest", + Mounts = new List(), + Environment = new Dictionary() + }; + + // Act + var command = _tool.BuildCommand(context); + + // Assert + await Assert.That(command).Contains("--model"); + await Assert.That(command).Contains("gpt-4"); + } + + [Test] + public async Task BuildCommand_InteractiveMode_AddsBannerFlag() + { + // Arrange + var context = new CommandContext + { + UserArgs = new List(), + IsYolo = false, + IsInteractive = true, + Model = null, + ImageTag = "latest", + Mounts = new List(), + Environment = new Dictionary() + }; + + // Act + var command = _tool.BuildCommand(context); + + // Assert + await Assert.That(command).Contains("--banner"); + } + + [Test] + public async Task BuildCommand_WithUserArgs_PassesThroughArgs() + { + // Arrange + var context = new CommandContext + { + UserArgs = new List { "--prompt", "hello world", "--continue" }, + IsYolo = false, + IsInteractive = false, + Model = null, + ImageTag = "latest", + Mounts = new List(), + Environment = new Dictionary() + }; + + // Act + var command = _tool.BuildCommand(context); + + // Assert + await Assert.That(command).Contains("--prompt"); + await Assert.That(command).Contains("hello world"); + await Assert.That(command).Contains("--continue"); + } + + [Test] + public async Task BuildCommand_YoloModeWithModel_ContainsBothFlags() + { + // Arrange + var context = new CommandContext + { + UserArgs = new List(), + IsYolo = true, + IsInteractive = true, + Model = "claude-sonnet-4.5", + ImageTag = "latest", + Mounts = new List(), + Environment = new Dictionary() + }; + + // Act + var command = _tool.BuildCommand(context); + + // Assert + await Assert.That(command).Contains("--allow-all-tools"); + await Assert.That(command).Contains("--allow-all-paths"); + await Assert.That(command).Contains("--model"); + await Assert.That(command).Contains("claude-sonnet-4.5"); + } + + [Test] + public async Task GetYoloModeFlags_ReturnsCorrectFlags() + { + // Act + var flags = _tool.GetYoloModeFlags(); + + // Assert + await Assert.That(flags).IsNotEmpty(); + await Assert.That(flags).Contains("--allow-all-tools"); + await Assert.That(flags).Contains("--allow-all-paths"); + } + + [Test] + public async Task GetInteractiveFlag_ReturnsCorrectFlag() + { + // Act + var flag = _tool.GetInteractiveFlag(); + + // Assert + await Assert.That(flag).IsEqualTo("--banner"); + } + + [Test] + public async Task GetConfigDirName_ReturnsCorrectValue() + { + // Act + var dirName = _tool.GetConfigDirName(); + + // Assert + await Assert.That(dirName).IsEqualTo(".copilot"); + } + + [Test] + public async Task GetSessionDataPath_ReturnsNull() + { + // Act + var path = _tool.GetSessionDataPath(); + + // Assert + await Assert.That(path).IsNull(); + } + + [Test] + public async Task GetRequiredDependencies_ContainsDockerAndGh() + { + // Act + var deps = _tool.GetRequiredDependencies(); + + // Assert + await Assert.That(deps).IsNotEmpty(); + await Assert.That(deps).Contains("docker"); + await Assert.That(deps).Contains("gh"); + } + + [Test] + public async Task GetAuthProvider_ReturnsNonNull() + { + // Act + var authProvider = _tool.GetAuthProvider(); + + // Assert + await Assert.That(authProvider).IsNotNull(); + } + + [Test] + public async Task GetModelProvider_ReturnsNonNull() + { + // Act + var modelProvider = _tool.GetModelProvider(); + + // Assert + await Assert.That(modelProvider).IsNotNull(); + } + + [Test] + public async Task SupportsModels_ReturnsTrue() + { + // Assert + await Assert.That(_tool.SupportsModels).IsTrue(); + } + + [Test] + public async Task SupportsYoloMode_ReturnsTrue() + { + // Assert + await Assert.That(_tool.SupportsYoloMode).IsTrue(); + } + + [Test] + public async Task SupportsInteractiveMode_ReturnsTrue() + { + // Assert + await Assert.That(_tool.SupportsInteractiveMode).IsTrue(); + } + + [Test] + public async Task BuildCommand_ComplexScenario_AllFeaturesEnabled() + { + // Arrange - complex real-world scenario + var context = new CommandContext + { + UserArgs = new List { "--prompt", "fix the bug", "--output", "json" }, + IsYolo = true, + IsInteractive = false, + Model = "gpt-4.5", + ImageTag = "dotnet", + Mounts = new List { "/src:/work/src" }, + Environment = new Dictionary { { "DEBUG", "true" } } + }; + + // Act + var command = _tool.BuildCommand(context); + + // Assert + await Assert.That(command[0]).IsEqualTo("copilot"); + await Assert.That(command).Contains("--allow-all-tools"); + await Assert.That(command).Contains("--allow-all-paths"); + await Assert.That(command).Contains("--model"); + await Assert.That(command).Contains("gpt-4.5"); + await Assert.That(command).Contains("--prompt"); + await Assert.That(command).Contains("fix the bug"); + await Assert.That(command).Contains("--output"); + await Assert.That(command).Contains("json"); + } +} diff --git a/tests/CopilotHere.UnitTests/ToolConfigTests.cs b/tests/CopilotHere.UnitTests/ToolConfigTests.cs new file mode 100644 index 0000000..e06b73c --- /dev/null +++ b/tests/CopilotHere.UnitTests/ToolConfigTests.cs @@ -0,0 +1,301 @@ +using CopilotHere.Infrastructure; +using TUnit.Core; + +namespace CopilotHere.Tests; + +/// +/// Integration tests for tool configuration file reading/writing. +/// Tests the actual file I/O operations for tool.conf files. +/// +public class ToolConfigTests +{ + private string _tempDir = null!; + private string _globalConfigDir = null!; + private string _localConfigDir = null!; + private string _legacyGlobalConfigDir = null!; + private string _legacyLocalConfigDir = null!; + + [Before(Test)] + public void Setup() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"copilot_here_tests_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + + // Create directory structure matching real usage + _globalConfigDir = Path.Combine(_tempDir, ".config", "cli_mate"); + _localConfigDir = Path.Combine(_tempDir, "project", ".cli_mate"); + _legacyGlobalConfigDir = Path.Combine(_tempDir, ".config", "copilot_here"); + _legacyLocalConfigDir = Path.Combine(_tempDir, "project", ".copilot_here"); + + Directory.CreateDirectory(_globalConfigDir); + Directory.CreateDirectory(_localConfigDir); + Directory.CreateDirectory(_legacyGlobalConfigDir); + Directory.CreateDirectory(_legacyLocalConfigDir); + } + + [After(Test)] + public void Cleanup() + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, recursive: true); + } + + [Test] + public async Task WriteAndReadLocal_ToolConfig_RoundTrips() + { + // Arrange + var toolConfigFile = Path.Combine(_localConfigDir, "tool.conf"); + + // Act + File.WriteAllText(toolConfigFile, "echo"); + var read = File.ReadAllText(toolConfigFile).Trim(); + + // Assert + await Assert.That(read).IsEqualTo("echo"); + } + + [Test] + public async Task WriteAndReadGlobal_ToolConfig_RoundTrips() + { + // Arrange + var toolConfigFile = Path.Combine(_globalConfigDir, "tool.conf"); + + // Act + File.WriteAllText(toolConfigFile, "github-copilot"); + var read = File.ReadAllText(toolConfigFile).Trim(); + + // Assert + await Assert.That(read).IsEqualTo("github-copilot"); + } + + [Test] + public async Task LocalToolConfig_TakesPriorityOverGlobal() + { + // Arrange + var localToolFile = Path.Combine(_localConfigDir, "tool.conf"); + var globalToolFile = Path.Combine(_globalConfigDir, "tool.conf"); + + File.WriteAllText(localToolFile, "echo"); + File.WriteAllText(globalToolFile, "github-copilot"); + + // Act - read local first + string? tool = null; + if (File.Exists(localToolFile)) + tool = File.ReadAllText(localToolFile).Trim(); + + // Assert + await Assert.That(tool).IsEqualTo("echo"); + } + + [Test] + public async Task NoLocalConfig_FallsBackToGlobal() + { + // Arrange + var localToolFile = Path.Combine(_localConfigDir, "tool.conf"); + var globalToolFile = Path.Combine(_globalConfigDir, "tool.conf"); + + File.WriteAllText(globalToolFile, "github-copilot"); + + // Act - read local first, then global + string? tool = null; + if (File.Exists(localToolFile)) + tool = File.ReadAllText(localToolFile).Trim(); + else if (File.Exists(globalToolFile)) + tool = File.ReadAllText(globalToolFile).Trim(); + + // Assert + await Assert.That(tool).IsEqualTo("github-copilot"); + } + + [Test] + public async Task LegacyLocalConfig_StillWorks() + { + // Arrange + var legacyToolFile = Path.Combine(_legacyLocalConfigDir, "tool.conf"); + File.WriteAllText(legacyToolFile, "echo"); + + // Act + var tool = File.ReadAllText(legacyToolFile).Trim(); + + // Assert + await Assert.That(tool).IsEqualTo("echo"); + } + + [Test] + public async Task LegacyGlobalConfig_StillWorks() + { + // Arrange + var legacyToolFile = Path.Combine(_legacyGlobalConfigDir, "tool.conf"); + File.WriteAllText(legacyToolFile, "github-copilot"); + + // Act + var tool = File.ReadAllText(legacyToolFile).Trim(); + + // Assert + await Assert.That(tool).IsEqualTo("github-copilot"); + } + + [Test] + public async Task NewLocalConfig_TakesPriorityOverLegacyLocal() + { + // Arrange + var newToolFile = Path.Combine(_localConfigDir, "tool.conf"); + var legacyToolFile = Path.Combine(_legacyLocalConfigDir, "tool.conf"); + + File.WriteAllText(newToolFile, "echo"); + File.WriteAllText(legacyToolFile, "github-copilot"); + + // Act - read new first + string? tool = null; + if (File.Exists(newToolFile)) + tool = File.ReadAllText(newToolFile).Trim(); + else if (File.Exists(legacyToolFile)) + tool = File.ReadAllText(legacyToolFile).Trim(); + + // Assert + await Assert.That(tool).IsEqualTo("echo"); + } + + [Test] + public async Task NewGlobalConfig_TakesPriorityOverLegacyGlobal() + { + // Arrange + var newToolFile = Path.Combine(_globalConfigDir, "tool.conf"); + var legacyToolFile = Path.Combine(_legacyGlobalConfigDir, "tool.conf"); + + File.WriteAllText(newToolFile, "echo"); + File.WriteAllText(legacyToolFile, "github-copilot"); + + // Act - read new first + string? tool = null; + if (File.Exists(newToolFile)) + tool = File.ReadAllText(newToolFile).Trim(); + else if (File.Exists(legacyToolFile)) + tool = File.ReadAllText(legacyToolFile).Trim(); + + // Assert + await Assert.That(tool).IsEqualTo("echo"); + } + + [Test] + public async Task EmptyToolConfig_ReturnsEmptyString() + { + // Arrange + var toolConfigFile = Path.Combine(_localConfigDir, "tool.conf"); + File.WriteAllText(toolConfigFile, ""); + + // Act + var tool = File.ReadAllText(toolConfigFile).Trim(); + + // Assert + await Assert.That(tool).IsEmpty(); + } + + [Test] + public async Task WhitespaceToolConfig_TrimmedToEmpty() + { + // Arrange + var toolConfigFile = Path.Combine(_localConfigDir, "tool.conf"); + File.WriteAllText(toolConfigFile, " \n\t "); + + // Act + var tool = File.ReadAllText(toolConfigFile).Trim(); + + // Assert + await Assert.That(tool).IsEmpty(); + } + + [Test] + public async Task ToolConfigWithWhitespace_TrimsCorrectly() + { + // Arrange + var toolConfigFile = Path.Combine(_localConfigDir, "tool.conf"); + File.WriteAllText(toolConfigFile, " echo \n"); + + // Act + var tool = File.ReadAllText(toolConfigFile).Trim(); + + // Assert + await Assert.That(tool).IsEqualTo("echo"); + } + + [Test] + public async Task ConfigFileReadValue_HandlesNonExistentFile() + { + // Arrange + var nonExistentFile = Path.Combine(_localConfigDir, "nonexistent.conf"); + + // Act + var value = ConfigFile.ReadValue(nonExistentFile); + + // Assert + await Assert.That(value).IsNull(); + } + + [Test] + public async Task ConfigFileWriteValue_CreatesDirectoryIfNeeded() + { + // Arrange + var newDir = Path.Combine(_tempDir, "newdir", "subdir"); + var configFile = Path.Combine(newDir, "tool.conf"); + + // Act + ConfigFile.WriteValue(configFile, "echo"); + + // Assert + await Assert.That(Directory.Exists(newDir)).IsTrue(); + await Assert.That(File.Exists(configFile)).IsTrue(); + await Assert.That(ConfigFile.ReadValue(configFile)).IsEqualTo("echo"); + } + + [Test] + public async Task ConfigFileWriteValue_OverwritesExistingFile() + { + // Arrange + var configFile = Path.Combine(_localConfigDir, "tool.conf"); + ConfigFile.WriteValue(configFile, "github-copilot"); + + // Act + ConfigFile.WriteValue(configFile, "echo"); + + // Assert + await Assert.That(ConfigFile.ReadValue(configFile)).IsEqualTo("echo"); + } + + [Test] + public async Task ToolConfigPriority_CompleteScenario() + { + // Arrange - Set up all config files + var newLocal = Path.Combine(_localConfigDir, "tool.conf"); + var legacyLocal = Path.Combine(_legacyLocalConfigDir, "tool.conf"); + var newGlobal = Path.Combine(_globalConfigDir, "tool.conf"); + var legacyGlobal = Path.Combine(_legacyGlobalConfigDir, "tool.conf"); + + File.WriteAllText(newLocal, "local-new"); + File.WriteAllText(legacyLocal, "local-legacy"); + File.WriteAllText(newGlobal, "global-new"); + File.WriteAllText(legacyGlobal, "global-legacy"); + + // Act - Simulate priority resolution + string? tool = null; + + // Priority 1: New local + if (File.Exists(newLocal)) + tool = File.ReadAllText(newLocal).Trim(); + + // Priority 2: Legacy local + if (string.IsNullOrWhiteSpace(tool) && File.Exists(legacyLocal)) + tool = File.ReadAllText(legacyLocal).Trim(); + + // Priority 3: New global + if (string.IsNullOrWhiteSpace(tool) && File.Exists(newGlobal)) + tool = File.ReadAllText(newGlobal).Trim(); + + // Priority 4: Legacy global + if (string.IsNullOrWhiteSpace(tool) && File.Exists(legacyGlobal)) + tool = File.ReadAllText(legacyGlobal).Trim(); + + // Assert - Should pick new local + await Assert.That(tool).IsEqualTo("local-new"); + } +} diff --git a/tests/CopilotHere.UnitTests/ToolRegistryTests.cs b/tests/CopilotHere.UnitTests/ToolRegistryTests.cs new file mode 100644 index 0000000..21ac4ff --- /dev/null +++ b/tests/CopilotHere.UnitTests/ToolRegistryTests.cs @@ -0,0 +1,155 @@ +using CopilotHere.Infrastructure; +using TUnit.Core; + +namespace CopilotHere.Tests; + +public class ToolRegistryTests +{ + [Test] + public async Task Get_ValidTool_GitHubCopilot_ReturnsCorrectTool() + { + // Act + var tool = ToolRegistry.Get("github-copilot"); + + // Assert + await Assert.That(tool).IsNotNull(); + await Assert.That(tool.Name).IsEqualTo("github-copilot"); + await Assert.That(tool.DisplayName).IsEqualTo("GitHub Copilot CLI"); + } + + [Test] + public async Task Get_ValidTool_Echo_ReturnsCorrectTool() + { + // Act + var tool = ToolRegistry.Get("echo"); + + // Assert + await Assert.That(tool).IsNotNull(); + await Assert.That(tool.Name).IsEqualTo("echo"); + await Assert.That(tool.DisplayName).IsEqualTo("Echo (Test Provider)"); + } + + [Test] + public async Task Get_InvalidTool_ThrowsArgumentException() + { + // Act & Assert + var exception = await Assert.That(() => ToolRegistry.Get("invalid-tool")) + .Throws(); + + await Assert.That(exception!.Message).Contains("Unknown tool: invalid-tool"); + } + + [Test] + public async Task Get_InvalidTool_ErrorMessageIncludesAvailableTools() + { + // Act & Assert + var exception = await Assert.That(() => ToolRegistry.Get("nonexistent")) + .Throws(); + + await Assert.That(exception!.Message).Contains("github-copilot"); + await Assert.That(exception.Message).Contains("echo"); + } + + [Test] + public async Task GetDefault_ReturnsGitHubCopilot() + { + // Act + var tool = ToolRegistry.GetDefault(); + + // Assert + await Assert.That(tool.Name).IsEqualTo("github-copilot"); + } + + [Test] + public async Task Exists_ValidTool_GitHubCopilot_ReturnsTrue() + { + // Act + var exists = ToolRegistry.Exists("github-copilot"); + + // Assert + await Assert.That(exists).IsTrue(); + } + + [Test] + public async Task Exists_ValidTool_Echo_ReturnsTrue() + { + // Act + var exists = ToolRegistry.Exists("echo"); + + // Assert + await Assert.That(exists).IsTrue(); + } + + [Test] + public async Task Exists_InvalidTool_ReturnsFalse() + { + // Act + var exists = ToolRegistry.Exists("invalid-tool"); + + // Assert + await Assert.That(exists).IsFalse(); + } + + [Test] + public async Task Exists_EmptyString_ReturnsFalse() + { + // Act + var exists = ToolRegistry.Exists(""); + + // Assert + await Assert.That(exists).IsFalse(); + } + + [Test] + public async Task GetToolNames_ReturnsAllRegisteredTools() + { + // Act + var names = ToolRegistry.GetToolNames().ToList(); + + // Assert + await Assert.That(names).IsNotEmpty(); + await Assert.That(names).Contains("github-copilot"); + await Assert.That(names).Contains("echo"); + await Assert.That(names.Count).IsGreaterThanOrEqualTo(2); + } + + [Test] + public async Task GetAll_ReturnsAllTools() + { + // Act + var tools = ToolRegistry.GetAll().ToList(); + + // Assert + await Assert.That(tools).IsNotEmpty(); + await Assert.That(tools.Count).IsGreaterThanOrEqualTo(2); + + // Verify we can get instances + var gitHubCopilot = tools.FirstOrDefault(t => t.Name == "github-copilot"); + var echo = tools.FirstOrDefault(t => t.Name == "echo"); + + await Assert.That(gitHubCopilot).IsNotNull(); + await Assert.That(echo).IsNotNull(); + } + + [Test] + public async Task GetAll_ToolsAreUnique() + { + // Act + var tools = ToolRegistry.GetAll().ToList(); + var names = tools.Select(t => t.Name).ToList(); + + // Assert - No duplicate names + await Assert.That(names.Count).IsEqualTo(names.Distinct().Count()); + } + + [Test] + public async Task Get_CalledMultipleTimes_ReturnsSameInstance() + { + // Act + var tool1 = ToolRegistry.Get("github-copilot"); + var tool2 = ToolRegistry.Get("github-copilot"); + + // Assert - Lazy should return same instance + await Assert.That(ReferenceEquals(tool1, tool2)).IsTrue(); + } +} diff --git a/tests/integration/test_airlock.ps1 b/tests/integration/test_airlock.ps1 index ef0e2f9..b1b70fa 100644 --- a/tests/integration/test_airlock.ps1 +++ b/tests/integration/test_airlock.ps1 @@ -172,17 +172,36 @@ function Initialize-TestEnvironment { # Read template and substitute values $templateContent = Get-Content -Path $templatePath -Raw - # Substitute placeholders + # Prepare networks section + $networksYaml = @" +networks: + airlock: + internal: true + bridge: +"@ + + # Prepare auth environment variables for the template + $authEnvVars = " - GITHUB_TOKEN=`${GITHUB_TOKEN}" + + # Substitute placeholders (template uses {{...}} format) $composeContent = $templateContent ` - -replace '\$\{PROXY_PORT\}', $proxyPort ` - -replace '\$\{PROJECT_NAME\}', $script:ProjectName ` - -replace '\$\{APP_IMAGE\}', 'ghcr.io/gordonbeeming/copilot_here:latest' ` - -replace '\$\{PROXY_IMAGE\}', 'ghcr.io/gordonbeeming/copilot_here:proxy' ` - -replace '\$\{NETWORK_CONFIG\}', $networkConfigDocker ` - -replace '\$\{WORK_DIR\}', $testDirDocker ` - -replace '\$\{LOGS_MOUNT\}', '' ` - -replace '\$\{USER_ID\}', '1000' ` - -replace '\$\{GROUP_ID\}', '1000' + -replace '\{\{NETWORKS\}\}', $networksYaml ` + -replace '\{\{AUTH_ENV_VARS\}\}', $authEnvVars ` + -replace '\{\{SESSION_INFO\}\}', '{}' ` + -replace '\{\{EXTERNAL_NETWORK\}\}', 'bridge' ` + -replace '\{\{PROJECT_NAME\}\}', $script:ProjectName ` + -replace '\{\{APP_IMAGE\}\}', 'ghcr.io/gordonbeeming/copilot_here:latest' ` + -replace '\{\{PROXY_IMAGE\}\}', 'ghcr.io/gordonbeeming/copilot_here:proxy' ` + -replace '\{\{NETWORK_CONFIG\}\}', $networkConfigDocker ` + -replace '\{\{WORK_DIR\}\}', $testDirDocker ` + -replace '\{\{CONTAINER_WORK_DIR\}\}', '/home/appuser/work' ` + -replace '\{\{COPILOT_CONFIG\}\}', "$testDirDocker/.copilot_here/copilot-config" ` + -replace '\{\{LOGS_MOUNT\}\}', '' ` + -replace '\{\{PUID\}\}', '1000' ` + -replace '\{\{PGID\}\}', '1000' ` + -replace '\{\{EXTRA_MOUNTS\}\}', '' ` + -replace '\{\{EXTRA_SANDBOX_FLAGS\}\}', '' ` + -replace '\{\{COPILOT_ARGS\}\}', '["sleep", "infinity"]' # Write compose file $script:ComposeFile = Join-Path $script:TestDir "docker-compose.yml" diff --git a/tests/integration/test_airlock.sh b/tests/integration/test_airlock.sh index 593b0be..ae17fa3 100755 --- a/tests/integration/test_airlock.sh +++ b/tests/integration/test_airlock.sh @@ -301,11 +301,17 @@ EOF -v pgid="$(id -g)" \ -v extra_mounts="" \ -v copilot_args="[\"sleep\", \"infinity\"]" \ + -v session_info="{}" \ + -v auth_env_vars=" - GITHUB_TOKEN=\${GITHUB_TOKEN}" \ '{ if ($0 ~ /\{\{NETWORKS\}\}/) { while ((getline line < "'"$TEST_DIR"'/.networks.tmp") > 0) print line; next; } + if ($0 ~ /\{\{AUTH_ENV_VARS\}\}/) { + print auth_env_vars; + next; + } gsub(/\{\{EXTERNAL_NETWORK\}\}/, external_network); gsub(/\{\{PROJECT_NAME\}\}/, project_name); gsub(/\{\{APP_IMAGE\}\}/, app_image); @@ -320,6 +326,7 @@ EOF gsub(/\{\{EXTRA_MOUNTS\}\}/, extra_mounts); gsub(/\{\{EXTRA_SANDBOX_FLAGS\}\}/, extra_sandbox_flags); gsub(/\{\{COPILOT_ARGS\}\}/, copilot_args); + gsub(/\{\{SESSION_INFO\}\}/, session_info); print }' "$template_file" > "$COMPOSE_FILE"