using System.Diagnostics; using System.Globalization; using ClaudeDo.Data.Environment; using ClaudeDo.Worker.Usage.TokenTracker.Interfaces; namespace ClaudeDo.Worker.Usage.TokenTracker; /// /// Starts the TokenTracker CLI. Resolution goes through because /// an npm-installed CLI is a .cmd shim, which UseShellExecute = false cannot exec /// directly. We deliberately never fall back to npx — that would silently download a /// package behind the user's back. /// public sealed class TokenTrackerClient : ITokenTrackerClient { private static readonly TimeSpan ExportTimeout = TimeSpan.FromSeconds(120); private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(20); private static readonly TimeSpan InstallTimeout = TimeSpan.FromMinutes(5); private const int MinimumNodeMajor = 20; public async Task ProbeAsync(CancellationToken ct = default) { var node = await RunAsync("node", ["--version"], ProbeTimeout, null, ct); var nodeVersion = node.Ok ? node.StdOut.Trim() : null; var nodeOk = NodeMajor(nodeVersion) >= MinimumNodeMajor; var cli = await RunAsync(TokenTrackerArgs.Command, TokenTrackerArgs.Version(), ProbeTimeout, null, ct); return cli.Ok ? new TokenTrackerProbe(true, cli.StdOut.Trim(), nodeOk, nodeVersion, null) : new TokenTrackerProbe(false, null, nodeOk, nodeVersion, cli.Error); } public Task ExportAsync(DateOnly from, DateOnly to, CancellationToken ct = default) => RunAsync(TokenTrackerArgs.Command, TokenTrackerArgs.Export(from, to), ExportTimeout, null, ct); public Task InstallAsync(IProgress? output = null, CancellationToken ct = default) => RunAsync(TokenTrackerArgs.NpmCommand, TokenTrackerArgs.Install(), InstallTimeout, output, ct); /// Visible for the probe's version parsing; returns 0 when unreadable. internal static int NodeMajor(string? version) { if (string.IsNullOrWhiteSpace(version)) return 0; var trimmed = version.Trim().TrimStart('v', 'V'); var major = trimmed.Split('.', 2)[0]; return int.TryParse(major, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) ? v : 0; } private static async Task RunAsync( string command, IReadOnlyList arguments, TimeSpan timeout, IProgress? output, CancellationToken ct) { var resolved = ExecutableResolver.Resolve(command); if (resolved is null) return new TokenTrackerRunResult(false, "", $"'{command}' not found on PATH."); var startInfo = new ProcessStartInfo { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, }; if (resolved.IsShim) { var shim = ExecutableResolver.BuildShimStartInfo(resolved.Path, arguments); startInfo.FileName = shim.FileName; startInfo.Arguments = shim.Arguments; } else { startInfo.FileName = resolved.Path; foreach (var arg in arguments) startInfo.ArgumentList.Add(arg); } using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); timeoutCts.CancelAfter(timeout); try { using var process = Process.Start(startInfo); if (process is null) return new TokenTrackerRunResult(false, "", $"Could not start '{command}'."); // Both pipes are drained concurrently — reading one to the end first deadlocks as // soon as the other fills its buffer, and a full-history export is ~1 MB of stdout. var stdoutTask = process.StandardOutput.ReadToEndAsync(timeoutCts.Token); var stderrTask = ReadLinesAsync(process, output, timeoutCts.Token); await process.WaitForExitAsync(timeoutCts.Token); var stdout = await stdoutTask; var stderr = await stderrTask; return process.ExitCode == 0 ? new TokenTrackerRunResult(true, stdout, null) : new TokenTrackerRunResult(false, stdout, $"'{command}' exited with code {process.ExitCode}. {Head(stderr)}".TrimEnd()); } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { return new TokenTrackerRunResult(false, "", $"'{command}' timed out after {timeout.TotalSeconds:0}s."); } catch (Exception ex) { return new TokenTrackerRunResult(false, "", $"'{command}' failed: {ex.Message}"); } } private static async Task ReadLinesAsync(Process process, IProgress? output, CancellationToken ct) { var lines = new List(); while (await process.StandardError.ReadLineAsync(ct) is { } line) { lines.Add(line); output?.Report(line); } return string.Join(System.Environment.NewLine, lines); } private static string Head(string text) { var trimmed = text.Trim(); return trimmed.Length <= 300 ? trimmed : trimmed[..300] + "…"; } }