Skip to content
8 changes: 4 additions & 4 deletions src/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.1" />
<PackageVersion Include="System.CommandLine" Version="2.0.1" />
<PackageVersion Include="Xcaciv.Command.Interface" Version="3.3.2" />
<PackageVersion Include="Xcaciv.Command.Core" Version="3.3.2" />
<PackageVersion Include="Xcaciv.Command.FileLoader" Version="3.3.2" />
<PackageVersion Include="Xcaciv.Command" Version="3.3.2" />
<PackageVersion Include="Xcaciv.Command.Interface" Version="3.3.3" />
<PackageVersion Include="Xcaciv.Command.Core" Version="3.3.3" />
<PackageVersion Include="Xcaciv.Command.FileLoader" Version="3.3.3" />
<PackageVersion Include="Xcaciv.Command" Version="3.3.3" />
<PackageVersion Include="YamlDotNet" Version="16.3.0" />
</ItemGroup>
<!-- Unit Test Versions -->
Expand Down
6 changes: 3 additions & 3 deletions src/Xcaciv.Command.Core/Xcaciv.Command.Core.csproj
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>3.3.2</Version>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>3.3.4</Version>
<AssemblyName>Xcaciv.Command.Core</AssemblyName>
<RootNamespace>Xcaciv.Command.Core</RootNamespace>
<IsPublishable>True</IsPublishable>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<PropertyGroup>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>3.3.2</Version>
<Version>3.3.4</Version>
<TargetFrameworks>$(XcacivTargetFrameworks)</TargetFrameworks>
<AssemblyName>Xcaciv.Command.DependencyInjection</AssemblyName>
<RootNamespace>Xcaciv.Command.DependencyInjection</RootNamespace>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<TargetFrameworks>$(XcacivTargetFrameworks)</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>3.3.1</Version>
<Version>3.3.4</Version>
<IsPublishable>True</IsPublishable>
<GeneratePackageOnBuild>True</GeneratePackageOnBuild>
<AssemblyName>Xcaciv.Command.Extensions.Commandline</AssemblyName>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<TargetFrameworks>$(XcacivTargetFrameworks)</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>3.3.2</Version>
<Version>3.3.4</Version>
<AssemblyName>Xcaciv.Command.FileLoader</AssemblyName>
<RootNamespace>Xcaciv.Command.FileLoader</RootNamespace>
<PackageLicenseExpression>AGPL-3.0-only</PackageLicenseExpression>
Expand Down
8 changes: 8 additions & 0 deletions src/Xcaciv.Command.Interface/ICommandController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,5 +58,13 @@ public interface ICommandController
/// <param name="modifiesEnvironment">Indicates whether the command modifies the environment. Set to <see langword="true"/> if the command alters
/// environment variables or state; otherwise, <see langword="false"/>.</param>
void AddCommand(string packageKey, ICommandDelegate command, bool modifiesEnvironment = false);
/// <summary>
/// Gets the current environment context for the controller.
/// </summary>
/// <returns>An instance of <see cref="IControllerEnvironmentContext"/> representing the default environment context given the commands registered.</returns>
/// <remarks> Practically, this method crawls the command stack to build a composite environment context calling GetEnvironment() on each command
/// in the registry.The resulting context reflects the cumulative environment state as influenced by all commands in the registry.
/// </remarks>
IControllerEnvironmentContext GetEnvironment();
}
}
8 changes: 8 additions & 0 deletions src/Xcaciv.Command.Interface/ICommandRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,12 @@ public interface ICommandRegistry
/// Returns all registered commands (root commands only).
/// </summary>
IEnumerable<ICommandDescription> GetAllCommands();

/// <summary>
/// Builds a composite environment context by crawling all registered commands
/// and collecting their default environment values via <see cref="ICommandDelegate.GetDefaultEnvironment"/>.
/// </summary>
/// <param name="commandFactory">Factory used to instantiate commands for retrieving their default environments.</param>
/// <returns>An <see cref="IControllerEnvironmentContext"/> populated with command-scoped defaults.</returns>
IControllerEnvironmentContext GetEnvironment(ICommandFactory commandFactory);
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ public interface IControllerEnvironmentContext : ICommandContext<IControllerEnvi
/// Returns a snapshot of the current environment. Modifications to the returned
/// dictionary do not affect the environment context; use SetValue() to modify variables.
/// </remarks>
Dictionary<string, string> GetEnvironment(string commandName);
Dictionary<string, string> GetEnvironment(string commandName, bool prefix = true);

/// <summary>
/// Indicates whether this context has modified any environment variables.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<LangVersion>14</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>3.3.2</Version>
<Version>3.3.4</Version>
<IsPublishable>True</IsPublishable>
<AssemblyName>Xcaciv.Command.Interface</AssemblyName>
<RootNamespace>Xcaciv.Command.Interface</RootNamespace>
Expand Down
27 changes: 23 additions & 4 deletions src/Xcaciv.Command/CommandController.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.IO.Abstractions;
using System.Threading.Tasks;
using Xcaciv.Command.Commands;
Expand Down Expand Up @@ -252,8 +253,8 @@ public async Task Run(string commandText, IIoContext ioContext, IControllerEnvir

if (controllerEnvironmentChild.HasChanged && _commandRegistry.TryGetCommand(lastCommandName, out var commandDesc) && commandDesc?.ModifiesEnvironment == true)
{
env.UpdateEnvironment(controllerEnvironmentChild.GetEnvironment());
env.UpdateEnvironment(controllerEnvironmentChild.GetEnvironment(lastCommandName), lastCommandName);
var modifiedEnv = controllerEnvironmentChild.GetEnvironment(lastCommandName, false);
env.UpdateEnvironment(modifiedEnv);
}
}
else
Expand All @@ -264,9 +265,18 @@ public async Task Run(string commandText, IIoContext ioContext, IControllerEnvir

var childEnv = await env.GetChild(commandName).ConfigureAwait(false);
await ExecuteCommandInternal(commandName, ioContext, childEnv, cancellationToken).ConfigureAwait(false);
if (childEnv.HasChanged && _commandRegistry.TryGetCommand(commandName, out var commandDesc) && commandDesc?.ModifiesEnvironment == true)
if (childEnv.HasChanged && _commandRegistry.TryGetCommand(commandName, out var commandDesc))
{
env.UpdateEnvironment(childEnv.GetEnvironment(), commandName);
// if this is a special command, it can update global values
if (commandDesc?.ModifiesEnvironment == true)
{
env.UpdateEnvironment(childEnv.GetEnvironment());
}
else
{
// only allow commands to update their own environment values
env.UpdateEnvironment(childEnv.GetEnvironment(), commandName);
}
}
}
}
Expand Down Expand Up @@ -304,4 +314,13 @@ private Task HandleHelpRequest(string commandKey, IIoContext ioContext, IEnviron

return _commandExecutor.GetHelpAsync(targetCommand, ioContext, env, cancellationToken);
}

/// <summary>
/// Gets the current environment context for the controller.
/// </summary>
/// <returns>An instance of <see cref="IControllerEnvironmentContext"/> representing the default environment context given the commands registered.</returns>
public IControllerEnvironmentContext GetEnvironment()
{
return _commandRegistry.GetEnvironment(_commandFactory);
}
}
55 changes: 55 additions & 0 deletions src/Xcaciv.Command/CommandRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,59 @@ public IEnumerable<ICommandDescription> GetAllCommands()
{
return new List<ICommandDescription>(_commands.Values);
}

/// <inheritdoc />
public IControllerEnvironmentContext GetEnvironment(ICommandFactory commandFactory)
{
ArgumentNullException.ThrowIfNull(commandFactory);

var controllerEnvironment = new ControllerEnvironmentContext();
foreach (var commandDescription in GetAllCommands())
{
ApplyDefaultEnvironment(controllerEnvironment, commandDescription, commandFactory);
}

return controllerEnvironment;
}

private static void ApplyDefaultEnvironment(IControllerEnvironmentContext controllerEnvironment, ICommandDescription commandDescription, ICommandFactory commandFactory, int depth = 0)
{
depth++;

if (!string.IsNullOrWhiteSpace(commandDescription.FullTypeName))
{
AddCommandDefaults(controllerEnvironment, commandDescription, commandFactory);
}

if (commandDescription.SubCommands.Count == 0)
{
return;
}

if (depth > 1) return;
foreach (var subCommand in commandDescription.SubCommands.Values)
{
AddCommandDefaults(controllerEnvironment, subCommand, commandFactory);
}
}
Comment on lines 101 to 117
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ApplyDefaultEnvironment increments a depth parameter and then immediately blocks traversal when depth > 1, but the method never recurses (it only iterates direct SubCommands). This makes the depth logic misleading/dead code; either remove it or implement true recursive traversal if deeper nesting is expected.

Copilot uses AI. Check for mistakes.

private static void AddCommandDefaults(IControllerEnvironmentContext controllerEnvironment, ICommandDescription commandDescription, ICommandFactory commandFactory)
{
var commandInstance = commandFactory.CreateCommand(commandDescription.FullTypeName, commandDescription.PackageDescription.FullPath);
try
{
var defaultEnvironment = commandInstance.GetDefaultEnvironment();
if (defaultEnvironment.Count == 0)
{
return;
}

var commandName = NamesValidator.GetValidCommandName(commandDescription.BaseCommand);
controllerEnvironment.UpdateEnvironment(defaultEnvironment, commandName);
}
finally
{
commandInstance.DisposeAsync().AsTask().GetAwaiter().GetResult();
}
}
}
52 changes: 22 additions & 30 deletions src/Xcaciv.Command/ControllerEnvironmentContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -149,17 +149,28 @@ public Dictionary<string, string> GetEnvironment()
/// environment variables are returned.</param>
/// <returns>A dictionary containing the environment variables for the specified command name. Returns an empty
/// dictionary if the command name does not exist in the command environment.</returns>
public Dictionary<string, string> GetEnvironment(string commandName)
public Dictionary<string, string> GetEnvironment(string commandName, bool prefix = true)
{
if (String.IsNullOrEmpty(commandName))
{
return this._environment.GetEnvironment();
}
else
{
return _commandEnvironment.TryGetValue(commandName, out var commandEnv)
? new Dictionary<string, string>(commandEnv)
: new Dictionary<string, string>();
if (_commandEnvironment.TryGetValue(commandName, out var commandEnv))
{
if (!prefix) return commandEnv.ToDictionary();
// Add command prefix to keys when retrieving
var commandPrefix = string.Concat(commandName, "_");
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var (key, value) in commandEnv)
{
var prefixedKey = key.StartsWith(commandPrefix, StringComparison.OrdinalIgnoreCase) ? key : commandPrefix + key;
result[prefixedKey] = value;
}
return result;
}
return new Dictionary<string, string>();
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetEnvironment(commandName, prefix: false) returns commandEnv.ToDictionary(), which creates a case-sensitive dictionary. This breaks the environment context’s case-insensitive contract and can cause lookups to behave differently depending on the prefix flag. Return a Dictionary<string,string> using StringComparer.OrdinalIgnoreCase (and do the same for the empty/not-found return path).

Suggested change
if (!prefix) return commandEnv.ToDictionary();
// Add command prefix to keys when retrieving
var commandPrefix = string.Concat(commandName, "_");
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var (key, value) in commandEnv)
{
var prefixedKey = key.StartsWith(commandPrefix, StringComparison.OrdinalIgnoreCase) ? key : commandPrefix + key;
result[prefixedKey] = value;
}
return result;
}
return new Dictionary<string, string>();
if (!prefix)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var environmentEntry in commandEnv)
{
result[environmentEntry.Key] = environmentEntry.Value;
}
return result;
}
// Add command prefix to keys when retrieving
var commandPrefix = string.Concat(commandName, "_");
var resultWithPrefix = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var (key, value) in commandEnv)
{
var prefixedKey = key.StartsWith(commandPrefix, StringComparison.OrdinalIgnoreCase) ? key : commandPrefix + key;
resultWithPrefix[prefixedKey] = value;
}
return resultWithPrefix;
}
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);

Copilot uses AI. Check for mistakes.
}
}
/// <summary>
Expand Down Expand Up @@ -232,39 +243,20 @@ public void UpdateEnvironment(Dictionary<string, string> dictionary, string comm
return;
}

Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The XML remarks for UpdateEnvironment(Dictionary<string,string>, string commandName) still describe splitting updates into command-specific (prefixed) vs shared/global, but the implementation now stores all keys in the command-specific dictionary and never updates _environment. Please update the documentation to match the new behavior, or restore the prior behavior if shared/global updates are still intended.

Copilot uses AI. Check for mistakes.
var commandPrefix = string.Concat(commandName, "_");
var sharedEnvironmentUpdates = new Dictionary<string, string>();
var commandEnvironment = _commandEnvironment.GetOrAdd(commandName, new ConcurrentDictionary<string, string>(StringComparer.OrdinalIgnoreCase));
var commandEnvironmentUpdated = false;

foreach ((var key, var addValue) in dictionary)
{
if (key.StartsWith(commandPrefix, StringComparison.OrdinalIgnoreCase))
{
string? oldValue = null;
commandEnvironment.AddOrUpdate(key, addValue, (environmentKey, existingValue) =>
{
oldValue = existingValue;
Trace.WriteLine($"CommandEnvironment [{commandName}] value {environmentKey} changed from {existingValue} to {addValue}.");
return addValue;
});
commandEnvironmentUpdated = true;
}
else
string? oldValue = null;
commandEnvironment.AddOrUpdate(key, addValue, (environmentKey, existingValue) =>
{
sharedEnvironmentUpdates[key] = addValue;
}
}

if (sharedEnvironmentUpdates.Count > 0)
{
_environment.UpdateEnvironment(sharedEnvironmentUpdates);
oldValue = existingValue;
Trace.WriteLine($"CommandEnvironment [{commandName}] value {environmentKey} changed from {existingValue} to {addValue}.");
return addValue;
});
}

if (commandEnvironmentUpdated)
{
HasChanged = true;
}
HasChanged = dictionary.Count > 0;
}

/// <summary>
Expand Down
1 change: 0 additions & 1 deletion src/Xcaciv.Command/PipelineExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ public PipelineExecutor()
{
}


public async Task ExecuteAsync(
string commandLine,
IIoContext ioContext,
Expand Down
2 changes: 1 addition & 1 deletion src/Xcaciv.Command/Xcaciv.Command.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<PropertyGroup>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>3.3.2</Version>
<Version>3.3.4</Version>
<AssemblyName>Xcaciv.Command</AssemblyName>
<RootNamespace>Xcaciv.Command</RootNamespace>
<IsPublishable>True</IsPublishable>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -657,7 +657,7 @@ public Dictionary<string, string> GetEnvironment()
return new Dictionary<string, string>(_globalEnvironment);
}

public Dictionary<string, string> GetEnvironment(string commandName)
public Dictionary<string, string> GetEnvironment(string commandName, bool prefix = true)
{
return _commandEnvironments.TryGetValue(commandName, out var env)
? new Dictionary<string, string>(env)
Expand Down
Loading
Loading