100 lines
4.5 KiB
C#
100 lines
4.5 KiB
C#
using Volcanic.AgenticCore.Abstractions.Extensions;
|
|
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
|
|
using Volcanic.AgenticCore.Abstractions.Messages;
|
|
using Volcanic.AgenticCore.Abstractions.Providers;
|
|
using Volcanic.AgenticCore.Abstractions.Ui;
|
|
|
|
namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
|
|
|
|
/// <summary>Host overrides for the extension; everything is located automatically when null.</summary>
|
|
public sealed record ClaudeCodeProviderOptions
|
|
{
|
|
public string? ClaudePath { get; init; }
|
|
public string? BridgeDllPath { get; init; }
|
|
public string? DotnetPath { get; init; }
|
|
public Func<string, string?>? GetEnv { get; init; }
|
|
/// <summary>The registry to use; the process-wide <see cref="SessionRegistry.Shared"/> when null.</summary>
|
|
public SessionRegistry? Registry { get; init; }
|
|
public string ServerName { get; init; } = ToolCatalog.DefaultServerName;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Registers the <c>claude-code</c> provider (driving the local <c>claude</c> CLI on a subscription), the
|
|
/// <c>/claude-code-doctor</c> command, and the session bookkeeping the provider resolves working directories from.
|
|
/// The provider is registered even when the CLI is missing so the doctor can explain what to fix; requests then fail
|
|
/// with <c>executable_missing</c>.
|
|
/// </summary>
|
|
public sealed class ClaudeCodeProviderExtension(ClaudeCodeProviderOptions? options = null) : IExtension
|
|
{
|
|
public const string ExtensionName = "claude-code-provider";
|
|
|
|
private readonly ClaudeCodeProviderOptions _options = options ?? new ClaudeCodeProviderOptions();
|
|
private string? _ownSessionId;
|
|
|
|
public string Name => ExtensionName;
|
|
|
|
/// <summary>The API instance created during activation.</summary>
|
|
public ClaudeCodeApi? Api { get; private set; }
|
|
|
|
public ValueTask ActivateAsync(IExtensionApi api, CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(api);
|
|
var getEnv = _options.GetEnv ?? Environment.GetEnvironmentVariable;
|
|
var registry = _options.Registry ?? SessionRegistry.Shared;
|
|
var apiOptions = new ClaudeCodeApiOptions
|
|
{
|
|
ClaudePath = _options.ClaudePath ?? ClaudeInstallation.Locate(getEnv),
|
|
BridgeDllPath = _options.BridgeDllPath ?? BridgeLocator.FindBridgeDll(),
|
|
DotnetPath = _options.DotnetPath ?? BridgeLocator.FindDotnet(getEnv),
|
|
GetEnv = getEnv,
|
|
ServerName = _options.ServerName,
|
|
};
|
|
var claude = new ClaudeCodeApi(registry, apiOptions);
|
|
Api = claude;
|
|
|
|
api.RegisterProvider(new ProviderRegistration
|
|
{
|
|
Id = ClaudeCodeModels.ProviderId,
|
|
Name = ClaudeCodeModels.ProviderName,
|
|
Api = ClaudeCodeModels.ApiId,
|
|
BaseUrl = ClaudeCodeModels.BaseUrl,
|
|
RequiresApiKey = false,
|
|
Models = ClaudeCodeModels.All,
|
|
Stream = claude.StreamAsync,
|
|
});
|
|
api.RegisterCommand(DoctorCommand.Name, DoctorCommand.Create(api, apiOptions, registry));
|
|
|
|
api.On<SessionStartEvent>((_, ctx, _) =>
|
|
{
|
|
// Idempotent: a session can start twice without a shutdown between (reload, resume); drop the earlier id
|
|
// so the second registration does not orphan the first.
|
|
if (_ownSessionId is not null && _ownSessionId != ctx.SessionId) registry.Remove(_ownSessionId);
|
|
_ownSessionId = ctx.SessionId;
|
|
registry.Register(ctx.SessionId, ctx.Cwd, ctx.HasUi ? ctx.Ui.Notify : null);
|
|
if (apiOptions.ClaudePath is null && ctx.HasUi)
|
|
ctx.Ui.Notify($"[claude-code] Claude Code (claude) was not found; run /{DoctorCommand.Name} for details", NotifyKind.Warning);
|
|
return ValueTask.CompletedTask;
|
|
});
|
|
|
|
api.On<SessionShutdownEvent>((_, ctx, _) =>
|
|
{
|
|
registry.Remove(ctx.SessionId);
|
|
if (_ownSessionId == ctx.SessionId) _ownSessionId = null;
|
|
return ValueTask.CompletedTask;
|
|
});
|
|
|
|
api.On<MessageEndEvent>((e, _, _) =>
|
|
{
|
|
// Claude's own overflow wording is prefixed so the core compacts and retries instead of surfacing an error.
|
|
if (e.Message is AssistantMessage { StopReason: StopReason.Error, ErrorMessage: { } error } assistant)
|
|
{
|
|
var normalized = StreamEventMapper.NormalizeOverflowMessage(error);
|
|
if (normalized != error) e.Message = assistant with { ErrorMessage = normalized };
|
|
}
|
|
return ValueTask.CompletedTask;
|
|
});
|
|
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
}
|