From 8b20f5224e6897655b0efc0078cede1e672a32f0 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Thu, 15 Oct 2026 11:49:22 +0200 Subject: [PATCH] Impostazioni: controllo e aggiornamento dei CLI con pulsante evidenziato MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sezione Aggiornamenti nel riquadro Impostazioni: per ogni CLI installato versione, esito del controllo (disponibile, aggiornato, non riuscito) e pulsante Aggiorna che esegue il comando ufficiale. Il pulsante e un puntino sul pulsante Impostazioni si evidenziano solo se c'è una versione nuova. Il controllo legge l'ultima versione dalle sorgenti ufficiali (downloads.claude.ai e release GitHub di Codex), parte all'avvio e all'apertura del riquadro con cache di 5 minuti e non fallisce mai in modo invasivo. L'aggiornamento ha tempo massimo di 10 minuti, blocca invio e cambio CLI, e in caso di errore suggerisce il comando manuale per npm, Homebrew, WinGet o installazione nativa. Co-Authored-By: Claude Sonnet 5.5 --- Core/Engine.cs | 18 ++++++ Core/EngineRunner.cs | 26 +++++++- Core/EngineUpdater.cs | 131 ++++++++++++++++++++++++++++++++++++++++ Gui/App.axaml | 25 ++++++++ Gui/MainViewModel.cs | 102 ++++++++++++++++++++++++++++--- Gui/MainWindow.axaml | 39 ++++++++++-- Gui/MainWindow.axaml.cs | 3 + 7 files changed, 328 insertions(+), 16 deletions(-) create mode 100644 Core/EngineUpdater.cs 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 @@ + + + + + + +