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] + "…";
}
}