diff --git a/README.md b/README.md
index b809b5d..07ccaa7 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 CliEnvVar("GITHUB_TOKEN", Description: "GitHub API token", Required: true),
+ new CliEnvVar("XDG_CONFIG_HOME", Description: "Config directory override"),
+ ],
+ configFiles:
+ [
+ new CliConfigFile("~/.config/myapp/config.json", Description: "Main config"),
+ ]);
+```
+
+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/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..7136df4 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;
@@ -34,6 +35,13 @@ IArghRootBuilder IArghRootBuilder.UseCliDescription(string description)
return this;
}
+ ///
+ IArghRootBuilder IArghRootBuilder.DocumentEnvironmentVariables(CliEnvVar[]? variables, CliConfigFile[]? 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..799cee7 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;
@@ -33,6 +34,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(CliEnvVar[]? variables = null, CliConfigFile[]? 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..d389716 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;
@@ -50,6 +51,15 @@ public IArghRootBuilder UseCliDescription(string description)
return this;
}
+ ///
+ public IArghRootBuilder DocumentEnvironmentVariables(
+ CliEnvVar[]? variables = null,
+ CliConfigFile[]? 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..3de661b 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,211 @@ 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 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?.ContainingNamespace?.ToDisplayString() != DocNs) continue;
+
+ switch (ad.AttributeClass.Name)
+ {
+ case "CommandIntentAttribute":
+ {
+ // 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;
+ }
+ 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;
+ }
+ }
+
+ 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 (bool IsOutput, ImmutableArray ExplicitFormats) TryGetCommandOutputAttribute(ISymbol symbol)
+ {
+ foreach (var ad in symbol.GetAttributes())
+ {
+ if (ad.AttributeClass?.Name == "CommandOutputAttribute" &&
+ ad.AttributeClass.ContainingNamespace?.ToDisplayString() == DocNs)
+ {
+ 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();
+ }
+ return (true, formats);
+ }
+ }
+ return (false, ImmutableArray.Empty);
+ }
+
+ private static bool HasConfirmationSkipAttribute(ISymbol symbol)
+ {
+ foreach (var ad in symbol.GetAttributes())
+ {
+ if (ad.AttributeClass?.Name == "ConfirmationSkipAttribute" &&
+ ad.AttributeClass.ContainingNamespace?.ToDisplayString() == DocNs)
+ 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() == 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)
+ {
+ 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 +9089,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 +9196,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 +9223,11 @@ public static CommandModel FromMethod(
mwData,
IsIntrinsic: HasCommandIntrinsicAttribute(method),
CommandAliases: TryGetCommandAliasesFromAttribute(method),
- IsHidden: HasHiddenAttribute(method));
+ IsHidden: HasHiddenAttribute(method),
+ IsDeprecated: isObs,
+ DeprecationMessage: obsMsg,
+ Intent: TryGetCommandIntentData(method),
+ Output: BuildCommandOutputFromParameters(withDocs));
}
/// Overload for the per-invocation Select step — uses instead of SourceProductionContext.
@@ -9047,7 +9296,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: TryGetCommandIntentData(method), Output: BuildCommandOutputFromParameters(withDocs));
}
private static ImmutableArray BuildParameterModels(
@@ -9416,7 +9666,13 @@ 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 IsCommandOutput = false,
+ ImmutableArray CommandOutputExplicitFormats = default,
+ bool IsDeprecated = false,
+ string? DeprecationMessage = null)
{
// ── shared helpers ──────────────────────────────────────────────────────
@@ -9481,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,
@@ -9520,7 +9778,13 @@ private static ParameterModel BuildCollectionParameterModel(
AsParametersClrName: asParams?.ClrName,
Validations: collValidations,
IsHidden: HasHiddenAttribute(attributeHost),
- IsVariadic: isVariadic);
+ IsVariadic: isVariadic,
+ IsConfirmationSkip: HasConfirmationSkipAttribute(attributeHost),
+ IsDryRun: HasDryRunAttribute(attributeHost),
+ IsCommandOutput: isOutputColl,
+ CommandOutputExplicitFormats: outputFormatsColl,
+ IsDeprecated: isDeprecatedColl,
+ DeprecationMessage: deprecationMsgColl);
}
// ── five factory methods ─────────────────────────────────────────────
@@ -9571,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),
@@ -9592,7 +9858,13 @@ 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),
+ IsCommandOutput: isOutputP,
+ CommandOutputExplicitFormats: outputFormatsP,
+ IsDeprecated: isDeprecatedP,
+ DeprecationMessage: deprecationMsgP);
}
public static ParameterModel FromOptionsProperty(IPropertySymbol prop, Compilation? compilation = null, string? defaultValueLiteral = null)
@@ -9646,7 +9918,13 @@ 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),
+ IsCommandOutput: TryGetCommandOutputAttribute(prop).IsOutput,
+ CommandOutputExplicitFormats: TryGetCommandOutputAttribute(prop).ExplicitFormats,
+ IsDeprecated: TryGetObsoleteAttribute(prop).IsDeprecated,
+ DeprecationMessage: TryGetObsoleteAttribute(prop).Message);
}
public static ParameterModel FromOptionsField(IFieldSymbol field, Compilation? compilation = null, string? defaultValueLiteral = null)
@@ -9779,6 +10057,8 @@ 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);
+ var (isOutputCp, outputFormatsCp) = TryGetCommandOutputAttribute(cp);
return new ParameterModel(
cp.Name,
local,
@@ -9803,7 +10083,13 @@ public static ParameterModel FromAsParametersCtorParameter(
AsParametersUseInit: false,
AsParametersClrName: cp.Name,
ExpandUserProfileBeforeBind: expandProf,
- Validations: validations);
+ Validations: validations,
+ IsConfirmationSkip: HasConfirmationSkipAttribute(cp),
+ IsDryRun: HasDryRunAttribute(cp),
+ IsCommandOutput: isOutputCp,
+ CommandOutputExplicitFormats: outputFormatsCp,
+ IsDeprecated: isDeprecatedCp,
+ DeprecationMessage: deprecationMsgCp);
}
public static ParameterModel FromAsParametersInitProperty(
@@ -9877,6 +10163,8 @@ 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);
+ var (isOutputProp, outputFormatsProp) = TryGetCommandOutputAttribute(prop);
return new ParameterModel(
prop.Name,
local,
@@ -9904,7 +10192,13 @@ 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),
+ IsCommandOutput: isOutputProp,
+ CommandOutputExplicitFormats: outputFormatsProp,
+ 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..b0d2e02 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;
@@ -150,6 +151,12 @@ IArghRootBuilder IArghRootBuilder.UseCliDescription(string description)
return this;
}
+ IArghRootBuilder IArghRootBuilder.DocumentEnvironmentVariables(CliEnvVar[]? variables, CliConfigFile[]? configFiles)
+ {
+ _ = _inner.DocumentEnvironmentVariables(variables, configFiles);
+ return this;
+ }
+
///
IArghBuilder IArghBuilder.MapRoot(Delegate handler)
{
diff --git a/src/Nullean.Argh.Interfaces/Annotations/DocumentationAttributes.cs b/src/Nullean.Argh.Interfaces/Annotations/DocumentationAttributes.cs
new file mode 100644
index 0000000..527b91b
--- /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 CliEnvVar
+{
+ public CliEnvVar(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 CliConfigFile
+{
+ public CliConfigFile(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 69ad552..21f83c0 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;
///
@@ -10,4 +12,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 CliEnvVar(...) or new CliConfigFile(...) object creation
+ /// expressions with string/bool literals so the generator can extract them statically.
+ ///
+ IArghRootBuilder DocumentEnvironmentVariables(
+ CliEnvVar[]? variables = null,
+ CliConfigFile[]? configFiles = null);
}
diff --git a/tests/Nullean.Argh.IntegrationTests/Completions/SchemaTests.cs b/tests/Nullean.Argh.IntegrationTests/Completions/SchemaTests.cs
index cba07bd..3bcc68c 100644
--- a/tests/Nullean.Argh.IntegrationTests/Completions/SchemaTests.cs
+++ b/tests/Nullean.Argh.IntegrationTests/Completions/SchemaTests.cs
@@ -239,4 +239,102 @@ 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("requiresAuth").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("--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.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..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;
@@ -37,3 +38,69 @@ 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(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(Intent.Idempotent)]
+ [NoOptionsInjection]
+ public static void SchemaIntentRead(
+ [DryRun] bool dryRun = false) =>
+ Console.Out.WriteLine("list");
+}
+
+/// 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.
+ [NoOptionsInjection]
+ public static void SchemaOutputFormats([CommandOutput] OutputFormat? format = null) =>
+ Console.Out.WriteLine($"format:{format}");
+}