231 lines
11 KiB
C#
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();
|
|
}
|