|
|
|
|
@@ -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);
|
|
|
|
|
}
|
|
|
|
|
}
|