From bd5fcd5d3eec9ca7856472da072e2ac60746da74 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 7 May 2026 10:54:44 +0200 Subject: [PATCH 1/4] feat(schema): intent, output, deprecated, confirmationSkip/dryRun, environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New attributes in Nullean.Argh: - [CommandIntent(CommandIntentFlags, Scope = CommandScope)] — emits intent object in __schema. Flags enum: Destructive | Idempotent | RequiresConfirmation | RequiresAuth. Scope enum: File | Directory | Global. - [CommandOutput("json", "table", FormatFlag = "--output")] — emits output object. - [ConfirmationSkip] / [DryRun] on flag parameters — changes parameter role to "confirmationSkip" / "dryRun" in __schema. - [Obsolete] on handler methods and [AsParameters] DTO properties — emits deprecated object (with message when provided) in __schema. New builder method: - IArghRootBuilder.DocumentEnvironmentVariables(CliEnvVarDoc[]?, CliConfigFileDoc[]?) Analyzed by the generator; emits environment object in __schema. Arguments must be new CliEnvVarDoc(...) / new CliConfigFileDoc(...) with literal values so the generator can extract them statically. Schema type additions (ArghCliSchemaDocument.cs): - CliDeprecationSchema — message, since, removedIn (all optional) - CliEnvironmentSchema + CliEnvVarSchema + CliConfigFileSchema - deprecated field on CliCommandSchema and CliParameterSchema - environment field on ArghCliSchemaDocument Generator changes (CliParserGenerator.cs + Schema.cs): - CommandModel: IsDeprecated, DeprecationMessage, Intent, Output fields - ParameterModel: IsConfirmationSkip, IsDryRun, IsDeprecated, DeprecationMessage - All ParameterModel factory paths updated (From, FromAsParametersCtorParameter, FromAsParametersInitProperty, FromOptionsProperty, BuildCollectionParameterModel) - EmitCliCommandSchemaBody emits deprecated / intent / output conditionally - EmitCliParameterSchemaNewExpression emits role / deprecated - EmitSchemaEnvironmentArg emits environment from AppEmitModel 19 new schema tests; help snapshots updated for new fixture commands. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- schema/argh-cli-schema.json | 140 +++++++++ src/Nullean.Argh.Core/ArghApp.IArghBuilder.cs | 7 + src/Nullean.Argh.Core/ArghApp.cs | 3 + src/Nullean.Argh.Core/Builder/ArghBuilder.cs | 9 + .../Schema/ArghCliSchemaDocument.cs | 44 ++- .../Schema/ArghCliSchemaJsonContext.cs | 6 + .../CliParserGenerator.Schema.cs | 80 ++++- .../CliParserGenerator.cs | 273 +++++++++++++++++- .../ArghHostingBuilder.cs | 6 + .../Annotations/Attributes.cs | 115 ++++++++ .../Builder/IArghRootBuilder.cs | 10 + .../Completions/SchemaTests.cs | 97 +++++++ .../Help/RootAndNamespaceHelpTests.cs | 9 + .../Help/RootHelpFullTextTests.cs | 9 + .../CliRegistrationModule.cs | 6 + .../Fixtures/SchemaSpecificHandlers.cs | 62 ++++ 16 files changed, 864 insertions(+), 12 deletions(-) diff --git a/schema/argh-cli-schema.json b/schema/argh-cli-schema.json index 2789c87..8497007 100644 --- a/schema/argh-cli-schema.json +++ b/schema/argh-cli-schema.json @@ -123,6 +123,36 @@ "type": "boolean", "default": false }, + "deprecated": { + "type": [ + "object", + "null" + ], + "properties": { + "message": { + "type": [ + "string", + "null" + ], + "default": null + }, + "since": { + "type": [ + "string", + "null" + ], + "default": null + }, + "removedIn": { + "type": [ + "string", + "null" + ], + "default": null + } + }, + "default": null + }, "validations": { "type": [ "array", @@ -328,6 +358,36 @@ }, "default": null }, + "deprecated": { + "type": [ + "object", + "null" + ], + "properties": { + "message": { + "type": [ + "string", + "null" + ], + "default": null + }, + "since": { + "type": [ + "string", + "null" + ], + "default": null + }, + "removedIn": { + "type": [ + "string", + "null" + ], + "default": null + } + }, + "default": null + }, "intent": { "type": [ "object", @@ -553,6 +613,86 @@ ] }, "default": null + }, + "environment": { + "type": [ + "object", + "null" + ], + "properties": { + "variables": { + "type": [ + "array", + "null" + ], + "items": { + "type": [ + "object", + "null" + ], + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ], + "default": null + }, + "required": { + "type": "boolean", + "default": false + }, + "defaultValue": { + "type": [ + "string", + "null" + ], + "default": null + } + }, + "required": [ + "name" + ] + }, + "default": null + }, + "configFiles": { + "type": [ + "array", + "null" + ], + "items": { + "type": [ + "object", + "null" + ], + "properties": { + "path": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ], + "default": null + }, + "required": { + "type": "boolean", + "default": false + } + }, + "required": [ + "path" + ] + }, + "default": null + } + }, + "default": null } }, "required": [ diff --git a/src/Nullean.Argh.Core/ArghApp.IArghBuilder.cs b/src/Nullean.Argh.Core/ArghApp.IArghBuilder.cs index b443811..302460a 100644 --- a/src/Nullean.Argh.Core/ArghApp.IArghBuilder.cs +++ b/src/Nullean.Argh.Core/ArghApp.IArghBuilder.cs @@ -34,6 +34,13 @@ IArghRootBuilder IArghRootBuilder.UseCliDescription(string description) return this; } + /// + IArghRootBuilder IArghRootBuilder.DocumentEnvironmentVariables(CliEnvVarDoc[]? variables, CliConfigFileDoc[]? configFiles) + { + _ = DocumentEnvironmentVariables(variables, configFiles); + return this; + } + /// IArghBuilder IArghBuilder.MapRoot(Delegate handler) { diff --git a/src/Nullean.Argh.Core/ArghApp.cs b/src/Nullean.Argh.Core/ArghApp.cs index 99380f9..ec74648 100644 --- a/src/Nullean.Argh.Core/ArghApp.cs +++ b/src/Nullean.Argh.Core/ArghApp.cs @@ -33,6 +33,9 @@ public ArghApp Map(string name, Delegate handler) /// Sets a one-line description shown in root --help output. Analyzed by the source generator; no-op at runtime. public ArghApp UseCliDescription(string description) => this; + /// Documents environment variables and config files the program reads. Analyzed by the source generator; no-op at runtime. + public ArghApp DocumentEnvironmentVariables(CliEnvVarDoc[]? variables = null, CliConfigFileDoc[]? configFiles = null) => this; + /// /// Registers a default handler when no subcommand or namespace segment applies at the current scope /// (app root or inside a block). Analyzed by the source generator. diff --git a/src/Nullean.Argh.Core/Builder/ArghBuilder.cs b/src/Nullean.Argh.Core/Builder/ArghBuilder.cs index 23a7784..72dfc1f 100644 --- a/src/Nullean.Argh.Core/Builder/ArghBuilder.cs +++ b/src/Nullean.Argh.Core/Builder/ArghBuilder.cs @@ -50,6 +50,15 @@ public IArghRootBuilder UseCliDescription(string description) return this; } + /// + public IArghRootBuilder DocumentEnvironmentVariables( + CliEnvVarDoc[]? variables = null, + CliConfigFileDoc[]? configFiles = null) + { + _ = _app.DocumentEnvironmentVariables(variables, configFiles); + return this; + } + /// public IArghBuilder MapRoot(Delegate handler) { diff --git a/src/Nullean.Argh.Core/Schema/ArghCliSchemaDocument.cs b/src/Nullean.Argh.Core/Schema/ArghCliSchemaDocument.cs index 48b815b..20e8984 100644 --- a/src/Nullean.Argh.Core/Schema/ArghCliSchemaDocument.cs +++ b/src/Nullean.Argh.Core/Schema/ArghCliSchemaDocument.cs @@ -18,7 +18,9 @@ public sealed record ArghCliSchemaDocument( [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] bool? RequiresAuth = null, [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - string[]? AuthCommands = null); + string[]? AuthCommands = null, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + CliEnvironmentSchema? Environment = null); /// Nested command namespace (subcommand group). public sealed record CliNamespaceSchema( @@ -46,6 +48,8 @@ public sealed record CliCommandSchema( [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string[]? Tags = null, [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + CliDeprecationSchema? Deprecated = null, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] CliIntentSchema? Intent = null, [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] CliOutputSchema? Output = null, @@ -86,7 +90,7 @@ public sealed record CliDefaultHandlerSchema( bool Hidden = false); /// CLI flag or positional parameter description. -/// flag or positional. +/// flag, positional, confirmationSkip, or dryRun. /// JSON Schema primitive: string, integer, number, boolean, array, or enum. public sealed record CliParameterSchema( string Role, @@ -112,6 +116,8 @@ public sealed record CliParameterSchema( [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] bool Variadic = false, [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + CliDeprecationSchema? Deprecated = null, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] CliConstraintSchema[]? Validations = null); /// A single validation constraint on a CLI parameter. @@ -122,3 +128,37 @@ public sealed record CliConstraintSchema( string? Max = null, string? Pattern = null, string[]? Values = null); + +/// Structured deprecation metadata for a command or parameter. +public sealed record CliDeprecationSchema( + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + string? Message = null, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + string? Since = null, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + string? RemovedIn = null); + +/// External context the program depends on (env vars and config files). +public sealed record CliEnvironmentSchema( + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + CliEnvVarSchema[]? Variables = null, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + CliConfigFileSchema[]? ConfigFiles = null); + +/// An environment variable the program reads. +public sealed record CliEnvVarSchema( + string Name, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + string? Description = null, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + bool Required = false, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + string? DefaultValue = null); + +/// A configuration file the program reads. +public sealed record CliConfigFileSchema( + string Path, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + string? Description = null, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + bool Required = false); diff --git a/src/Nullean.Argh.Core/Schema/ArghCliSchemaJsonContext.cs b/src/Nullean.Argh.Core/Schema/ArghCliSchemaJsonContext.cs index cf70674..64971a3 100644 --- a/src/Nullean.Argh.Core/Schema/ArghCliSchemaJsonContext.cs +++ b/src/Nullean.Argh.Core/Schema/ArghCliSchemaJsonContext.cs @@ -10,6 +10,12 @@ namespace Nullean.Argh.Schema; [JsonSerializable(typeof(CliParameterSchema))] [JsonSerializable(typeof(CliIntentSchema))] [JsonSerializable(typeof(CliOutputSchema))] +[JsonSerializable(typeof(CliDeprecationSchema))] +[JsonSerializable(typeof(CliEnvironmentSchema))] +[JsonSerializable(typeof(CliEnvVarSchema))] +[JsonSerializable(typeof(CliConfigFileSchema))] +[JsonSerializable(typeof(CliEnvVarSchema[]))] +[JsonSerializable(typeof(CliConfigFileSchema[]))] [JsonSerializable(typeof(string[]))] [JsonSerializable(typeof(CliParameterSchema[]))] [JsonSerializable(typeof(CliCommandSchema[]))] diff --git a/src/Nullean.Argh.Generator/CliParserGenerator.Schema.cs b/src/Nullean.Argh.Generator/CliParserGenerator.Schema.cs index 308d018..dbc4b3f 100644 --- a/src/Nullean.Argh.Generator/CliParserGenerator.Schema.cs +++ b/src/Nullean.Argh.Generator/CliParserGenerator.Schema.cs @@ -36,6 +36,7 @@ private static void EmitBuildCliSchemaDocumentHierarchical(StringBuilder sb, App EmitSchemaRootCommandsExpression(sb, app, entryAssemblyName, "\t\t\t\t"); sb.AppendLine(","); EmitSchemaNamespacesExpression(sb, app.Root.Children, entryAssemblyName, "\t\t\t\t"); + EmitSchemaEnvironmentArg(sb, app, "\t\t\t\t"); sb.AppendLine(");"); } @@ -167,6 +168,34 @@ private static void EmitCliCommandSchemaBody(StringBuilder sb, CommandModel cmd, sb.AppendLine(","); sb.Append($"{indent}\tHidden: true"); } + if (cmd.IsDeprecated) + { + sb.AppendLine(","); + if (cmd.DeprecationMessage is not null) + sb.Append($"{indent}\tDeprecated: new CliDeprecationSchema(Message: \"{Escape(cmd.DeprecationMessage)}\")"); + else + sb.Append($"{indent}\tDeprecated: new CliDeprecationSchema()"); + } + if (cmd.Intent is { } intent) + { + sb.AppendLine(","); + sb.Append($"{indent}\tIntent: new CliIntentSchema("); + var intentParts = new System.Collections.Generic.List(); + if (intent.Destructive is bool d) intentParts.Add($"Destructive: {(d ? "true" : "false")}"); + if (intent.Idempotent is bool i) intentParts.Add($"Idempotent: {(i ? "true" : "false")}"); + if (intent.Scope is string sc) intentParts.Add($"Scope: \"{Escape(sc)}\""); + if (intent.RequiresConfirmation is bool rc) intentParts.Add($"RequiresConfirmation: {(rc ? "true" : "false")}"); + if (intent.RequiresAuth is bool ra) intentParts.Add($"RequiresAuth: {(ra ? "true" : "false")}"); + sb.Append(string.Join(", ", intentParts)); + sb.Append(")"); + } + if (cmd.Output is { } output && !output.Formats.IsDefaultOrEmpty) + { + sb.AppendLine(","); + var fmts = string.Join(", ", output.Formats.Select(f => $"\"{Escape(f)}\"")); + var fflag = output.FormatFlag is not null ? $", FormatFlag: \"{Escape(output.FormatFlag)}\"" : ""; + sb.Append($"{indent}\tOutput: new CliOutputSchema(Formats: new string[] {{ {fmts} }}{fflag})"); + } sb.AppendLine(); sb.Append(indent); sb.Append(")"); @@ -348,9 +377,50 @@ private static void EmitExamplesStringArray(StringBuilder sb, string? examplesRe sb.Append("}"); } + private static void EmitSchemaEnvironmentArg(StringBuilder sb, AppEmitModel app, string indent) + { + var hasVars = !app.EnvironmentVars.IsDefaultOrEmpty; + var hasCfgs = !app.ConfigFiles.IsDefaultOrEmpty; + if (!hasVars && !hasCfgs) return; + + sb.AppendLine(","); + sb.AppendLine(indent + "Environment: new CliEnvironmentSchema("); + if (hasVars) + { + sb.AppendLine(indent + "\tVariables: new CliEnvVarSchema[]"); + sb.AppendLine(indent + "\t{"); + foreach (var v in app.EnvironmentVars) + { + var desc = v.Description is not null ? $", Description: \"{Escape(v.Description)}\"" : ""; + var req = v.Required ? ", Required: true" : ""; + var defVal = v.DefaultValue is not null ? $", DefaultValue: \"{Escape(v.DefaultValue)}\"" : ""; + sb.AppendLine($"{indent}\t\tnew CliEnvVarSchema(\"{Escape(v.Name)}\"{desc}{req}{defVal}),"); + } + sb.Append(indent + "\t}"); + if (hasCfgs) sb.AppendLine(","); + } + if (hasCfgs) + { + sb.AppendLine(indent + "\tConfigFiles: new CliConfigFileSchema[]"); + sb.AppendLine(indent + "\t{"); + foreach (var c in app.ConfigFiles) + { + var desc = c.Description is not null ? $", Description: \"{Escape(c.Description)}\"" : ""; + var req = c.Required ? ", Required: true" : ""; + sb.AppendLine($"{indent}\t\tnew CliConfigFileSchema(\"{Escape(c.Path)}\"{desc}{req}),"); + } + sb.Append(indent + "\t}"); + } + sb.AppendLine(); + sb.Append(indent + ")"); + } + private static string EmitCliParameterSchemaNewExpression(ParameterModel p) { - var role = p.Kind == ParameterKind.Positional ? "positional" : "flag"; + var role = p.IsConfirmationSkip ? "confirmationSkip" + : p.IsDryRun ? "dryRun" + : p.Kind == ParameterKind.Positional ? "positional" + : "flag"; var shortName = p.ShortOpt is char c ? $"\"{Escape(c.ToString())}\"" : "null"; var type = MapToJsonSchemaType(p.ScalarKind, p.TypeName); var req = p.IsRequired ? "true" : "false"; @@ -395,6 +465,14 @@ private static string EmitCliParameterSchemaNewExpression(ParameterModel p) if (p.IsVariadic) sb.Append(", Variadic: true"); + if (p.IsDeprecated) + { + if (p.DeprecationMessage is not null) + sb.Append($", Deprecated: new CliDeprecationSchema(Message: \"{Escape(p.DeprecationMessage)}\")"); + else + sb.Append(", Deprecated: new CliDeprecationSchema()"); + } + var validations = BuildConstraintsExpression(p.Validations, p.ExpandUserProfileBeforeBind); if (validations != "null") sb.Append($", Validations: {validations}"); diff --git a/src/Nullean.Argh.Generator/CliParserGenerator.cs b/src/Nullean.Argh.Generator/CliParserGenerator.cs index 6ddee6b..83a2e80 100644 --- a/src/Nullean.Argh.Generator/CliParserGenerator.cs +++ b/src/Nullean.Argh.Generator/CliParserGenerator.cs @@ -313,6 +313,7 @@ private static bool IsTrackedArghInvocation(InvocationExpressionSyntax inv, Memb switch (methodName) { case "UseCliDescription": + case "DocumentEnvironmentVariables": case "MapNamespace": case "MapRoot": case "UseGlobalOptions": @@ -537,6 +538,8 @@ private sealed class AppEmitModel public ImmutableArray AllCommands = ImmutableArray.Empty; public ImmutableArray GlobalMiddleware = ImmutableArray.Empty; public readonly List ArglessNamespaceCodegen = new(); + public ImmutableArray EnvironmentVars = ImmutableArray.Empty; + public ImmutableArray ConfigFiles = ImmutableArray.Empty; /// Pre-computed injection chains per command (keyed by ). Set once in TryBuildAppEmitModel after AllCommands is populated. public ImmutableDictionary AllBaseTypeMetadataNames, string StaticFieldName, string LocalVarName, ImmutableArray FlatMembers, ImmutableArray? BestCtorParamOrder)>> InjectionChains = ImmutableDictionary, string, string, ImmutableArray, ImmutableArray?)>>.Empty; @@ -653,6 +656,26 @@ private sealed record AIUseMiddleware(string FilePath, int SpanStart, GlobalMidd private sealed record AIUseCliDescription(string FilePath, int SpanStart, string Description) : AnalyzedInvocation(FilePath, SpanStart); + /// A DocumentEnvironmentVariables(...) invocation — only meaningful at root scope. + private sealed record AIDocumentEnvironmentVariables( + string FilePath, + int SpanStart, + ImmutableArray Variables, + ImmutableArray ConfigFiles) + : AnalyzedInvocation(FilePath, SpanStart); + + /// Symbol-free representation of a passed to DocumentEnvironmentVariables. + private sealed record EnvVarDocEntry(string Name, string? Description, bool Required, string? DefaultValue); + + /// Symbol-free representation of a passed to DocumentEnvironmentVariables. + private sealed record ConfigFileDocEntry(string Path, string? Description, bool Required); + + /// Symbol-free intent data extracted from [CommandIntent]. + private sealed record CommandIntentData(bool? Destructive, bool? Idempotent, string? Scope, bool? RequiresConfirmation, bool? RequiresAuth); + + /// Symbol-free output data extracted from [CommandOutput]. + private sealed record CommandOutputData(ImmutableArray Formats, string? FormatFlag); + /// /// An Add<T>() or Add(name, handler) invocation. /// For Add<T>, holds the full registry structure; @@ -876,6 +899,8 @@ public ImmutableArray ToImmutable() => var desc = TryGetStringLiteral(descExpr) ?? ""; return new AIUseCliDescription(filePath, spanStart, desc); } + case "DocumentEnvironmentVariables": + return AnalyzeDocumentEnvironmentVariables(invocation, filePath, spanStart); case "MapRoot": { if (invocation.ArgumentList.Arguments.Count < 1) return null; @@ -1159,6 +1184,16 @@ private static bool TryBuildAppEmitModel( } } + foreach (var ai in rootAnalyzed) + { + if (ai is AIDocumentEnvironmentVariables { Variables: var vars, ConfigFiles: var cfgs }) + { + if (!vars.IsDefaultOrEmpty) app.EnvironmentVars = vars; + if (!cfgs.IsDefaultOrEmpty) app.ConfigFiles = cfgs; + break; + } + } + ProcessAnalyzedInvocationsForNode(context, sorted, rootAnalyzed, app.Root, ImmutableArray.Empty, app, isRoot: true); if (!string.IsNullOrWhiteSpace(app.RootSummary) && app.Root.RootCommand is not null) @@ -2433,6 +2468,190 @@ private static ImmutableArray TryGetCommandAliasesFromAttribute(IMethodS return ImmutableArray.Empty; } + private static (bool IsDeprecated, string? Message) TryGetObsoleteAttribute(ISymbol symbol) + { + foreach (var ad in symbol.GetAttributes()) + { + if (ad.AttributeClass?.Name is "ObsoleteAttribute" or "Obsolete" && + (ad.AttributeClass.ContainingNamespace?.ToDisplayString() is "System" or "")) + { + var msg = ad.ConstructorArguments.Length >= 1 && ad.ConstructorArguments[0].Value is string s && !string.IsNullOrWhiteSpace(s) + ? s + : null; + return (true, msg); + } + } + + return (false, null); + } + + private static CommandIntentData? TryGetCommandIntentAttribute(IMethodSymbol method) + { + foreach (var ad in method.GetAttributes()) + { + if (ad.AttributeClass?.Name == "CommandIntentAttribute" && + ad.AttributeClass.ContainingNamespace?.ToDisplayString() == "Nullean.Argh") + { + // Constructor arg 0 is the CommandIntentFlags enum value (underlying int) + var flagsInt = 0; + if (ad.ConstructorArguments.Length >= 1 && ad.ConstructorArguments[0].Value is int f) + flagsInt = f; + + bool? destructive = (flagsInt & 1) != 0 ? true : null; // Destructive = 1<<0 + bool? idempotent = (flagsInt & 2) != 0 ? true : null; // Idempotent = 1<<1 + bool? requiresConfirmation = (flagsInt & 4) != 0 ? true : null; // RequiresConfirmation = 1<<2 + bool? requiresAuth = (flagsInt & 8) != 0 ? true : null; // RequiresAuth = 1<<3 + + // Named arg Scope is a CommandScope enum (underlying int: 0=Unspecified,1=File,2=Directory,3=Global) + string? scope = null; + foreach (var na in ad.NamedArguments) + { + if (na.Key == "Scope" && na.Value.Value is int s && s != 0) + { + scope = s switch { 1 => "file", 2 => "directory", 3 => "global", _ => null }; + } + } + + if (flagsInt == 0 && scope is null) return null; + return new CommandIntentData(destructive, idempotent, scope, requiresConfirmation, requiresAuth); + } + } + + return null; + } + + private static CommandOutputData? TryGetCommandOutputAttribute(IMethodSymbol method) + { + foreach (var ad in method.GetAttributes()) + { + if (ad.AttributeClass?.Name == "CommandOutputAttribute" && + ad.AttributeClass.ContainingNamespace?.ToDisplayString() == "Nullean.Argh") + { + var formats = ImmutableArray.Empty; + if (ad.ConstructorArguments.Length >= 1 && ad.ConstructorArguments[0].Kind == TypedConstantKind.Array) + { + var builder = ImmutableArray.CreateBuilder(); + foreach (var v in ad.ConstructorArguments[0].Values) + { + if (v.Value is string s && !string.IsNullOrWhiteSpace(s)) + builder.Add(s); + } + formats = builder.ToImmutable(); + } + + string? formatFlag = null; + foreach (var na in ad.NamedArguments) + { + if (na.Key == "FormatFlag" && na.Value.Value is string s) + formatFlag = s; + } + + return new CommandOutputData(formats, formatFlag); + } + } + + return null; + } + + private static bool HasConfirmationSkipAttribute(ISymbol symbol) + { + foreach (var ad in symbol.GetAttributes()) + { + if (ad.AttributeClass?.Name == "ConfirmationSkipAttribute" && + ad.AttributeClass.ContainingNamespace?.ToDisplayString() == "Nullean.Argh") + return true; + } + + return false; + } + + private static bool HasDryRunAttribute(ISymbol symbol) + { + foreach (var ad in symbol.GetAttributes()) + { + if (ad.AttributeClass?.Name == "DryRunAttribute" && + ad.AttributeClass.ContainingNamespace?.ToDisplayString() == "Nullean.Argh") + return true; + } + + return false; + } + + private static AIDocumentEnvironmentVariables? AnalyzeDocumentEnvironmentVariables( + InvocationExpressionSyntax invocation, string filePath, int spanStart) + { + var varsBuilder = ImmutableArray.CreateBuilder(); + var cfgBuilder = ImmutableArray.CreateBuilder(); + + foreach (var arg in invocation.ArgumentList.Arguments) + { + var nameColon = arg.NameColon?.Name.Identifier.Text; + + if (arg.Expression is not (ObjectCreationExpressionSyntax or ImplicitObjectCreationExpressionSyntax)) + continue; + + ArgumentListSyntax? ctorArgs = arg.Expression switch + { + ObjectCreationExpressionSyntax o => o.ArgumentList, + ImplicitObjectCreationExpressionSyntax i => i.ArgumentList, + _ => null + }; + if (ctorArgs is null) continue; + + // Determine type from name colon or array element pattern + var typeName = arg.Expression is ObjectCreationExpressionSyntax oce + ? oce.Type.ToString() + : null; + bool isConfigFile = typeName?.Contains("ConfigFile") == true || nameColon == "configFiles"; + + if (isConfigFile) + { + var path = ctorArgs.Arguments.Count >= 1 + ? TryGetStringLiteral(ctorArgs.Arguments[0].Expression) + : null; + if (path is null) continue; + string? desc = null; + bool req = false; + foreach (var ca in ctorArgs.Arguments) + { + var n = ca.NameColon?.Name.Identifier.Text; + if (n == "Description") desc = TryGetStringLiteral(ca.Expression); + if (n == "Required") req = TryGetBoolLiteral(ca.Expression) ?? false; + } + cfgBuilder.Add(new ConfigFileDocEntry(path, desc, req)); + } + else + { + var name = ctorArgs.Arguments.Count >= 1 + ? TryGetStringLiteral(ctorArgs.Arguments[0].Expression) + : null; + if (name is null) continue; + string? desc = null; + bool req = false; + string? defVal = null; + foreach (var ca in ctorArgs.Arguments) + { + var n = ca.NameColon?.Name.Identifier.Text; + if (n == "Description") desc = TryGetStringLiteral(ca.Expression); + if (n == "Required") req = TryGetBoolLiteral(ca.Expression) ?? false; + if (n == "DefaultValue") defVal = TryGetStringLiteral(ca.Expression); + } + varsBuilder.Add(new EnvVarDocEntry(name, desc, req, defVal)); + } + } + + if (varsBuilder.Count == 0 && cfgBuilder.Count == 0) return null; + return new AIDocumentEnvironmentVariables(filePath, spanStart, varsBuilder.ToImmutable(), cfgBuilder.ToImmutable()); + } + + private static bool? TryGetBoolLiteral(ExpressionSyntax expr) => + expr.Kind() switch + { + Microsoft.CodeAnalysis.CSharp.SyntaxKind.TrueLiteralExpression => true, + Microsoft.CodeAnalysis.CSharp.SyntaxKind.FalseLiteralExpression => false, + _ => null + }; + private static ExpressionSyntax? TryGetPropertyInitializerValueSyntax(IPropertySymbol prop) { foreach (var syntaxRef in prop.DeclaringSyntaxReferences) @@ -8849,7 +9068,11 @@ private sealed record CommandModel( string LambdaDelegateFq = "", bool IsIntrinsic = false, ImmutableArray CommandAliases = default, - bool IsHidden = false) + bool IsHidden = false, + bool IsDeprecated = false, + string? DeprecationMessage = null, + CommandIntentData? Intent = null, + CommandOutputData? Output = null) { public static CommandModel FromRootMethod( IMethodSymbol method, @@ -8952,6 +9175,7 @@ public static CommandModel FromMethod( HasPublicParameterlessCtor(namedCt); var (retFq, retIsAsync, retIsVoid, handlerNoInj, handlerParams, handlerLoc, ctorParams, mwData, docId) = ExtractHandlerAnalysis(method); + var (isObs, obsMsg) = TryGetObsoleteAttribute(method); return new CommandModel( routePrefix, commandName, @@ -8978,7 +9202,11 @@ public static CommandModel FromMethod( mwData, IsIntrinsic: HasCommandIntrinsicAttribute(method), CommandAliases: TryGetCommandAliasesFromAttribute(method), - IsHidden: HasHiddenAttribute(method)); + IsHidden: HasHiddenAttribute(method), + IsDeprecated: isObs, + DeprecationMessage: obsMsg, + Intent: TryGetCommandIntentAttribute(method), + Output: TryGetCommandOutputAttribute(method)); } /// Overload for the per-invocation Select step — uses instead of SourceProductionContext. @@ -9047,7 +9275,8 @@ public static CommandModel FromMethod( var containingFq = method.ContainingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); var hasParamlessCtor = method.ContainingType is INamedTypeSymbol namedCt && HasPublicParameterlessCtor(namedCt); var (retFq, retIsAsync, retIsVoid, handlerNoInj, handlerParams, handlerLoc, ctorParams, mwData, docId) = ExtractHandlerAnalysis(method); - return new CommandModel(routePrefix, commandName, runName, containingFq, method.Name, !method.IsStatic, hasParamlessCtor, retFq, retIsAsync, retIsVoid, withDocs, handlerNoInj, handlerParams, handlerLoc, ctorParams, docId, docs.SummaryOneLiner, docs.RemarksRendered, docs.SummaryInnerXml, docs.RemarksInnerXml, docs.ExamplesRendered, usage, mwData, IsIntrinsic: HasCommandIntrinsicAttribute(method), CommandAliases: TryGetCommandAliasesFromAttribute(method), IsHidden: HasHiddenAttribute(method)); + var (isDeprecated, deprecationMsg) = TryGetObsoleteAttribute(method); + return new CommandModel(routePrefix, commandName, runName, containingFq, method.Name, !method.IsStatic, hasParamlessCtor, retFq, retIsAsync, retIsVoid, withDocs, handlerNoInj, handlerParams, handlerLoc, ctorParams, docId, docs.SummaryOneLiner, docs.RemarksRendered, docs.SummaryInnerXml, docs.RemarksInnerXml, docs.ExamplesRendered, usage, mwData, IsIntrinsic: HasCommandIntrinsicAttribute(method), CommandAliases: TryGetCommandAliasesFromAttribute(method), IsHidden: HasHiddenAttribute(method), IsDeprecated: isDeprecated, DeprecationMessage: deprecationMsg, Intent: TryGetCommandIntentAttribute(method), Output: TryGetCommandOutputAttribute(method)); } private static ImmutableArray BuildParameterModels( @@ -9416,7 +9645,11 @@ private sealed record ParameterModel( /// Used by to emit string? even when /// is true, preventing CS8600 when the runtime default for a nullable property is null. /// - bool IsNullableAnnotated = false) + bool IsNullableAnnotated = false, + bool IsConfirmationSkip = false, + bool IsDryRun = false, + bool IsDeprecated = false, + string? DeprecationMessage = null) { // ── shared helpers ────────────────────────────────────────────────────── @@ -9520,7 +9753,11 @@ private static ParameterModel BuildCollectionParameterModel( AsParametersClrName: asParams?.ClrName, Validations: collValidations, IsHidden: HasHiddenAttribute(attributeHost), - IsVariadic: isVariadic); + IsVariadic: isVariadic, + IsConfirmationSkip: HasConfirmationSkipAttribute(attributeHost), + IsDryRun: HasDryRunAttribute(attributeHost), + IsDeprecated: TryGetObsoleteAttribute(attributeHost).IsDeprecated, + DeprecationMessage: TryGetObsoleteAttribute(attributeHost).Message); } // ── five factory methods ───────────────────────────────────────────── @@ -9592,7 +9829,11 @@ public static ParameterModel From(IParameterSymbol p, SourceProductionContext? r EnumMemberDocs: enumDocs, ExpandUserProfileBeforeBind: expandProf, Validations: validations, - IsHidden: HasHiddenAttribute(p)); + IsHidden: HasHiddenAttribute(p), + IsConfirmationSkip: HasConfirmationSkipAttribute(p), + IsDryRun: HasDryRunAttribute(p), + IsDeprecated: TryGetObsoleteAttribute(p).IsDeprecated, + DeprecationMessage: TryGetObsoleteAttribute(p).Message); } public static ParameterModel FromOptionsProperty(IPropertySymbol prop, Compilation? compilation = null, string? defaultValueLiteral = null) @@ -9646,7 +9887,11 @@ public static ParameterModel FromOptionsProperty(IPropertySymbol prop, Compilati IsHidden: HasHiddenAttribute(prop), UsesRuntimeDefault: isCrossAssemblyDefault, IsNullableAnnotated: prop.Type.NullableAnnotation == NullableAnnotation.Annotated - || prop.Type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T }); + || prop.Type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T }, + IsConfirmationSkip: HasConfirmationSkipAttribute(prop), + IsDryRun: HasDryRunAttribute(prop), + IsDeprecated: TryGetObsoleteAttribute(prop).IsDeprecated, + DeprecationMessage: TryGetObsoleteAttribute(prop).Message); } public static ParameterModel FromOptionsField(IFieldSymbol field, Compilation? compilation = null, string? defaultValueLiteral = null) @@ -9779,6 +10024,7 @@ public static ParameterModel FromAsParametersCtorParameter( var enumCliNames = sk == CliScalarKind.Enum ? TryGetEnumCliNames(cp.Type) : default; var validations = ReadValidationConstraints(cp, sk, typeName); var expandProf = TryReadExpandUserProfileBeforeBind(cp, sk); + var (isDeprecatedCp, deprecationMsgCp) = TryGetObsoleteAttribute(cp); return new ParameterModel( cp.Name, local, @@ -9803,7 +10049,11 @@ public static ParameterModel FromAsParametersCtorParameter( AsParametersUseInit: false, AsParametersClrName: cp.Name, ExpandUserProfileBeforeBind: expandProf, - Validations: validations); + Validations: validations, + IsConfirmationSkip: HasConfirmationSkipAttribute(cp), + IsDryRun: HasDryRunAttribute(cp), + IsDeprecated: isDeprecatedCp, + DeprecationMessage: deprecationMsgCp); } public static ParameterModel FromAsParametersInitProperty( @@ -9877,6 +10127,7 @@ public static ParameterModel FromAsParametersInitProperty( var enumCliNames = sk == CliScalarKind.Enum ? TryGetEnumCliNames(prop.Type) : default; var validations = ReadValidationConstraints(prop, sk, typeName); var expandProf = TryReadExpandUserProfileBeforeBind(prop, sk); + var (isDeprecatedProp, deprecationMsgProp) = TryGetObsoleteAttribute(prop); return new ParameterModel( prop.Name, local, @@ -9904,7 +10155,11 @@ public static ParameterModel FromAsParametersInitProperty( Validations: validations, UsesRuntimeDefault: isCrossAssemblyDefault, IsNullableAnnotated: prop.Type.NullableAnnotation == NullableAnnotation.Annotated - || prop.Type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T }); + || prop.Type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T }, + IsConfirmationSkip: HasConfirmationSkipAttribute(prop), + IsDryRun: HasDryRunAttribute(prop), + IsDeprecated: isDeprecatedProp, + DeprecationMessage: deprecationMsgProp); } private static bool ComputeRequiredForOptionsType(ITypeSymbol type, BoolSpecialKind bs) diff --git a/src/Nullean.Argh.Hosting/ArghHostingBuilder.cs b/src/Nullean.Argh.Hosting/ArghHostingBuilder.cs index 187e592..e1fb8d1 100644 --- a/src/Nullean.Argh.Hosting/ArghHostingBuilder.cs +++ b/src/Nullean.Argh.Hosting/ArghHostingBuilder.cs @@ -150,6 +150,12 @@ IArghRootBuilder IArghRootBuilder.UseCliDescription(string description) return this; } + IArghRootBuilder IArghRootBuilder.DocumentEnvironmentVariables(CliEnvVarDoc[]? variables, CliConfigFileDoc[]? configFiles) + { + _ = _inner.DocumentEnvironmentVariables(variables, configFiles); + return this; + } + /// IArghBuilder IArghBuilder.MapRoot(Delegate handler) { diff --git a/src/Nullean.Argh.Interfaces/Annotations/Attributes.cs b/src/Nullean.Argh.Interfaces/Annotations/Attributes.cs index 54db4a5..ca0b0f0 100644 --- a/src/Nullean.Argh.Interfaces/Annotations/Attributes.cs +++ b/src/Nullean.Argh.Interfaces/Annotations/Attributes.cs @@ -190,3 +190,118 @@ public sealed class EnumValueAttribute : Attribute [AttributeUsage(AttributeTargets.Method | AttributeTargets.Parameter | AttributeTargets.Property)] public sealed class HiddenAttribute : Attribute; +/// +/// Boolean side-effect flags for . +/// Combine with |: [CommandIntent(CommandIntentFlags.Destructive | CommandIntentFlags.RequiresConfirmation)]. +/// +[Flags] +public enum CommandIntentFlags +{ + /// No flags set. + None = 0, + /// The command deletes, overwrites, or irreversibly modifies data or resources. + Destructive = 1 << 0, + /// Calling the command multiple times produces the same result as calling it once. + Idempotent = 1 << 1, + /// The command blocks on an interactive stdin prompt when run without a confirmationSkip-role parameter. + RequiresConfirmation = 1 << 2, + /// This specific command requires an authenticated session. + RequiresAuth = 1 << 3, +} + +/// Blast radius of a command's side effects, used in . +public enum CommandScope +{ + /// Not declared. + Unspecified = 0, + /// Affects a single file. + File, + /// Affects a directory tree. + Directory, + /// Affects cloud resources, databases, registries, or other shared state. + Global, +} + +/// +/// Declares the side-effect profile of a command for agent-reasoning consumers. +/// Emitted as the intent object in __schema output. +/// +/// +/// [CommandIntent(CommandIntentFlags.Destructive | CommandIntentFlags.RequiresConfirmation, Scope = CommandScope.Global)] +/// +[AttributeUsage(AttributeTargets.Method)] +public sealed class CommandIntentAttribute : Attribute +{ + public CommandIntentAttribute(CommandIntentFlags flags = CommandIntentFlags.None) => Flags = flags; + + /// Boolean side-effect flags. + public CommandIntentFlags Flags { get; } + + /// Blast radius of the command's side effects. + public CommandScope Scope { get; set; } = CommandScope.Unspecified; +} + +/// +/// Declares the machine-readable output formats a command supports. +/// Emitted as the output object in __schema output. +/// +[AttributeUsage(AttributeTargets.Method)] +public sealed class CommandOutputAttribute : Attribute +{ + /// Supported output format names (e.g. "json", "table"). + public string[] Formats { get; } + + /// The flag name used to select the format (e.g. "--output"). + public string? FormatFlag { get; set; } + + public CommandOutputAttribute(params string[] formats) => Formats = formats; +} + +/// +/// Marks a boolean flag parameter as a confirmation-skip signal (e.g. --yes, --force). +/// Emits role: "confirmationSkip" in __schema so agent consumers know to pass this flag +/// automatically on destructive commands when running non-interactively. +/// +[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)] +public sealed class ConfirmationSkipAttribute : Attribute; + +/// +/// Marks a boolean flag parameter as a dry-run signal (e.g. --dry-run, --whatif). +/// Emits role: "dryRun" in __schema so agent consumers know to pass this flag +/// when they want to preview effects without committing side effects. +/// +[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)] +public sealed class DryRunAttribute : Attribute; + +/// Describes an environment variable the program reads. Used with . +public sealed class CliEnvVarDoc +{ + public CliEnvVarDoc(string name, string? description = null, bool required = false, string? defaultValue = null) + { + Name = name; + Description = description; + Required = required; + DefaultValue = defaultValue; + } + + public string Name { get; } + public string? Description { get; } + public bool Required { get; } + public string? DefaultValue { get; } +} + +/// Describes a configuration file the program reads. Used with . +public sealed class CliConfigFileDoc +{ + public CliConfigFileDoc(string path, string? description = null, bool required = false) + { + Path = path; + Description = description; + Required = required; + } + + public string Path { get; } + public string? Description { get; } + public bool Required { get; } +} + diff --git a/src/Nullean.Argh.Interfaces/Builder/IArghRootBuilder.cs b/src/Nullean.Argh.Interfaces/Builder/IArghRootBuilder.cs index 69ad552..4981bc8 100644 --- a/src/Nullean.Argh.Interfaces/Builder/IArghRootBuilder.cs +++ b/src/Nullean.Argh.Interfaces/Builder/IArghRootBuilder.cs @@ -10,4 +10,14 @@ public interface IArghRootBuilder : IArghBuilder /// Cannot be combined with MapRoot; the generator reports AGH0023 if both are present. /// IArghRootBuilder UseCliDescription(string description); + + /// + /// Documents environment variables and optional configuration files the program reads. + /// Analyzed by the source generator and emitted as the environment object in __schema output. + /// Arguments must be new CliEnvVarDoc(...) or new CliConfigFileDoc(...) object creation + /// expressions with string/bool literals so the generator can extract them statically. + /// + IArghRootBuilder DocumentEnvironmentVariables( + CliEnvVarDoc[]? variables = null, + CliConfigFileDoc[]? configFiles = null); } diff --git a/tests/Nullean.Argh.IntegrationTests/Completions/SchemaTests.cs b/tests/Nullean.Argh.IntegrationTests/Completions/SchemaTests.cs index cba07bd..6cc029f 100644 --- a/tests/Nullean.Argh.IntegrationTests/Completions/SchemaTests.cs +++ b/tests/Nullean.Argh.IntegrationTests/Completions/SchemaTests.cs @@ -239,4 +239,101 @@ public void Schema_enum_parameter_has_type_enum_and_enum_values() enumParam.ValueKind.Should().Be(JsonValueKind.Object); enumParam.GetProperty("enumValues").EnumerateArray().Should().NotBeEmpty(); } + + [Fact] + public void Schema_deprecated_command_without_message_emits_deprecated_object() + { + var result = CliHostRunner.Run("__schema"); + result.ExitCode.Should().Be(0); + using var doc = JsonDocument.Parse(CliHostRunner.StdoutText(result)); + var cmd = doc.RootElement.GetProperty("commands").EnumerateArray() + .FirstOrDefault(c => c.GetProperty("name").GetString() == "schema-deprecated-simple"); + cmd.ValueKind.Should().Be(JsonValueKind.Object); + cmd.TryGetProperty("deprecated", out var dep).Should().BeTrue(); + dep.ValueKind.Should().Be(JsonValueKind.Object); + } + + [Fact] + public void Schema_deprecated_command_with_message_emits_deprecated_message() + { + var result = CliHostRunner.Run("__schema"); + result.ExitCode.Should().Be(0); + using var doc = JsonDocument.Parse(CliHostRunner.StdoutText(result)); + var cmd = doc.RootElement.GetProperty("commands").EnumerateArray() + .FirstOrDefault(c => c.GetProperty("name").GetString() == "schema-deprecated-with-message"); + cmd.ValueKind.Should().Be(JsonValueKind.Object); + cmd.TryGetProperty("deprecated", out var dep).Should().BeTrue(); + dep.GetProperty("message").GetString().Should().Contain("schema-deprecated-replacement"); + } + + [Fact] + public void Schema_deprecated_parameter_emits_deprecated_with_message() + { + var result = CliHostRunner.Run("__schema"); + result.ExitCode.Should().Be(0); + using var doc = JsonDocument.Parse(CliHostRunner.StdoutText(result)); + var cmd = doc.RootElement.GetProperty("commands").EnumerateArray() + .FirstOrDefault(c => c.GetProperty("name").GetString() == "schema-deprecated-param"); + var oldNameParam = cmd.GetProperty("parameters").EnumerateArray() + .FirstOrDefault(p => p.GetProperty("name").GetString() == "old-name"); + oldNameParam.TryGetProperty("deprecated", out var dep).Should().BeTrue(); + dep.GetProperty("message").GetString().Should().Contain("--name"); + } + + [Fact] + public void Schema_command_with_intent_emits_intent_object() + { + var result = CliHostRunner.Run("__schema"); + result.ExitCode.Should().Be(0); + using var doc = JsonDocument.Parse(CliHostRunner.StdoutText(result)); + var cmd = doc.RootElement.GetProperty("commands").EnumerateArray() + .FirstOrDefault(c => c.GetProperty("name").GetString() == "schema-intent-destructive"); + cmd.ValueKind.Should().Be(JsonValueKind.Object); + var intent = cmd.GetProperty("intent"); + intent.GetProperty("destructive").GetBoolean().Should().BeTrue(); + intent.GetProperty("requiresConfirmation").GetBoolean().Should().BeTrue(); + intent.GetProperty("scope").GetString().Should().Be("global"); + intent.TryGetProperty("idempotent", out _).Should().BeFalse(); + } + + [Fact] + public void Schema_confirmationSkip_parameter_has_correct_role() + { + var result = CliHostRunner.Run("__schema"); + result.ExitCode.Should().Be(0); + using var doc = JsonDocument.Parse(CliHostRunner.StdoutText(result)); + var cmd = doc.RootElement.GetProperty("commands").EnumerateArray() + .FirstOrDefault(c => c.GetProperty("name").GetString() == "schema-intent-destructive"); + var yesParam = cmd.GetProperty("parameters").EnumerateArray() + .FirstOrDefault(p => p.GetProperty("name").GetString() == "yes"); + yesParam.GetProperty("role").GetString().Should().Be("confirmationSkip"); + } + + [Fact] + public void Schema_dryRun_parameter_has_correct_role() + { + var result = CliHostRunner.Run("__schema"); + result.ExitCode.Should().Be(0); + using var doc = JsonDocument.Parse(CliHostRunner.StdoutText(result)); + var cmd = doc.RootElement.GetProperty("commands").EnumerateArray() + .FirstOrDefault(c => c.GetProperty("name").GetString() == "schema-intent-read"); + var dryRunParam = cmd.GetProperty("parameters").EnumerateArray() + .FirstOrDefault(p => p.GetProperty("name").GetString() == "dry-run"); + dryRunParam.GetProperty("role").GetString().Should().Be("dryRun"); + } + + [Fact] + public void Schema_command_with_output_formats_emits_output_object() + { + var result = CliHostRunner.Run("__schema"); + result.ExitCode.Should().Be(0); + using var doc = JsonDocument.Parse(CliHostRunner.StdoutText(result)); + var cmd = doc.RootElement.GetProperty("commands").EnumerateArray() + .FirstOrDefault(c => c.GetProperty("name").GetString() == "schema-output-formats"); + cmd.ValueKind.Should().Be(JsonValueKind.Object); + var output = cmd.GetProperty("output"); + output.GetProperty("formatFlag").GetString().Should().Be("--output"); + var formats = output.GetProperty("formats").EnumerateArray().Select(f => f.GetString()).ToArray(); + formats.Should().BeEquivalentTo(new[] { "json", "table", "csv" }); + } } diff --git a/tests/Nullean.Argh.IntegrationTests/Help/RootAndNamespaceHelpTests.cs b/tests/Nullean.Argh.IntegrationTests/Help/RootAndNamespaceHelpTests.cs index 4f3fee5..b20b8d3 100644 --- a/tests/Nullean.Argh.IntegrationTests/Help/RootAndNamespaceHelpTests.cs +++ b/tests/Nullean.Argh.IntegrationTests/Help/RootAndNamespaceHelpTests.cs @@ -93,8 +93,17 @@ handler parameter (-m still parses). renamed-cmd schema-default-value Schema test: command with a default value on --level. + schema-deprecated-param A command with a deprecated parameter + via [AsParameters]. + schema-deprecated-simple A command that is deprecated without a + message. + schema-deprecated-with-message A command that is deprecated with a + migration message. schema-hidden-param Schema test: command with a hidden parameter. + schema-intent-destructive Deletes all resources permanently. + schema-intent-read Lists resources safely. + schema-output-formats Reports status in multiple formats. schema-separator-list Schema test: command with a separator-based collection on --ids. severity-cmd diff --git a/tests/Nullean.Argh.IntegrationTests/Help/RootHelpFullTextTests.cs b/tests/Nullean.Argh.IntegrationTests/Help/RootHelpFullTextTests.cs index 19e3bf9..2f07d95 100644 --- a/tests/Nullean.Argh.IntegrationTests/Help/RootHelpFullTextTests.cs +++ b/tests/Nullean.Argh.IntegrationTests/Help/RootHelpFullTextTests.cs @@ -94,8 +94,17 @@ handler parameter (-m still parses). renamed-cmd schema-default-value Schema test: command with a default value on --level. + schema-deprecated-param A command with a deprecated parameter + via [AsParameters]. + schema-deprecated-simple A command that is deprecated without a + message. + schema-deprecated-with-message A command that is deprecated with a + migration message. schema-hidden-param Schema test: command with a hidden parameter. + schema-intent-destructive Deletes all resources permanently. + schema-intent-read Lists resources safely. + schema-output-formats Reports status in multiple formats. schema-separator-list Schema test: command with a separator-based collection on --ids. severity-cmd diff --git a/tests/Nullean.Argh.Tests/CliRegistrationModule.cs b/tests/Nullean.Argh.Tests/CliRegistrationModule.cs index e6b74a4..daded34 100644 --- a/tests/Nullean.Argh.Tests/CliRegistrationModule.cs +++ b/tests/Nullean.Argh.Tests/CliRegistrationModule.cs @@ -76,6 +76,12 @@ internal static void RegisterCommands() app.Map("schema-separator-list", SchemaSpecificHandlers.SchemaSeparatorList); app.Map("schema-hidden-param", SchemaSpecificHandlers.SchemaHiddenParam); app.Map(); + app.Map("schema-deprecated-simple", SchemaDeprecatedHandlers.SchemaDeprecatedSimple); + app.Map("schema-deprecated-with-message", SchemaDeprecatedHandlers.SchemaDeprecatedWithMessage); + app.Map("schema-deprecated-param", SchemaDeprecatedHandlers.SchemaDeprecatedParam); + app.Map("schema-intent-destructive", SchemaIntentHandlers.SchemaIntentDestructive); + app.Map("schema-intent-read", SchemaIntentHandlers.SchemaIntentRead); + app.Map("schema-output-formats", SchemaOutputHandlers.SchemaOutputFormats); app.MapNamespace("storage", g => { g.UseNamespaceOptions(); diff --git a/tests/Nullean.Argh.Tests/Fixtures/SchemaSpecificHandlers.cs b/tests/Nullean.Argh.Tests/Fixtures/SchemaSpecificHandlers.cs index 21bf256..1eb3f4c 100644 --- a/tests/Nullean.Argh.Tests/Fixtures/SchemaSpecificHandlers.cs +++ b/tests/Nullean.Argh.Tests/Fixtures/SchemaSpecificHandlers.cs @@ -37,3 +37,65 @@ internal sealed class SchemaHiddenCommands [NoOptionsInjection] public static void HiddenCmd() => Console.Out.WriteLine("hidden"); } + +/// Schema test: deprecated commands and parameters. +internal static partial class SchemaDeprecatedHandlers +{ + /// A command that is deprecated without a message. + [Obsolete] + [NoOptionsInjection] + public static void SchemaDeprecatedSimple() => Console.Out.WriteLine("deprecated-simple"); + + /// A command that is deprecated with a migration message. + [Obsolete("Use schema-deprecated-replacement instead.")] + [NoOptionsInjection] + public static void SchemaDeprecatedWithMessage() => Console.Out.WriteLine("deprecated-message"); + +} + +/// Schema test: a DTO with a deprecated property, used via [AsParameters]. +internal sealed class SchemaDeprecatedParamArgs +{ + /// The new flag to use. + public string Name { get; init; } = ""; + + /// Deprecated alias for name. + [Obsolete("Use --name instead.")] + public string? OldName { get; init; } +} + +internal static partial class SchemaDeprecatedHandlers +{ + /// A command with a deprecated parameter via [AsParameters]. + [NoOptionsInjection] + public static void SchemaDeprecatedParam([AsParameters] SchemaDeprecatedParamArgs args) => + Console.Out.WriteLine($"name:{args.Name ?? args.OldName}"); +} + +/// Schema test: intent annotation on a destructive command. +internal static class SchemaIntentHandlers +{ + /// Deletes all resources permanently. + [CommandIntent(CommandIntentFlags.Destructive | CommandIntentFlags.RequiresConfirmation, Scope = CommandScope.Global)] + [NoOptionsInjection] + public static void SchemaIntentDestructive( + [ConfirmationSkip] bool yes = false) => + Console.Out.WriteLine("deleted"); + + /// Lists resources safely. + [CommandIntent(CommandIntentFlags.Idempotent)] + [NoOptionsInjection] + public static void SchemaIntentRead( + [DryRun] bool dryRun = false) => + Console.Out.WriteLine("list"); +} + +/// Schema test: output formats on a command. +internal static class SchemaOutputHandlers +{ + /// Reports status in multiple formats. + [CommandOutput("json", "table", "csv", FormatFlag = "--output")] + [NoOptionsInjection] + public static void SchemaOutputFormats(string? output = null) => + Console.Out.WriteLine($"format:{output}"); +} From 54f70616fa50a471ebd058ccd7e070bfd71a2d25 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 7 May 2026 12:04:26 +0200 Subject: [PATCH 2/4] refactor(schema): Documentation namespace, Intent enum, MutationScope + RequiresAuth attrs, [CommandOutput] on params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documentation-only attributes moved to Nullean.Argh.Documentation namespace (no impact on parsing or validation): Intent (enum, renamed from CommandIntentFlags — RequiresAuth removed) MutationScope (enum) + [MutationScope] attribute (scope split from [CommandIntent]) [RequiresAuth] marker attribute (split from Intent flags) [CommandOutput] (moves from method to Parameter|Property; FormatFlag inferred) [ConfirmationSkip], [DryRun] CliEnvVarDoc, CliConfigFileDoc New [CommandOutput] design: - Apply to an enum parameter: formats inferred from enum CLI values, flagFlag derived from the parameter's CLI name — no FormatFlag arg needed - Apply with explicit formats: [CommandOutput("json","table")] on any param - Works on regular parameters, [AsParameters] DTO properties, and GlobalOptions/NamespaceOptions properties Intent attribute changes: - Renamed CommandIntentFlags → Intent (three flags: Destructive, Idempotent, RequiresConfirmation) - [CommandIntent(Intent.Destructive)] — boolean side-effects only - [MutationScope(MutationScope.Global)] — separate attribute for blast radius; Global = beyond the filesystem (databases, cloud, registries, etc.) - [RequiresAuth] — separate marker attribute Generator changes: - TryGetCommandIntentData reads [CommandIntent]+[MutationScope]+[RequiresAuth] and combines them into a single CommandIntentData - TryGetCommandOutputAttribute detects [CommandOutput] on ISymbol (parameter or property) in Nullean.Argh.Documentation namespace - BuildCommandOutputFromParameters scans WithDocs for IsCommandOutput param and resolves formats via ResolveEnumMemberCliName (same path as help rendering) - All ParameterModel factory paths updated: From, FromAsParametersCtorParameter, FromAsParametersInitProperty, FromOptionsProperty, BuildCollectionParameterModel - DocNs constant centralizes the namespace string for all doc-attr checks Tests: fixtures updated to new API; 275/275 pass. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/Nullean.Argh.Core/ArghApp.IArghBuilder.cs | 1 + src/Nullean.Argh.Core/ArghApp.cs | 1 + src/Nullean.Argh.Core/Builder/ArghBuilder.cs | 1 + .../CliParserGenerator.cs | 137 +++++++++------ .../ArghHostingBuilder.cs | 1 + .../Annotations/Attributes.cs | 115 ------------ .../Annotations/DocumentationAttributes.cs | 166 ++++++++++++++++++ .../Builder/IArghRootBuilder.cs | 2 + .../Completions/SchemaTests.cs | 3 +- .../Fixtures/SchemaSpecificHandlers.cs | 17 +- 10 files changed, 273 insertions(+), 171 deletions(-) create mode 100644 src/Nullean.Argh.Interfaces/Annotations/DocumentationAttributes.cs diff --git a/src/Nullean.Argh.Core/ArghApp.IArghBuilder.cs b/src/Nullean.Argh.Core/ArghApp.IArghBuilder.cs index 302460a..04a59a3 100644 --- a/src/Nullean.Argh.Core/ArghApp.IArghBuilder.cs +++ b/src/Nullean.Argh.Core/ArghApp.IArghBuilder.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using Nullean.Argh.Builder; +using Nullean.Argh.Documentation; using Nullean.Argh.Middleware; namespace Nullean.Argh; diff --git a/src/Nullean.Argh.Core/ArghApp.cs b/src/Nullean.Argh.Core/ArghApp.cs index ec74648..d64d9b8 100644 --- a/src/Nullean.Argh.Core/ArghApp.cs +++ b/src/Nullean.Argh.Core/ArghApp.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using Nullean.Argh.Builder; +using Nullean.Argh.Documentation; using Nullean.Argh.Help; using Nullean.Argh.Runtime; diff --git a/src/Nullean.Argh.Core/Builder/ArghBuilder.cs b/src/Nullean.Argh.Core/Builder/ArghBuilder.cs index 72dfc1f..4e0fa34 100644 --- a/src/Nullean.Argh.Core/Builder/ArghBuilder.cs +++ b/src/Nullean.Argh.Core/Builder/ArghBuilder.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using Nullean.Argh; +using Nullean.Argh.Documentation; using Nullean.Argh.Middleware; using Nullean.Argh.Runtime; diff --git a/src/Nullean.Argh.Generator/CliParserGenerator.cs b/src/Nullean.Argh.Generator/CliParserGenerator.cs index 83a2e80..44566ce 100644 --- a/src/Nullean.Argh.Generator/CliParserGenerator.cs +++ b/src/Nullean.Argh.Generator/CliParserGenerator.cs @@ -2485,47 +2485,55 @@ private static (bool IsDeprecated, string? Message) TryGetObsoleteAttribute(ISym return (false, null); } - private static CommandIntentData? TryGetCommandIntentAttribute(IMethodSymbol method) + private const string DocNs = "Nullean.Argh.Documentation"; + + private static CommandIntentData? TryGetCommandIntentData(IMethodSymbol method) { + bool? destructive = null, idempotent = null, requiresConfirmation = null, requiresAuth = null; + string? scope = null; + foreach (var ad in method.GetAttributes()) { - if (ad.AttributeClass?.Name == "CommandIntentAttribute" && - ad.AttributeClass.ContainingNamespace?.ToDisplayString() == "Nullean.Argh") - { - // Constructor arg 0 is the CommandIntentFlags enum value (underlying int) - var flagsInt = 0; - if (ad.ConstructorArguments.Length >= 1 && ad.ConstructorArguments[0].Value is int f) - flagsInt = f; - - bool? destructive = (flagsInt & 1) != 0 ? true : null; // Destructive = 1<<0 - bool? idempotent = (flagsInt & 2) != 0 ? true : null; // Idempotent = 1<<1 - bool? requiresConfirmation = (flagsInt & 4) != 0 ? true : null; // RequiresConfirmation = 1<<2 - bool? requiresAuth = (flagsInt & 8) != 0 ? true : null; // RequiresAuth = 1<<3 + if (ad.AttributeClass?.ContainingNamespace?.ToDisplayString() != DocNs) continue; - // Named arg Scope is a CommandScope enum (underlying int: 0=Unspecified,1=File,2=Directory,3=Global) - string? scope = null; - foreach (var na in ad.NamedArguments) + switch (ad.AttributeClass.Name) + { + case "CommandIntentAttribute": { - if (na.Key == "Scope" && na.Value.Value is int s && s != 0) - { - scope = s switch { 1 => "file", 2 => "directory", 3 => "global", _ => null }; - } + // Constructor arg 0 is the Intent flags enum (underlying int) + // Destructive=1, Idempotent=2, RequiresConfirmation=4 + var flagsInt = 0; + if (ad.ConstructorArguments.Length >= 1 && ad.ConstructorArguments[0].Value is int f) + flagsInt = f; + if ((flagsInt & 1) != 0) destructive = true; + if ((flagsInt & 2) != 0) idempotent = true; + if ((flagsInt & 4) != 0) requiresConfirmation = true; + break; } - - if (flagsInt == 0 && scope is null) return null; - return new CommandIntentData(destructive, idempotent, scope, requiresConfirmation, requiresAuth); + case "MutationScopeAttribute": + { + // Constructor arg 0 is MutationScope enum: 0=File, 1=Directory, 2=Global + if (ad.ConstructorArguments.Length >= 1 && ad.ConstructorArguments[0].Value is int s) + scope = s switch { 0 => "file", 1 => "directory", 2 => "global", _ => null }; + break; + } + case "RequiresAuthAttribute": + requiresAuth = true; + break; } } - return null; + if (destructive is null && idempotent is null && requiresConfirmation is null && requiresAuth is null && scope is null) + return null; + return new CommandIntentData(destructive, idempotent, scope, requiresConfirmation, requiresAuth); } - private static CommandOutputData? TryGetCommandOutputAttribute(IMethodSymbol method) + private static (bool IsOutput, ImmutableArray ExplicitFormats) TryGetCommandOutputAttribute(ISymbol symbol) { - foreach (var ad in method.GetAttributes()) + foreach (var ad in symbol.GetAttributes()) { if (ad.AttributeClass?.Name == "CommandOutputAttribute" && - ad.AttributeClass.ContainingNamespace?.ToDisplayString() == "Nullean.Argh") + ad.AttributeClass.ContainingNamespace?.ToDisplayString() == DocNs) { var formats = ImmutableArray.Empty; if (ad.ConstructorArguments.Length >= 1 && ad.ConstructorArguments[0].Kind == TypedConstantKind.Array) @@ -2538,19 +2546,10 @@ private static (bool IsDeprecated, string? Message) TryGetObsoleteAttribute(ISym } formats = builder.ToImmutable(); } - - string? formatFlag = null; - foreach (var na in ad.NamedArguments) - { - if (na.Key == "FormatFlag" && na.Value.Value is string s) - formatFlag = s; - } - - return new CommandOutputData(formats, formatFlag); + return (true, formats); } } - - return null; + return (false, ImmutableArray.Empty); } private static bool HasConfirmationSkipAttribute(ISymbol symbol) @@ -2558,10 +2557,9 @@ private static bool HasConfirmationSkipAttribute(ISymbol symbol) foreach (var ad in symbol.GetAttributes()) { if (ad.AttributeClass?.Name == "ConfirmationSkipAttribute" && - ad.AttributeClass.ContainingNamespace?.ToDisplayString() == "Nullean.Argh") + ad.AttributeClass.ContainingNamespace?.ToDisplayString() == DocNs) return true; } - return false; } @@ -2570,13 +2568,36 @@ private static bool HasDryRunAttribute(ISymbol symbol) foreach (var ad in symbol.GetAttributes()) { if (ad.AttributeClass?.Name == "DryRunAttribute" && - ad.AttributeClass.ContainingNamespace?.ToDisplayString() == "Nullean.Argh") + ad.AttributeClass.ContainingNamespace?.ToDisplayString() == DocNs) return true; } - return false; } + private static CommandOutputData? BuildCommandOutputFromParameters(ImmutableArray parameters) + { + foreach (var p in parameters) + { + if (!p.IsCommandOutput) continue; + var flagName = "--" + p.CliLongName; + ImmutableArray formats; + if (!p.CommandOutputExplicitFormats.IsDefaultOrEmpty) + formats = p.CommandOutputExplicitFormats; + else if (p.ScalarKind == CliScalarKind.Enum && !p.EnumMemberNames.IsDefaultOrEmpty) + { + // Resolve CLI names the same way the help/schema emitter does + var builder = ImmutableArray.CreateBuilder(p.EnumMemberNames.Length); + for (var i = 0; i < p.EnumMemberNames.Length; i++) + builder.Add(ResolveEnumMemberCliName(p.EnumMemberCliNames, i, p.EnumMemberNames[i])); + formats = builder.ToImmutable(); + } + else + formats = ImmutableArray.Empty; + return new CommandOutputData(formats, flagName); + } + return null; + } + private static AIDocumentEnvironmentVariables? AnalyzeDocumentEnvironmentVariables( InvocationExpressionSyntax invocation, string filePath, int spanStart) { @@ -9205,8 +9226,8 @@ public static CommandModel FromMethod( IsHidden: HasHiddenAttribute(method), IsDeprecated: isObs, DeprecationMessage: obsMsg, - Intent: TryGetCommandIntentAttribute(method), - Output: TryGetCommandOutputAttribute(method)); + Intent: TryGetCommandIntentData(method), + Output: BuildCommandOutputFromParameters(withDocs)); } /// Overload for the per-invocation Select step — uses instead of SourceProductionContext. @@ -9276,7 +9297,7 @@ public static CommandModel FromMethod( var hasParamlessCtor = method.ContainingType is INamedTypeSymbol namedCt && HasPublicParameterlessCtor(namedCt); var (retFq, retIsAsync, retIsVoid, handlerNoInj, handlerParams, handlerLoc, ctorParams, mwData, docId) = ExtractHandlerAnalysis(method); var (isDeprecated, deprecationMsg) = TryGetObsoleteAttribute(method); - return new CommandModel(routePrefix, commandName, runName, containingFq, method.Name, !method.IsStatic, hasParamlessCtor, retFq, retIsAsync, retIsVoid, withDocs, handlerNoInj, handlerParams, handlerLoc, ctorParams, docId, docs.SummaryOneLiner, docs.RemarksRendered, docs.SummaryInnerXml, docs.RemarksInnerXml, docs.ExamplesRendered, usage, mwData, IsIntrinsic: HasCommandIntrinsicAttribute(method), CommandAliases: TryGetCommandAliasesFromAttribute(method), IsHidden: HasHiddenAttribute(method), IsDeprecated: isDeprecated, DeprecationMessage: deprecationMsg, Intent: TryGetCommandIntentAttribute(method), Output: TryGetCommandOutputAttribute(method)); + return new CommandModel(routePrefix, commandName, runName, containingFq, method.Name, !method.IsStatic, hasParamlessCtor, retFq, retIsAsync, retIsVoid, withDocs, handlerNoInj, handlerParams, handlerLoc, ctorParams, docId, docs.SummaryOneLiner, docs.RemarksRendered, docs.SummaryInnerXml, docs.RemarksInnerXml, docs.ExamplesRendered, usage, mwData, IsIntrinsic: HasCommandIntrinsicAttribute(method), CommandAliases: TryGetCommandAliasesFromAttribute(method), IsHidden: HasHiddenAttribute(method), IsDeprecated: isDeprecated, DeprecationMessage: deprecationMsg, Intent: TryGetCommandIntentData(method), Output: BuildCommandOutputFromParameters(withDocs)); } private static ImmutableArray BuildParameterModels( @@ -9648,6 +9669,8 @@ private sealed record ParameterModel( bool IsNullableAnnotated = false, bool IsConfirmationSkip = false, bool IsDryRun = false, + bool IsCommandOutput = false, + ImmutableArray CommandOutputExplicitFormats = default, bool IsDeprecated = false, string? DeprecationMessage = null) { @@ -9714,6 +9737,8 @@ private static ParameterModel BuildCollectionParameterModel( // Variadic positionals always allow zero items by C# params convention. // Minimum count enforcement is handled via CollectionCountConstraint ([MinLength]). if (isVariadic) required = false; + var (isOutputColl, outputFormatsColl) = TryGetCommandOutputAttribute(attributeHost); + var (isDeprecatedColl, deprecationMsgColl) = TryGetObsoleteAttribute(attributeHost); return new ParameterModel( symbolName, localVarName, @@ -9756,8 +9781,10 @@ private static ParameterModel BuildCollectionParameterModel( IsVariadic: isVariadic, IsConfirmationSkip: HasConfirmationSkipAttribute(attributeHost), IsDryRun: HasDryRunAttribute(attributeHost), - IsDeprecated: TryGetObsoleteAttribute(attributeHost).IsDeprecated, - DeprecationMessage: TryGetObsoleteAttribute(attributeHost).Message); + IsCommandOutput: isOutputColl, + CommandOutputExplicitFormats: outputFormatsColl, + IsDeprecated: isDeprecatedColl, + DeprecationMessage: deprecationMsgColl); } // ── five factory methods ───────────────────────────────────────────── @@ -9808,6 +9835,8 @@ public static ParameterModel From(IParameterSymbol p, SourceProductionContext? r var enumCliNames = sk == CliScalarKind.Enum ? TryGetEnumCliNames(p.Type) : default; var validations = ReadValidationConstraints(p, sk, typeName); var expandProf = TryReadExpandUserProfileBeforeBind(p, sk); + var (isOutputP, outputFormatsP) = TryGetCommandOutputAttribute(p); + var (isDeprecatedP, deprecationMsgP) = TryGetObsoleteAttribute(p); return new ParameterModel( p.Name, SafeLocalName(p.Name), @@ -9832,8 +9861,10 @@ public static ParameterModel From(IParameterSymbol p, SourceProductionContext? r IsHidden: HasHiddenAttribute(p), IsConfirmationSkip: HasConfirmationSkipAttribute(p), IsDryRun: HasDryRunAttribute(p), - IsDeprecated: TryGetObsoleteAttribute(p).IsDeprecated, - DeprecationMessage: TryGetObsoleteAttribute(p).Message); + IsCommandOutput: isOutputP, + CommandOutputExplicitFormats: outputFormatsP, + IsDeprecated: isDeprecatedP, + DeprecationMessage: deprecationMsgP); } public static ParameterModel FromOptionsProperty(IPropertySymbol prop, Compilation? compilation = null, string? defaultValueLiteral = null) @@ -9890,6 +9921,8 @@ public static ParameterModel FromOptionsProperty(IPropertySymbol prop, Compilati || prop.Type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T }, IsConfirmationSkip: HasConfirmationSkipAttribute(prop), IsDryRun: HasDryRunAttribute(prop), + IsCommandOutput: TryGetCommandOutputAttribute(prop).IsOutput, + CommandOutputExplicitFormats: TryGetCommandOutputAttribute(prop).ExplicitFormats, IsDeprecated: TryGetObsoleteAttribute(prop).IsDeprecated, DeprecationMessage: TryGetObsoleteAttribute(prop).Message); } @@ -10025,6 +10058,7 @@ public static ParameterModel FromAsParametersCtorParameter( var validations = ReadValidationConstraints(cp, sk, typeName); var expandProf = TryReadExpandUserProfileBeforeBind(cp, sk); var (isDeprecatedCp, deprecationMsgCp) = TryGetObsoleteAttribute(cp); + var (isOutputCp, outputFormatsCp) = TryGetCommandOutputAttribute(cp); return new ParameterModel( cp.Name, local, @@ -10052,6 +10086,8 @@ public static ParameterModel FromAsParametersCtorParameter( Validations: validations, IsConfirmationSkip: HasConfirmationSkipAttribute(cp), IsDryRun: HasDryRunAttribute(cp), + IsCommandOutput: isOutputCp, + CommandOutputExplicitFormats: outputFormatsCp, IsDeprecated: isDeprecatedCp, DeprecationMessage: deprecationMsgCp); } @@ -10128,6 +10164,7 @@ public static ParameterModel FromAsParametersInitProperty( var validations = ReadValidationConstraints(prop, sk, typeName); var expandProf = TryReadExpandUserProfileBeforeBind(prop, sk); var (isDeprecatedProp, deprecationMsgProp) = TryGetObsoleteAttribute(prop); + var (isOutputProp, outputFormatsProp) = TryGetCommandOutputAttribute(prop); return new ParameterModel( prop.Name, local, @@ -10158,6 +10195,8 @@ public static ParameterModel FromAsParametersInitProperty( || prop.Type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T }, IsConfirmationSkip: HasConfirmationSkipAttribute(prop), IsDryRun: HasDryRunAttribute(prop), + IsCommandOutput: isOutputProp, + CommandOutputExplicitFormats: outputFormatsProp, IsDeprecated: isDeprecatedProp, DeprecationMessage: deprecationMsgProp); } diff --git a/src/Nullean.Argh.Hosting/ArghHostingBuilder.cs b/src/Nullean.Argh.Hosting/ArghHostingBuilder.cs index e1fb8d1..e4c94b9 100644 --- a/src/Nullean.Argh.Hosting/ArghHostingBuilder.cs +++ b/src/Nullean.Argh.Hosting/ArghHostingBuilder.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Nullean.Argh.Builder; +using Nullean.Argh.Documentation; using Nullean.Argh.Middleware; using Nullean.Argh.Runtime; diff --git a/src/Nullean.Argh.Interfaces/Annotations/Attributes.cs b/src/Nullean.Argh.Interfaces/Annotations/Attributes.cs index ca0b0f0..54db4a5 100644 --- a/src/Nullean.Argh.Interfaces/Annotations/Attributes.cs +++ b/src/Nullean.Argh.Interfaces/Annotations/Attributes.cs @@ -190,118 +190,3 @@ public sealed class EnumValueAttribute : Attribute [AttributeUsage(AttributeTargets.Method | AttributeTargets.Parameter | AttributeTargets.Property)] public sealed class HiddenAttribute : Attribute; -/// -/// Boolean side-effect flags for . -/// Combine with |: [CommandIntent(CommandIntentFlags.Destructive | CommandIntentFlags.RequiresConfirmation)]. -/// -[Flags] -public enum CommandIntentFlags -{ - /// No flags set. - None = 0, - /// The command deletes, overwrites, or irreversibly modifies data or resources. - Destructive = 1 << 0, - /// Calling the command multiple times produces the same result as calling it once. - Idempotent = 1 << 1, - /// The command blocks on an interactive stdin prompt when run without a confirmationSkip-role parameter. - RequiresConfirmation = 1 << 2, - /// This specific command requires an authenticated session. - RequiresAuth = 1 << 3, -} - -/// Blast radius of a command's side effects, used in . -public enum CommandScope -{ - /// Not declared. - Unspecified = 0, - /// Affects a single file. - File, - /// Affects a directory tree. - Directory, - /// Affects cloud resources, databases, registries, or other shared state. - Global, -} - -/// -/// Declares the side-effect profile of a command for agent-reasoning consumers. -/// Emitted as the intent object in __schema output. -/// -/// -/// [CommandIntent(CommandIntentFlags.Destructive | CommandIntentFlags.RequiresConfirmation, Scope = CommandScope.Global)] -/// -[AttributeUsage(AttributeTargets.Method)] -public sealed class CommandIntentAttribute : Attribute -{ - public CommandIntentAttribute(CommandIntentFlags flags = CommandIntentFlags.None) => Flags = flags; - - /// Boolean side-effect flags. - public CommandIntentFlags Flags { get; } - - /// Blast radius of the command's side effects. - public CommandScope Scope { get; set; } = CommandScope.Unspecified; -} - -/// -/// Declares the machine-readable output formats a command supports. -/// Emitted as the output object in __schema output. -/// -[AttributeUsage(AttributeTargets.Method)] -public sealed class CommandOutputAttribute : Attribute -{ - /// Supported output format names (e.g. "json", "table"). - public string[] Formats { get; } - - /// The flag name used to select the format (e.g. "--output"). - public string? FormatFlag { get; set; } - - public CommandOutputAttribute(params string[] formats) => Formats = formats; -} - -/// -/// Marks a boolean flag parameter as a confirmation-skip signal (e.g. --yes, --force). -/// Emits role: "confirmationSkip" in __schema so agent consumers know to pass this flag -/// automatically on destructive commands when running non-interactively. -/// -[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)] -public sealed class ConfirmationSkipAttribute : Attribute; - -/// -/// Marks a boolean flag parameter as a dry-run signal (e.g. --dry-run, --whatif). -/// Emits role: "dryRun" in __schema so agent consumers know to pass this flag -/// when they want to preview effects without committing side effects. -/// -[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)] -public sealed class DryRunAttribute : Attribute; - -/// Describes an environment variable the program reads. Used with . -public sealed class CliEnvVarDoc -{ - public CliEnvVarDoc(string name, string? description = null, bool required = false, string? defaultValue = null) - { - Name = name; - Description = description; - Required = required; - DefaultValue = defaultValue; - } - - public string Name { get; } - public string? Description { get; } - public bool Required { get; } - public string? DefaultValue { get; } -} - -/// Describes a configuration file the program reads. Used with . -public sealed class CliConfigFileDoc -{ - public CliConfigFileDoc(string path, string? description = null, bool required = false) - { - Path = path; - Description = description; - Required = required; - } - - public string Path { get; } - public string? Description { get; } - public bool Required { get; } -} - diff --git a/src/Nullean.Argh.Interfaces/Annotations/DocumentationAttributes.cs b/src/Nullean.Argh.Interfaces/Annotations/DocumentationAttributes.cs new file mode 100644 index 0000000..36a6d9b --- /dev/null +++ b/src/Nullean.Argh.Interfaces/Annotations/DocumentationAttributes.cs @@ -0,0 +1,166 @@ +namespace Nullean.Argh.Documentation; + +/// +/// Boolean side-effect flags for . +/// Combine with |: [CommandIntent(Intent.Destructive | Intent.RequiresConfirmation)]. +/// +[Flags] +public enum Intent +{ + /// No flags set. + None = 0, + /// The command deletes, overwrites, or irreversibly modifies data or resources. + Destructive = 1 << 0, + /// Calling the command multiple times produces the same result as calling it once. + Idempotent = 1 << 1, + /// The command blocks on an interactive stdin prompt when run without a confirmationSkip-role parameter. + RequiresConfirmation = 1 << 2, +} + +/// +/// Mutation scope of a command — declares the blast radius of its side effects. +/// Used with . +/// +public enum MutationScope +{ + /// Affects a single file or path. + File, + /// Affects a directory tree. + Directory, + /// + /// Reaches beyond the local filesystem — e.g. writes to a cloud service, database, registry, + /// message queue, or any shared remote state. + /// + Global, +} + +/// +/// Declares the side-effect profile of a command for agent-reasoning consumers. +/// Emitted as the intent object in __schema output. +/// Has no effect on parsing or validation. +/// +/// +/// [CommandIntent(Intent.Destructive | Intent.RequiresConfirmation)] +/// [MutationScope(MutationScope.Global)] +/// [RequiresAuth] +/// public static Task Delete([ConfirmationSkip] bool yes = false) { ... } +/// +[AttributeUsage(AttributeTargets.Method)] +public sealed class CommandIntentAttribute : Attribute +{ + public CommandIntentAttribute(Intent flags = Intent.None) => Flags = flags; + + /// Boolean side-effect flags (combine with |). + public Intent Flags { get; } +} + +/// +/// Declares the mutation scope of a command — the blast radius of its side effects. +/// Emitted as intent.scope in __schema output. +/// Has no effect on parsing or validation. +/// +/// +/// +/// — affects a single file or path. +/// — affects a directory tree. +/// — reaches beyond the local filesystem +/// (cloud resources, databases, registries, network services, etc.). +/// +/// +[AttributeUsage(AttributeTargets.Method)] +public sealed class MutationScopeAttribute : Attribute +{ + public MutationScopeAttribute(MutationScope scope) => Scope = scope; + + /// The mutation scope of the command. + public MutationScope Scope { get; } +} + +/// +/// Marks a command as requiring an authenticated session. +/// Emitted as intent.requiresAuth: true in __schema output. +/// Has no effect on parsing or validation. +/// +[AttributeUsage(AttributeTargets.Method)] +public sealed class RequiresAuthAttribute : Attribute; + +/// +/// Marks a parameter or property as a confirmation-skip signal (e.g. --yes, --force). +/// Emits role: "confirmationSkip" in __schema so agent consumers know to pass this flag +/// automatically on destructive commands when running non-interactively. +/// Has no effect on parsing or validation. +/// +[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)] +public sealed class ConfirmationSkipAttribute : Attribute; + +/// +/// Marks a parameter or property as a dry-run signal (e.g. --dry-run, --whatif). +/// Emits role: "dryRun" in __schema so agent consumers know to pass this flag +/// when they want to preview effects without committing side effects. +/// Has no effect on parsing or validation. +/// +[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)] +public sealed class DryRunAttribute : Attribute; + +/// +/// Marks a parameter or property as the command's output-format selector. +/// Emits the output object in __schema, with formatFlag derived from the +/// parameter's CLI name and formats from the enum's CLI values (or the explicit list). +/// Has no effect on parsing or validation. +/// +/// +/// Apply to an enum-typed parameter or property to infer formats automatically: +/// public static void Report([CommandOutput] OutputFormat? format = null) { ... } +/// +/// Or supply an explicit list for string-typed parameters: +/// public static void Report([CommandOutput("json", "table")] string? format = null) { ... } +/// +/// Also works on [AsParameters] DTO properties and on GlobalOptions / NamespaceOptions properties. +/// +[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)] +public sealed class CommandOutputAttribute : Attribute +{ + /// Explicit output format names. When empty and the parameter is enum-typed, formats are inferred from the enum's CLI values. + public string[] Formats { get; } + + public CommandOutputAttribute(params string[] formats) => Formats = formats; +} + +/// Describes an environment variable the program reads. Used with . +public sealed class CliEnvVarDoc +{ + public CliEnvVarDoc(string name, string? description = null, bool required = false, string? defaultValue = null) + { + Name = name; + Description = description; + Required = required; + DefaultValue = defaultValue; + } + + /// Variable name (e.g. "GITHUB_TOKEN"). + public string Name { get; } + /// What the variable controls. + public string? Description { get; } + /// Whether the program requires this variable to function. + public bool Required { get; } + /// Default value if not set. + public string? DefaultValue { get; } +} + +/// Describes a configuration file the program reads. Used with . +public sealed class CliConfigFileDoc +{ + public CliConfigFileDoc(string path, string? description = null, bool required = false) + { + Path = path; + Description = description; + Required = required; + } + + /// File path (~ is expanded to the user's home directory). + public string Path { get; } + /// What the file controls. + public string? Description { get; } + /// Whether the program requires this file to exist. + public bool Required { get; } +} diff --git a/src/Nullean.Argh.Interfaces/Builder/IArghRootBuilder.cs b/src/Nullean.Argh.Interfaces/Builder/IArghRootBuilder.cs index 4981bc8..13d0db7 100644 --- a/src/Nullean.Argh.Interfaces/Builder/IArghRootBuilder.cs +++ b/src/Nullean.Argh.Interfaces/Builder/IArghRootBuilder.cs @@ -1,3 +1,5 @@ +using Nullean.Argh.Documentation; + namespace Nullean.Argh.Builder; /// diff --git a/tests/Nullean.Argh.IntegrationTests/Completions/SchemaTests.cs b/tests/Nullean.Argh.IntegrationTests/Completions/SchemaTests.cs index 6cc029f..3bcc68c 100644 --- a/tests/Nullean.Argh.IntegrationTests/Completions/SchemaTests.cs +++ b/tests/Nullean.Argh.IntegrationTests/Completions/SchemaTests.cs @@ -292,6 +292,7 @@ public void Schema_command_with_intent_emits_intent_object() var intent = cmd.GetProperty("intent"); intent.GetProperty("destructive").GetBoolean().Should().BeTrue(); intent.GetProperty("requiresConfirmation").GetBoolean().Should().BeTrue(); + intent.GetProperty("requiresAuth").GetBoolean().Should().BeTrue(); intent.GetProperty("scope").GetString().Should().Be("global"); intent.TryGetProperty("idempotent", out _).Should().BeFalse(); } @@ -332,7 +333,7 @@ public void Schema_command_with_output_formats_emits_output_object() .FirstOrDefault(c => c.GetProperty("name").GetString() == "schema-output-formats"); cmd.ValueKind.Should().Be(JsonValueKind.Object); var output = cmd.GetProperty("output"); - output.GetProperty("formatFlag").GetString().Should().Be("--output"); + output.GetProperty("formatFlag").GetString().Should().Be("--format"); var formats = output.GetProperty("formats").EnumerateArray().Select(f => f.GetString()).ToArray(); formats.Should().BeEquivalentTo(new[] { "json", "table", "csv" }); } diff --git a/tests/Nullean.Argh.Tests/Fixtures/SchemaSpecificHandlers.cs b/tests/Nullean.Argh.Tests/Fixtures/SchemaSpecificHandlers.cs index 1eb3f4c..439007f 100644 --- a/tests/Nullean.Argh.Tests/Fixtures/SchemaSpecificHandlers.cs +++ b/tests/Nullean.Argh.Tests/Fixtures/SchemaSpecificHandlers.cs @@ -1,4 +1,5 @@ using Nullean.Argh; +using Nullean.Argh.Documentation; namespace Nullean.Argh.Tests.Fixtures; @@ -76,26 +77,30 @@ public static void SchemaDeprecatedParam([AsParameters] SchemaDeprecatedParamArg internal static class SchemaIntentHandlers { /// Deletes all resources permanently. - [CommandIntent(CommandIntentFlags.Destructive | CommandIntentFlags.RequiresConfirmation, Scope = CommandScope.Global)] + [CommandIntent(Intent.Destructive | Intent.RequiresConfirmation)] + [MutationScope(MutationScope.Global)] + [RequiresAuth] [NoOptionsInjection] public static void SchemaIntentDestructive( [ConfirmationSkip] bool yes = false) => Console.Out.WriteLine("deleted"); /// Lists resources safely. - [CommandIntent(CommandIntentFlags.Idempotent)] + [CommandIntent(Intent.Idempotent)] [NoOptionsInjection] public static void SchemaIntentRead( [DryRun] bool dryRun = false) => Console.Out.WriteLine("list"); } -/// Schema test: output formats on a command. +/// Enum for output format selection. +internal enum OutputFormat { Json, Table, Csv } + +/// Schema test: output formats on a command — enum parameter. internal static class SchemaOutputHandlers { /// Reports status in multiple formats. - [CommandOutput("json", "table", "csv", FormatFlag = "--output")] [NoOptionsInjection] - public static void SchemaOutputFormats(string? output = null) => - Console.Out.WriteLine($"format:{output}"); + public static void SchemaOutputFormats([CommandOutput] OutputFormat? format = null) => + Console.Out.WriteLine($"format:{format}"); } From 740c8c9d5c008fa42108032188742794524f5b02 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 7 May 2026 13:02:07 +0200 Subject: [PATCH 3/4] docs(readme): document schema enrichment attributes in Schema JSON section Covers [CommandIntent]/[MutationScope]/[RequiresAuth] intent profile, [ConfirmationSkip]/[DryRun] parameter roles, [CommandOutput] output formats, [Obsolete] deprecation, and DocumentEnvironmentVariables builder method. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- README.md | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b809b5d..22e5c3d 100644 --- a/README.md +++ b/README.md @@ -785,7 +785,60 @@ Use cases: - **Generated documentation** — pipe into a docs generator or templating step to keep reference docs in sync without manual maintenance. - **CI validation** — diff `cli-schema.json` across commits to catch unintentional breaking changes to the CLI surface. -The shape is defined by [`ArghCliSchemaDocument`](src/Nullean.Argh.Core/Schema/ArghCliSchemaDocument.cs). Output is indented camelCase JSON. Reserved meta-commands (`__complete`, `__completion`, `__schema`) appear under `reservedMetaCommands`. +The shape is defined by [`ArghCliSchemaDocument`](src/Nullean.Argh.Core/Schema/ArghCliSchemaDocument.cs) and conforms to the [cli-schema v1 specification](https://github.com/cli-schema/cli-schema). Output is indented camelCase JSON. Reserved meta-commands (`__complete`, `__completion`, `__schema`) appear under `reservedMetaCommands`. + +### Schema enrichment attributes + +Add `using Nullean.Argh.Documentation;` to access the attributes below. They have no effect on parsing or validation — they only enrich the `__schema` output for agent tooling and documentation consumers. + +**Side-effect profile** (`intent` object): + +```csharp +[CommandIntent(Intent.Destructive | Intent.RequiresConfirmation)] +[MutationScope(MutationScope.Global)] // File | Directory | Global +[RequiresAuth] +public static Task Delete([ConfirmationSkip] bool yes = false, ...) { } +``` + +`Intent` is a flags enum — combine with `|`. `MutationScope.Global` means the command reaches beyond the local filesystem (cloud resources, databases, registries, etc.). `[RequiresAuth]` signals that an authenticated session is required. `[ConfirmationSkip]` on a flag parameter sets its schema `role` to `"confirmationSkip"` so agent consumers know to pass it automatically on destructive commands. `[DryRun]` sets `role` to `"dryRun"`. + +**Output formats** (`output` object): + +```csharp +// Enum parameter — formats and formatFlag inferred automatically +public static void Report([CommandOutput] OutputFormat? format = null) { } + +// Explicit format list on a string parameter +public static void Export([CommandOutput("json", "table")] string? fmt = null) { } +``` + +Place `[CommandOutput]` on the parameter (or `[AsParameters]` DTO property, or GlobalOptions property) that selects the output format. The flag name and format list are derived from the parameter — no extra arguments needed for enum types. + +**Deprecated commands and parameters** (`deprecated` object): + +```csharp +[Obsolete("Use new-cmd instead.")] +public static void OldCmd(...) { } +``` + +`[Obsolete]` on a handler method or an `[AsParameters]` DTO property emits a `deprecated` object in the schema. The message, if provided, appears as `deprecated.message`. + +**Environment variables and config files** (`environment` object): + +```csharp +builder.DocumentEnvironmentVariables( + variables: + [ + new CliEnvVarDoc("GITHUB_TOKEN", Description: "GitHub API token", Required: true), + new CliEnvVarDoc("XDG_CONFIG_HOME", Description: "Config directory override"), + ], + configFiles: + [ + new CliConfigFileDoc("~/.config/myapp/config.json", Description: "Main config"), + ]); +``` + +Arguments must be `new CliEnvVarDoc(...)` / `new CliConfigFileDoc(...)` object creation expressions with string/bool literals so the source generator can extract them statically. **Native AOT in CI:** The GitHub Actions workflow runs an **`aot-validate`** job that publishes [`examples/ArghAotSmoketest`](examples/ArghAotSmoketest) with Native AOT on Linux, macOS, and Windows and invokes `__schema` on the native binary. That sample uses `Microsoft.Extensions.Hosting` and `AddArgh` so **`Map` / `MapNamespace` DI registration** is included in the AOT publish. The repo uses the SDK unified artifacts layout (output under **`.artifacts/`**, gitignored). From e5f2323ceba9f8b53bf67d67c92b66ed0f243f91 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 7 May 2026 13:24:36 +0200 Subject: [PATCH 4/4] =?UTF-8?q?refactor:=20rename=20CliEnvVarDoc=20?= =?UTF-8?q?=E2=86=92=20CliEnvVar,=20CliConfigFileDoc=20=E2=86=92=20CliConf?= =?UTF-8?q?igFile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 (1M context) --- README.md | 8 ++++---- src/Nullean.Argh.Core/ArghApp.IArghBuilder.cs | 2 +- src/Nullean.Argh.Core/ArghApp.cs | 2 +- src/Nullean.Argh.Core/Builder/ArghBuilder.cs | 4 ++-- src/Nullean.Argh.Generator/CliParserGenerator.cs | 4 ++-- src/Nullean.Argh.Hosting/ArghHostingBuilder.cs | 2 +- .../Annotations/DocumentationAttributes.cs | 8 ++++---- src/Nullean.Argh.Interfaces/Builder/IArghRootBuilder.cs | 6 +++--- 8 files changed, 18 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 22e5c3d..07ccaa7 100644 --- a/README.md +++ b/README.md @@ -829,16 +829,16 @@ public static void OldCmd(...) { } builder.DocumentEnvironmentVariables( variables: [ - new CliEnvVarDoc("GITHUB_TOKEN", Description: "GitHub API token", Required: true), - new CliEnvVarDoc("XDG_CONFIG_HOME", Description: "Config directory override"), + new CliEnvVar("GITHUB_TOKEN", Description: "GitHub API token", Required: true), + new CliEnvVar("XDG_CONFIG_HOME", Description: "Config directory override"), ], configFiles: [ - new CliConfigFileDoc("~/.config/myapp/config.json", Description: "Main config"), + new CliConfigFile("~/.config/myapp/config.json", Description: "Main config"), ]); ``` -Arguments must be `new CliEnvVarDoc(...)` / `new CliConfigFileDoc(...)` object creation expressions with string/bool literals so the source generator can extract them statically. +Arguments must be `new CliEnvVar(...)` / `new CliConfigFile(...)` object creation expressions with string/bool literals so the source generator can extract them statically. **Native AOT in CI:** The GitHub Actions workflow runs an **`aot-validate`** job that publishes [`examples/ArghAotSmoketest`](examples/ArghAotSmoketest) with Native AOT on Linux, macOS, and Windows and invokes `__schema` on the native binary. That sample uses `Microsoft.Extensions.Hosting` and `AddArgh` so **`Map` / `MapNamespace` DI registration** is included in the AOT publish. The repo uses the SDK unified artifacts layout (output under **`.artifacts/`**, gitignored). diff --git a/src/Nullean.Argh.Core/ArghApp.IArghBuilder.cs b/src/Nullean.Argh.Core/ArghApp.IArghBuilder.cs index 04a59a3..7136df4 100644 --- a/src/Nullean.Argh.Core/ArghApp.IArghBuilder.cs +++ b/src/Nullean.Argh.Core/ArghApp.IArghBuilder.cs @@ -36,7 +36,7 @@ IArghRootBuilder IArghRootBuilder.UseCliDescription(string description) } /// - IArghRootBuilder IArghRootBuilder.DocumentEnvironmentVariables(CliEnvVarDoc[]? variables, CliConfigFileDoc[]? configFiles) + IArghRootBuilder IArghRootBuilder.DocumentEnvironmentVariables(CliEnvVar[]? variables, CliConfigFile[]? configFiles) { _ = DocumentEnvironmentVariables(variables, configFiles); return this; diff --git a/src/Nullean.Argh.Core/ArghApp.cs b/src/Nullean.Argh.Core/ArghApp.cs index d64d9b8..799cee7 100644 --- a/src/Nullean.Argh.Core/ArghApp.cs +++ b/src/Nullean.Argh.Core/ArghApp.cs @@ -35,7 +35,7 @@ public ArghApp Map(string name, Delegate handler) public ArghApp UseCliDescription(string description) => this; /// Documents environment variables and config files the program reads. Analyzed by the source generator; no-op at runtime. - public ArghApp DocumentEnvironmentVariables(CliEnvVarDoc[]? variables = null, CliConfigFileDoc[]? configFiles = null) => this; + public ArghApp DocumentEnvironmentVariables(CliEnvVar[]? variables = null, CliConfigFile[]? configFiles = null) => this; /// /// Registers a default handler when no subcommand or namespace segment applies at the current scope diff --git a/src/Nullean.Argh.Core/Builder/ArghBuilder.cs b/src/Nullean.Argh.Core/Builder/ArghBuilder.cs index 4e0fa34..d389716 100644 --- a/src/Nullean.Argh.Core/Builder/ArghBuilder.cs +++ b/src/Nullean.Argh.Core/Builder/ArghBuilder.cs @@ -53,8 +53,8 @@ public IArghRootBuilder UseCliDescription(string description) /// public IArghRootBuilder DocumentEnvironmentVariables( - CliEnvVarDoc[]? variables = null, - CliConfigFileDoc[]? configFiles = null) + CliEnvVar[]? variables = null, + CliConfigFile[]? configFiles = null) { _ = _app.DocumentEnvironmentVariables(variables, configFiles); return this; diff --git a/src/Nullean.Argh.Generator/CliParserGenerator.cs b/src/Nullean.Argh.Generator/CliParserGenerator.cs index 44566ce..3de661b 100644 --- a/src/Nullean.Argh.Generator/CliParserGenerator.cs +++ b/src/Nullean.Argh.Generator/CliParserGenerator.cs @@ -664,10 +664,10 @@ private sealed record AIDocumentEnvironmentVariables( ImmutableArray ConfigFiles) : AnalyzedInvocation(FilePath, SpanStart); - /// Symbol-free representation of a passed to DocumentEnvironmentVariables. + /// Symbol-free representation of a passed to DocumentEnvironmentVariables. private sealed record EnvVarDocEntry(string Name, string? Description, bool Required, string? DefaultValue); - /// Symbol-free representation of a passed to DocumentEnvironmentVariables. + /// Symbol-free representation of a passed to DocumentEnvironmentVariables. private sealed record ConfigFileDocEntry(string Path, string? Description, bool Required); /// Symbol-free intent data extracted from [CommandIntent]. diff --git a/src/Nullean.Argh.Hosting/ArghHostingBuilder.cs b/src/Nullean.Argh.Hosting/ArghHostingBuilder.cs index e4c94b9..b0d2e02 100644 --- a/src/Nullean.Argh.Hosting/ArghHostingBuilder.cs +++ b/src/Nullean.Argh.Hosting/ArghHostingBuilder.cs @@ -151,7 +151,7 @@ IArghRootBuilder IArghRootBuilder.UseCliDescription(string description) return this; } - IArghRootBuilder IArghRootBuilder.DocumentEnvironmentVariables(CliEnvVarDoc[]? variables, CliConfigFileDoc[]? configFiles) + IArghRootBuilder IArghRootBuilder.DocumentEnvironmentVariables(CliEnvVar[]? variables, CliConfigFile[]? configFiles) { _ = _inner.DocumentEnvironmentVariables(variables, configFiles); return this; diff --git a/src/Nullean.Argh.Interfaces/Annotations/DocumentationAttributes.cs b/src/Nullean.Argh.Interfaces/Annotations/DocumentationAttributes.cs index 36a6d9b..527b91b 100644 --- a/src/Nullean.Argh.Interfaces/Annotations/DocumentationAttributes.cs +++ b/src/Nullean.Argh.Interfaces/Annotations/DocumentationAttributes.cs @@ -127,9 +127,9 @@ public sealed class CommandOutputAttribute : Attribute } /// Describes an environment variable the program reads. Used with . -public sealed class CliEnvVarDoc +public sealed class CliEnvVar { - public CliEnvVarDoc(string name, string? description = null, bool required = false, string? defaultValue = null) + public CliEnvVar(string name, string? description = null, bool required = false, string? defaultValue = null) { Name = name; Description = description; @@ -148,9 +148,9 @@ public CliEnvVarDoc(string name, string? description = null, bool required = fal } /// Describes a configuration file the program reads. Used with . -public sealed class CliConfigFileDoc +public sealed class CliConfigFile { - public CliConfigFileDoc(string path, string? description = null, bool required = false) + public CliConfigFile(string path, string? description = null, bool required = false) { Path = path; Description = description; diff --git a/src/Nullean.Argh.Interfaces/Builder/IArghRootBuilder.cs b/src/Nullean.Argh.Interfaces/Builder/IArghRootBuilder.cs index 13d0db7..21f83c0 100644 --- a/src/Nullean.Argh.Interfaces/Builder/IArghRootBuilder.cs +++ b/src/Nullean.Argh.Interfaces/Builder/IArghRootBuilder.cs @@ -16,10 +16,10 @@ public interface IArghRootBuilder : IArghBuilder /// /// Documents environment variables and optional configuration files the program reads. /// Analyzed by the source generator and emitted as the environment object in __schema output. - /// Arguments must be new CliEnvVarDoc(...) or new CliConfigFileDoc(...) object creation + /// Arguments must be new CliEnvVar(...) or new CliConfigFile(...) object creation /// expressions with string/bool literals so the generator can extract them statically. /// IArghRootBuilder DocumentEnvironmentVariables( - CliEnvVarDoc[]? variables = null, - CliConfigFileDoc[]? configFiles = null); + CliEnvVar[]? variables = null, + CliConfigFile[]? configFiles = null); }