Files
VolcanicAgenticCore/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeInstallation.cs

231 lines
11 KiB
C#

using System.ComponentModel;
using System.Diagnostics;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
/// <summary>What the doctor and the preflight learned about the installed <c>claude</c>.</summary>
public sealed record ClaudeInstallationInfo(
string Path,
string? Version,
bool LoggedIn,
string? AuthMethod,
string? SubscriptionType,
IReadOnlyList<string> MissingFlags,
IReadOnlyList<string> Warnings)
{
/// <summary><c>firstParty</c> for a claude.ai login.</summary>
public string? ApiProvider { get; init; }
}
/// <summary>Result of one diagnostic run of the CLI.</summary>
public sealed record ProbeResult(int ExitCode, string Stdout, string Stderr, bool TimedOut);
/// <summary>Locates and inspects the Claude Code CLI without ever sending it a prompt.</summary>
public static partial class ClaudeInstallation
{
public const string PathVariable = "VOLCANIC_CLAUDE_CODE_PATH";
public static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(10);
/// <summary>
/// Flags the headless launch relies on, checked against <c>claude --help</c>. <c>--system-prompt-file</c> is
/// deliberately represented by <c>--system-prompt</c>: the file variant is documented but hidden from the help screen.
/// </summary>
public static readonly IReadOnlyList<string> RequiredFlags =
[
"--print", "--setting-sources", "--settings", "--disable-slash-commands", "--permission-mode", "--no-chrome",
"--prompt-suggestions", "--output-format", "--input-format", "--include-partial-messages", "--verbose",
"--no-session-persistence", "--strict-mcp-config", "--mcp-config", "--tools", "--model", "--effort", "--system-prompt",
];
/// <summary><c>VOLCANIC_CLAUDE_CODE_PATH</c>, else <c>claude</c> on PATH (with PATHEXT suffixes on Windows); null when absent.</summary>
public static string? Locate(Func<string, string?> getEnv)
{
ArgumentNullException.ThrowIfNull(getEnv);
var configured = getEnv(PathVariable)?.Trim();
if (!string.IsNullOrEmpty(configured)) return configured;
var names = OperatingSystem.IsWindows() ? WindowsNames(getEnv("PATHEXT")) : ["claude"];
foreach (var directory in (getEnv("PATH") ?? "").Split(System.IO.Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
{
foreach (var name in names)
{
string candidate;
try
{
candidate = System.IO.Path.Combine(directory, name);
}
catch (ArgumentException)
{
continue;
}
if (File.Exists(candidate) && IsExecutable(candidate)) return candidate;
}
}
return null;
}
/// <summary>Required flags that do not appear in the help text.</summary>
public static IReadOnlyList<string> MissingFlags(string helpText)
{
ArgumentNullException.ThrowIfNull(helpText);
return RequiredFlags.Where(flag => !HasCliOption(helpText, flag)).ToList();
}
/// <summary>Runs <c>--version</c>, <c>auth status</c> and <c>--help</c> in parallel (10 s each) with the allow-listed environment.</summary>
public static async Task<ClaudeInstallationInfo> InspectAsync(string path, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
if (!File.Exists(path))
throw new ClaudeCodeException(ClaudeCodeException.Codes.ExecutableMissing, $"Claude Code executable was not found: {path}");
var warnings = new List<string>();
var parent = ClaudeArgs.CurrentEnvironment();
if (ClaudeArgs.FindDeniedVariable(parent) is { } denied)
warnings.Add($"{denied} is set in the environment; the provider never forwards it and refuses requests while it is set");
var env = ClaudeArgs.AllowListed(parent, maxOutputTokens: null);
var versionTask = RunAsync(path, ["--version"], env, ProbeTimeout, cancellationToken);
var authTask = RunAsync(path, ["auth", "status"], env, ProbeTimeout, cancellationToken);
var helpTask = RunAsync(path, ["--help"], env, ProbeTimeout, cancellationToken);
await Task.WhenAll(versionTask, authTask, helpTask).ConfigureAwait(false);
var version = await versionTask.ConfigureAwait(false);
var auth = await authTask.ConfigureAwait(false);
var help = await helpTask.ConfigureAwait(false);
var versionText = VersionPattern().Match(version.Stdout + "\n" + version.Stderr) is { Success: true } match ? match.Value : null;
if (versionText is null) warnings.Add($"Could not determine the Claude Code version{Describe(version)}");
var loggedIn = false;
string? authMethod = null, apiProvider = null, subscription = null;
try
{
using var document = JsonDocument.Parse(ExtractJson(auth.Stdout));
var root = document.RootElement;
loggedIn = root.TryGetProperty("loggedIn", out var l) && l.ValueKind == JsonValueKind.True;
authMethod = StringOrNull(root, "authMethod");
apiProvider = StringOrNull(root, "apiProvider");
subscription = StringOrNull(root, "subscriptionType")?.ToLowerInvariant();
}
catch (JsonException)
{
warnings.Add($"claude auth status returned no JSON; treating the CLI as not logged in{Describe(auth)}");
}
if (loggedIn && authMethod is not null && authMethod != "claude.ai")
warnings.Add($"Claude Code is logged in through '{authMethod}', not a claude.ai subscription; the provider requires subscription auth");
var missing = MissingFlags(help.Stdout + "\n" + help.Stderr);
if (help.ExitCode != 0 && string.IsNullOrWhiteSpace(help.Stdout))
warnings.Add($"claude --help failed{Describe(help)}");
return new ClaudeInstallationInfo(path, versionText, loggedIn, authMethod, subscription, missing, warnings) { ApiProvider = apiProvider };
}
/// <summary>Runs the CLI with closed stdin and captured output. Throws <c>executable_missing</c> when it cannot start.</summary>
public static async Task<ProbeResult> RunAsync(string executable, IReadOnlyList<string> arguments, IReadOnlyDictionary<string, string> environment, TimeSpan timeout, CancellationToken cancellationToken)
{
var psi = new ProcessStartInfo(executable)
{
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
foreach (var argument in arguments) psi.ArgumentList.Add(argument);
psi.Environment.Clear();
foreach (var (name, value) in environment) psi.Environment[name] = value;
using var process = new Process { StartInfo = psi };
try
{
process.Start();
}
catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or IOException)
{
throw new ClaudeCodeException(ClaudeCodeException.Codes.ExecutableMissing, $"Claude Code could not be started ({executable}): {ex.Message}");
}
process.StandardInput.Close();
var stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(timeout);
var timedOut = false;
try
{
await process.WaitForExitAsync(cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
timedOut = true;
try { process.Kill(entireProcessTree: true); } catch (Exception ex) when (ex is InvalidOperationException or Win32Exception or NotSupportedException or AggregateException) { }
process.WaitForExit(2000);
}
if (cancellationToken.IsCancellationRequested)
{
try { process.Kill(entireProcessTree: true); } catch (Exception ex) when (ex is InvalidOperationException or Win32Exception or NotSupportedException or AggregateException) { }
cancellationToken.ThrowIfCancellationRequested();
}
var stdout = await stdoutTask.ConfigureAwait(false);
var stderr = await stderrTask.ConfigureAwait(false);
return new ProbeResult(timedOut ? -1 : process.ExitCode, stdout, stderr, timedOut);
}
private static bool HasCliOption(string helpText, string option)
{
var pattern = $@"(?:^|[\s,]){Regex.Escape(option)}(?=$|[\s,=<\[])";
return Regex.IsMatch(helpText, pattern, RegexOptions.Multiline | RegexOptions.CultureInvariant);
}
private static IReadOnlyList<string> WindowsNames(string? pathext)
{
var allowed = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".exe", ".cmd", ".com" };
var suffixes = (pathext ?? ".COM;.EXE;.BAT;.CMD")
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(allowed.Contains)
.Select(s => s.ToLowerInvariant())
.Distinct()
.ToList();
if (suffixes.Count == 0) suffixes = [".exe", ".cmd", ".com"];
return suffixes.Select(s => $"claude{s}").ToList();
}
private static bool IsExecutable(string path)
{
if (OperatingSystem.IsWindows()) return true;
try
{
var mode = File.GetUnixFileMode(path);
return (mode & (UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute)) != 0;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException)
{
return false;
}
}
private static string ExtractJson(string stdout)
{
var trimmed = stdout.Trim();
var start = trimmed.IndexOf('{');
var end = trimmed.LastIndexOf('}');
return start >= 0 && end > start ? trimmed[start..(end + 1)] : trimmed;
}
private static string? StringOrNull(JsonElement root, string name) =>
root.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null;
private static string Describe(ProbeResult result)
{
if (result.TimedOut) return " (timed out)";
var stderr = result.Stderr.Trim();
var detail = stderr.Length > 200 ? stderr[^200..] : stderr;
return detail.Length == 0 ? $" (exit code {result.ExitCode})" : $" (exit code {result.ExitCode}: {detail})";
}
[GeneratedRegex(@"\d+\.\d+\.\d+")]
private static partial Regex VersionPattern();
}