2026-10-15 11:40:47 +02:00
using System.Text.Json ;
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 ; }
/// <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" ];
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" ];
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 ; }
}
}
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 ;
}
}