Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 76 additions & 13 deletions DevMaid.CLI/Commands/OpenCodeCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,28 +89,54 @@ private static Command BuildDefaultModelCommand()
/// <param name="global">If true, modifies the global config; otherwise modifies the local config.</param>
public static void SetDefaultModel(string? modelId, bool global)
{
var availableModels = GetAvailableModels();

string selectedModel;

if (modelId is not null)
{
if (!availableModels.Contains(modelId, StringComparer.Ordinal))
// Model ID supplied explicitly: try to validate, but proceed anyway if opencode is not in PATH.
try
{
Console.Error.WriteLine($"Modelo '{modelId}' nao encontrado.");
Console.Error.WriteLine("Modelos disponiveis:");
foreach (var m in availableModels)
var availableModels = GetAvailableModels();
if (!availableModels.Contains(modelId, StringComparer.Ordinal))
{
Console.Error.WriteLine($" {m}");
Console.Error.WriteLine($"Modelo '{modelId}' nao encontrado.");
Console.Error.WriteLine("Modelos disponiveis:");
foreach (var m in availableModels)
{
Console.Error.WriteLine($" {m}");
}

Environment.Exit(1);
return;
}

Environment.Exit(1);
return;
}
catch (InvalidOperationException)
{
// opencode not available in PATH (e.g. Desktop installer without PATH entry).
// Skip validation and trust the provided model ID.
AnsiConsole.MarkupLine(
"[yellow]Aviso:[/] Nao foi possivel validar o modelo (opencode nao encontrado no PATH). Definindo sem validacao.");
}

selectedModel = modelId;
}
else
{
// No model ID supplied: need opencode to list candidates for interactive selection.
IReadOnlyList<string> availableModels;
try
{
availableModels = GetAvailableModels();
}
catch (InvalidOperationException ex)
{
AnsiConsole.MarkupLine($"[red]Erro:[/] {ex.Message}");
AnsiConsole.MarkupLine(
"[yellow]Dica:[/] Forneça o ID do modelo diretamente: devmaid opencode settings default-model [grey]<model-id>[/]");
Environment.Exit(1);
return;
}

var chosen = SelectModelInteractively(availableModels);
if (chosen is null)
{
Expand Down Expand Up @@ -150,24 +176,55 @@ public static string ResolveConfigPath(bool global)
return jsonc;
}

/// <summary>
/// Looks for the opencode executable in known installation locations.
/// Falls back to a plain <c>"opencode"</c> name so the OS PATH is tried last.
/// </summary>
/// <returns>Full path to the executable, or <c>"opencode"</c> if no known path was found.</returns>
internal static string ResolveOpenCodeExecutable()
{
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);

// 1. WinGet portable package: winget install SST.opencode
var wingetPackages = Path.Combine(localAppData, "Microsoft", "WinGet", "Packages");
if (Directory.Exists(wingetPackages))
{
foreach (var dir in Directory.GetDirectories(wingetPackages, "SST.opencode_*"))
{
var exe = Path.Combine(dir, "opencode.exe");
if (File.Exists(exe)) return exe;
}
}

// 2. Tauri Desktop installer: winget install SST.OpenCodeDesktop
// Installs to %LocalAppData%\OpenCode\ with main binary OpenCode.exe
var desktopExe = Path.Combine(localAppData, "OpenCode", "OpenCode.exe");
if (File.Exists(desktopExe)) return desktopExe;

// 3. Fall back to PATH lookup
return "opencode";
}

/// <summary>
/// Returns the list of available models from the `opencode models` command.
/// </summary>
/// <returns>A read-only list of model IDs.</returns>
/// <exception cref="InvalidOperationException">Thrown when opencode is not found in PATH.</exception>
/// <exception cref="InvalidOperationException">Thrown when opencode is not found.</exception>
public static IReadOnlyList<string> GetAvailableModels()
{
if (ModelsProvider is not null)
{
return ModelsProvider();
}

var executable = ResolveOpenCodeExecutable();

try
{
using var process = new Process();
process.StartInfo = new ProcessStartInfo
{
FileName = "opencode",
FileName = executable,
Arguments = "models",
RedirectStandardOutput = true,
RedirectStandardError = true,
Expand All @@ -189,7 +246,13 @@ public static IReadOnlyList<string> GetAvailableModels()
catch (Win32Exception ex)
{
throw new InvalidOperationException(
"Nao foi possivel executar 'opencode'. Verifique se o OpenCode esta instalado e disponivel no PATH.", ex);
"""
Nao foi possivel encontrar o executavel do OpenCode.
Tente uma das opcoes abaixo:
1. Instalar o CLI via WinGet: winget install SST.opencode
2. Adicionar o diretorio de instalacao ao PATH manualmente.
3. Fornecer o model-id diretamente: devmaid opencode settings default-model <model-id>
""", ex);
}
}

Expand Down
75 changes: 75 additions & 0 deletions DevMaid.Tests/Commands/OpenCodeCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,81 @@ public void SetDefaultModel_JsoncWithComments_DoesNotThrow()
Assert.AreEqual("github-copilot/gpt-4o", node!["model"]!.GetValue<string>());
}

// --- Comportamento quando opencode nao esta no PATH ---

[TestMethod]
public void SetDefaultModel_OpenCodeUnavailable_WithExplicitModelId_WritesToFile()
{
// Simula opencode ausente no PATH
CLI.Commands.OpenCodeCommand.ModelsProvider = () =>
throw new InvalidOperationException("Nao foi possivel executar 'opencode'.");

Directory.SetCurrentDirectory(_testDirectory);

// Redireciona stdout para suprimir o aviso do AnsiConsole durante o teste
var originalOut = Console.Out;
Console.SetOut(TextWriter.Null);
try
{
// Deve gravar o modelo sem lançar exceção, mesmo sem opencode no PATH
CLI.Commands.OpenCodeCommand.SetDefaultModel("github-copilot/gpt-4o", global: false);
}
finally
{
Console.SetOut(originalOut);
}

var configPath = Path.Combine(_testDirectory, "opencode.jsonc");
Assert.IsTrue(File.Exists(configPath), "O arquivo de config deve ter sido criado.");
var node = JsonNode.Parse(File.ReadAllText(configPath));
Assert.AreEqual("github-copilot/gpt-4o", node!["model"]!.GetValue<string>());
}

// --- ResolveOpenCodeExecutable ---

[TestMethod]
public void ResolveOpenCodeExecutable_WinGetPackageExists_ReturnsFullPath()
{
// Cria estrutura fake de pacote WinGet portátil
var fakeLocalAppData = Path.Combine(Path.GetTempPath(), $"FakeLocalAppData_{Guid.NewGuid():N}");
var pkgDir = Path.Combine(fakeLocalAppData, "Microsoft", "WinGet", "Packages",
"SST.opencode_Microsoft.Winget.Source_8wekyb3d8bbwe");
Directory.CreateDirectory(pkgDir);
var fakeExe = Path.Combine(pkgDir, "opencode.exe");
File.WriteAllText(fakeExe, "fake");

// Substituímos LOCALAPPDATA apenas para este teste usando um método auxiliar
// Como não podemos alterar a variável de ambiente facilmente, verificamos a lógica
// indiretamente: o método deve retornar um caminho terminando em opencode.exe
// quando o diretório existe. Aqui testamos o contrato de busca real na máquina atual.
try
{
var result = CLI.Commands.OpenCodeCommand.ResolveOpenCodeExecutable();

// Deve retornar um caminho absoluto (encontrou em path conhecido)
// ou "opencode" como fallback para PATH
Assert.IsTrue(
result == "opencode" || Path.IsPathRooted(result),
"Deve retornar caminho absoluto ou fallback 'opencode'.");
}
finally
{
Directory.Delete(fakeLocalAppData, recursive: true);
}
}

[TestMethod]
public void ResolveOpenCodeExecutable_NoKnownPathExists_ReturnsFallback()
{
// Nesta máquina de teste, se nenhum dos caminhos conhecidos existir,
// o método deve retornar "opencode" para tentar via PATH.
var result = CLI.Commands.OpenCodeCommand.ResolveOpenCodeExecutable();

Assert.IsTrue(
result == "opencode" || File.Exists(result),
"Deve retornar 'opencode' (fallback PATH) ou um caminho existente.");
}

// --- Validacao de model-id ---

[TestMethod]
Expand Down
3 changes: 2 additions & 1 deletion version.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json",
"version": "1.1",
"publicReleaseRefSpec": [
"^refs/heads/main$"
"^refs/heads/main$",
"^refs/tags/v\\d+\\.\\d+"
]
}
Loading