feat(claude-code): /claude-code-login command

This commit is contained in:
EugeneTes
2026-09-25 17:38:39 +00:00
parent d43fa52e4a
commit 1aa9197d9c
5 changed files with 186 additions and 4 deletions

View File

@@ -75,6 +75,7 @@ public sealed class ClaudeCodeProviderExtension(ClaudeCodeProviderOptions? optio
Stream = claude.StreamAsync,
});
api.RegisterCommand(DoctorCommand.Name, DoctorCommand.Create(api, apiOptions, registry));
api.RegisterCommand(LoginCommand.Name, LoginCommand.Create(api, claude));
api.On<SessionStartEvent>((_, ctx, _) =>
{

View File

@@ -81,7 +81,7 @@ public static class DoctorCommand
else
{
ok = false;
lines.Add("- Auth: **not logged in**; run `claude auth login` with a Claude subscription");
lines.Add("- Auth: **not logged in**; run `/claude-code-login` (or `claude auth login` on the host) with a Claude subscription");
}
if (info.MissingFlags.Count == 0) lines.Add("- Required flags: all present");
else

View File

@@ -0,0 +1,37 @@
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Ui;
namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
/// <summary>
/// <c>/claude-code-login</c>: signs the <c>claude</c> CLI in (again) through the current session's own UI, with no
/// owner redirect - whoever runs the command is already allow-listed and at the keyboard. Also useful to switch
/// accounts or refresh a session that hasn't expired yet. The outcome is reported by <see cref="ClaudeCodeApi.LoginThroughAsync"/>
/// through that same UI.
/// </summary>
public static class LoginCommand
{
public const string Name = "claude-code-login";
public const string Description = "Sign Claude Code in to the Claude subscription (link + pasted code)";
public static CommandDefinition Create(IExtensionApi api, ClaudeCodeApi claude)
{
ArgumentNullException.ThrowIfNull(api);
ArgumentNullException.ThrowIfNull(claude);
return new CommandDefinition(Description, async (_, ctx, ct) =>
{
if (!ctx.HasUi)
{
api.SendMessage(
CustomMessage.FromText(Name, $"/{Name} needs an interactive UI; run \"claude auth login\" on the host instead."),
MessageDelivery.Steer, triggerTurn: false);
return;
}
var ui = ctx.Ui;
await (claude.Options.Coordinator ?? ClaudeLoginCoordinator.Shared)
.EnsureLoggedInAsync(flowCt => claude.RunLoginFlowAsync(_ => Task.FromResult<IUserInterface?>(ui), flowCt), ct)
.ConfigureAwait(false);
});
}
}

View File

@@ -0,0 +1,145 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
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.Sessions;
using Volcanic.AgenticCore.Abstractions.Tools;
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Extensions.ClaudeCode;
namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode;
/// <summary>Drives <see cref="LoginCommand"/> directly (not through activation), matching <see cref="DoctorCommandTests"/>.</summary>
[Collection(FakeScripts.Collection)]
public class LoginCommandTests : IDisposable
{
private readonly string _root = Directory.CreateTempSubdirectory("claude-login-command-test-").FullName;
public void Dispose()
{
try { Directory.Delete(_root, recursive: true); } catch (IOException) { }
}
/// <summary>Records what the command sends; everything else is unsupported so a misuse during the command is loud.</summary>
private sealed class FakeExtensionApi : IExtensionApi
{
public List<(CustomMessage Message, MessageDelivery Delivery, bool TriggerTurn)> Sent { get; } = [];
public string ExtensionName => "claude-code-provider";
public ILogger Logger => NullLogger.Instance;
public IEventBus Events => throw new NotSupportedException();
public IDisposable On<TEvent>(ExtensionHandler<TEvent> handler) where TEvent : ExtensionEvent => throw new NotSupportedException();
public void RegisterTool(ITool tool) => throw new NotSupportedException();
public void RegisterCommand(string name, CommandDefinition command) => throw new NotSupportedException();
public void RegisterProvider(ProviderRegistration provider) => throw new NotSupportedException();
public void UnregisterProvider(string providerId) => throw new NotSupportedException();
public void SendMessage(CustomMessage message, MessageDelivery delivery = MessageDelivery.Steer, bool triggerTurn = true) => Sent.Add((message, delivery, triggerTurn));
public void SendUserMessage(string text, IReadOnlyList<ImageContent>? images = null, MessageDelivery delivery = MessageDelivery.Steer) => throw new NotSupportedException();
public void AppendEntry(string customType, JsonElement? data = null) => throw new NotSupportedException();
public Task<bool> SetModelAsync(Model model, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public ThinkingLevel GetThinkingLevel() => throw new NotSupportedException();
public void SetThinkingLevel(ThinkingLevel level) => throw new NotSupportedException();
public IReadOnlyList<string> GetActiveTools() => throw new NotSupportedException();
public void SetActiveTools(IEnumerable<string> names) => throw new NotSupportedException();
public IReadOnlyList<ToolInfo> GetAllTools() => throw new NotSupportedException();
public IReadOnlyList<CommandInfo> GetCommands() => throw new NotSupportedException();
public string? GetSessionName() => throw new NotSupportedException();
public void SetSessionName(string? name) => throw new NotSupportedException();
public Task<ExecResult> ExecAsync(string command, IReadOnlyList<string> args, ExecOptions? options = null, CancellationToken cancellationToken = default) => throw new NotSupportedException();
}
/// <summary>A minimal command context: only <see cref="HasUi"/> and <see cref="Ui"/> matter to the command.</summary>
private sealed class FakeContext(IUserInterface? ui) : IExtensionCommandContext
{
public string Cwd => "/";
public string SessionId => "s";
public bool HasUi => ui is not null;
public IUserInterface Ui => ui ?? NullUserInterface.Instance;
public IReadOnlySessionManager SessionManager => throw new NotSupportedException();
public IModelRegistry ModelRegistry => throw new NotSupportedException();
public Model? Model => null;
public ThinkingLevel ThinkingLevel => ThinkingLevel.Off;
public CancellationToken Signal => CancellationToken.None;
public ILogger Logger => NullLogger.Instance;
public bool IsIdle() => true;
public void Abort() { }
public bool HasPendingMessages() => false;
public ContextUsage? GetContextUsage() => null;
public string GetSystemPrompt() => "";
public void Compact(CompactOptions? options = null) { }
public Task WaitForIdleAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<SwitchResult> NewSessionAsync(NewSessionOptions? options = null, CancellationToken cancellationToken = default) => Task.FromResult(new SwitchResult(false));
public Task<SwitchResult> SwitchSessionAsync(string sessionId, CancellationToken cancellationToken = default) => Task.FromResult(new SwitchResult(false));
}
/// <summary>A fake claude handling only <c>auth login</c> and <c>auth status</c> (as in <c>ClaudeLoginTests</c>).</summary>
private string FakeClaude()
{
var script = Path.Combine(_root, "claude");
var state = Path.Combine(_root, "state");
Directory.CreateDirectory(state);
File.WriteAllText(script, $$"""
#!/bin/sh
STATE="{{state}}"
if [ "$1" = "auth" ] && [ "$2" = "login" ]; then
echo "If the browser didn't open, visit: https://claude.com/cai/oauth/authorize?state=x"
printf "Paste code here if prompted > "
read code
echo "$code" > "$STATE/code"
exit 0
fi
if [ "$1" = "auth" ] && [ "$2" = "status" ]; then
if [ "$(cat "$STATE/code" 2>/dev/null)" = "good" ]; then
echo '{"loggedIn":true,"authMethod":"claude.ai"}'
else
echo '{"loggedIn":false}'
fi
exit 0
fi
exit 1
""".Replace("\r\n", "\n"));
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(script, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
return script;
}
private ClaudeCodeApi Claude(string claudePath) => new(new SessionRegistry(), new ClaudeCodeApiOptions
{
ClaudePath = claudePath,
Coordinator = new ClaudeLoginCoordinator(),
GetEnv = _ => null,
ParentEnvironment = new Dictionary<string, string?> { ["PATH"] = Environment.GetEnvironmentVariable("PATH") },
});
[Fact]
public async Task Without_ui_the_command_explains_it_needs_one()
{
var api = new FakeExtensionApi();
var command = LoginCommand.Create(api, Claude("/nonexistent/claude"));
await command.Handler("", new FakeContext(null), CancellationToken.None);
var (message, delivery, triggerTurn) = Assert.Single(api.Sent);
Assert.Equal(LoginCommand.Name, message.CustomType);
Assert.Contains("needs an interactive UI", message.Text);
Assert.Equal(MessageDelivery.Steer, delivery);
Assert.False(triggerTurn);
}
[Fact]
public async Task With_ui_it_runs_the_login_through_that_ui()
{
if (!Fixtures.IsPosix) return;
var api = new FakeExtensionApi();
var command = LoginCommand.Create(api, Claude(FakeClaude()));
var ui = new RecordingUi("good");
await command.Handler("", new FakeContext(ui), CancellationToken.None);
Assert.Contains(ui.Notes, n => n.Message.Contains("login complete"));
Assert.Empty(api.Sent);
}
}

View File

@@ -115,9 +115,8 @@ public class ProviderExtensionTests
Assert.Null(provider.Models[0].ThinkingLevelMap![ThinkingLevel.Off]);
Assert.Contains(ModelInput.Image, provider.Models[1].Input);
var command = Assert.Single(api.Commands);
Assert.Equal("claude-code-doctor", command.Key);
Assert.NotNull(command.Value.Description);
Assert.Equal(["claude-code-doctor", "claude-code-login"], api.Commands.Keys.Order().ToArray());
Assert.All(api.Commands.Values, c => Assert.NotNull(c.Description));
Assert.Equal(3, api.Handlers.Count);
Assert.NotNull(extension.Api);
}