diff --git a/src/ClaudeDo.Worker/Usage/TokenTracker/Interfaces/ITokenTrackerClient.cs b/src/ClaudeDo.Worker/Usage/TokenTracker/Interfaces/ITokenTrackerClient.cs new file mode 100644 index 00000000..8840b9da --- /dev/null +++ b/src/ClaudeDo.Worker/Usage/TokenTracker/Interfaces/ITokenTrackerClient.cs @@ -0,0 +1,14 @@ +namespace ClaudeDo.Worker.Usage.TokenTracker.Interfaces; + +public interface ITokenTrackerClient +{ + /// Checks whether the CLI and a usable Node runtime are present. Never throws. + Task ProbeAsync(CancellationToken ct = default); + + /// Runs the sessions export. Never throws — a failure comes back as + /// false plus an error message. + Task ExportAsync(DateOnly from, DateOnly to, CancellationToken ct = default); + + /// Runs npm i -g tokentracker-cli, reporting each output line. + Task InstallAsync(IProgress? output = null, CancellationToken ct = default); +} diff --git a/src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerClient.cs b/src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerClient.cs new file mode 100644 index 00000000..8f02becd --- /dev/null +++ b/src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerClient.cs @@ -0,0 +1,129 @@ +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] + "…"; + } +}