Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
5 changes: 5 additions & 0 deletions src/Main/CLI/Commands/BaseCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ protected BaseCommand(AppConfig config)
this._config = config ?? throw new ArgumentNullException(nameof(config));
}

/// <summary>
/// Gets the injected application configuration.
/// </summary>
protected AppConfig Config => this._config;

/// <summary>
/// Initializes command dependencies: node and formatter.
/// Config is already injected via constructor (no file I/O).
Expand Down
68 changes: 44 additions & 24 deletions src/Main/CLI/Commands/ConfigCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,17 @@ public override async Task<int> 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(),
Expand All @@ -62,6 +64,7 @@ public override async Task<int> ExecuteAsync(
else if (settings.ShowCache)
{
// Show cache configuration
var config = this.Config;
output = new CacheInfoDto
{
EmbeddingsCache = config.EmbeddingsCache != null ? new CacheConfigDto
Expand All @@ -78,30 +81,47 @@ public override async Task<int> 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
}
};
}

Expand Down
166 changes: 166 additions & 0 deletions tests/Main.Tests/Integration/ConfigCommandTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Tests for ConfigCommand behavior.
/// These tests verify that config command shows the entire configuration,
/// not just a single node.
/// </summary>
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<string, NodeConfig>
{
["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);
}
}

/// <summary>
/// Helper class to provide empty remaining arguments for CommandContext.
/// </summary>
private sealed class EmptyRemainingArguments : IRemainingArguments
{
public IReadOnlyList<string> Raw => Array.Empty<string>();
public ILookup<string, string?> Parsed => Enumerable.Empty<string>().ToLookup(x => x, x => (string?)null);
}
}
Loading