File: Agents\OpenCode\OpenCodeAgentEnvironmentScanner.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.Tool.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Cli.Resources;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.Agents.OpenCode;
 
/// <summary>
/// Scans for OpenCode environments and provides an applicator to configure the Aspire MCP server.
/// </summary>
internal sealed class OpenCodeAgentEnvironmentScanner : IAgentEnvironmentScanner
{
    private const string OpenCodeConfigFileName = "opencode.jsonc";
    private const string AspireServerName = "aspire";
 
    private readonly IOpenCodeCliRunner _openCodeCliRunner;
    private readonly ILogger<OpenCodeAgentEnvironmentScanner> _logger;
 
    /// <summary>
    /// Initializes a new instance of <see cref="OpenCodeAgentEnvironmentScanner"/>.
    /// </summary>
    /// <param name="openCodeCliRunner">The OpenCode CLI runner for checking if OpenCode is installed.</param>
    /// <param name="logger">The logger for diagnostic output.</param>
    public OpenCodeAgentEnvironmentScanner(IOpenCodeCliRunner openCodeCliRunner, ILogger<OpenCodeAgentEnvironmentScanner> logger)
    {
        ArgumentNullException.ThrowIfNull(openCodeCliRunner);
        ArgumentNullException.ThrowIfNull(logger);
        _openCodeCliRunner = openCodeCliRunner;
        _logger = logger;
    }
 
    /// <inheritdoc />
    public async Task ScanAsync(AgentEnvironmentScanContext context, CancellationToken cancellationToken)
    {
        _logger.LogDebug("Starting OpenCode environment scan in directory: {WorkingDirectory}", context.WorkingDirectory.FullName);
        _logger.LogDebug("Workspace root: {RepositoryRoot}", context.RepositoryRoot.FullName);
 
        // Look for existing opencode.jsonc file at workspace root
        var configDirectory = context.RepositoryRoot;
        var configFilePath = Path.Combine(configDirectory.FullName, OpenCodeConfigFileName);
        var configFileExists = File.Exists(configFilePath);
 
        if (configFileExists)
        {
            _logger.LogDebug("Found existing opencode.jsonc at: {ConfigFilePath}", configFilePath);
            
            // Check if aspire is already configured
            _logger.LogDebug("Checking if Aspire MCP server is already configured in opencode.jsonc...");
            if (!HasAspireServerConfigured(configFilePath))
            {
                // Config file exists but aspire is not configured - offer to add it
                _logger.LogDebug("Adding OpenCode applicator to update existing opencode.jsonc");
                context.AddApplicator(CreateApplicator(configDirectory));
            }
            else
            {
                _logger.LogDebug("Aspire MCP server is already configured");
            }
 
            // Add Playwright applicator if not already configured
            if (!HasPlaywrightServerConfigured(configFilePath))
            {
                _logger.LogDebug("Adding Playwright MCP applicator for OpenCode");
                context.AddApplicator(CreatePlaywrightApplicator(configDirectory));
            }
            else
            {
                _logger.LogDebug("Playwright MCP server is already configured");
            }
 
            // Try to add agent instructions applicator (only once across all scanners)
            CommonAgentApplicators.TryAddAgentInstructionsApplicator(context, context.RepositoryRoot);
        }
        else
        {
            // No config file - check if OpenCode CLI is installed
            _logger.LogDebug("No opencode.jsonc found, checking for OpenCode CLI installation...");
            var openCodeVersion = await _openCodeCliRunner.GetVersionAsync(cancellationToken).ConfigureAwait(false);
 
            if (openCodeVersion is not null)
            {
                _logger.LogDebug("Found OpenCode CLI version: {Version}", openCodeVersion);
                // OpenCode is installed - offer to create config
                _logger.LogDebug("Adding OpenCode applicator to create new opencode.jsonc at: {ConfigDirectory}", configDirectory.FullName);
                context.AddApplicator(CreateApplicator(configDirectory));
                context.AddApplicator(CreatePlaywrightApplicator(configDirectory));
                
                // Try to add agent instructions applicator (only once across all scanners)
                CommonAgentApplicators.TryAddAgentInstructionsApplicator(context, context.RepositoryRoot);
            }
            else
            {
                _logger.LogDebug("OpenCode CLI not found - skipping");
            }
        }
    }
 
    /// <summary>
    /// Checks if the opencode.jsonc file has an "aspire" server configured.
    /// </summary>
    /// <param name="configFilePath">The path to the opencode.jsonc file.</param>
    /// <returns>True if the aspire server is already configured, false otherwise.</returns>
    private static bool HasAspireServerConfigured(string configFilePath)
    {
        try
        {
            var content = File.ReadAllText(configFilePath);
 
            // Remove single-line comments for parsing (JSONC support)
            content = RemoveJsonComments(content);
 
            var config = JsonNode.Parse(content)?.AsObject();
 
            if (config is null)
            {
                return false;
            }
 
            if (config.TryGetPropertyValue("mcp", out var mcpNode) && mcpNode is JsonObject mcp)
            {
                return mcp.ContainsKey(AspireServerName);
            }
 
            return false;
        }
        catch (JsonException)
        {
            // If the JSON is malformed, assume aspire is not configured
            return false;
        }
    }
 
    /// <summary>
    /// Removes single-line comments from JSONC content.
    /// </summary>
    private static string RemoveJsonComments(string jsonc)
    {
        var result = new System.Text.StringBuilder();
        var lines = jsonc.Split('\n');
 
        foreach (var line in lines)
        {
            var trimmedLine = line;
            var commentIndex = line.IndexOf("//", StringComparison.Ordinal);
 
            // Simple heuristic: if // appears and it's not inside a string, remove it
            // This is a simplified approach - a full JSONC parser would be more robust
            if (commentIndex >= 0)
            {
                // Count quotes before the comment to check if we're in a string
                var beforeComment = line[..commentIndex];
                var quoteCount = beforeComment.Count(c => c == '"');
 
                // If even number of quotes, we're not in a string
                if (quoteCount % 2 == 0)
                {
                    trimmedLine = beforeComment;
                }
            }
 
            result.AppendLine(trimmedLine);
        }
 
        return result.ToString();
    }
 
    /// <summary>
    /// Creates an applicator for configuring the MCP server in the opencode.jsonc file.
    /// </summary>
    private static AgentEnvironmentApplicator CreateApplicator(DirectoryInfo configDirectory)
    {
        return new AgentEnvironmentApplicator(
            OpenCodeAgentEnvironmentScannerStrings.ApplicatorDescription,
            async cancellationToken => await ApplyMcpConfigurationAsync(
                configDirectory,
                cancellationToken));
    }
 
    /// <summary>
    /// Creates or updates the opencode.jsonc file with the Aspire MCP server configuration.
    /// </summary>
    private static async Task ApplyMcpConfigurationAsync(
        DirectoryInfo configDirectory,
        CancellationToken cancellationToken)
    {
        var configFilePath = Path.Combine(configDirectory.FullName, OpenCodeConfigFileName);
        JsonObject config;
 
        // Read existing config or create new
        if (File.Exists(configFilePath))
        {
            var existingContent = await File.ReadAllTextAsync(configFilePath, cancellationToken);
 
            // Remove comments for parsing
            var jsonContent = RemoveJsonComments(existingContent);
            config = JsonNode.Parse(jsonContent)?.AsObject() ?? new JsonObject();
        }
        else
        {
            config = new JsonObject
            {
                ["$schema"] = "https://opencode.ai/config.json"
            };
        }
 
        // Ensure "mcp" object exists
        if (!config.ContainsKey("mcp") || config["mcp"] is not JsonObject)
        {
            config["mcp"] = new JsonObject();
        }
 
        var mcp = config["mcp"]!.AsObject();
 
        // Add the "aspire" server configuration
        mcp[AspireServerName] = new JsonObject
        {
            ["type"] = "local",
            ["command"] = new JsonArray("aspire", "mcp", "start"),
            ["enabled"] = true
        };
 
        // Write the updated config using AOT-compatible serialization
        var jsonOutput = JsonSerializer.Serialize(config, JsonSourceGenerationContext.Default.JsonObject);
        await File.WriteAllTextAsync(configFilePath, jsonOutput, cancellationToken);
    }
 
    /// <summary>
    /// Creates an applicator for configuring the Playwright MCP server.
    /// </summary>
    private static AgentEnvironmentApplicator CreatePlaywrightApplicator(DirectoryInfo configDirectory)
    {
        return new AgentEnvironmentApplicator(
            "Configure Playwright MCP server for OpenCode",
            async cancellationToken => await ApplyPlaywrightMcpConfigurationAsync(configDirectory, cancellationToken));
    }
 
    /// <summary>
    /// Creates or updates the opencode.jsonc file with Playwright MCP configuration.
    /// </summary>
    private static async Task ApplyPlaywrightMcpConfigurationAsync(
        DirectoryInfo configDirectory,
        CancellationToken cancellationToken)
    {
        var configFilePath = Path.Combine(configDirectory.FullName, OpenCodeConfigFileName);
        JsonObject config;
 
        // Read existing config or create new
        if (File.Exists(configFilePath))
        {
            var existingContent = await File.ReadAllTextAsync(configFilePath, cancellationToken);
 
            // Remove comments for parsing
            var jsonContent = RemoveJsonComments(existingContent);
            config = JsonNode.Parse(jsonContent)?.AsObject() ?? new JsonObject();
        }
        else
        {
            config = new JsonObject
            {
                ["$schema"] = "https://opencode.ai/config.json"
            };
        }
 
        // Ensure "mcp" object exists
        if (!config.ContainsKey("mcp") || config["mcp"] is not JsonObject)
        {
            config["mcp"] = new JsonObject();
        }
 
        var mcp = config["mcp"]!.AsObject();
 
        // Add Playwright MCP server configuration
        mcp["playwright"] = new JsonObject
        {
            ["type"] = "local",
            ["command"] = new JsonArray("npx", "-y", "@playwright/mcp@latest"),
            ["enabled"] = true
        };
 
        // Write the updated config using AOT-compatible serialization
        var jsonOutput = JsonSerializer.Serialize(config, JsonSourceGenerationContext.Default.JsonObject);
        await File.WriteAllTextAsync(configFilePath, jsonOutput, cancellationToken);
    }
 
    /// <summary>
    /// Checks if the Playwright MCP server is already configured in the opencode.jsonc file.
    /// </summary>
    private static bool HasPlaywrightServerConfigured(string configFilePath)
    {
        if (!File.Exists(configFilePath))
        {
            return false;
        }
 
        try
        {
            var content = File.ReadAllText(configFilePath);
            var jsonContent = RemoveJsonComments(content);
            var config = JsonNode.Parse(jsonContent)?.AsObject();
            
            if (config is null)
            {
                return false;
            }
 
            if (config.TryGetPropertyValue("mcp", out var mcpNode) && mcpNode is JsonObject mcp)
            {
                return mcp.ContainsKey("playwright");
            }
 
            return false;
        }
        catch (JsonException)
        {
            return false;
        }
    }
}