diff --git a/AGENTS.md b/AGENTS.md index 54402a2b8..08aef0e4b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,11 +1,16 @@ - Follow @docs/AGENTS.md instructions without exceptions - Ignore the "archived" directory +- For any task with multiple steps, create a todo list FIRST, then execute. +- Avoid destructive operations like "get reset", "git clean", etc. # Definition of done -- `format.sh` is passing without errors or warnings -- `build.sh` is passing without errors or warnings -- `coverage.sh` is passing without errors or warnings, coverage > 80% (use coverage reports to find which code is not covered) +- [ ] instructions in `docs/AGENTS.md` have been followed without exceptions +- [ ] magic values and constants are centralized in `Constants.cs` +- [ ] `build.sh` runs successfully without warnings or errors +- [ ] `format.sh` runs successfully without warnings or errors +- [ ] `coverage.sh` runs successfully without warnings or errors +- [ ] there are zero skipped tests # C# Code Style diff --git a/src/Main/CLI/Commands/BaseCommand.cs b/src/Main/CLI/Commands/BaseCommand.cs index e62012a32..6435c5341 100644 --- a/src/Main/CLI/Commands/BaseCommand.cs +++ b/src/Main/CLI/Commands/BaseCommand.cs @@ -31,6 +31,11 @@ protected BaseCommand(AppConfig config) this._config = config ?? throw new ArgumentNullException(nameof(config)); } + /// + /// Gets the injected application configuration. + /// + protected AppConfig Config => this._config; + /// /// Initializes command dependencies: node and formatter. /// Config is already injected via constructor (no file I/O). diff --git a/src/Main/CLI/Commands/ConfigCommand.cs b/src/Main/CLI/Commands/ConfigCommand.cs index 14706c96f..fb623fb61 100644 --- a/src/Main/CLI/Commands/ConfigCommand.cs +++ b/src/Main/CLI/Commands/ConfigCommand.cs @@ -41,15 +41,17 @@ public override async Task ExecuteAsync( { try { - var (config, node, formatter) = this.Initialize(settings); + // ConfigCommand doesn't need node selection - it queries the entire configuration + // So we skip Initialize() and just use the injected config directly + var formatter = CLI.OutputFormatters.OutputFormatterFactory.Create(settings); // Determine what to show object output; if (settings.ShowNodes) { - // Show all nodes - output = config.Nodes.Select(kvp => new NodeSummaryDto + // Show all nodes summary + output = this.Config.Nodes.Select(kvp => new NodeSummaryDto { Id = kvp.Key, Access = kvp.Value.Access.ToString(), @@ -62,6 +64,7 @@ public override async Task ExecuteAsync( else if (settings.ShowCache) { // Show cache configuration + var config = this.Config; output = new CacheInfoDto { EmbeddingsCache = config.EmbeddingsCache != null ? new CacheConfigDto @@ -78,30 +81,47 @@ public override async Task ExecuteAsync( } else { - // Default: show current node details - output = new NodeDetailsDto + // Default: show entire configuration with all nodes + var config = this.Config; + output = new { - NodeId = node.Id, - Access = node.Access.ToString(), - ContentIndex = new ContentIndexConfigDto - { - Type = node.ContentIndex.Type.ToString(), - Path = node.ContentIndex is KernelMemory.Core.Config.ContentIndex.SqliteContentIndexConfig sqlite - ? sqlite.Path - : null - }, - FileStorage = node.FileStorage != null ? new StorageConfigDto + Nodes = config.Nodes.Select(kvp => new NodeDetailsDto { - Type = node.FileStorage.Type.ToString() - } : null, - RepoStorage = node.RepoStorage != null ? new StorageConfigDto - { - Type = node.RepoStorage.Type.ToString() - } : null, - SearchIndexes = node.SearchIndexes.Select(si => new SearchIndexDto + NodeId = kvp.Key, + Access = kvp.Value.Access.ToString(), + ContentIndex = new ContentIndexConfigDto + { + Type = kvp.Value.ContentIndex.Type.ToString(), + Path = kvp.Value.ContentIndex is KernelMemory.Core.Config.ContentIndex.SqliteContentIndexConfig sqlite + ? sqlite.Path + : null + }, + FileStorage = kvp.Value.FileStorage != null ? new StorageConfigDto + { + Type = kvp.Value.FileStorage.Type.ToString() + } : null, + RepoStorage = kvp.Value.RepoStorage != null ? new StorageConfigDto + { + Type = kvp.Value.RepoStorage.Type.ToString() + } : null, + SearchIndexes = kvp.Value.SearchIndexes.Select(si => new SearchIndexDto + { + Type = si.Type.ToString() + }).ToList() + }).ToList(), + Cache = new CacheInfoDto { - Type = si.Type.ToString() - }).ToList() + EmbeddingsCache = config.EmbeddingsCache != null ? new CacheConfigDto + { + Type = config.EmbeddingsCache.Type.ToString(), + Path = config.EmbeddingsCache.Path + } : null, + LlmCache = config.LLMCache != null ? new CacheConfigDto + { + Type = config.LLMCache.Type.ToString(), + Path = config.LLMCache.Path + } : null + } }; } diff --git a/tests/Main.Tests/Integration/ConfigCommandTests.cs b/tests/Main.Tests/Integration/ConfigCommandTests.cs new file mode 100644 index 000000000..50eead491 --- /dev/null +++ b/tests/Main.Tests/Integration/ConfigCommandTests.cs @@ -0,0 +1,166 @@ +// Copyright (c) Microsoft. All rights reserved. + +using KernelMemory.Core.Config; +using KernelMemory.Main.CLI.Commands; +using Spectre.Console.Cli; +using Xunit; + +namespace KernelMemory.Main.Tests.Integration; + +/// +/// Tests for ConfigCommand behavior. +/// These tests verify that config command shows the entire configuration, +/// not just a single node. +/// +public sealed class ConfigCommandTests : IDisposable +{ + private readonly string _tempDir; + private readonly string _configPath; + + public ConfigCommandTests() + { + this._tempDir = Path.Combine(Path.GetTempPath(), $"km-config-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(this._tempDir); + + this._configPath = Path.Combine(this._tempDir, "config.json"); + + // Create test config with MULTIPLE nodes to verify entire config is shown + var config = new AppConfig + { + Nodes = new Dictionary + { + ["personal"] = NodeConfig.CreateDefaultPersonalNode(Path.Combine(this._tempDir, "nodes", "personal")), + ["work"] = NodeConfig.CreateDefaultPersonalNode(Path.Combine(this._tempDir, "nodes", "work")), + ["shared"] = NodeConfig.CreateDefaultPersonalNode(Path.Combine(this._tempDir, "nodes", "shared")) + } + }; + + var json = System.Text.Json.JsonSerializer.Serialize(config); + File.WriteAllText(this._configPath, json); + } + + public void Dispose() + { + try + { + if (Directory.Exists(this._tempDir)) + { + Directory.Delete(this._tempDir, true); + } + } + catch (IOException) + { + // Ignore cleanup errors + } + catch (UnauthorizedAccessException) + { + // Ignore cleanup errors + } + } + + [Fact] + public void ConfigCommand_WithoutFlags_ShouldShowEntireConfiguration() + { + // This test verifies the bug: km config should show ALL nodes, not just the selected one + + // Arrange + var config = ConfigParser.LoadFromFile(this._configPath); + + var settings = new ConfigCommandSettings + { + ConfigPath = this._configPath, + Format = "json" // Use JSON format for easier assertion + }; + + var command = new ConfigCommand(config); + var context = new CommandContext( + new[] { "--config", this._configPath }, + new EmptyRemainingArguments(), + "config", + null); + + // Capture stdout to verify output + using var outputCapture = new StringWriter(); + var originalOutput = Console.Out; + Console.SetOut(outputCapture); + + try + { + // Act + var exitCode = command.ExecuteAsync(context, settings).GetAwaiter().GetResult(); + + // Assert + Assert.Equal(Constants.ExitCodeSuccess, exitCode); + + var output = outputCapture.ToString(); + + // The output should contain ALL THREE nodes, not just one + Assert.Contains("personal", output); + Assert.Contains("work", output); + Assert.Contains("shared", output); + + // Verify it's showing the entire config structure + // Current bug: it only shows the first node's details + // Expected: it should show all nodes + } + finally + { + Console.SetOut(originalOutput); + } + } + + [Fact] + public void ConfigCommand_WithShowNodesFlag_ShouldShowAllNodesSummary() + { + // Arrange + var config = ConfigParser.LoadFromFile(this._configPath); + + var settings = new ConfigCommandSettings + { + ConfigPath = this._configPath, + Format = "json", + ShowNodes = true + }; + + var command = new ConfigCommand(config); + var context = new CommandContext( + new[] { "--config", this._configPath }, + new EmptyRemainingArguments(), + "config", + null); + + // Capture stdout + using var outputCapture = new StringWriter(); + var originalOutput = Console.Out; + Console.SetOut(outputCapture); + + try + { + // Act + var exitCode = command.ExecuteAsync(context, settings).GetAwaiter().GetResult(); + + // Assert + Assert.Equal(Constants.ExitCodeSuccess, exitCode); + + var output = outputCapture.ToString(); + + // Should show all three nodes + Assert.Contains("personal", output); + Assert.Contains("work", output); + Assert.Contains("shared", output); + } + finally + { + Console.SetOut(originalOutput); + } + } + + /// + /// Helper class to provide empty remaining arguments for CommandContext. + /// + private sealed class EmptyRemainingArguments : IRemainingArguments + { + public IReadOnlyList Raw => Array.Empty(); + public ILookup Parsed => Enumerable.Empty().ToLookup(x => x, x => (string?)null); + } +}