-
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Initial config and chat command boilerplate
- Loading branch information
Showing
12 changed files
with
480 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; } | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; } | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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."); | ||
} | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
/launchSettings.json |
Oops, something went wrong.