clispark 1.14.0 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,45 @@
1
+ # {{projectName}}
2
+
3
+ Generated with [clispark](https://github.com/martinwichner/clispark).
4
+
5
+ ## Requirements
6
+
7
+ .NET SDK **10.0** or later.
8
+
9
+ ## Building and running
10
+
11
+ ```bash
12
+ dotnet build Cli.slnx
13
+ dotnet run --project src -- hello World
14
+ ```
15
+
16
+ Or install it as a global tool on your own machine:
17
+
18
+ ```bash
19
+ dotnet pack src -c Release -o ./nupkg
20
+ dotnet tool install -g {{projectName}} --add-source ./nupkg
21
+ {{projectName}} hello World
22
+ ```
23
+
24
+ ## Example commands
25
+
26
+ Four example commands ship in `src/Commands/` as copy-paste starting points:
27
+
28
+ - **`hello`** (`src/Commands/HelloCommand.cs`) — the minimal case: one required string argument.
29
+ ```bash
30
+ dotnet run --project src -- hello World
31
+ ```
32
+ - **`task`** / **`task list`** / **`task complete`** (`src/Commands/TaskCommand.cs`, `TaskListCommand.cs`, `TaskCompleteCommand.cs`) — a reference for `System.CommandLine`'s argument and subcommand patterns: an optional allowed-values argument, a nested subcommand with a string + boolean pair, and a required integer argument that demonstrates the `CliUserException` error path.
33
+ ```bash
34
+ dotnet run --project src -- task open
35
+ dotnet run --project src -- task list groceries true
36
+ dotnet run --project src -- task complete 1
37
+ ```
38
+ See `ARCHITECTURE.md`'s "Argument Types" section for details.
39
+
40
+ ## Logging & debugging
41
+
42
+ Every command run writes a structured log file (one per invocation, in an OS-appropriate log directory — see `ARCHITECTURE.md`'s "Logging" section). By default the terminal only shows a clean `Error: <message>` on failure, or nothing on success.
43
+
44
+ - **`DEBUG=1`** — streams the raw log lines to stdout live as the command runs, and prints `Details: <path>` to the log file on both success and failure (normally that line only appears on failure).
45
+ - Fields that look like secrets (`password`, `token`, `apiKey`, etc. — see `SensitiveKeys` in `src/Logging/CliLoggerFactory.cs`) are redacted from log output automatically; edit that list directly in your own copy if you log other sensitive fields.
@@ -0,0 +1,4 @@
1
+ bin/
2
+ obj/
3
+ *.user
4
+ .vs/
@@ -0,0 +1,24 @@
1
+ <Project Sdk="Microsoft.NET.Sdk">
2
+
3
+ <PropertyGroup>
4
+ <OutputType>Exe</OutputType>
5
+ <TargetFramework>net10.0</TargetFramework>
6
+ <ImplicitUsings>enable</ImplicitUsings>
7
+ <Nullable>enable</Nullable>
8
+ <RootNamespace>Cli</RootNamespace>
9
+ <AssemblyName>{{projectName}}</AssemblyName>
10
+ <Version>0.0.0</Version>
11
+ <PackAsTool>true</PackAsTool>
12
+ <ToolCommandName>{{projectName}}</ToolCommandName>
13
+ <PackageId>{{projectName}}</PackageId>
14
+ </PropertyGroup>
15
+
16
+ <ItemGroup>
17
+ <PackageReference Include="System.CommandLine" Version="2.0.10" />
18
+ <PackageReference Include="Serilog" Version="4.4.0" />
19
+ <PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
20
+ <PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
21
+ <PackageReference Include="Xdg.Directories" Version="0.1.2" />
22
+ </ItemGroup>
23
+
24
+ </Project>
@@ -0,0 +1,4 @@
1
+ namespace Cli;
2
+
3
+ /// <summary>An expected, user-fixable failure — distinct from an unexpected crash. Mirrors clispark's own UserError.</summary>
4
+ public sealed class CliUserException(string message) : Exception(message);
@@ -0,0 +1,48 @@
1
+ using System.CommandLine;
2
+ using System.Reflection;
3
+
4
+ namespace Cli;
5
+
6
+ public static class CommandDiscovery
7
+ {
8
+ /// <summary>
9
+ /// Scans the given assembly for every ICliCommand, reads its CommandPathAttribute,
10
+ /// and attaches it to the tree rooted at <paramref name="root"/> — creating bare
11
+ /// container commands for any missing intermediate path segments.
12
+ /// </summary>
13
+ public static void RegisterAll(RootCommand root, Assembly assembly)
14
+ {
15
+ var commandTypes = assembly
16
+ .GetTypes()
17
+ .Where(t => t is { IsClass: true, IsAbstract: false } && typeof(ICliCommand).IsAssignableFrom(t));
18
+
19
+ foreach (var type in commandTypes)
20
+ {
21
+ var attribute = type.GetCustomAttribute<CommandPathAttribute>()
22
+ ?? throw new InvalidOperationException($"{type.FullName} implements ICliCommand but has no [CommandPath].");
23
+
24
+ var instance = (ICliCommand)Activator.CreateInstance(type)!;
25
+ var command = instance.Build();
26
+
27
+ var segments = attribute.Path.Split(' ', StringSplitOptions.RemoveEmptyEntries);
28
+ var parent = FindOrCreateParent(root, segments[..^1]);
29
+ parent.Subcommands.Add(command);
30
+ }
31
+ }
32
+
33
+ private static Command FindOrCreateParent(RootCommand root, string[] parentSegments)
34
+ {
35
+ Command current = root;
36
+ foreach (var segment in parentSegments)
37
+ {
38
+ var existing = current.Subcommands.FirstOrDefault(c => c.Name == segment);
39
+ if (existing is null)
40
+ {
41
+ existing = new Command(segment);
42
+ current.Subcommands.Add(existing);
43
+ }
44
+ current = existing;
45
+ }
46
+ return current;
47
+ }
48
+ }
@@ -0,0 +1,12 @@
1
+ namespace Cli;
2
+
3
+ /// <summary>
4
+ /// Declares the full, space-separated invocation path for an <see cref="ICliCommand"/>
5
+ /// (e.g. "task list" for a "list" subcommand nested under "task"). The class name alone
6
+ /// cannot express nesting, so this is required on every discovered command.
7
+ /// </summary>
8
+ [AttributeUsage(AttributeTargets.Class)]
9
+ public sealed class CommandPathAttribute(string path) : Attribute
10
+ {
11
+ public string Path { get; } = path;
12
+ }
@@ -0,0 +1,28 @@
1
+ using System.CommandLine;
2
+ using Serilog;
3
+
4
+ namespace Cli.Commands;
5
+
6
+ /// <summary>Required string argument.</summary>
7
+ [CommandPath("hello")]
8
+ public sealed class HelloCommand : ICliCommand
9
+ {
10
+ public Command Build()
11
+ {
12
+ var nameArgument = new Argument<string>("name")
13
+ {
14
+ Description = "Who to greet",
15
+ };
16
+
17
+ var command = new Command("hello", "Says hello to someone");
18
+ command.Arguments.Add(nameArgument);
19
+ command.SetAction(parseResult =>
20
+ {
21
+ var name = parseResult.GetValue(nameArgument);
22
+ Log.Information("Greeting {Name}", name);
23
+ Console.WriteLine($"Hello, {name}!");
24
+ });
25
+
26
+ return command;
27
+ }
28
+ }
@@ -0,0 +1,28 @@
1
+ using System.CommandLine;
2
+
3
+ namespace Cli.Commands;
4
+
5
+ /// <summary>Optional argument constrained to a fixed set of allowed values.</summary>
6
+ [CommandPath("task")]
7
+ public sealed class TaskCommand : ICliCommand
8
+ {
9
+ public Command Build()
10
+ {
11
+ var statusArgument = new Argument<string?>("status")
12
+ {
13
+ Description = "Filter by status",
14
+ Arity = ArgumentArity.ZeroOrOne,
15
+ };
16
+ statusArgument.AcceptOnlyFromAmong("open", "done");
17
+
18
+ var command = new Command("task", "Shows tasks, optionally filtered by status");
19
+ command.Arguments.Add(statusArgument);
20
+ command.SetAction(parseResult =>
21
+ {
22
+ var status = parseResult.GetValue(statusArgument);
23
+ Console.WriteLine(status is null ? "Showing all tasks" : $"Showing {status} tasks");
24
+ });
25
+
26
+ return command;
27
+ }
28
+ }
@@ -0,0 +1,30 @@
1
+ using System.CommandLine;
2
+
3
+ namespace Cli.Commands;
4
+
5
+ /// <summary>Nested subcommand ("task complete") with a required integer argument. Demonstrates CliUserException.</summary>
6
+ [CommandPath("task complete")]
7
+ public sealed class TaskCompleteCommand : ICliCommand
8
+ {
9
+ public Command Build()
10
+ {
11
+ var idArgument = new Argument<int>("id")
12
+ {
13
+ Description = "Task ID to complete",
14
+ };
15
+
16
+ var command = new Command("complete", "Marks a task as complete");
17
+ command.Arguments.Add(idArgument);
18
+ command.SetAction(parseResult =>
19
+ {
20
+ var id = parseResult.GetValue(idArgument);
21
+ if (id <= 0)
22
+ {
23
+ throw new CliUserException($"Task {id} does not exist.");
24
+ }
25
+ Console.WriteLine($"Completed task {id}");
26
+ });
27
+
28
+ return command;
29
+ }
30
+ }
@@ -0,0 +1,34 @@
1
+ using System.CommandLine;
2
+
3
+ namespace Cli.Commands;
4
+
5
+ /// <summary>Nested subcommand ("task list") with two optional arguments: string + boolean.</summary>
6
+ [CommandPath("task list")]
7
+ public sealed class TaskListCommand : ICliCommand
8
+ {
9
+ public Command Build()
10
+ {
11
+ var labelArgument = new Argument<string?>("label")
12
+ {
13
+ Description = "Filter by label",
14
+ Arity = ArgumentArity.ZeroOrOne,
15
+ };
16
+ var allArgument = new Argument<bool?>("all")
17
+ {
18
+ Description = "Include completed tasks",
19
+ Arity = ArgumentArity.ZeroOrOne,
20
+ };
21
+
22
+ var command = new Command("list", "Lists tasks");
23
+ command.Arguments.Add(labelArgument);
24
+ command.Arguments.Add(allArgument);
25
+ command.SetAction(parseResult =>
26
+ {
27
+ var label = parseResult.GetValue(labelArgument);
28
+ var all = parseResult.GetValue(allArgument) ?? false;
29
+ Console.WriteLine($"Listing tasks (label={label ?? "any"}, all={all})");
30
+ });
31
+
32
+ return command;
33
+ }
34
+ }
@@ -0,0 +1,8 @@
1
+ using System.CommandLine;
2
+
3
+ namespace Cli;
4
+
5
+ public interface ICliCommand
6
+ {
7
+ Command Build();
8
+ }
@@ -0,0 +1,70 @@
1
+ using Serilog;
2
+ using Serilog.Core;
3
+ using Xdg.Directories;
4
+
5
+ namespace Cli.Logging;
6
+
7
+ public static class CliLoggerFactory
8
+ {
9
+ private static readonly string[] SensitiveKeys = ["password", "secret", "token", "apiKey", "registryUrl"];
10
+ private const int RetentionDays = 14;
11
+ private const string SweepMarkerFile = ".last-sweep";
12
+ private static readonly TimeSpan SweepThrottle = TimeSpan.FromHours(24);
13
+
14
+ public static (Logger Logger, string LogFilePath) Create(string commandName, string appName)
15
+ {
16
+ var logDir = Path.Combine(BaseDirectory.StateHome, appName, "Log");
17
+ Directory.CreateDirectory(logDir);
18
+ SweepOldLogs(logDir);
19
+
20
+ var timestamp = DateTime.UtcNow.ToString("yyyy-MM-ddTHH-mm-ss-fffZ");
21
+ var suffix = Guid.NewGuid().ToString("N")[..6];
22
+ var logFilePath = Path.Combine(logDir, $"{commandName}-{timestamp}-{suffix}.log");
23
+
24
+ var config = new LoggerConfiguration()
25
+ .Enrich.With(new SensitivePropertyEnricher(SensitiveKeys))
26
+ .WriteTo.File(logFilePath);
27
+
28
+ if (Environment.GetEnvironmentVariable("DEBUG") is not null)
29
+ {
30
+ config = config.WriteTo.Console();
31
+ }
32
+
33
+ var logger = config.CreateLogger();
34
+
35
+ if (!OperatingSystem.IsWindows())
36
+ {
37
+ File.SetUnixFileMode(logFilePath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
38
+ }
39
+
40
+ return (logger, logFilePath);
41
+ }
42
+
43
+ private static void SweepOldLogs(string logDir)
44
+ {
45
+ try
46
+ {
47
+ var markerPath = Path.Combine(logDir, SweepMarkerFile);
48
+ if (File.Exists(markerPath) && DateTime.UtcNow - File.GetLastWriteTimeUtc(markerPath) < SweepThrottle)
49
+ {
50
+ return;
51
+ }
52
+
53
+ var cutoff = DateTime.UtcNow.AddDays(-RetentionDays);
54
+ foreach (var file in Directory.GetFiles(logDir))
55
+ {
56
+ if (Path.GetFileName(file) == SweepMarkerFile) continue;
57
+ if (File.GetLastWriteTimeUtc(file) < cutoff)
58
+ {
59
+ File.Delete(file);
60
+ }
61
+ }
62
+
63
+ File.WriteAllText(markerPath, string.Empty);
64
+ }
65
+ catch
66
+ {
67
+ // best-effort; a sweep failure must never affect the surrounding command
68
+ }
69
+ }
70
+ }
@@ -0,0 +1,19 @@
1
+ using Serilog.Core;
2
+ using Serilog.Events;
3
+
4
+ namespace Cli.Logging;
5
+
6
+ /// <summary>Masks known sensitive property names before they reach any sink. Mirrors the Node template's SENSITIVE_LOG_KEYS/pino redact.</summary>
7
+ public sealed class SensitivePropertyEnricher(IReadOnlyCollection<string> sensitiveKeys) : ILogEventEnricher
8
+ {
9
+ public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
10
+ {
11
+ foreach (var actualKey in logEvent.Properties.Keys.ToList())
12
+ {
13
+ if (sensitiveKeys.Any(k => string.Equals(k, actualKey, StringComparison.OrdinalIgnoreCase)))
14
+ {
15
+ logEvent.AddOrUpdateProperty(propertyFactory.CreateProperty(actualKey, "***REDACTED***"));
16
+ }
17
+ }
18
+ }
19
+ }
@@ -0,0 +1,35 @@
1
+ using System.CommandLine;
2
+ using System.Reflection;
3
+ using Cli;
4
+ using Cli.Logging;
5
+ using Serilog;
6
+
7
+ var commandName = args.Length > 0 ? args[0] : "cli";
8
+ var (logger, logFilePath) = CliLoggerFactory.Create(commandName, "{{projectName}}");
9
+ Log.Logger = logger;
10
+
11
+ var root = new RootCommand("Interactive scaffolded CLI project");
12
+ CommandDiscovery.RegisterAll(root, Assembly.GetExecutingAssembly());
13
+
14
+ var config = new InvocationConfiguration { EnableDefaultExceptionHandler = false };
15
+
16
+ try
17
+ {
18
+ var exitCode = root.Parse(args).Invoke(config);
19
+ if (Environment.GetEnvironmentVariable("DEBUG") is not null)
20
+ {
21
+ Console.WriteLine($"Details: {logFilePath}");
22
+ }
23
+ return exitCode;
24
+ }
25
+ catch (CliUserException ex)
26
+ {
27
+ Log.Error(ex, "Command failed");
28
+ Console.Error.WriteLine($"\nError: {ex.Message}");
29
+ Console.Error.WriteLine($"Details: {logFilePath}");
30
+ return 1;
31
+ }
32
+ finally
33
+ {
34
+ Log.CloseAndFlush();
35
+ }
@@ -0,0 +1,25 @@
1
+ <Project Sdk="Microsoft.NET.Sdk">
2
+
3
+ <PropertyGroup>
4
+ <TargetFramework>net10.0</TargetFramework>
5
+ <ImplicitUsings>enable</ImplicitUsings>
6
+ <Nullable>enable</Nullable>
7
+ <IsPackable>false</IsPackable>
8
+ </PropertyGroup>
9
+
10
+ <ItemGroup>
11
+ <PackageReference Include="coverlet.collector" Version="6.0.4" />
12
+ <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
13
+ <PackageReference Include="xunit" Version="2.9.3" />
14
+ <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
15
+ </ItemGroup>
16
+
17
+ <ItemGroup>
18
+ <Using Include="Xunit" />
19
+ </ItemGroup>
20
+
21
+ <ItemGroup>
22
+ <ProjectReference Include="..\src\Cli.csproj" />
23
+ </ItemGroup>
24
+
25
+ </Project>
@@ -0,0 +1,28 @@
1
+ using Cli.Commands;
2
+
3
+ namespace Cli.Tests;
4
+
5
+ public class HelloCommandTests
6
+ {
7
+ [Fact]
8
+ public void SucceedsWithARequiredNameArgument()
9
+ {
10
+ var command = new HelloCommand().Build();
11
+ var parseResult = command.Parse(["World"]);
12
+
13
+ var exitCode = parseResult.Invoke();
14
+
15
+ Assert.Equal(0, exitCode);
16
+ }
17
+
18
+ [Fact]
19
+ public void FailsWhenNameArgumentIsMissing()
20
+ {
21
+ var command = new HelloCommand().Build();
22
+ var parseResult = command.Parse([]);
23
+
24
+ var exitCode = parseResult.Invoke();
25
+
26
+ Assert.NotEqual(0, exitCode);
27
+ }
28
+ }
@@ -0,0 +1,34 @@
1
+ using System.CommandLine;
2
+ using Cli;
3
+ using Cli.Commands;
4
+
5
+ namespace Cli.Tests;
6
+
7
+ public class TaskCompleteCommandTests
8
+ {
9
+ // Mirrors Program.cs: the default InvocationConfiguration silently swallows
10
+ // exceptions thrown from a command's action, so tests exercising that
11
+ // behavior must disable it the same way production wiring does.
12
+ private static readonly InvocationConfiguration NonSwallowingConfig = new() { EnableDefaultExceptionHandler = false };
13
+
14
+ [Fact]
15
+ public void ThrowsCliUserExceptionForNonPositiveId()
16
+ {
17
+ var command = new TaskCompleteCommand().Build();
18
+ var parseResult = command.Parse(["-1"]);
19
+
20
+ var ex = Assert.Throws<CliUserException>(() => parseResult.Invoke(NonSwallowingConfig));
21
+ Assert.Equal("Task -1 does not exist.", ex.Message);
22
+ }
23
+
24
+ [Fact]
25
+ public void SucceedsForPositiveId()
26
+ {
27
+ var command = new TaskCompleteCommand().Build();
28
+ var parseResult = command.Parse(["5"]);
29
+
30
+ var exitCode = parseResult.Invoke(NonSwallowingConfig);
31
+
32
+ Assert.Equal(0, exitCode);
33
+ }
34
+ }