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

191 lines
9.6 KiB
C#

using System.Text.Json;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Ui;
namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
/// <summary>Outcome of a real initialize + tools/list handshake against the proposal bridge.</summary>
public sealed record BridgeProbeResult(bool Ok, IReadOnlyList<string> Argv, string Detail);
/// <summary><c>/claude-code-doctor</c>: reports the CLI path, version, login, required flags and a bridge handshake probe.</summary>
public static class DoctorCommand
{
public const string Name = "claude-code-doctor";
public const string Description = "Check the Claude Code CLI, its login, the required flags and the proposal MCP bridge";
public static readonly TimeSpan BridgeProbeTimeout = TimeSpan.FromSeconds(10);
public static CommandDefinition Create(IExtensionApi api, ClaudeCodeApiOptions options, SessionRegistry registry)
{
ArgumentNullException.ThrowIfNull(api);
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(registry);
return new CommandDefinition(Description, async (_, ctx, ct) =>
{
string report;
var ok = false;
try
{
(report, ok) = await BuildReportAsync(options, registry, ct).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
report = $"# Claude Code doctor\n\nThe doctor itself failed: {ex.Message}";
}
// Idle and triggerTurn false: the report is appended to the transcript for the user without starting a turn.
api.SendMessage(CustomMessage.FromText(Name, report), MessageDelivery.Steer, triggerTurn: false);
if (ctx.HasUi)
{
ctx.Ui.Notify(
ok ? "Claude Code doctor: everything checks out" : $"Claude Code doctor: problems found; see the {Name} message",
ok ? NotifyKind.Info : NotifyKind.Warning);
}
});
}
/// <summary>Builds the markdown report. <c>Ok</c> is false when anything would stop a request from working.</summary>
public static async Task<(string Report, bool Ok)> BuildReportAsync(ClaudeCodeApiOptions options, SessionRegistry registry, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(registry);
var lines = new List<string> { "# Claude Code doctor", "" };
var ok = true;
var claudePath = options.ClaudePath;
ClaudeInstallationInfo? info = null;
string? inspectError = null;
if (string.IsNullOrEmpty(claudePath))
{
ok = false;
lines.Add($"- Claude Code: **not found**. Install the `claude` CLI or set `{ClaudeInstallation.PathVariable}` to its executable.");
}
else
{
try
{
info = await ClaudeInstallation.InspectAsync(claudePath, cancellationToken).ConfigureAwait(false);
}
catch (ClaudeCodeException ex)
{
inspectError = ex.Message;
}
}
if (info is not null)
{
lines.Add($"- Claude Code: `{info.Path}` version {info.Version ?? "unknown"}");
if (info.LoggedIn)
{
var subscription = info.SubscriptionType is null ? "" : $" ({info.SubscriptionType} subscription)";
lines.Add($"- Auth: logged in via {info.AuthMethod ?? "unknown"}{subscription}");
}
else
{
ok = false;
lines.Add("- Auth: **not logged in**; run `claude auth login` with a Claude subscription");
}
if (info.MissingFlags.Count == 0) lines.Add("- Required flags: all present");
else
{
ok = false;
lines.Add($"- Required flags: **missing** {string.Join(", ", info.MissingFlags)}; update Claude Code");
}
foreach (var warning in info.Warnings) lines.Add($"- Warning: {warning}");
}
else if (inspectError is not null)
{
ok = false;
lines.Add($"- Claude Code: `{claudePath}` could not be inspected: {inspectError}");
}
var probe = await ProbeBridgeAsync(options.DotnetPath, options.BridgeDllPath, BridgeProbeTimeout, cancellationToken).ConfigureAwait(false);
if (!probe.Ok) ok = false;
lines.Add($"- Bridge: {(probe.Ok ? "ok" : "**BROKEN**")} via `{JsonSerializer.Serialize(probe.Argv)}` ({probe.Detail})");
if (ClaudeArgs.FindDeniedVariable(options.ParentEnvironment ?? ClaudeArgs.CurrentEnvironment()) is { } denied)
{
ok = false;
lines.Add($"- Environment: **{denied} is set**; the provider refuses to run while API-key or third-party credentials are present");
}
lines.Add($"- Models: {string.Join(", ", ClaudeCodeModels.All.Select(m => m.Id))}");
lines.Add($"- Registered sessions: {registry.Count}");
lines.Add("");
lines.Add(ok ? "Everything checks out." : $"Problems found; fix the items in bold and run `/{Name}` again.");
return (string.Join("\n", lines), ok);
}
/// <summary>
/// Completes a real initialize plus tools/list handshake against the bridge with a one-tool catalogue, mirroring
/// what Claude Code hands it (the allow-listed environment plus the catalogue and ready-marker variables).
/// </summary>
public static async Task<BridgeProbeResult> ProbeBridgeAsync(string? dotnetPath, string? bridgeDllPath, TimeSpan timeout, CancellationToken cancellationToken)
{
var argv = new List<string> { dotnetPath ?? "dotnet", bridgeDllPath ?? BridgeLocator.BridgeFileName };
if (string.IsNullOrEmpty(dotnetPath)) return new BridgeProbeResult(false, argv, "the dotnet muxer was not located");
if (string.IsNullOrEmpty(bridgeDllPath) || !File.Exists(bridgeDllPath))
return new BridgeProbeResult(false, argv, $"bridge assembly not found{(bridgeDllPath is null ? "" : $": {bridgeDllPath}")}");
var directory = Directory.CreateTempSubdirectory("volcanic-claude-doctor-").FullName;
try
{
var catalogPath = Path.Combine(directory, "catalog.json");
var readyPath = Path.Combine(directory, "ready");
await File.WriteAllTextAsync(catalogPath, "[{\"name\":\"probe\",\"description\":\"doctor probe\",\"inputSchema\":{\"type\":\"object\"}}]", cancellationToken).ConfigureAwait(false);
var env = new Dictionary<string, string>(ClaudeArgs.AllowListed(ClaudeArgs.CurrentEnvironment(), maxOutputTokens: null), StringComparer.Ordinal)
{
[ClaudeArgs.CatalogVariable] = catalogPath,
[ClaudeArgs.ReadyVariable] = readyPath,
};
using var process = ClaudeProcess.Start(new ClaudeProcessOptions(dotnetPath, [bridgeDllPath], directory, env, timeout, timeout));
await process.WriteStdinAsync(
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}\n{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}",
cancellationToken).ConfigureAwait(false);
var responses = new List<JsonElement>();
try
{
await foreach (var record in JsonlReader.ReadAsync(process.Stdout, cancellationToken: cancellationToken).ConfigureAwait(false))
{
process.Touch();
responses.Add(record);
}
}
catch (ClaudeCodeException ex)
{
return new BridgeProbeResult(false, argv, $"bridge output was not JSONL: {ex.Message}");
}
var exitCode = await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
if (process.TimeoutError is { } timeoutError) return new BridgeProbeResult(false, argv, timeoutError.Message);
if (exitCode != 0) return new BridgeProbeResult(false, argv, $"bridge process failed (exit code {exitCode}){Stderr(process)}");
var toolCount = -1;
foreach (var response in responses)
{
if (!response.TryGetProperty("id", out var id) || id.ValueKind != JsonValueKind.Number || !id.TryGetInt32(out var value) || value != 2) continue;
toolCount = response.TryGetProperty("result", out var result) && result.TryGetProperty("tools", out var tools) && tools.ValueKind == JsonValueKind.Array
? tools.GetArrayLength()
: -1;
}
var ready = File.Exists(readyPath);
if (toolCount != 1 || !ready)
return new BridgeProbeResult(false, argv,
$"handshake failed ({(toolCount < 0 ? "no tools/list result" : $"{toolCount} tools")}, ready marker {(ready ? "written" : "missing")}){Stderr(process)}");
return new BridgeProbeResult(true, argv, "handshake completed: 1 tool listed, ready marker written");
}
catch (ClaudeCodeException ex)
{
return new BridgeProbeResult(false, argv, ex.Message);
}
finally
{
try { Directory.Delete(directory, recursive: true); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { }
}
}
private static string Stderr(ClaudeProcess process)
{
var excerpt = process.StderrExcerpt(256);
return excerpt.Length == 0 ? "" : $": {excerpt}";
}
}