diff --git a/Core/Engine.cs b/Core/Engine.cs
index 74faa13..50c7f8e 100644
--- a/Core/Engine.cs
+++ b/Core/Engine.cs
@@ -1,4 +1,5 @@
using System.Text.Json;
+using System.Net.Http.Headers;
namespace AgentWikiUi.Core;
@@ -24,6 +25,8 @@ abstract class Engine
/// 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);
+ /// Testo che annuncia l'ultima versione pubblicata (sola lettura, vedi ADR-0004). Eccezioni di rete/formato: le gestisce il chiamante.
+ public abstract Task FetchLatestVersionAsync(HttpClient http, CancellationToken ct);
public bool IsInstalled() => PathSearch.Find(Executable) is not null;
@@ -60,6 +63,10 @@ sealed class ClaudeEngine : Engine
public override EngineReply Parse(int code, string stdout, string stderr) =>
code == 0 ? new(stdout.Trim(), null, null) : new("", null, Failed(code, stderr.Trim()));
+
+ // Stessa sorgente dell'installer nativo: un file di testo con la versione.
+ public override async Task FetchLatestVersionAsync(HttpClient http, CancellationToken ct) =>
+ (await http.GetStringAsync("https://downloads.claude.ai/claude-code-releases/latest", ct)).Trim();
}
sealed class CodexEngine : Engine
@@ -123,6 +130,17 @@ sealed class CodexEngine : Engine
return new(lastMessage.Trim(), threadId, null);
}
+ // Ultima release su GitHub: tag_name tipo "rust-v0.162.1". L'API richiede lo User-Agent (impostato dal chiamante).
+ public override async Task 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();
+ }
+
// L'errore arriva come stringa che contiene un JSON {"error":{"message":…}}: si mostra il messaggio interno.
static string? InnerMessage(string? raw)
{
diff --git a/Core/EngineRunner.cs b/Core/EngineRunner.cs
index da9bb8c..7bd393f 100644
--- a/Core/EngineRunner.cs
+++ b/Core/EngineRunner.cs
@@ -5,7 +5,10 @@ namespace AgentWikiUi.Core;
// Unico punto di avvio dei motori, condiviso da CLI e GUI.
static class EngineRunner
{
- public static ProcessStartInfo CreateStart(Engine engine, string prompt, bool captured, string? sessionId = null, bool resume = false, string model = "", string effort = "")
+ public static ProcessStartInfo CreateStart(Engine engine, string prompt, bool captured, string? sessionId = null, bool resume = false, string model = "", string effort = "") =>
+ CreateStart(engine, engine.BuildArgs(prompt, sessionId, resume, model, effort, structured: captured), captured);
+
+ public static ProcessStartInfo CreateStart(Engine engine, IEnumerable args, bool captured)
{
var psi = new ProcessStartInfo(engine.Executable)
{
@@ -16,7 +19,7 @@ static class EngineRunner
RedirectStandardError = captured,
CreateNoWindow = true,
};
- foreach (var a in engine.BuildArgs(prompt, sessionId, resume, model, effort, structured: captured)) psi.ArgumentList.Add(a);
+ foreach (var a in args) psi.ArgumentList.Add(a);
return psi;
}
@@ -47,4 +50,23 @@ static class EngineRunner
await p.WaitForExitAsync();
return engine.Parse(p.ExitCode, await o, await e);
}
+
+ // Comandi di servizio del motore (--version, update): output catturato, tempo massimo, nessuna chat.
+ // Win32Exception se l'eseguibile non è nel PATH; allo scadere il processo e i suoi figli sono terminati.
+ public static async Task<(int Code, string Out, string Err, bool TimedOut)> RunToolAsync(Engine engine, IEnumerable args, TimeSpan timeout)
+ {
+ using var p = Process.Start(CreateStart(engine, args, captured: true))!;
+ p.StandardInput.Close();
+ var o = p.StandardOutput.ReadToEndAsync();
+ var e = p.StandardError.ReadToEndAsync();
+ using var cts = new CancellationTokenSource(timeout);
+ try { await p.WaitForExitAsync(cts.Token); }
+ catch (OperationCanceledException)
+ {
+ try { p.Kill(entireProcessTree: true); } catch (InvalidOperationException) { }
+ await p.WaitForExitAsync();
+ return (-1, await o, await e, true);
+ }
+ return (p.ExitCode, await o, await e, false);
+ }
}
diff --git a/Core/EngineUpdater.cs b/Core/EngineUpdater.cs
new file mode 100644
index 0000000..d4fb21e
--- /dev/null
+++ b/Core/EngineUpdater.cs
@@ -0,0 +1,131 @@
+using System.Net.Http.Headers;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+
+namespace AgentWikiUi.Core;
+
+enum InstallMethod { Native, Npm, Homebrew, WinGet }
+
+/// Esito del controllo (sola lettura): versione installata (riga di `--version`), ultima pubblicata e se è più nuova. Available null = sconosciuto.
+record UpdateCheck(string? InstalledText, string? Latest, bool? Available);
+
+/// Esito di un aggiornamento: messaggio da mostrare e, se riuscito, le versioni prima e dopo.
+record UpdateOutcome(bool Ok, string Message, string? Before, string? After);
+
+// Aggiornamento di un CLI con il suo comando ufficiale (` update`). Vedi ADR-0004.
+static class EngineUpdater
+{
+ public static readonly TimeSpan UpdateTimeout = TimeSpan.FromMinutes(10);
+ static readonly TimeSpan VersionTimeout = TimeSpan.FromSeconds(30);
+
+ static readonly TimeSpan CheckTimeout = TimeSpan.FromSeconds(10);
+ static readonly HttpClient SharedHttp = NewHttp();
+
+ static HttpClient NewHttp()
+ {
+ var http = new HttpClient { Timeout = CheckTimeout };
+ http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(AppInfo.Name, "1.0")); // GitHub lo richiede
+ return http;
+ }
+
+ /// Primo numero N.N.N del testo ("2.1.300 (Claude Code)", "codex-cli 0.162.1", "rust-v0.162.1").
+ public static Version? ParseVersion(string? text)
+ {
+ var m = text is null ? null : Regex.Match(text, @"\d+\.\d+\.\d+");
+ return m is { Success: true } && Version.TryParse(m.Value, out var v) ? v : null;
+ }
+
+ /// true se è più nuova di ; null se una delle due non si legge.
+ public static bool? IsNewer(string? latest, string? installed) =>
+ ParseVersion(latest) is { } l && ParseVersion(installed) is { } i ? l > i : null;
+
+ /// Controlla se c'è una versione più nuova senza installare nulla. Mai eccezioni: rete o formato inattesi ⇒ sconosciuto.
+ public static async Task CheckAsync(Engine engine, HttpClient? http = null)
+ {
+ var installed = await GetVersionAsync(engine);
+ string? latest = null;
+ try
+ {
+ using var cts = new CancellationTokenSource(CheckTimeout);
+ latest = await engine.FetchLatestVersionAsync(http ?? SharedHttp, cts.Token);
+ }
+ catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or OperationCanceledException or JsonException or KeyNotFoundException or InvalidOperationException) { }
+ return new(installed, latest, IsNewer(latest, installed));
+ }
+
+ /// Prima riga di `--version` ripulita; null se l'eseguibile manca o fallisce.
+ public static async Task GetVersionAsync(Engine engine, TimeSpan? timeout = null)
+ {
+ try
+ {
+ var r = await EngineRunner.RunToolAsync(engine, ["--version"], timeout ?? VersionTimeout);
+ if (r.TimedOut || r.Code != 0) return null;
+ return r.Out.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault();
+ }
+ catch (System.ComponentModel.Win32Exception) { return null; }
+ }
+
+ public static async Task UpdateAsync(Engine engine, TimeSpan? timeout = null)
+ {
+ var before = await GetVersionAsync(engine);
+ (int Code, string Out, string Err, bool TimedOut) r;
+ try { r = await EngineRunner.RunToolAsync(engine, ["update"], timeout ?? UpdateTimeout); }
+ catch (System.ComponentModel.Win32Exception)
+ {
+ return new(false, $"'{engine.Executable}' non trovato nel PATH.", before, null);
+ }
+
+ var manual = ManualCommand(engine, DetectMethod(PathSearch.Find(engine.Executable)));
+ if (r.TimedOut) return new(false, $"Aggiornamento interrotto: più di {Elapsed(timeout ?? UpdateTimeout)}.\nComando manuale: {manual}", before, null);
+ if (r.Code != 0) return new(false, $"Aggiornamento non riuscito (codice {r.Code}).\n{Tail(r.Err, r.Out)}\nComando manuale: {manual}".Replace("\n\n", "\n"), before, null);
+
+ var after = await GetVersionAsync(engine);
+ if (before is not null && after is not null && before != after) return new(true, $"Aggiornato da {before} a {after}.", before, after);
+ return new(true, $"Già aggiornato ({after ?? before ?? "versione sconosciuta"}).", before, after);
+ }
+
+ static string Elapsed(TimeSpan t) => t.TotalMinutes >= 1 ? $"{t.TotalMinutes:0} minuti" : $"{t.TotalSeconds:0} secondi";
+
+ // Ultime righe utili dell'output (stderr se c'è, altrimenti stdout).
+ static string Tail(string err, string output)
+ {
+ var text = string.IsNullOrWhiteSpace(err) ? output : err;
+ return string.Join('\n', text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).TakeLast(4));
+ }
+
+ /// Metodo di installazione dedotto dal percorso dell'eseguibile, anche dopo aver risolto i collegamenti simbolici.
+ public static InstallMethod DetectMethod(string? path)
+ {
+ if (path is null) return InstallMethod.Native;
+ foreach (var p in new[] { path, RealPath(path) })
+ {
+ if (Has(p, "node_modules") || Has(p, ".npm")) return InstallMethod.Npm;
+ if (Has(p, "Cellar") || Has(p, "homebrew") || Has(p, "linuxbrew")) return InstallMethod.Homebrew;
+ if (Has(p, "WinGet") || Has(p, "WindowsApps")) return InstallMethod.WinGet;
+ }
+ return InstallMethod.Native;
+ }
+
+ static bool Has(string path, string part) => path.Contains(part, StringComparison.OrdinalIgnoreCase);
+
+ static string RealPath(string path)
+ {
+ try { return new FileInfo(path).ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? path; }
+ catch (IOException) { return path; }
+ }
+
+ /// Comando da eseguire a mano quando l'aggiornamento automatico non basta (tabella dell'ADR-0004).
+ public static string ManualCommand(Engine engine, InstallMethod method) => (engine.Id, method) switch
+ {
+ ("claude", InstallMethod.Npm) => "npm install -g @anthropic-ai/claude-code@latest",
+ ("claude", InstallMethod.Homebrew) => "brew upgrade claude-code",
+ ("claude", InstallMethod.WinGet) => "winget upgrade Anthropic.ClaudeCode",
+ ("claude", _) => "claude update",
+ ("codex", InstallMethod.Npm) => "npm install -g @openai/codex@latest",
+ ("codex", InstallMethod.Homebrew) => "brew upgrade --cask codex",
+ ("codex", _) => OperatingSystem.IsWindows()
+ ? "powershell -ExecutionPolicy ByPass -c \"irm https://chatgpt.com/codex/install.ps1 | iex\""
+ : "curl -fsSL https://chatgpt.com/codex/install.sh | sh",
+ _ => $"{engine.Executable} update",
+ };
+}
diff --git a/Gui/App.axaml b/Gui/App.axaml
index b40b891..9694d89 100644
--- a/Gui/App.axaml
+++ b/Gui/App.axaml
@@ -57,6 +57,24 @@
+
+
+
+
+
+
+