2026-10-15 11:40:47 +02:00
using System.Text.Json ;
2026-10-18 14:41:29 +02:00
using System.Text.RegularExpressions ;
2026-10-15 11:49:22 +02:00
using System.Net.Http.Headers ;
2026-10-15 11:40:47 +02:00
namespace AgentWikiUi.Core ;
record ModelOption ( string Label , string Id );
/// <summary>Esito di un messaggio: testo, id di sessione appreso dall'output (se il motore lo sceglie lui) o errore.</summary>
record EngineReply ( string Text , string? SessionId , string? Error );
/// <summary>Un motore CLI (Claude Code, Codex): come si avvia, cosa si può scegliere, come si legge l'output. Vedi ADR-0002.</summary>
abstract class Engine
{
/// <summary>Identificatore stabile, salvato nei JSON delle chat e usato da <c>--engine</c>.</summary>
public abstract string Id { get ; }
public abstract string Name { get ; }
/// <summary>Etichetta sulle risposte ("Claude", "Codex").</summary>
public abstract string Author { get ; }
public abstract string Executable { get ; }
public abstract ModelOption [] Models { get ; }
public abstract string [] Efforts { get ; }
2026-10-15 12:03:03 +02:00
/// <summary>Modello scelto all'avvio e a ogni cambio di CLI: il più piccolo, per risposte rapide ed economiche.</summary>
public abstract string DefaultModelId { get ; }
public string DefaultEffort => "low" ;
2026-10-15 11:40:47 +02:00
/// <summary>True se l'id di sessione lo genera il motore e l'app lo legge dall'output.</summary>
public abstract bool OwnsSessionId { get ; }
/// <param name="structured">true = GUI (output catturato e da interpretare); false = CLI (output ereditato).</param>
public abstract List < string > BuildArgs ( string prompt , string? sessionId , bool resume , string model , string effort , bool structured );
public abstract EngineReply Parse ( int code , string stdout , string stderr );
2026-10-15 11:49:22 +02:00
/// <summary>Testo che annuncia l'ultima versione pubblicata (sola lettura, vedi ADR-0004). Eccezioni di rete/formato: le gestisce il chiamante.</summary>
public abstract Task < string? > FetchLatestVersionAsync ( HttpClient http , CancellationToken ct );
2026-10-15 11:40:47 +02:00
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" ];
2026-10-15 12:03:03 +02:00
public override string DefaultModelId => "claude-haiku-5-5" ;
2026-10-15 11:40:47 +02:00
public override List < string > BuildArgs ( string prompt , string? sessionId , bool resume , string model , string effort , bool structured )
{
var args = new List < string > { "-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 ()));
2026-10-15 11:49:22 +02:00
// Stessa sorgente dell'installer nativo: un file di testo con la versione.
public override async Task < string? > FetchLatestVersionAsync ( HttpClient http , CancellationToken ct ) =>
( await http . GetStringAsync ( "https://downloads.claude.ai/claude-code-releases/latest" , ct )). Trim ();
2026-10-15 11:40:47 +02:00
}
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" ];
2026-10-15 12:03:03 +02:00
public override string DefaultModelId => "gpt-5.6-luna" ;
2026-10-15 11:40:47 +02:00
public override List < string > BuildArgs ( string prompt , string? sessionId , bool resume , string model , string effort , bool structured )
{
var args = new List < string > { "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 );
}
2026-10-15 11:49:22 +02:00
// Ultima release su GitHub: tag_name tipo "rust-v0.162.1". L'API richiede lo User-Agent (impostato dal chiamante).
public override async Task < string? > FetchLatestVersionAsync ( HttpClient http , CancellationToken ct )
{
using var req = new HttpRequestMessage ( HttpMethod . Get , "https://api.github.com/repos/openai/codex/releases/latest" );
req . Headers . Accept . Add ( new MediaTypeWithQualityHeaderValue ( "application/vnd.github+json" ));
using var resp = await http . SendAsync ( req , ct );
resp . EnsureSuccessStatusCode ();
using var doc = JsonDocument . Parse ( await resp . Content . ReadAsStringAsync ( ct ));
return doc . RootElement . GetProperty ( "tag_name" ). GetString ();
}
2026-10-15 11:40:47 +02:00
// 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 ; }
}
}
2026-10-18 14:41:29 +02:00
sealed class AgyEngine : Engine
{
public override string Id => "agy" ;
public override string Name => "Antigravity" ;
public override string Author => "Antigravity" ;
public override string Executable => "agy" ;
public override bool OwnsSessionId => true ;
public override ModelOption [] Models { get ; } =
[
new("Gemini 3.0 Flash (Low)", "gemini-3.0-flash-low"),
new("Gemini 3.0 Flash (Medium)", "gemini-3.0-flash-medium"),
new("Gemini 3.0 Flash (High)", "gemini-3.0-flash-high"),
new("Claude Sonnet 4.6 (Thinking)", "claude-sonnet-4-6"),
new("Claude Opus 4.6 (Thinking)", "claude-opus-4-6-thinking"),
new("GPT-OSS 120B (Medium)", "gpt-oss-120b-medium"),
] ;
public override string [] Efforts { get ; } = [ "low" , "medium" , "high" , "xhigh" , "max" ];
public override string DefaultModelId => "gemini-3.0-flash-low" ;
public override List < string > BuildArgs ( string prompt , string? sessionId , bool resume , string model , string effort , bool structured )
{
var args = new List < string >();
if ( structured ) args . AddRange ([ "--output-format" , "json" ]);
if ( resume && sessionId is not null ) args . AddRange ([ "--conversation" , sessionId ]);
if ( model != "" ) args . AddRange ([ "--model" , model ]);
if ( effort != "" ) args . AddRange ([ "--effort" , effort ]);
args . AddRange ([ "--print-timeout" , "120s" ]);
args . AddRange ([ "-p" , prompt ]); // tutti i flag vanno passati PRIMA di -p
return args ;
}
public override EngineReply Parse ( int code , string stdout , string stderr )
{
string? conversationId = null ;
string? response = null ;
string? error = null ;
var trimmed = stdout . Trim ();
if (! string . IsNullOrEmpty ( trimmed ))
{
var start = trimmed . IndexOf ( '{' );
var end = trimmed . LastIndexOf ( '}' );
if ( start >= 0 && end > start )
{
try
{
using var doc = JsonDocument . Parse ( trimmed . Substring ( start , end - start + 1 ));
var root = doc . RootElement ;
if ( root . TryGetProperty ( "conversation_id" , out var convProp ))
conversationId = convProp . GetString ();
if ( root . TryGetProperty ( "response" , out var respProp ))
response = respProp . GetString ();
if ( root . TryGetProperty ( "status" , out var statusProp ) && statusProp . GetString () != "SUCCESS" )
error = $"Stato {statusProp.GetString()}" ;
if ( root . TryGetProperty ( "error" , out var errProp ))
error = errProp . GetString () ?? error ;
}
catch ( JsonException ) { }
}
}
if ( code != 0 ) return new ( "" , conversationId , Failed ( code , error ?? stderr . Trim ()));
if ( error is not null && string . IsNullOrWhiteSpace ( response )) return new ( "" , conversationId , error );
if ( string . IsNullOrWhiteSpace ( response )) return new ( "" , conversationId , error ?? ( string . IsNullOrWhiteSpace ( stderr ) ? "agy non ha restituito nessuna risposta." : stderr . Trim ()));
return new ( response . Trim (), conversationId , null );
}
public override async Task < string? > FetchLatestVersionAsync ( HttpClient http , CancellationToken ct )
{
var text = await http . GetStringAsync ( "https://antigravity-cli-auto-updater-974169037036.us-central1.run.app" , ct );
var m = Regex . Match ( text , @"Stable Version:\s*v?(\d+\.\d+(?:\.\d+)?)" );
return m . Success ? m . Groups [ 1 ]. Value : text . Trim ();
}
}
2026-10-15 11:40:47 +02:00
static class Engines
{
public static readonly Engine Claude = new ClaudeEngine ();
public static readonly Engine Codex = new CodexEngine ();
2026-10-18 14:41:29 +02:00
public static readonly Engine Agy = new AgyEngine ();
public static readonly Engine [] All = [ Claude , Codex , Agy ];
2026-10-15 11:40:47 +02:00
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 ;
}
}