Skip to content

Commit a4597a6

Browse files
committed
Initial CLI with basic command for help/sponsoring
1 parent 1012d71 commit a4597a6

20 files changed

+1353
-4
lines changed

.netconfig

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,8 @@
141141

142142
etag = ec1645067cc2319c2ce3304900c260eb8ec700d50b6d8d62285914a6c96e01f9
143143
weak
144+
[file "src/dotnet-meai/Sponsors/SponsorManifest.cs"]
145+
url = https://github.com/devlooped/SponsorLink/blob/main/samples/dotnet/SponsorLink/SponsorManifest.cs
146+
sha = a755e4be0f7cb73cfde208857e28f7cfeba2dcc3
147+
etag = 55ef89e8441156541c1c74a50675b7f56633b56493031f0ffa877460839e3536
148+
weak

Extensions.AI.sln

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AI", "src\AI\AI.csproj", "{
77
EndProject
88
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AI.Tests", "src\AI.Tests\AI.Tests.csproj", "{3553B2FB-B06C-4766-93FD-1B7004761080}"
99
EndProject
10+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "dotnet-meai", "src\dotnet-meai\dotnet-meai.csproj", "{58FC11CF-7D61-48B9-B95F-0EAA889D6DED}"
11+
EndProject
1012
Global
1113
GlobalSection(SolutionConfigurationPlatforms) = preSolution
1214
Debug|Any CPU = Debug|Any CPU
@@ -41,6 +43,18 @@ Global
4143
{3553B2FB-B06C-4766-93FD-1B7004761080}.Release|x64.Build.0 = Release|Any CPU
4244
{3553B2FB-B06C-4766-93FD-1B7004761080}.Release|x86.ActiveCfg = Release|Any CPU
4345
{3553B2FB-B06C-4766-93FD-1B7004761080}.Release|x86.Build.0 = Release|Any CPU
46+
{58FC11CF-7D61-48B9-B95F-0EAA889D6DED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
47+
{58FC11CF-7D61-48B9-B95F-0EAA889D6DED}.Debug|Any CPU.Build.0 = Debug|Any CPU
48+
{58FC11CF-7D61-48B9-B95F-0EAA889D6DED}.Debug|x64.ActiveCfg = Debug|Any CPU
49+
{58FC11CF-7D61-48B9-B95F-0EAA889D6DED}.Debug|x64.Build.0 = Debug|Any CPU
50+
{58FC11CF-7D61-48B9-B95F-0EAA889D6DED}.Debug|x86.ActiveCfg = Debug|Any CPU
51+
{58FC11CF-7D61-48B9-B95F-0EAA889D6DED}.Debug|x86.Build.0 = Debug|Any CPU
52+
{58FC11CF-7D61-48B9-B95F-0EAA889D6DED}.Release|Any CPU.ActiveCfg = Release|Any CPU
53+
{58FC11CF-7D61-48B9-B95F-0EAA889D6DED}.Release|Any CPU.Build.0 = Release|Any CPU
54+
{58FC11CF-7D61-48B9-B95F-0EAA889D6DED}.Release|x64.ActiveCfg = Release|Any CPU
55+
{58FC11CF-7D61-48B9-B95F-0EAA889D6DED}.Release|x64.Build.0 = Release|Any CPU
56+
{58FC11CF-7D61-48B9-B95F-0EAA889D6DED}.Release|x86.ActiveCfg = Release|Any CPU
57+
{58FC11CF-7D61-48B9-B95F-0EAA889D6DED}.Release|x86.Build.0 = Release|Any CPU
4458
EndGlobalSection
4559
GlobalSection(SolutionProperties) = preSolution
4660
HideSolutionNode = FALSE

src/Directory.props

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
<RootNamespace>Devlooped.Extensions.AI</RootNamespace>
66
<ImplicitUsings>enable</ImplicitUsings>
77
<UserSecretsId>6eb457f9-16bc-49c5-81f2-33399b254e04</UserSecretsId>
8+
9+
<RestoreSources>https://api.nuget.org/v3/index.json;https://pkg.kzu.app/index.json</RestoreSources>
810
</PropertyGroup>
911

1012
</Project>

src/Directory.targets

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,3 @@
11
<Project>
22

3-
<PropertyGroup>
4-
5-
</PropertyGroup>
6-
73
</Project>

src/dotnet-meai/App.cs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
using System.Diagnostics.CodeAnalysis;
2+
using Devlooped.Sponsors;
3+
using GitCredentialManager;
4+
using Microsoft.Extensions.Configuration;
5+
using Microsoft.Extensions.DependencyInjection;
6+
using Spectre.Console;
7+
using Spectre.Console.Cli;
8+
9+
namespace Devlooped.Extensions.AI;
10+
11+
public static class App
12+
{
13+
static readonly CancellationTokenSource shutdownCancellation = new();
14+
15+
static App() => Console.CancelKeyPress += (s, e) => shutdownCancellation.Cancel();
16+
17+
/// <summary>
18+
/// Whether the CLI app is not interactive (i.e. part of a script run,
19+
/// running in CI, or in a non-interactive user session).
20+
/// </summary>
21+
public static bool IsNonInteractive => !Environment.UserInteractive
22+
|| Console.IsInputRedirected
23+
|| Console.IsOutputRedirected;
24+
25+
public static CommandApp Create() => Create(out _);
26+
27+
public static CommandApp Create([NotNull] out ITypeRegistrar registrar)
28+
=> Create(new ServiceCollection(), out registrar);
29+
30+
public static CommandApp Create(IAnsiConsole console) => Create(console, out _);
31+
32+
public static CommandApp Create(IAnsiConsole console, [NotNull] out ITypeRegistrar registrar)
33+
{
34+
#pragma warning disable DDI001
35+
var app = Create(new ServiceCollection().AddSingleton(console), out registrar);
36+
#pragma warning restore DDI001
37+
app.Configure(config => config.ConfigureConsole(console));
38+
return app;
39+
}
40+
41+
static CommandApp Create(IServiceCollection collection, [NotNull] out ITypeRegistrar registrar)
42+
{
43+
var config = new ConfigurationBuilder()
44+
.AddEnvironmentVariables()
45+
#if DEBUG
46+
.AddUserSecrets<TypeRegistrar>()
47+
#endif
48+
.Build();
49+
50+
collection.AddSingleton(config)
51+
.AddSingleton<IConfiguration>(_ => config)
52+
.AddSingleton(_ => CredentialManager.Create(ThisAssembly.Project.PackageId));
53+
54+
collection.AddSingleton(shutdownCancellation);
55+
collection.AddServices();
56+
collection.ConfigureSponsors();
57+
58+
registrar = new TypeRegistrar(collection);
59+
var app = new CommandApp(registrar);
60+
61+
app.Configure(config =>
62+
{
63+
// Allows emitting help markdown on build
64+
if (Environment.GetEnvironmentVariables().Contains("NO_COLOR"))
65+
config.Settings.HelpProviderStyles = null;
66+
});
67+
68+
app.UseSponsors();
69+
70+
return app;
71+
}
72+
}

src/dotnet-meai/AppExtensions.cs

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
using DotNetConfig;
2+
using NuGet.Configuration;
3+
using NuGet.Protocol.Core.Types;
4+
using NuGet.Versioning;
5+
using Spectre.Console;
6+
using Spectre.Console.Cli;
7+
8+
namespace Devlooped.Extensions.AI;
9+
10+
static class AppExtensions
11+
{
12+
/// <summary>
13+
/// Runs the app and update check in paralell, and renders any update messages if available
14+
/// after the command app completes.
15+
/// </summary>
16+
public static async Task<int> RunWithUpdatesAsync(this ICommandApp app, string[] args)
17+
{
18+
var updates = Task.Run(() => GetUpdatesAsync(args));
19+
var exit = await app.RunAsync(args);
20+
21+
if (await updates is { Length: > 0 } messages)
22+
{
23+
foreach (var message in messages)
24+
AnsiConsole.MarkupLine(message);
25+
}
26+
27+
return exit;
28+
}
29+
30+
/// <summary>
31+
/// Checks for updates to the tool and shows them if available.
32+
/// </summary>
33+
public static async Task ShowUpdatesAsync(this ICommandApp app, string[] args)
34+
{
35+
if (await GetUpdatesAsync(args, true) is { Length: > 0 } messages)
36+
{
37+
foreach (var message in messages)
38+
AnsiConsole.MarkupLine(message);
39+
}
40+
}
41+
42+
/// <summary>
43+
/// Shows the app version, build date and release link.
44+
/// </summary>
45+
/// <param name="app"></param>
46+
public static void ShowVersion(this ICommandApp app)
47+
{
48+
AnsiConsole.MarkupLine($"{ThisAssembly.Project.ToolCommandName} version [lime]{ThisAssembly.Project.Version}[/] ({ThisAssembly.Project.BuildDate})");
49+
AnsiConsole.MarkupLine($"[link]{ThisAssembly.Git.Url}/releases/tag/{ThisAssembly.Project.BuildRef}[/]");
50+
}
51+
52+
static async Task<string[]> GetUpdatesAsync(string[] args, bool forced = false)
53+
{
54+
if (args.Contains("-u") || args.Contains("--unattended"))
55+
return [];
56+
57+
var config = Config.Build(ConfigLevel.Global).GetSection(ThisAssembly.Project.ToolCommandName);
58+
59+
// Check once a day max
60+
if (!forced)
61+
{
62+
var lastCheck = config.GetDateTime("checked") ?? DateTime.UtcNow.AddDays(-2);
63+
// if it's been > 24 hours since the last check, we'll check again
64+
if (lastCheck > DateTime.UtcNow.AddDays(-1))
65+
return [];
66+
}
67+
68+
// We check from a different feed in this case.
69+
var civersion = ThisAssembly.Project.VersionPrefix.StartsWith("42.42.");
70+
71+
var providers = Repository.Provider.GetCoreV3();
72+
var repository = new SourceRepository(new PackageSource(
73+
// use CI feed rather than production feed depending on which version we're using
74+
civersion && !string.IsNullOrEmpty(ThisAssembly.Project.SLEET_FEED_URL) ?
75+
ThisAssembly.Project.SLEET_FEED_URL :
76+
"https://api.nuget.org/v3/index.json"), providers);
77+
var resource = await repository.GetResourceAsync<PackageMetadataResource>();
78+
var localVersion = new NuGetVersion(ThisAssembly.Project.Version);
79+
// Only update to stable versions, not pre-releases
80+
var metadata = await resource.GetMetadataAsync(ThisAssembly.Project.PackageId, includePrerelease: false, false,
81+
new SourceCacheContext
82+
{
83+
NoCache = true,
84+
RefreshMemoryCache = true,
85+
},
86+
NuGet.Common.NullLogger.Instance, CancellationToken.None);
87+
88+
var update = metadata
89+
.Select(x => x.Identity)
90+
.Where(x => x.Version > localVersion)
91+
.OrderByDescending(x => x.Version)
92+
.Select(x => x.Version)
93+
.FirstOrDefault();
94+
95+
config.SetDateTime("checked", DateTime.UtcNow);
96+
97+
if (update != null)
98+
{
99+
return [
100+
$"There is a new version of [yellow]{ThisAssembly.Project.PackageId}[/]: [dim]v{localVersion.ToNormalizedString()}[/] -> [lime]v{update.ToNormalizedString()}[/]",
101+
$"Update with: [yellow]dotnet[/] tool update -g {ThisAssembly.Project.PackageId}" +
102+
(civersion ? $" --source {ThisAssembly.Project.SLEET_FEED_URL}" : ""),
103+
];
104+
}
105+
106+
return [];
107+
}
108+
}

src/dotnet-meai/Program.cs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
using System.Diagnostics;
2+
using System.Runtime.InteropServices;
3+
using System.Text;
4+
using Devlooped.Extensions.AI;
5+
using Microsoft.Extensions.DependencyInjection;
6+
using Spectre.Console.Cli;
7+
8+
// Some users reported not getting emoji on Windows, so we force UTF-8 encoding.
9+
// This not great, but I couldn't find a better way to do it.
10+
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
11+
Console.InputEncoding = Console.OutputEncoding = Encoding.UTF8;
12+
13+
args = [.. ExpandResponseFiles(args)];
14+
15+
// Alias -? to -h for help
16+
if (args.Contains("-?"))
17+
args = [.. args.Select(x => x == "-?" ? "-h" : x)];
18+
19+
if (args.Contains("--debug"))
20+
{
21+
Debugger.Launch();
22+
args = [.. args.Where(x => x != "--debug")];
23+
}
24+
25+
var app = App.Create(out var registrar);
26+
27+
#if DEBUG
28+
app.Configure(c => c.PropagateExceptions());
29+
#else
30+
if (args.Contains("--exceptions"))
31+
{
32+
app.Configure(c => c.PropagateExceptions());
33+
args = [.. args.Where(x => x != "--exceptions")];
34+
}
35+
#endif
36+
37+
app.Configure(config => config.SetApplicationName(ThisAssembly.Project.ToolCommandName));
38+
39+
if (args.Contains("--version"))
40+
{
41+
app.ShowVersion();
42+
await app.ShowUpdatesAsync(args);
43+
return 0;
44+
}
45+
46+
var result = 0;
47+
48+
#if DEBUG
49+
result = await app.RunAsync(args);
50+
#else
51+
result = await app.RunWithUpdatesAsync(args);
52+
#endif
53+
54+
// --quiet does not report sponsor tier on every run.
55+
await ((IServiceProvider)registrar).GetRequiredService<Devlooped.Sponsors.CheckCommand>().ExecuteAsync(
56+
new CommandContext(["--quiet"], RemainingArguments.Empty, "check", null),
57+
new Devlooped.Sponsors.CheckCommand.CheckSettings { Quiet = true });
58+
59+
return result;
60+
61+
static IEnumerable<string> ExpandResponseFiles(IEnumerable<string> args)
62+
{
63+
foreach (var arg in args)
64+
{
65+
if (arg.StartsWith('@'))
66+
{
67+
var filePath = arg[1..];
68+
69+
if (!File.Exists(filePath))
70+
throw new FileNotFoundException($"Response file not found: {filePath}");
71+
72+
foreach (var line in File.ReadAllLines(filePath))
73+
{
74+
yield return line;
75+
}
76+
}
77+
else
78+
{
79+
yield return arg;
80+
}
81+
}
82+
}
83+
84+
class RemainingArguments : IRemainingArguments
85+
{
86+
public static IRemainingArguments Empty { get; } = new RemainingArguments();
87+
88+
RemainingArguments() { }
89+
90+
public ILookup<string, string?> Parsed => Enumerable.Empty<string>().ToLookup(x => x, x => default(string));
91+
92+
public IReadOnlyList<string> Raw => [];
93+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
launchSettings.json

src/dotnet-meai/Sponsors/App.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
namespace Devlooped.Sponsors;
2+
3+
public static class App
4+
{
5+
/// <summary>
6+
/// Whether the CLI app is not interactive (i.e. part of a script run,
7+
/// running in CI, or in a non-interactive user session).
8+
/// </summary>
9+
public static bool IsNonInteractive => !Environment.UserInteractive
10+
|| Console.IsInputRedirected
11+
|| Console.IsOutputRedirected;
12+
}

0 commit comments

Comments
 (0)