130 lines
5.3 KiB
C#
130 lines
5.3 KiB
C#
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] + "…";
|
|
}
|
|
}
|