Skip to content

Commit

Permalink
Initial config and chat command boilerplate
Browse files Browse the repository at this point in the history
  • Loading branch information
kzu committed Sep 6, 2024
1 parent e3aad4a commit ee549a8
Show file tree
Hide file tree
Showing 12 changed files with 480 additions and 3 deletions.
6 changes: 6 additions & 0 deletions gh-chat.sln
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ VisualStudioVersion = 17.12.35209.166
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Extension", "src\Extension\Extension.csproj", "{88B14B7B-FBC8-4685-8DD2-6044115BAFF5}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "src\Tests\Tests.csproj", "{95AC93D0-F86B-420E-98A6-1749959F0E5D}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -15,6 +17,10 @@ Global
{88B14B7B-FBC8-4685-8DD2-6044115BAFF5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{88B14B7B-FBC8-4685-8DD2-6044115BAFF5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{88B14B7B-FBC8-4685-8DD2-6044115BAFF5}.Release|Any CPU.Build.0 = Release|Any CPU
{95AC93D0-F86B-420E-98A6-1749959F0E5D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{95AC93D0-F86B-420E-98A6-1749959F0E5D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{95AC93D0-F86B-420E-98A6-1749959F0E5D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{95AC93D0-F86B-420E-98A6-1749959F0E5D}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
18 changes: 15 additions & 3 deletions src/Extension/App.cs
Original file line number Diff line number Diff line change
@@ -1,25 +1,37 @@
using DotNetConfig;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace Devlooped;

public static class App
{
public static CommandApp Create()
public static ICommandApp Create()
{
var builder = new ServiceCollection();
var cfgdir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "gh-chat");
Directory.CreateDirectory(cfgdir);

builder
.AddOptions<AzureOptions>()
.Configure<IConfiguration>((options, configuration) => configuration.GetSection("gh-chat").Bind(options));

// Made transient so each command gets a new copy with potentially updated values.
builder.AddTransient(sp => Config.Build(cfgdir));
builder.AddTransient<IConfiguration>(sp => new ConfigurationBuilder()
.AddDotNetConfig(cfgdir)
.Build());

var registrar = new TypeRegistrar(builder);
var app = new CommandApp(registrar);
var app = new CommandApp<ChatCommand>(registrar);

builder.AddSingleton<ICommandApp>(app);

app.Configure(config => config.SetApplicationName("gh chat"));
app.Configure(config =>
{
config.SetApplicationName("gh chat");
config.AddCommand<ConfigCommand>("config");
});

// TODO: add commands, etc.

Expand Down
7 changes: 7 additions & 0 deletions src/Extension/AzureOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Devlooped;

public class AzureOptions
{
public string? Key { get; set; }
public string? Endpoint { get; set; }
}
32 changes: 32 additions & 0 deletions src/Extension/ChatCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using System.ComponentModel;
using Microsoft.Extensions.Options;
using Spectre.Console;

namespace Devlooped;

[Description("Chat with GitHub via the CLI using natural language")]
public class ChatCommand(IOptions<AzureOptions> options) : AsyncCommand<ChatCommand.ChatSettings>
{
AzureOptions options = options.Value;

public override Task<int> ExecuteAsync(CommandContext context, ChatSettings settings)
{
if (string.IsNullOrEmpty(options.Key) || string.IsNullOrEmpty(options.Endpoint))
{
AnsiConsole.MarkupLine($"[red]x[/] {ThisAssembly.Strings.MissingConfiguration}");
return Task.FromResult(-1);
}

AnsiConsole.MarkupLine($"Key: {options.Key}");
AnsiConsole.MarkupLine($"Endpoint: {options.Endpoint}");

return Task.FromResult(0);
}

public class ChatSettings : CommandSettings
{
[Description("The question to ask GitHub")]
[CommandArgument(0, "<question>")]
public required string Question { get; set; }
}
}
40 changes: 40 additions & 0 deletions src/Extension/ConfigCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using System.ComponentModel;
using DotNetConfig;
using Spectre.Console;

namespace Devlooped;

[Description("Manages configuration")]
public class ConfigCommand(Config config) : Command<ConfigCommand.ConfigSettings>
{
public override int Execute(CommandContext context, ConfigSettings settings)
{
config.SetString("gh-chat", "key", settings.Key);
config.SetString("gh-chat", "endpoint", settings.Endpoint);

AnsiConsole.MarkupLine($"[green]✓[/] {ThisAssembly.Strings.ConfigurationSaved}");
return 0;
}

public class ConfigSettings : CommandSettings
{
[Description("Azure OpenAI key to use")]
[CommandOption("-k|--key")]
public required string Key { get; set; }

[Description("Azure OpenAI endpoint to use")]
[CommandOption("-e|--endpoint|")]
public required string Endpoint { get; set; }

public override ValidationResult Validate()
{
if (string.IsNullOrWhiteSpace(Key))
return ValidationResult.Error("Key is required.");

if (string.IsNullOrWhiteSpace(Endpoint))
return ValidationResult.Error("Endpoint is required.");

return base.Validate();
}
}
}
4 changes: 4 additions & 0 deletions src/Extension/Extension.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,13 @@

<ItemGroup>
<PackageReference Include="DotNetConfig.Configuration" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.2" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
<PackageReference Include="Spectre.Console.Analyzer" Version="0.49.1" PrivateAssets="all" />
<PackageReference Include="Spectre.Console.Cli" Version="0.49.1" />
<PackageReference Include="ThisAssembly.Strings" Version="1.5.0" PrivateAssets="all" />
</ItemGroup>

<ItemGroup>
Expand Down
135 changes: 135 additions & 0 deletions src/Extension/GitHub.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Spectre.Console;
using static Devlooped.Process;

namespace Devlooped;

public record AccountInfo(int Id, string Login)
{
public string[] Emails { get; init; } = Array.Empty<string>();
}

public static class GitHub
{
public static bool IsInstalled { get; } = TryIsInstalled(out var _);

public static bool TryIsInstalled(out string? output)
=> TryExecute("gh", "--version", out output) && output?.StartsWith("gh version") == true;

public static bool TryApi(string endpoint, string jq, out string? json)
{
var args = $"api {endpoint}";
if (jq?.Length > 0)
args += $" --jq \"{jq}\"";

return TryExecute("gh", args, out json);
}

public static bool TryAuthenticate([NotNullWhen(true)] out AccountInfo? account)
{
account = null;

if (!IsInstalled)
{
AnsiConsole.MarkupLine("[yellow]Please install GitHub CLI from [/][link]https://cli.github.com/[/]");
return false;
}

account = Authenticate();
if (account is not null)
return true;

// Continuing from here requires an interactive console.
// See https://stackoverflow.com/questions/1188658/how-can-a-c-sharp-windows-console-application-tell-if-it-is-run-interactively
if (!Environment.UserInteractive || Console.IsInputRedirected || Console.IsOutputRedirected || Console.IsErrorRedirected)
return false;

if (!AnsiConsole.Confirm("[lime]?[/] [white]Do you want to log into the GitHub CLI?[/]"))
{
AnsiConsole.MarkupLine("[dim]-[/] Please run [yellow]gh auth login[/] to authenticate, [yellow]gh auth status -h github.com[/] to verify your status.");
return false;
}

var process = System.Diagnostics.Process.Start("gh", "auth login");

process.WaitForExit();
if (process.ExitCode != 0)
return false;

account = Authenticate();
if (account is not null)
return true;

AnsiConsole.MarkupLine("[red]x[/] Could not retrieve authenticated user with GitHub CLI.");
AnsiConsole.MarkupLine("[dim]-[/] Please run [yellow]gh auth login[/] to authenticate, [yellow]gh auth status -h github.com[/] to verify your status.");
return false;
}

public static IDisposable? WithToken(string? token)
{
if (token == null)
return null;

if (!IsInstalled)
{
AnsiConsole.MarkupLine("[yellow]Please install GitHub CLI from [/][link]https://cli.github.com/[/]");
return null;
}

return new TransientToken(token);
}

public static AccountInfo? Authenticate()
{
if (!TryExecute("gh", "auth status -h github.com", out var output) || output is null)
return default;

if (output.Contains("gh auth login"))
return default;

if (!TryExecute("gh", "api user", out output) || output is null)
return default;

if (JsonSerializer.Deserialize<AccountInfo>(output, JsonOptions.Default) is not { } account)
return default;

if (!TryApi("user/emails", "[.[] | select(.verified == true) | .email]", out output) ||
string.IsNullOrEmpty(output))
return account;

return account with
{
Emails = JsonSerializer.Deserialize<string[]>(output, JsonOptions.Default) ?? []
};
}

class TransientToken : IDisposable
{
readonly string? existingToken;

public TransientToken(string token)
{
if (TryExecute("gh", "auth status", out var _))
TryExecute("gh", "auth token", out existingToken);

if (!TryExecute("gh", $"auth login --with-token", token, out var output))
Debug.Fail(output);
}

public void Dispose()
{
if (existingToken != null &&
TryExecute("gh", "auth token", out var currentToken) &&
existingToken != currentToken)
{
string? output = default;
Debug.Assert(TryExecute("gh", $"auth login --with-token", existingToken, out output), output);
Debug.Assert(TryExecute("gh", "auth status", out output), output);
Debug.Assert(TryExecute("gh", "auth token", out var newToken), newToken);
Debug.Assert(newToken == existingToken, "Could not restore previous auth token.");
}
}
}
}
33 changes: 33 additions & 0 deletions src/Extension/JsonOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using System.Globalization;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace Devlooped;

static partial class JsonOptions
{
public static JsonSerializerOptions Default { get; } = new(JsonSerializerDefaults.Web)
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
AllowTrailingCommas = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
ReadCommentHandling = JsonCommentHandling.Skip,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault | JsonIgnoreCondition.WhenWritingNull,
WriteIndented = true,
Converters =
{
new JsonStringEnumConverter(allowIntegerValues: false),
new DateOnlyJsonConverter()
}
};

public class DateOnlyJsonConverter : JsonConverter<DateOnly>
{
public override DateOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
=> DateOnly.Parse(reader.GetString()?[..10] ?? "", CultureInfo.InvariantCulture);

public override void Write(Utf8JsonWriter writer, DateOnly value, JsonSerializerOptions options)
=> writer.WriteStringValue(value.ToString("O", CultureInfo.InvariantCulture));
}
}
63 changes: 63 additions & 0 deletions src/Extension/Process.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using System.Diagnostics;

namespace Devlooped;

public static class Process
{
public static bool TryExecute(string program, string arguments, out string? output)
=> TryExecuteCore(program, arguments, null, out output);

public static bool TryExecute(string program, string arguments, string input, out string? output)
=> TryExecuteCore(program, arguments, input, out output);

static bool TryExecuteCore(string program, string arguments, string? input, out string? output)
{
var info = new ProcessStartInfo(program, arguments)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = input != null
};

try
{
var proc = System.Diagnostics.Process.Start(info);
if (proc == null)
{
output = null;
return false;
}

var gotError = false;
proc.ErrorDataReceived += (_, __) => gotError = true;

if (input != null)
{
// Write the input to the standard input stream
proc.StandardInput.WriteLine(input);
proc.StandardInput.Close();
}

output = proc.StandardOutput.ReadToEnd();
if (!proc.WaitForExit(5000))
{
proc.Kill();
output = null;
return false;
}

var error = proc.StandardError.ReadToEnd();
gotError |= error.Length > 0;
output = output.Trim();
if (string.IsNullOrEmpty(output))
output = null;

return !gotError && proc.ExitCode == 0;
}
catch (Exception ex)
{
output = ex.Message;
return false;
}
}
}
1 change: 1 addition & 0 deletions src/Extension/Properties/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/launchSettings.json
Loading

0 comments on commit ee549a8

Please sign in to comment.