diff --git a/.netconfig b/.netconfig
index 3b6edfa..d621926 100644
--- a/.netconfig
+++ b/.netconfig
@@ -141,3 +141,8 @@
etag = ec1645067cc2319c2ce3304900c260eb8ec700d50b6d8d62285914a6c96e01f9
weak
+[file "src/dotnet-meai/Sponsors/SponsorManifest.cs"]
+ url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/SponsorLink/SponsorManifest.cs
+ sha = a755e4be0f7cb73cfde208857e28f7cfeba2dcc3
+ etag = 55ef89e8441156541c1c74a50675b7f56633b56493031f0ffa877460839e3536
+ weak
diff --git a/Extensions.AI.sln b/Extensions.AI.sln
index 7af11f4..ffed918 100644
--- a/Extensions.AI.sln
+++ b/Extensions.AI.sln
@@ -7,6 +7,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AI", "src\AI\AI.csproj", "{
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AI.Tests", "src\AI.Tests\AI.Tests.csproj", "{3553B2FB-B06C-4766-93FD-1B7004761080}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "dotnet-meai", "src\dotnet-meai\dotnet-meai.csproj", "{58FC11CF-7D61-48B9-B95F-0EAA889D6DED}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -41,6 +43,18 @@ Global
{3553B2FB-B06C-4766-93FD-1B7004761080}.Release|x64.Build.0 = Release|Any CPU
{3553B2FB-B06C-4766-93FD-1B7004761080}.Release|x86.ActiveCfg = Release|Any CPU
{3553B2FB-B06C-4766-93FD-1B7004761080}.Release|x86.Build.0 = Release|Any CPU
+ {58FC11CF-7D61-48B9-B95F-0EAA889D6DED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {58FC11CF-7D61-48B9-B95F-0EAA889D6DED}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {58FC11CF-7D61-48B9-B95F-0EAA889D6DED}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {58FC11CF-7D61-48B9-B95F-0EAA889D6DED}.Debug|x64.Build.0 = Debug|Any CPU
+ {58FC11CF-7D61-48B9-B95F-0EAA889D6DED}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {58FC11CF-7D61-48B9-B95F-0EAA889D6DED}.Debug|x86.Build.0 = Debug|Any CPU
+ {58FC11CF-7D61-48B9-B95F-0EAA889D6DED}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {58FC11CF-7D61-48B9-B95F-0EAA889D6DED}.Release|Any CPU.Build.0 = Release|Any CPU
+ {58FC11CF-7D61-48B9-B95F-0EAA889D6DED}.Release|x64.ActiveCfg = Release|Any CPU
+ {58FC11CF-7D61-48B9-B95F-0EAA889D6DED}.Release|x64.Build.0 = Release|Any CPU
+ {58FC11CF-7D61-48B9-B95F-0EAA889D6DED}.Release|x86.ActiveCfg = Release|Any CPU
+ {58FC11CF-7D61-48B9-B95F-0EAA889D6DED}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/src/Directory.props b/src/Directory.props
index 042a953..caa219a 100644
--- a/src/Directory.props
+++ b/src/Directory.props
@@ -5,6 +5,8 @@
Devlooped.Extensions.AI
enable
6eb457f9-16bc-49c5-81f2-33399b254e04
+
+ https://api.nuget.org/v3/index.json;https://pkg.kzu.app/index.json
\ No newline at end of file
diff --git a/src/Directory.targets b/src/Directory.targets
index 2b17673..4de98b5 100644
--- a/src/Directory.targets
+++ b/src/Directory.targets
@@ -1,7 +1,3 @@
-
-
-
-
\ No newline at end of file
diff --git a/src/dotnet-meai/App.cs b/src/dotnet-meai/App.cs
new file mode 100644
index 0000000..3c34a41
--- /dev/null
+++ b/src/dotnet-meai/App.cs
@@ -0,0 +1,72 @@
+using System.Diagnostics.CodeAnalysis;
+using Devlooped.Sponsors;
+using GitCredentialManager;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace Devlooped.Extensions.AI;
+
+public static class App
+{
+ static readonly CancellationTokenSource shutdownCancellation = new();
+
+ static App() => Console.CancelKeyPress += (s, e) => shutdownCancellation.Cancel();
+
+ ///
+ /// Whether the CLI app is not interactive (i.e. part of a script run,
+ /// running in CI, or in a non-interactive user session).
+ ///
+ public static bool IsNonInteractive => !Environment.UserInteractive
+ || Console.IsInputRedirected
+ || Console.IsOutputRedirected;
+
+ public static CommandApp Create() => Create(out _);
+
+ public static CommandApp Create([NotNull] out ITypeRegistrar registrar)
+ => Create(new ServiceCollection(), out registrar);
+
+ public static CommandApp Create(IAnsiConsole console) => Create(console, out _);
+
+ public static CommandApp Create(IAnsiConsole console, [NotNull] out ITypeRegistrar registrar)
+ {
+#pragma warning disable DDI001
+ var app = Create(new ServiceCollection().AddSingleton(console), out registrar);
+#pragma warning restore DDI001
+ app.Configure(config => config.ConfigureConsole(console));
+ return app;
+ }
+
+ static CommandApp Create(IServiceCollection collection, [NotNull] out ITypeRegistrar registrar)
+ {
+ var config = new ConfigurationBuilder()
+ .AddEnvironmentVariables()
+#if DEBUG
+ .AddUserSecrets()
+#endif
+ .Build();
+
+ collection.AddSingleton(config)
+ .AddSingleton(_ => config)
+ .AddSingleton(_ => CredentialManager.Create(ThisAssembly.Project.PackageId));
+
+ collection.AddSingleton(shutdownCancellation);
+ collection.AddServices();
+ collection.ConfigureSponsors();
+
+ registrar = new TypeRegistrar(collection);
+ var app = new CommandApp(registrar);
+
+ app.Configure(config =>
+ {
+ // Allows emitting help markdown on build
+ if (Environment.GetEnvironmentVariables().Contains("NO_COLOR"))
+ config.Settings.HelpProviderStyles = null;
+ });
+
+ app.UseSponsors();
+
+ return app;
+ }
+}
diff --git a/src/dotnet-meai/AppExtensions.cs b/src/dotnet-meai/AppExtensions.cs
new file mode 100644
index 0000000..3a6aadb
--- /dev/null
+++ b/src/dotnet-meai/AppExtensions.cs
@@ -0,0 +1,108 @@
+using DotNetConfig;
+using NuGet.Configuration;
+using NuGet.Protocol.Core.Types;
+using NuGet.Versioning;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace Devlooped.Extensions.AI;
+
+static class AppExtensions
+{
+ ///
+ /// Runs the app and update check in paralell, and renders any update messages if available
+ /// after the command app completes.
+ ///
+ public static async Task RunWithUpdatesAsync(this ICommandApp app, string[] args)
+ {
+ var updates = Task.Run(() => GetUpdatesAsync(args));
+ var exit = await app.RunAsync(args);
+
+ if (await updates is { Length: > 0 } messages)
+ {
+ foreach (var message in messages)
+ AnsiConsole.MarkupLine(message);
+ }
+
+ return exit;
+ }
+
+ ///
+ /// Checks for updates to the tool and shows them if available.
+ ///
+ public static async Task ShowUpdatesAsync(this ICommandApp app, string[] args)
+ {
+ if (await GetUpdatesAsync(args, true) is { Length: > 0 } messages)
+ {
+ foreach (var message in messages)
+ AnsiConsole.MarkupLine(message);
+ }
+ }
+
+ ///
+ /// Shows the app version, build date and release link.
+ ///
+ ///
+ public static void ShowVersion(this ICommandApp app)
+ {
+ AnsiConsole.MarkupLine($"{ThisAssembly.Project.ToolCommandName} version [lime]{ThisAssembly.Project.Version}[/] ({ThisAssembly.Project.BuildDate})");
+ AnsiConsole.MarkupLine($"[link]{ThisAssembly.Git.Url}/releases/tag/{ThisAssembly.Project.BuildRef}[/]");
+ }
+
+ static async Task GetUpdatesAsync(string[] args, bool forced = false)
+ {
+ if (args.Contains("-u") || args.Contains("--unattended"))
+ return [];
+
+ var config = Config.Build(ConfigLevel.Global).GetSection(ThisAssembly.Project.ToolCommandName);
+
+ // Check once a day max
+ if (!forced)
+ {
+ var lastCheck = config.GetDateTime("checked") ?? DateTime.UtcNow.AddDays(-2);
+ // if it's been > 24 hours since the last check, we'll check again
+ if (lastCheck > DateTime.UtcNow.AddDays(-1))
+ return [];
+ }
+
+ // We check from a different feed in this case.
+ var civersion = ThisAssembly.Project.VersionPrefix.StartsWith("42.42.");
+
+ var providers = Repository.Provider.GetCoreV3();
+ var repository = new SourceRepository(new PackageSource(
+ // use CI feed rather than production feed depending on which version we're using
+ civersion && !string.IsNullOrEmpty(ThisAssembly.Project.SLEET_FEED_URL) ?
+ ThisAssembly.Project.SLEET_FEED_URL :
+ "https://api.nuget.org/v3/index.json"), providers);
+ var resource = await repository.GetResourceAsync();
+ var localVersion = new NuGetVersion(ThisAssembly.Project.Version);
+ // Only update to stable versions, not pre-releases
+ var metadata = await resource.GetMetadataAsync(ThisAssembly.Project.PackageId, includePrerelease: false, false,
+ new SourceCacheContext
+ {
+ NoCache = true,
+ RefreshMemoryCache = true,
+ },
+ NuGet.Common.NullLogger.Instance, CancellationToken.None);
+
+ var update = metadata
+ .Select(x => x.Identity)
+ .Where(x => x.Version > localVersion)
+ .OrderByDescending(x => x.Version)
+ .Select(x => x.Version)
+ .FirstOrDefault();
+
+ config.SetDateTime("checked", DateTime.UtcNow);
+
+ if (update != null)
+ {
+ return [
+ $"There is a new version of [yellow]{ThisAssembly.Project.PackageId}[/]: [dim]v{localVersion.ToNormalizedString()}[/] -> [lime]v{update.ToNormalizedString()}[/]",
+ $"Update with: [yellow]dotnet[/] tool update -g {ThisAssembly.Project.PackageId}" +
+ (civersion ? $" --source {ThisAssembly.Project.SLEET_FEED_URL}" : ""),
+ ];
+ }
+
+ return [];
+ }
+}
diff --git a/src/dotnet-meai/Program.cs b/src/dotnet-meai/Program.cs
new file mode 100644
index 0000000..15d6cd5
--- /dev/null
+++ b/src/dotnet-meai/Program.cs
@@ -0,0 +1,93 @@
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using System.Text;
+using Devlooped.Extensions.AI;
+using Microsoft.Extensions.DependencyInjection;
+using Spectre.Console.Cli;
+
+// Some users reported not getting emoji on Windows, so we force UTF-8 encoding.
+// This not great, but I couldn't find a better way to do it.
+if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ Console.InputEncoding = Console.OutputEncoding = Encoding.UTF8;
+
+args = [.. ExpandResponseFiles(args)];
+
+// Alias -? to -h for help
+if (args.Contains("-?"))
+ args = [.. args.Select(x => x == "-?" ? "-h" : x)];
+
+if (args.Contains("--debug"))
+{
+ Debugger.Launch();
+ args = [.. args.Where(x => x != "--debug")];
+}
+
+var app = App.Create(out var registrar);
+
+#if DEBUG
+app.Configure(c => c.PropagateExceptions());
+#else
+if (args.Contains("--exceptions"))
+{
+ app.Configure(c => c.PropagateExceptions());
+ args = [.. args.Where(x => x != "--exceptions")];
+}
+#endif
+
+app.Configure(config => config.SetApplicationName(ThisAssembly.Project.ToolCommandName));
+
+if (args.Contains("--version"))
+{
+ app.ShowVersion();
+ await app.ShowUpdatesAsync(args);
+ return 0;
+}
+
+var result = 0;
+
+#if DEBUG
+result = await app.RunAsync(args);
+#else
+result = await app.RunWithUpdatesAsync(args);
+#endif
+
+// --quiet does not report sponsor tier on every run.
+await ((IServiceProvider)registrar).GetRequiredService().ExecuteAsync(
+ new CommandContext(["--quiet"], RemainingArguments.Empty, "check", null),
+ new Devlooped.Sponsors.CheckCommand.CheckSettings { Quiet = true });
+
+return result;
+
+static IEnumerable ExpandResponseFiles(IEnumerable args)
+{
+ foreach (var arg in args)
+ {
+ if (arg.StartsWith('@'))
+ {
+ var filePath = arg[1..];
+
+ if (!File.Exists(filePath))
+ throw new FileNotFoundException($"Response file not found: {filePath}");
+
+ foreach (var line in File.ReadAllLines(filePath))
+ {
+ yield return line;
+ }
+ }
+ else
+ {
+ yield return arg;
+ }
+ }
+}
+
+class RemainingArguments : IRemainingArguments
+{
+ public static IRemainingArguments Empty { get; } = new RemainingArguments();
+
+ RemainingArguments() { }
+
+ public ILookup Parsed => Enumerable.Empty().ToLookup(x => x, x => default(string));
+
+ public IReadOnlyList Raw => [];
+}
\ No newline at end of file
diff --git a/src/dotnet-meai/Properties/.gitignore b/src/dotnet-meai/Properties/.gitignore
new file mode 100644
index 0000000..8392c90
--- /dev/null
+++ b/src/dotnet-meai/Properties/.gitignore
@@ -0,0 +1 @@
+launchSettings.json
\ No newline at end of file
diff --git a/src/dotnet-meai/Sponsors/App.cs b/src/dotnet-meai/Sponsors/App.cs
new file mode 100644
index 0000000..c0f0cdf
--- /dev/null
+++ b/src/dotnet-meai/Sponsors/App.cs
@@ -0,0 +1,12 @@
+namespace Devlooped.Sponsors;
+
+public static class App
+{
+ ///
+ /// Whether the CLI app is not interactive (i.e. part of a script run,
+ /// running in CI, or in a non-interactive user session).
+ ///
+ public static bool IsNonInteractive => !Environment.UserInteractive
+ || Console.IsInputRedirected
+ || Console.IsOutputRedirected;
+}
diff --git a/src/dotnet-meai/Sponsors/CheckCommand.cs b/src/dotnet-meai/Sponsors/CheckCommand.cs
new file mode 100644
index 0000000..ae4f1d9
--- /dev/null
+++ b/src/dotnet-meai/Sponsors/CheckCommand.cs
@@ -0,0 +1,90 @@
+using System.ComponentModel;
+using DotNetConfig;
+using Microsoft.Extensions.DependencyInjection;
+using Spectre.Console;
+using Spectre.Console.Cli;
+using static Devlooped.Sponsors.CheckCommand;
+using static ThisAssembly.Strings;
+
+namespace Devlooped.Sponsors;
+
+[Description("Checks the current sponsorship status with [lime]devlooped[/], entirely offline")]
+[Service]
+public class CheckCommand(Config config, Lazy sync, IAnsiConsole console) : AsyncCommand
+{
+ public class CheckSettings : CommandSettings
+ {
+ [CommandOption("-q|--quiet", IsHidden = true)]
+ public bool Quiet { get; set; }
+ }
+
+ public override async Task ExecuteAsync(CommandContext context, CheckSettings settings)
+ {
+ // Don't render anything if not interactive, so we don't disrupt usage in CI for example.
+ // In GH actions, console input/output is redirected, for example, and output is redirected
+ // when using the app in a powershell pipeline or capturing its output in a variable.
+ if (App.IsNonInteractive)
+ return 0;
+
+ // Check if we have a manifest at all
+ var jwtPath = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ ".sponsorlink", "github", "devlooped.jwt");
+
+ var url = ThisAssembly.Git.Url;
+ if (url.EndsWith(".git"))
+ url = url[0..^4];
+ var project = $"[blueviolet][link={url}]{ThisAssembly.Info.Product}[/][/]";
+ var link = "[link=https://github.com/devlooped/#sponsorlink]devlooped[/]";
+
+ if (!System.IO.File.Exists(jwtPath))
+ return MarkupLine(Unknown.Message(project, link));
+
+ var manifest = SponsorLink.GetManifest("devlooped", ThisAssembly.Metadata.Funding.GitHub.devlooped, true);
+ if (manifest.Status == ManifestStatus.Valid)
+ return 0;
+
+ // If not valid and we can auto-sync, do it now.
+ if (config.GetBoolean("sponsorlink", "autosync") == true)
+ {
+ await sync.Value.ExecuteAsync(context, new() { Unattended = settings.Quiet });
+ manifest = SponsorLink.GetManifest("devlooped", ThisAssembly.Metadata.Funding.GitHub.devlooped, true);
+ if (manifest.Status == ManifestStatus.Valid)
+ return 0;
+ }
+
+ if (manifest.Status == ManifestStatus.Unknown || manifest.Status == ManifestStatus.Invalid)
+ return MarkupLine(Unknown.Message(project, link));
+
+ if (manifest.Status == ManifestStatus.Expired)
+ return MarkupLine(Expired.Message);
+
+ if (settings.Quiet)
+ return 0;
+
+ if (manifest.Principal.IsInRole("team"))
+ return MarkupLine(Team.Message(link));
+
+ if (manifest.Principal.IsInRole("user"))
+ return MarkupLine(ThisAssembly.Strings.Sponsor.Message(project));
+
+ if (manifest.Principal.IsInRole("contrib"))
+ return MarkupLine(Contributor.Message(link));
+
+ if (manifest.Principal.IsInRole("org"))
+ return MarkupLine(ThisAssembly.Strings.Sponsor.Message(project));
+
+ if (manifest.Principal.IsInRole("oss"))
+ return MarkupLine(ThisAssembly.Strings.OpenSource.Message);
+
+ return MarkupLine(Unknown.Message(project, link));
+ }
+
+ int MarkupLine(string message)
+ {
+ console.WriteLine();
+ console.MarkupLine(message);
+ console.WriteLine();
+ return 0;
+ }
+}
diff --git a/src/dotnet-meai/Sponsors/SponsorManifest.cs b/src/dotnet-meai/Sponsors/SponsorManifest.cs
new file mode 100644
index 0000000..b4aa9d7
--- /dev/null
+++ b/src/dotnet-meai/Sponsors/SponsorManifest.cs
@@ -0,0 +1,160 @@
+//
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Security.Claims;
+using Microsoft.IdentityModel.JsonWebTokens;
+using Microsoft.IdentityModel.Tokens;
+
+namespace Devlooped.Sponsors;
+
+///
+/// The resulting status from validation.
+///
+public enum ManifestStatus
+{
+ ///
+ /// The manifest couldn't be read at all.
+ ///
+ Unknown,
+ ///
+ /// The manifest was read and is valid (not expired and properly signed).
+ ///
+ Valid,
+ ///
+ /// The manifest was read but has expired.
+ ///
+ Expired,
+ ///
+ /// The manifest was read, but its signature is invalid.
+ ///
+ Invalid,
+}
+
+///
+/// Represents the sponsorship status of a user.
+///
+/// The status.
+/// The principal potentially containing roles validated from the manifest.
+/// The security token from the validated manifest.
+public record SponsorManifest(ManifestStatus Status, ClaimsPrincipal Principal, SecurityToken? SecurityToken)
+{
+ ///
+ /// Whether the manifest is .
+ ///
+ public bool IsValid => Status == ManifestStatus.Valid;
+}
+
+static partial class SponsorLink
+{
+ ///
+ /// Reads the local manifest (if present) for the specified sponsorable account and validates it
+ /// against the given JWK key.
+ ///
+ /// The sponsorable account to read.
+ /// The public key to validate the signature on the manifest JWT if found.
+ /// Whether to validate the manifest expiration. If ,
+ /// an expired manifest will be reported as . The expiration date
+ /// can be checked in that case via the .
+ /// A manifest that represents the user status.
+ public static SponsorManifest GetManifest(string sponsorable, string jwk, bool validateExpiration = true)
+ {
+ var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ ".sponsorlink", "github", sponsorable + ".jwt");
+
+ if (!File.Exists(path))
+ return new SponsorManifest(ManifestStatus.Unknown, new ClaimsPrincipal(), null);
+
+ return ParseManifest(File.ReadAllText(path), jwk, validateExpiration);
+ }
+
+ internal static SponsorManifest ParseManifest(string jwt, string jwk, bool validateExpiration)
+ {
+ var status = Validate(jwt, jwk, out var token, out var identity, validateExpiration);
+
+ if (status == ManifestStatus.Unknown || identity == null)
+ return new SponsorManifest(status, new ClaimsPrincipal(), token);
+
+ return new SponsorManifest(status, new JwtRolesPrincipal(identity), token);
+ }
+
+ ///
+ /// Validates the manifest signature and optional expiration.
+ ///
+ /// The JWT to validate.
+ /// The key to validate the manifest signature with.
+ /// Except when returning , returns the security token read from the JWT, even if signature check failed.
+ /// The associated claims, only when return value is not .
+ /// Whether to check for expiration.
+ /// The status of the validation.
+ public static ManifestStatus Validate(string jwt, string jwk, out SecurityToken? token, out ClaimsIdentity? identity, bool validateExpiration)
+ {
+ token = default;
+ identity = default;
+
+ SecurityKey key;
+ try
+ {
+ key = JsonWebKey.Create(jwk);
+ }
+ catch (ArgumentException)
+ {
+ return ManifestStatus.Unknown;
+ }
+
+ var handler = new JsonWebTokenHandler { MapInboundClaims = false };
+
+ if (!handler.CanReadToken(jwt))
+ return ManifestStatus.Unknown;
+
+ var validation = new TokenValidationParameters
+ {
+ RequireExpirationTime = false,
+ ValidateLifetime = false,
+ ValidateAudience = false,
+ ValidateIssuer = false,
+ ValidateIssuerSigningKey = true,
+ IssuerSigningKey = key,
+ RoleClaimType = "roles",
+ NameClaimType = "sub",
+ };
+
+ var result = handler.ValidateTokenAsync(jwt, validation).Result;
+ if (!result.IsValid || result.Exception != null)
+ {
+ if (result.Exception is SecurityTokenInvalidSignatureException)
+ {
+ var jwtToken = handler.ReadJsonWebToken(jwt);
+ token = jwtToken;
+ identity = new ClaimsIdentity(jwtToken.Claims);
+ return ManifestStatus.Invalid;
+ }
+ else
+ {
+ var jwtToken = handler.ReadJsonWebToken(jwt);
+ token = jwtToken;
+ identity = new ClaimsIdentity(jwtToken.Claims);
+ return ManifestStatus.Invalid;
+ }
+ }
+
+ token = result.SecurityToken;
+ identity = new ClaimsIdentity(result.ClaimsIdentity.Claims, "JWT");
+
+ if (validateExpiration && token.ValidTo == DateTime.MinValue)
+ return ManifestStatus.Invalid;
+
+ // The sponsorable manifest does not have an expiration time.
+ if (validateExpiration && token.ValidTo < DateTimeOffset.UtcNow)
+ return ManifestStatus.Expired;
+
+ return ManifestStatus.Valid;
+ }
+
+ class JwtRolesPrincipal(ClaimsIdentity identity) : ClaimsPrincipal([identity])
+ {
+ public override bool IsInRole(string role) => HasClaim("roles", role) || base.IsInRole(role);
+ }
+}
diff --git a/src/dotnet-meai/Sponsors/Sponsors.Designer.cs b/src/dotnet-meai/Sponsors/Sponsors.Designer.cs
new file mode 100644
index 0000000..614e3c8
--- /dev/null
+++ b/src/dotnet-meai/Sponsors/Sponsors.Designer.cs
@@ -0,0 +1,135 @@
+//------------------------------------------------------------------------------
+//
+// This code was generated by a tool.
+// Runtime Version:4.0.30319.42000
+//
+// Changes to this file may cause incorrect behavior and will be lost if
+// the code is regenerated.
+//
+//------------------------------------------------------------------------------
+
+namespace Devlooped.Extensions.AI.Sponsors {
+ using System;
+
+
+ ///
+ /// A strongly-typed resource class, for looking up localized strings, etc.
+ ///
+ // This class was auto-generated by the StronglyTypedResourceBuilder
+ // class via a tool like ResGen or Visual Studio.
+ // To add or remove a member, edit your .ResX file then rerun ResGen
+ // with the /str option, or rebuild your VS project.
+ [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
+ [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
+ internal class Sponsors {
+
+ private static global::System.Resources.ResourceManager resourceMan;
+
+ private static global::System.Globalization.CultureInfo resourceCulture;
+
+ [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
+ internal Sponsors() {
+ }
+
+ ///
+ /// Returns the cached ResourceManager instance used by this class.
+ ///
+ [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
+ internal static global::System.Resources.ResourceManager ResourceManager {
+ get {
+ if (object.ReferenceEquals(resourceMan, null)) {
+ global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Devlooped.Extensions.AI.Sponsors.Sponsors", typeof(Sponsors).Assembly);
+ resourceMan = temp;
+ }
+ return resourceMan;
+ }
+ }
+
+ ///
+ /// Overrides the current thread's CurrentUICulture property for all
+ /// resource lookups using this strongly typed resource class.
+ ///
+ [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
+ internal static global::System.Globalization.CultureInfo Culture {
+ get {
+ return resourceCulture;
+ }
+ set {
+ resourceCulture = value;
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Thank you for being part of team {0} with your contributions 💟!.
+ ///
+ internal static string Contributor_Message {
+ get {
+ return ResourceManager.GetString("Contributor_Message", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Sponsor status has expired and automatic sync has not been enabled..
+ ///
+ internal static string Expired_Message {
+ get {
+ return ResourceManager.GetString("Expired_Message", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Sponsor status needs periodic updating and automatic sync has not been enabled..
+ ///
+ internal static string Expiring_Message {
+ get {
+ return ResourceManager.GetString("Expiring_Message", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Grace period ends in {0} days. Enjoy and please consider supporting {1} by sponsoring {2} 🙏.
+ ///
+ internal static string Grace_Message {
+ get {
+ return ResourceManager.GetString("Grace_Message", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Thank you for being an open source author 💟!.
+ ///
+ internal static string OpenSource_Message {
+ get {
+ return ResourceManager.GetString("OpenSource_Message", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Thank you for supporting {0} with your sponsorship 💟!.
+ ///
+ internal static string Sponsor_Message {
+ get {
+ return ResourceManager.GetString("Sponsor_Message", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Team {0} 💪.
+ ///
+ internal static string Team_Message {
+ get {
+ return ResourceManager.GetString("Team_Message", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Please consider supporting {0} by sponsoring {1} 🙏.
+ ///
+ internal static string Unknown_Message {
+ get {
+ return ResourceManager.GetString("Unknown_Message", resourceCulture);
+ }
+ }
+ }
+}
diff --git a/src/dotnet-meai/Sponsors/Sponsors.es-AR.resx b/src/dotnet-meai/Sponsors/Sponsors.es-AR.resx
new file mode 100644
index 0000000..d2a815d
--- /dev/null
+++ b/src/dotnet-meai/Sponsors/Sponsors.es-AR.resx
@@ -0,0 +1,141 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ Gracias por ser parte del equipo {0} con tu contribución 💟!
+
+
+ El estado de patrocino ha expirado y la sincronización automática no está habilitada.
+
+
+ El estado de patrocino necesita actualización periódica y la sincronización automática no está habilitada.
+
+
+ El período de prueba finaliza en {0} día(s). Disfrutá y por favor considerá apoyar {1} patrocinando {2} 🙏
+
+
+ Gracias por apoyar a {0} con tu patrocinio 💟!
+
+
+
+
+
+ Por favor considerá apoyar {0} patrocinando {1} 🙏
+
+
\ No newline at end of file
diff --git a/src/dotnet-meai/Sponsors/Sponsors.es.resx b/src/dotnet-meai/Sponsors/Sponsors.es.resx
new file mode 100644
index 0000000..7da943b
--- /dev/null
+++ b/src/dotnet-meai/Sponsors/Sponsors.es.resx
@@ -0,0 +1,144 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ Gracias por ser parte del equipo {0} con tu contribución 💟!
+
+
+ El estado de patrocino ha expirado y la sincronización automática no está habilitada.
+
+
+ El estado de patrocino necesita actualización periódica y la sincronización automática no está habilitada.
+
+
+ El período de prueba finaliza en {0} día(s). Disfrute y por favor considere apoyar {1} patrocinando {2} 🙏
+
+
+ Gracias por ser autor de código abierto 💟!
+
+
+ Gracias por apoyar a {0} con tu patrocinio 💟!
+
+
+
+
+
+ Por favor considere apoyar {0} patrocinando {1} 🙏
+
+
\ No newline at end of file
diff --git a/src/dotnet-meai/Sponsors/Sponsors.resx b/src/dotnet-meai/Sponsors/Sponsors.resx
new file mode 100644
index 0000000..0bdc13a
--- /dev/null
+++ b/src/dotnet-meai/Sponsors/Sponsors.resx
@@ -0,0 +1,144 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ Thank you for being part of team {0} with your contributions 💟!
+
+
+ Sponsor status has expired and automatic sync has not been enabled.
+
+
+ Sponsor status needs periodic updating and automatic sync has not been enabled.
+
+
+ Grace period ends in {0} days. Enjoy and please consider supporting {1} by sponsoring {2} 🙏
+
+
+ Thank you for being an open source author 💟!
+
+
+ Thank you for supporting {0} with your sponsorship 💟!
+
+
+ Team {0} 💪
+
+
+ Please consider supporting {0} by sponsoring {1} 🙏
+
+
\ No newline at end of file
diff --git a/src/dotnet-meai/Sponsors/SponsorsAppExtensions.cs b/src/dotnet-meai/Sponsors/SponsorsAppExtensions.cs
new file mode 100644
index 0000000..bc90f22
--- /dev/null
+++ b/src/dotnet-meai/Sponsors/SponsorsAppExtensions.cs
@@ -0,0 +1,37 @@
+using DotNetConfig;
+using Spectre.Console.Cli;
+
+namespace Devlooped.Sponsors;
+
+static class SponsorsAppExtensions
+{
+ public static ICommandApp UseSponsors(this ICommandApp app)
+ {
+ app.Configure(config =>
+ {
+ config.AddBranch("sponsor", group =>
+ {
+ group.AddCommand("check");
+ group.AddCommand("config");
+ group.AddCommand("view");
+ group.AddCommand("sync");
+ group.AddCommand("welcome").IsHidden();
+ });
+ });
+ return app;
+ }
+
+ public static bool ShouldRunWelcome(this CommandContext context, Config config, ToSSettings settings)
+ {
+ // If we don't have ToS acceptance, we don't run any command other than welcome.
+ var tos = config.TryGetBoolean("sponsorlink", "tos", out var completed) && completed;
+ if (!tos && settings.ToS == true)
+ {
+ // Implicit acceptance on first run of another tool, like `sponsor sync --tos`
+ new ConfigCommand(config).Execute(context, new ConfigCommand.ConfigSettings { ToS = true, Quiet = true });
+ return false;
+ }
+
+ return tos == false;
+ }
+}
\ No newline at end of file
diff --git a/src/dotnet-meai/Sponsors/SyncCommand.cs b/src/dotnet-meai/Sponsors/SyncCommand.cs
new file mode 100644
index 0000000..aecc833
--- /dev/null
+++ b/src/dotnet-meai/Sponsors/SyncCommand.cs
@@ -0,0 +1,29 @@
+using System.ComponentModel;
+using DotNetConfig;
+using Microsoft.Extensions.DependencyInjection;
+using Spectre.Console.Cli;
+
+namespace Devlooped.Sponsors;
+
+[Description("Synchronizes your sponsorship manifest for [lime]devlooped[/]")]
+[Service]
+public class DevloopedSyncCommand(Config config, IGraphQueryClient client, IGitHubAppAuthenticator authenticator, IHttpClientFactory httpFactory)
+ : SyncCommand(config, client, authenticator, httpFactory)
+{
+ public class DevloopedSyncSettings : SyncSettings, ISponsorableSettings
+ {
+ public string[]? Sponsorable { get; set; } = ["devlooped"];
+ }
+
+ public override async Task ExecuteAsync(CommandContext context, DevloopedSyncSettings settings)
+ {
+ if (context.ShouldRunWelcome(config, settings))
+ {
+ if (new WelcomeCommand(config).Execute(context, new WelcomeCommand.WelcomeSettings { ToS = settings.ToS }) is var result && result != 0)
+ return result;
+ }
+
+ settings.Sponsorable = ["devlooped"];
+ return await base.ExecuteAsync(context, settings);
+ }
+}
diff --git a/src/dotnet-meai/Sponsors/ViewCommand.cs b/src/dotnet-meai/Sponsors/ViewCommand.cs
new file mode 100644
index 0000000..ba00142
--- /dev/null
+++ b/src/dotnet-meai/Sponsors/ViewCommand.cs
@@ -0,0 +1,26 @@
+using System.ComponentModel;
+using DotNetConfig;
+using Spectre.Console.Cli;
+
+namespace Devlooped.Sponsors;
+
+[Description("Validates and displays your sponsor manifest for [lime]devlooped[/], if present")]
+class DevloopedViewCommand(Config config, IHttpClientFactory http) : ViewCommand(http)
+{
+ public class DevloopedViewSettings : ViewSettings, ISponsorableSettings
+ {
+ public string[]? Sponsorable { get; set; } = ["devlooped"];
+ }
+
+ public override async Task ExecuteAsync(CommandContext context, DevloopedViewSettings settings)
+ {
+ if (context.ShouldRunWelcome(config, settings))
+ {
+ if (new WelcomeCommand(config).Execute(context, new WelcomeCommand.WelcomeSettings { ToS = settings.ToS }) is var result && result != 0)
+ return result;
+ }
+
+ settings.Sponsorable = ["devlooped"];
+ return await base.ExecuteAsync(context, settings);
+ }
+}
diff --git a/src/dotnet-meai/TypeRegistrar.cs b/src/dotnet-meai/TypeRegistrar.cs
new file mode 100644
index 0000000..4f2dbaa
--- /dev/null
+++ b/src/dotnet-meai/TypeRegistrar.cs
@@ -0,0 +1,59 @@
+using Microsoft.Extensions.DependencyInjection;
+using Spectre.Console.Cli;
+
+namespace Devlooped.Extensions.AI;
+
+#pragma warning disable DDI001
+public sealed class TypeRegistrar(IServiceCollection? builder = default) : ITypeRegistrar, IServiceProvider
+{
+ readonly IServiceCollection builder = builder ?? new ServiceCollection();
+ IServiceProvider? services;
+
+ public IServiceCollection Services => builder;
+
+ public object? GetService(Type serviceType) => Build().GetService(serviceType);
+
+ public void Register(Type service, Type implementation)
+ {
+ ResetServiceProvider();
+ builder.AddSingleton(service, implementation);
+ }
+
+ public void RegisterInstance(Type service, object implementation)
+ {
+ ResetServiceProvider();
+ builder.AddSingleton(service, implementation);
+ }
+
+ public void RegisterLazy(Type service, Func