using System.Text.Json; namespace AgentWikiUi.Core; record ModelOption(string Label, string Id); /// Esito di un messaggio: testo, id di sessione appreso dall'output (se il motore lo sceglie lui) o errore. record EngineReply(string Text, string? SessionId, string? Error); /// Un motore CLI (Claude Code, Codex): come si avvia, cosa si può scegliere, come si legge l'output. Vedi ADR-0002. abstract class Engine { /// Identificatore stabile, salvato nei JSON delle chat e usato da --engine. public abstract string Id { get; } public abstract string Name { get; } /// Etichetta sulle risposte ("Claude", "Codex"). public abstract string Author { get; } public abstract string Executable { get; } public abstract ModelOption[] Models { get; } public abstract string[] Efforts { get; } /// True se l'id di sessione lo genera il motore e l'app lo legge dall'output. public abstract bool OwnsSessionId { get; } /// true = GUI (output catturato e da interpretare); false = CLI (output ereditato). public abstract List BuildArgs(string prompt, string? sessionId, bool resume, string model, string effort, bool structured); public abstract EngineReply Parse(int code, string stdout, string stderr); public bool IsInstalled() => PathSearch.Find(Executable) is not null; protected string Failed(int code, string detail) => $"{Executable} è uscito con codice {code}\n{detail}".Trim(); } sealed class ClaudeEngine : Engine { public override string Id => "claude"; public override string Name => "Claude Code"; public override string Author => "Claude"; public override string Executable => "claude"; public override bool OwnsSessionId => false; // Da aggiornare quando escono nuovi modelli. public override ModelOption[] Models { get; } = [ new("Fable 5.1", "claude-fable-5-1"), new("Opus 5.5", "claude-opus-5-5"), new("Sonnet 5.5", "claude-sonnet-5-5"), new("Haiku 5.5", "claude-haiku-5-5"), ]; public override string[] Efforts { get; } = ["low", "medium", "high", "xhigh", "max"]; public override List BuildArgs(string prompt, string? sessionId, bool resume, string model, string effort, bool structured) { var args = new List { "-p", prompt }; if (sessionId is not null) args.AddRange([resume ? "--resume" : "--session-id", sessionId]); if (model != "") args.AddRange(["--model", model]); if (effort != "") args.AddRange(["--effort", effort]); return args; } public override EngineReply Parse(int code, string stdout, string stderr) => code == 0 ? new(stdout.Trim(), null, null) : new("", null, Failed(code, stderr.Trim())); } sealed class CodexEngine : Engine { public override string Id => "codex"; public override string Name => "Codex"; public override string Author => "Codex"; public override string Executable => "codex"; public override bool OwnsSessionId => true; // Il catalogo di Codex è dinamico (~/.codex/models_cache.json): elenco fisso, da aggiornare. public override ModelOption[] Models { get; } = [ new("GPT-5.6 Sol", "gpt-5.6-sol"), new("GPT-5.6 Terra", "gpt-5.6-terra"), new("GPT-5.6 Luna", "gpt-5.6-luna"), ]; public override string[] Efforts { get; } = ["low", "medium", "high", "xhigh", "max"]; public override List BuildArgs(string prompt, string? sessionId, bool resume, string model, string effort, bool structured) { var args = new List { "exec" }; var resuming = resume && sessionId is not null; if (resuming) args.Add("resume"); if (structured) args.Add("--json"); args.Add("--skip-git-repo-check"); // `exec resume` non accetta -s: la sola lettura si impone con la configurazione if (resuming) args.AddRange(["-c", "sandbox_mode=\"read-only\""]); else args.AddRange(["-s", "read-only"]); if (model != "") args.AddRange(["-m", model]); if (effort != "") args.AddRange(["-c", $"model_reasoning_effort={effort}"]); if (resuming) args.Add(sessionId!); args.AddRange(["--", prompt]); // `--`: un prompt che inizia con "-" non è un'opzione return args; } // stdout è JSONL: thread.started (id), item.completed/agent_message (testo), turn.failed/error (errore). public override EngineReply Parse(int code, string stdout, string stderr) { string? threadId = null, lastMessage = null, error = null; foreach (var line in stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { try { using var doc = JsonDocument.Parse(line); var e = doc.RootElement; switch (e.GetProperty("type").GetString()) { case "thread.started": threadId = e.GetProperty("thread_id").GetString(); break; case "item.completed" when e.GetProperty("item").GetProperty("type").GetString() == "agent_message": lastMessage = e.GetProperty("item").GetProperty("text").GetString(); break; case "turn.failed": error = InnerMessage(e.GetProperty("error").GetProperty("message").GetString()); break; case "error": error ??= InnerMessage(e.GetProperty("message").GetString()); break; } } catch (Exception ex) when (ex is JsonException or KeyNotFoundException or InvalidOperationException) { } // riga non JSON o evento sconosciuto } if (code != 0) return new("", threadId, Failed(code, error ?? stderr.Trim())); if (string.IsNullOrWhiteSpace(lastMessage)) return new("", threadId, error ?? "codex non ha restituito nessuna risposta."); return new(lastMessage.Trim(), threadId, null); } // L'errore arriva come stringa che contiene un JSON {"error":{"message":…}}: si mostra il messaggio interno. static string? InnerMessage(string? raw) { if (raw is null) return null; try { using var doc = JsonDocument.Parse(raw); return doc.RootElement.GetProperty("error").GetProperty("message").GetString() ?? raw; } catch (Exception ex) when (ex is JsonException or KeyNotFoundException or InvalidOperationException) { return raw; } } } static class Engines { public static readonly Engine Claude = new ClaudeEngine(); public static readonly Engine Codex = new CodexEngine(); public static readonly Engine[] All = [Claude, Codex]; public static Engine? Find(string? id) => All.FirstOrDefault(e => e.Id == id); } static class PathSearch { // Su Windows i motori si avviano direttamente: solo .exe (niente script .cmd, vedi ADR-0002). public static string? Find(string name) { var file = OperatingSystem.IsWindows() ? name + ".exe" : name; foreach (var dir in (Environment.GetEnvironmentVariable("PATH") ?? "").Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) { try { var full = Path.Combine(dir.Trim('"'), file); if (File.Exists(full)) return full; } catch (ArgumentException) { } // voce del PATH non valida } return null; } }