feat(usage): run the TokenTracker CLI through the shared executable resolver

This commit is contained in:
mika kuns
2026-08-24 13:37:57 +02:00
parent d6b22ea528
commit a92180456d
2 changed files with 143 additions and 0 deletions
@@ -0,0 +1,14 @@
namespace ClaudeDo.Worker.Usage.TokenTracker.Interfaces;
public interface ITokenTrackerClient
{
/// <summary>Checks whether the CLI and a usable Node runtime are present. Never throws.</summary>
Task<TokenTrackerProbe> ProbeAsync(CancellationToken ct = default);
/// <summary>Runs the sessions export. Never throws — a failure comes back as
/// <see cref="TokenTrackerRunResult.Ok"/> false plus an error message.</summary>
Task<TokenTrackerRunResult> ExportAsync(DateOnly from, DateOnly to, CancellationToken ct = default);
/// <summary>Runs <c>npm i -g tokentracker-cli</c>, reporting each output line.</summary>
Task<TokenTrackerRunResult> InstallAsync(IProgress<string>? output = null, CancellationToken ct = default);
}
@@ -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;
/// <summary>
/// Starts the TokenTracker CLI. Resolution goes through <see cref="ExecutableResolver"/> because
/// an npm-installed CLI is a <c>.cmd</c> shim, which <c>UseShellExecute = false</c> cannot exec
/// directly. We deliberately never fall back to <c>npx</c> — that would silently download a
/// package behind the user's back.
/// </summary>
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<TokenTrackerProbe> 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<TokenTrackerRunResult> ExportAsync(DateOnly from, DateOnly to, CancellationToken ct = default) =>
RunAsync(TokenTrackerArgs.Command, TokenTrackerArgs.Export(from, to), ExportTimeout, null, ct);
public Task<TokenTrackerRunResult> InstallAsync(IProgress<string>? output = null, CancellationToken ct = default) =>
RunAsync(TokenTrackerArgs.NpmCommand, TokenTrackerArgs.Install(), InstallTimeout, output, ct);
/// <summary>Visible for the probe's version parsing; returns 0 when unreadable.</summary>
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<TokenTrackerRunResult> RunAsync(
string command,
IReadOnlyList<string> arguments,
TimeSpan timeout,
IProgress<string>? 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<string> ReadLinesAsync(Process process, IProgress<string>? output, CancellationToken ct)
{
var lines = new List<string>();
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] + "…";
}
}