diff --git a/AGENTS.md b/AGENTS.md
index fd380a65f..54402a2b8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,19 +1,17 @@
-see @docs/AGENTS.md
-
-Ignore the "archived" directory.
+- Follow @docs/AGENTS.md instructions without exceptions
+- Ignore the "archived" directory
# Definition of done
- `format.sh` is passing without errors or warnings
- `build.sh` is passing without errors or warnings
-- Test coverage is greater than 80% and `coverage.sh` is not failing
-- Problems are not hidden, problems are addressed.
-- Report breaking changes and ask how to handle them, do not assume migrations are required.
+- `coverage.sh` is passing without errors or warnings, coverage > 80% (use coverage reports to find which code is not covered)
# C# Code Style
- Use .NET 10 and C# 14
- Always use `this.` prefix
+- Async methods have mandatory `Async` name suffix (optional only for tests, not required for `Main` method)
- Keep magic values and constants in a centralized `Constants.cs` file
- One class per file, matching the class name with the file name
- Sort class methods by visibility: public first, private at the end
diff --git a/coverage.sh b/coverage.sh
index ab879e3f9..5e4df238f 100755
--- a/coverage.sh
+++ b/coverage.sh
@@ -12,6 +12,9 @@ MIN_COVERAGE=${1:-80}
echo "Running tests with coverage collection..."
echo ""
+# Clean previous results
+rm -rf ./TestResults
+
# Run tests with coverage using coverlet.collector
# --collect:"XPlat Code Coverage" enables the collector
# --results-directory specifies output location
@@ -23,39 +26,87 @@ echo ""
echo "Coverage collection complete!"
echo ""
-# Find the most recent coverage file (coverlet.collector creates it in a GUID subfolder)
-COVERAGE_REPORT=$(find ./TestResults -name "coverage.cobertura.xml" | head -1)
+# Find all coverage files
+COVERAGE_FILES=$(find ./TestResults -name "coverage.cobertura.xml" | sort)
+FILE_COUNT=$(echo "$COVERAGE_FILES" | wc -l | tr -d ' ')
-echo "Coverage report location: $COVERAGE_REPORT"
+echo "Found $FILE_COUNT coverage report(s)"
echo ""
-if [ -f "$COVERAGE_REPORT" ]; then
- # Parse line coverage from cobertura XML
- LINE_RATE=$(grep -o 'line-rate="[0-9.]*"' "$COVERAGE_REPORT" | head -1 | grep -o '[0-9.]*')
-
- if [ -n "$LINE_RATE" ]; then
- # Convert to percentage
- COVERAGE_PCT=$(awk "BEGIN {printf \"%.2f\", $LINE_RATE * 100}")
-
- echo "====================================="
- echo " Test Coverage: ${COVERAGE_PCT}%"
- echo " Threshold: ${MIN_COVERAGE}%"
- echo "====================================="
- echo ""
-
- # Check if coverage meets threshold
- MEETS_THRESHOLD=$(awk "BEGIN {print ($COVERAGE_PCT >= $MIN_COVERAGE) ? 1 : 0}")
+# Parse and display coverage for each project
+declare -a COVERAGE_RATES
+declare -a COVERAGE_SOURCES
+declare -a PROJECT_NAMES
+
+while IFS= read -r COVERAGE_FILE; do
+ if [ -f "$COVERAGE_FILE" ]; then
+ # Extract source path and line rate
+ SOURCE=$(grep -o '.*' "$COVERAGE_FILE" | head -1 | sed 's///;s/<\/source>//')
+ LINE_RATE=$(grep -o 'line-rate="[0-9.]*"' "$COVERAGE_FILE" | head -1 | grep -o '[0-9.]*')
- if [ "$MEETS_THRESHOLD" -eq 0 ]; then
- echo "❌ Coverage ${COVERAGE_PCT}% is below minimum threshold of ${MIN_COVERAGE}%"
- exit 1
- else
- echo "✅ Coverage meets minimum threshold"
- rm -rf TestResults
+ if [ -n "$LINE_RATE" ]; then
+ COVERAGE_PCT=$(awk "BEGIN {printf \"%.2f\", $LINE_RATE * 100}")
+
+ # Determine project name from source path
+ if [[ "$SOURCE" == *"/Core/"* ]] || [[ "$SOURCE" == *"/Core" ]]; then
+ PROJECT_NAME="Core"
+ # Only track Core and Main for threshold checking (exclude Combined)
+ COVERAGE_RATES+=("$COVERAGE_PCT")
+ COVERAGE_SOURCES+=("$SOURCE")
+ PROJECT_NAMES+=("$PROJECT_NAME")
+ elif [[ "$SOURCE" == *"/Main/"* ]] || [[ "$SOURCE" == *"/Main" ]]; then
+ PROJECT_NAME="Main"
+ # Only track Core and Main for threshold checking (exclude Combined)
+ COVERAGE_RATES+=("$COVERAGE_PCT")
+ COVERAGE_SOURCES+=("$SOURCE")
+ PROJECT_NAMES+=("$PROJECT_NAME")
+ else
+ PROJECT_NAME="Combined (includes untestable entry points)"
+ # Skip adding to COVERAGE_RATES - we don't check threshold for Combined
+ fi
+
+ echo " $PROJECT_NAME: ${COVERAGE_PCT}%"
fi
+ fi
+done <<< "$COVERAGE_FILES"
+
+echo ""
+
+# Calculate minimum coverage across Core and Main assemblies only
+# (Combined report is excluded as it includes untestable entry points like Program.Main)
+MIN_PROJECT_COVERAGE=""
+MIN_PROJECT_NAME=""
+for i in "${!COVERAGE_RATES[@]}"; do
+ rate="${COVERAGE_RATES[$i]}"
+ if [ -z "$MIN_PROJECT_COVERAGE" ] || (( $(awk "BEGIN {print ($rate < $MIN_PROJECT_COVERAGE) ? 1 : 0}") )); then
+ MIN_PROJECT_COVERAGE="$rate"
+ MIN_PROJECT_NAME="${PROJECT_NAMES[$i]}"
+ fi
+done
+
+if [ -n "$MIN_PROJECT_COVERAGE" ]; then
+ echo "====================================="
+ echo " Minimum Coverage: ${MIN_PROJECT_COVERAGE}%"
+ echo " Threshold: ${MIN_COVERAGE}%"
+ echo "====================================="
+ echo ""
+
+ # Check if coverage meets threshold
+ MEETS_THRESHOLD=$(awk "BEGIN {print ($MIN_PROJECT_COVERAGE >= $MIN_COVERAGE) ? 1 : 0}")
+
+ if [ "$MEETS_THRESHOLD" -eq 0 ]; then
+ echo "❌ Coverage ${MIN_PROJECT_COVERAGE}% is below minimum threshold of ${MIN_COVERAGE}%"
+ echo ""
+ echo "Coverage by project:"
+ for i in "${!COVERAGE_RATES[@]}"; do
+ echo " - ${COVERAGE_SOURCES[$i]}: ${COVERAGE_RATES[$i]}%"
+ done
+ exit 1
else
- echo "⚠️ Could not parse coverage percentage from report"
+ echo "✅ All projects meet minimum threshold"
+ rm -rf TestResults
fi
else
- echo "⚠️ Coverage report not found at: $COVERAGE_REPORT"
+ echo "⚠️ Could not parse coverage from reports"
+ exit 1
fi
diff --git a/km.sh b/km.sh
new file mode 100755
index 000000000..6c068c184
--- /dev/null
+++ b/km.sh
@@ -0,0 +1,2 @@
+#!/bin/bash
+dotnet run --project src/Main/Main.csproj -- "$@"
diff --git a/mem.slnx b/mem.slnx
index 9f0b5fc0f..9c191f621 100644
--- a/mem.slnx
+++ b/mem.slnx
@@ -1,7 +1,8 @@
-
+
-
+
+
-
-
+
+
\ No newline at end of file
diff --git a/src/.editorconfig b/src/.editorconfig
index 96fcc6217..7932f011a 100644
--- a/src/.editorconfig
+++ b/src/.editorconfig
@@ -32,6 +32,7 @@ file_header_template = Copyright (c) Microsoft. All rights reserved.
# .NET Coding Conventions #
###############################
[*.{cs,vb}]
+file_header_template = Copyright (c) Microsoft. All rights reserved.
# Organize usings
dotnet_sort_system_directives_first = true
# this. preferences
@@ -394,6 +395,8 @@ resharper_Arrange_Type_Modifiers_highlighting = none
resharper_Arrange_Type_Member_Modifiers_highlighting = none
# InconsistentNaming
resharper_Inconsistent_Naming_highlighting = none
+# GrammarMistakeInComment
+resharper_Grammar_Mistake_In_Comment_highlighting = none
# CA1056: URI properties should not be strings
diff --git a/src/Core/Config/AppConfig.cs b/src/Core/Config/AppConfig.cs
index a2098b9b2..36999a53d 100644
--- a/src/Core/Config/AppConfig.cs
+++ b/src/Core/Config/AppConfig.cs
@@ -1,3 +1,4 @@
+// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using KernelMemory.Core.Config.Cache;
using KernelMemory.Core.Config.Validation;
diff --git a/src/Core/Config/Cache/CacheConfig.cs b/src/Core/Config/Cache/CacheConfig.cs
index a31305b6c..bff3606ec 100644
--- a/src/Core/Config/Cache/CacheConfig.cs
+++ b/src/Core/Config/Cache/CacheConfig.cs
@@ -1,3 +1,4 @@
+// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using KernelMemory.Core.Config.Enums;
using KernelMemory.Core.Config.Validation;
diff --git a/src/Core/Config/ConfigParser.cs b/src/Core/Config/ConfigParser.cs
index 7db74bc76..c04cfa5bb 100644
--- a/src/Core/Config/ConfigParser.cs
+++ b/src/Core/Config/ConfigParser.cs
@@ -1,3 +1,4 @@
+// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using KernelMemory.Core.Config.Cache;
using KernelMemory.Core.Config.ContentIndex;
diff --git a/src/Core/Config/ContentIndex/ContentIndexConfig.cs b/src/Core/Config/ContentIndex/ContentIndexConfig.cs
index 10b6df46e..e36631f61 100644
--- a/src/Core/Config/ContentIndex/ContentIndexConfig.cs
+++ b/src/Core/Config/ContentIndex/ContentIndexConfig.cs
@@ -1,3 +1,4 @@
+// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using KernelMemory.Core.Config.Enums;
using KernelMemory.Core.Config.Validation;
diff --git a/src/Core/Config/ContentIndex/PostgresContentIndexConfig.cs b/src/Core/Config/ContentIndex/PostgresContentIndexConfig.cs
index 811703b0c..dff53be5d 100644
--- a/src/Core/Config/ContentIndex/PostgresContentIndexConfig.cs
+++ b/src/Core/Config/ContentIndex/PostgresContentIndexConfig.cs
@@ -1,3 +1,4 @@
+// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using KernelMemory.Core.Config.Enums;
using KernelMemory.Core.Config.Validation;
diff --git a/src/Core/Config/ContentIndex/SqliteContentIndexConfig.cs b/src/Core/Config/ContentIndex/SqliteContentIndexConfig.cs
index 90e9d1831..a61249b24 100644
--- a/src/Core/Config/ContentIndex/SqliteContentIndexConfig.cs
+++ b/src/Core/Config/ContentIndex/SqliteContentIndexConfig.cs
@@ -1,3 +1,4 @@
+// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using KernelMemory.Core.Config.Enums;
using KernelMemory.Core.Config.Validation;
diff --git a/src/Core/Config/Embeddings/AzureOpenAIEmbeddingsConfig.cs b/src/Core/Config/Embeddings/AzureOpenAIEmbeddingsConfig.cs
index 5bf10ec3d..e2cfc5475 100644
--- a/src/Core/Config/Embeddings/AzureOpenAIEmbeddingsConfig.cs
+++ b/src/Core/Config/Embeddings/AzureOpenAIEmbeddingsConfig.cs
@@ -1,3 +1,4 @@
+// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using KernelMemory.Core.Config.Enums;
using KernelMemory.Core.Config.Validation;
diff --git a/src/Core/Config/Embeddings/EmbeddingsConfig.cs b/src/Core/Config/Embeddings/EmbeddingsConfig.cs
index 88f205252..780e136c3 100644
--- a/src/Core/Config/Embeddings/EmbeddingsConfig.cs
+++ b/src/Core/Config/Embeddings/EmbeddingsConfig.cs
@@ -1,3 +1,4 @@
+// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using KernelMemory.Core.Config.Enums;
using KernelMemory.Core.Config.Validation;
diff --git a/src/Core/Config/Embeddings/OllamaEmbeddingsConfig.cs b/src/Core/Config/Embeddings/OllamaEmbeddingsConfig.cs
index 2fcb36dad..e938b73e9 100644
--- a/src/Core/Config/Embeddings/OllamaEmbeddingsConfig.cs
+++ b/src/Core/Config/Embeddings/OllamaEmbeddingsConfig.cs
@@ -1,3 +1,4 @@
+// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using KernelMemory.Core.Config.Enums;
using KernelMemory.Core.Config.Validation;
diff --git a/src/Core/Config/Embeddings/OpenAIEmbeddingsConfig.cs b/src/Core/Config/Embeddings/OpenAIEmbeddingsConfig.cs
index 9f73966fc..fa70cbe3d 100644
--- a/src/Core/Config/Embeddings/OpenAIEmbeddingsConfig.cs
+++ b/src/Core/Config/Embeddings/OpenAIEmbeddingsConfig.cs
@@ -1,3 +1,4 @@
+// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using KernelMemory.Core.Config.Enums;
using KernelMemory.Core.Config.Validation;
diff --git a/src/Core/Config/Enums/CacheTypes.cs b/src/Core/Config/Enums/CacheTypes.cs
index 501b51049..21fd0d4b4 100644
--- a/src/Core/Config/Enums/CacheTypes.cs
+++ b/src/Core/Config/Enums/CacheTypes.cs
@@ -1,3 +1,4 @@
+// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace KernelMemory.Core.Config.Enums;
diff --git a/src/Core/Config/Enums/ContentIndexTypes.cs b/src/Core/Config/Enums/ContentIndexTypes.cs
index 4780f84af..4309c06ec 100644
--- a/src/Core/Config/Enums/ContentIndexTypes.cs
+++ b/src/Core/Config/Enums/ContentIndexTypes.cs
@@ -1,3 +1,4 @@
+// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace KernelMemory.Core.Config.Enums;
diff --git a/src/Core/Config/Enums/EmbeddingsTypes.cs b/src/Core/Config/Enums/EmbeddingsTypes.cs
index 5421d6313..77d93c471 100644
--- a/src/Core/Config/Enums/EmbeddingsTypes.cs
+++ b/src/Core/Config/Enums/EmbeddingsTypes.cs
@@ -1,3 +1,4 @@
+// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace KernelMemory.Core.Config.Enums;
diff --git a/src/Core/Config/Enums/NodeAccessLevels.cs b/src/Core/Config/Enums/NodeAccessLevels.cs
index ba0183c1d..bae001d6e 100644
--- a/src/Core/Config/Enums/NodeAccessLevels.cs
+++ b/src/Core/Config/Enums/NodeAccessLevels.cs
@@ -1,3 +1,4 @@
+// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace KernelMemory.Core.Config.Enums;
diff --git a/src/Core/Config/Enums/SearchIndexTypes.cs b/src/Core/Config/Enums/SearchIndexTypes.cs
index abfaf9ca1..1174f3f4d 100644
--- a/src/Core/Config/Enums/SearchIndexTypes.cs
+++ b/src/Core/Config/Enums/SearchIndexTypes.cs
@@ -1,3 +1,4 @@
+// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace KernelMemory.Core.Config.Enums;
diff --git a/src/Core/Config/Enums/StorageTypes.cs b/src/Core/Config/Enums/StorageTypes.cs
index d32177b17..73001f707 100644
--- a/src/Core/Config/Enums/StorageTypes.cs
+++ b/src/Core/Config/Enums/StorageTypes.cs
@@ -1,3 +1,4 @@
+// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace KernelMemory.Core.Config.Enums;
diff --git a/src/Core/Config/Enums/VectorMetrics.cs b/src/Core/Config/Enums/VectorMetrics.cs
index 5b84c00fb..9ca49d362 100644
--- a/src/Core/Config/Enums/VectorMetrics.cs
+++ b/src/Core/Config/Enums/VectorMetrics.cs
@@ -1,3 +1,4 @@
+// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace KernelMemory.Core.Config.Enums;
diff --git a/src/Core/Config/NodeConfig.cs b/src/Core/Config/NodeConfig.cs
index 53497eaed..69a25caf2 100644
--- a/src/Core/Config/NodeConfig.cs
+++ b/src/Core/Config/NodeConfig.cs
@@ -1,3 +1,4 @@
+// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using KernelMemory.Core.Config.ContentIndex;
using KernelMemory.Core.Config.Enums;
diff --git a/src/Core/Config/SearchIndex/FtsSearchIndexConfig.cs b/src/Core/Config/SearchIndex/FtsSearchIndexConfig.cs
index 95b10b7f0..5844c3675 100644
--- a/src/Core/Config/SearchIndex/FtsSearchIndexConfig.cs
+++ b/src/Core/Config/SearchIndex/FtsSearchIndexConfig.cs
@@ -1,3 +1,4 @@
+// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using KernelMemory.Core.Config.Enums;
using KernelMemory.Core.Config.Validation;
diff --git a/src/Core/Config/SearchIndex/GraphSearchIndexConfig.cs b/src/Core/Config/SearchIndex/GraphSearchIndexConfig.cs
index 272788cfb..097c00026 100644
--- a/src/Core/Config/SearchIndex/GraphSearchIndexConfig.cs
+++ b/src/Core/Config/SearchIndex/GraphSearchIndexConfig.cs
@@ -1,3 +1,4 @@
+// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using KernelMemory.Core.Config.Validation;
diff --git a/src/Core/Config/SearchIndex/SearchIndexConfig.cs b/src/Core/Config/SearchIndex/SearchIndexConfig.cs
index a939116e3..57414b039 100644
--- a/src/Core/Config/SearchIndex/SearchIndexConfig.cs
+++ b/src/Core/Config/SearchIndex/SearchIndexConfig.cs
@@ -1,3 +1,4 @@
+// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using KernelMemory.Core.Config.Embeddings;
using KernelMemory.Core.Config.Enums;
diff --git a/src/Core/Config/SearchIndex/VectorSearchIndexConfig.cs b/src/Core/Config/SearchIndex/VectorSearchIndexConfig.cs
index bd6f23baf..04eb88dad 100644
--- a/src/Core/Config/SearchIndex/VectorSearchIndexConfig.cs
+++ b/src/Core/Config/SearchIndex/VectorSearchIndexConfig.cs
@@ -1,3 +1,4 @@
+// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using KernelMemory.Core.Config.Enums;
using KernelMemory.Core.Config.Validation;
diff --git a/src/Core/Config/Storage/AzureBlobStorageConfig.cs b/src/Core/Config/Storage/AzureBlobStorageConfig.cs
index e02590fd8..13f39485a 100644
--- a/src/Core/Config/Storage/AzureBlobStorageConfig.cs
+++ b/src/Core/Config/Storage/AzureBlobStorageConfig.cs
@@ -1,3 +1,4 @@
+// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using KernelMemory.Core.Config.Enums;
using KernelMemory.Core.Config.Validation;
diff --git a/src/Core/Config/Storage/DiskStorageConfig.cs b/src/Core/Config/Storage/DiskStorageConfig.cs
index d4e7a900f..2dd172ada 100644
--- a/src/Core/Config/Storage/DiskStorageConfig.cs
+++ b/src/Core/Config/Storage/DiskStorageConfig.cs
@@ -1,3 +1,4 @@
+// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using KernelMemory.Core.Config.Enums;
using KernelMemory.Core.Config.Validation;
diff --git a/src/Core/Config/Storage/StorageConfig.cs b/src/Core/Config/Storage/StorageConfig.cs
index 69b5ac9e9..4135a59ef 100644
--- a/src/Core/Config/Storage/StorageConfig.cs
+++ b/src/Core/Config/Storage/StorageConfig.cs
@@ -1,3 +1,4 @@
+// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using KernelMemory.Core.Config.Enums;
using KernelMemory.Core.Config.Validation;
diff --git a/src/Core/Config/Validation/ConfigException.cs b/src/Core/Config/Validation/ConfigException.cs
index 8c4ccbb6c..1eefc7469 100644
--- a/src/Core/Config/Validation/ConfigException.cs
+++ b/src/Core/Config/Validation/ConfigException.cs
@@ -1,3 +1,4 @@
+// Copyright (c) Microsoft. All rights reserved.
namespace KernelMemory.Core.Config.Validation;
///
diff --git a/src/Core/Config/Validation/IValidatable.cs b/src/Core/Config/Validation/IValidatable.cs
index 55ea06d87..ab05b2367 100644
--- a/src/Core/Config/Validation/IValidatable.cs
+++ b/src/Core/Config/Validation/IValidatable.cs
@@ -1,3 +1,4 @@
+// Copyright (c) Microsoft. All rights reserved.
namespace KernelMemory.Core.Config.Validation;
///
diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj
index 2b8e87b72..23a8286a0 100644
--- a/src/Core/Core.csproj
+++ b/src/Core/Core.csproj
@@ -11,4 +11,9 @@
+
+
+
+
+
diff --git a/src/Core/Storage/ContentStorageDbContext.cs b/src/Core/Storage/ContentStorageDbContext.cs
index f3e031a9e..39a8974b1 100644
--- a/src/Core/Storage/ContentStorageDbContext.cs
+++ b/src/Core/Storage/ContentStorageDbContext.cs
@@ -1,3 +1,4 @@
+// Copyright (c) Microsoft. All rights reserved.
using System.Globalization;
using KernelMemory.Core.Storage.Entities;
using Microsoft.EntityFrameworkCore;
diff --git a/src/Core/Storage/ContentStorageService.cs b/src/Core/Storage/ContentStorageService.cs
index 8141d7c7d..1ae249aa9 100644
--- a/src/Core/Storage/ContentStorageService.cs
+++ b/src/Core/Storage/ContentStorageService.cs
@@ -1,3 +1,4 @@
+// Copyright (c) Microsoft. All rights reserved.
using System.Text;
using System.Text.Json;
using KernelMemory.Core.Storage.Entities;
@@ -153,6 +154,38 @@ public async Task CountAsync(CancellationToken cancellationToken = default
return await this._context.Content.LongCountAsync(cancellationToken).ConfigureAwait(false);
}
+ ///
+ /// Lists content records with pagination support.
+ ///
+ /// Number of records to skip.
+ /// Number of records to take.
+ /// Cancellation token.
+ /// List of content DTOs.
+ public async Task> ListAsync(int skip, int take, CancellationToken cancellationToken = default)
+ {
+ var records = await this._context.Content
+ .AsNoTracking()
+ .OrderByDescending(c => c.RecordCreatedAt)
+ .Skip(skip)
+ .Take(take)
+ .ToListAsync(cancellationToken).ConfigureAwait(false);
+
+ return records.Select(record => new ContentDto
+ {
+ Id = record.Id,
+ Content = record.Content,
+ MimeType = record.MimeType,
+ ByteSize = record.ByteSize,
+ ContentCreatedAt = record.ContentCreatedAt,
+ RecordCreatedAt = record.RecordCreatedAt,
+ RecordUpdatedAt = record.RecordUpdatedAt,
+ Title = record.Title,
+ Description = record.Description,
+ Tags = record.Tags,
+ Metadata = record.Metadata
+ }).ToList();
+ }
+
// ========== Phase 1: Queue Operations (REQUIRED) ==========
///
diff --git a/src/Core/Storage/CuidGenerator.cs b/src/Core/Storage/CuidGenerator.cs
index 4ab894399..90df7d79a 100644
--- a/src/Core/Storage/CuidGenerator.cs
+++ b/src/Core/Storage/CuidGenerator.cs
@@ -1,3 +1,4 @@
+// Copyright (c) Microsoft. All rights reserved.
using Visus.Cuid;
namespace KernelMemory.Core.Storage;
diff --git a/src/Core/Storage/Entities/ContentRecord.cs b/src/Core/Storage/Entities/ContentRecord.cs
index 3fcb0de0b..f32c99d66 100644
--- a/src/Core/Storage/Entities/ContentRecord.cs
+++ b/src/Core/Storage/Entities/ContentRecord.cs
@@ -1,3 +1,4 @@
+// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
namespace KernelMemory.Core.Storage.Entities;
diff --git a/src/Core/Storage/Entities/OperationRecord.cs b/src/Core/Storage/Entities/OperationRecord.cs
index 7de369916..fe997f3a4 100644
--- a/src/Core/Storage/Entities/OperationRecord.cs
+++ b/src/Core/Storage/Entities/OperationRecord.cs
@@ -1,3 +1,4 @@
+// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
namespace KernelMemory.Core.Storage.Entities;
diff --git a/src/Core/Storage/Exceptions/ContentStorageException.cs b/src/Core/Storage/Exceptions/ContentStorageException.cs
index 4b691da10..00466b58b 100644
--- a/src/Core/Storage/Exceptions/ContentStorageException.cs
+++ b/src/Core/Storage/Exceptions/ContentStorageException.cs
@@ -1,3 +1,4 @@
+// Copyright (c) Microsoft. All rights reserved.
namespace KernelMemory.Core.Storage.Exceptions;
///
diff --git a/src/Core/Storage/IContentStorage.cs b/src/Core/Storage/IContentStorage.cs
index 919f5a1f5..1bbce7e1f 100644
--- a/src/Core/Storage/IContentStorage.cs
+++ b/src/Core/Storage/IContentStorage.cs
@@ -1,3 +1,4 @@
+// Copyright (c) Microsoft. All rights reserved.
using KernelMemory.Core.Storage.Exceptions;
using KernelMemory.Core.Storage.Models;
@@ -43,4 +44,13 @@ public interface IContentStorage
/// Cancellation token.
/// Total count of content records.
Task CountAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Lists content records with pagination support.
+ ///
+ /// Number of records to skip.
+ /// Number of records to take.
+ /// Cancellation token.
+ /// List of content DTOs.
+ Task> ListAsync(int skip, int take, CancellationToken cancellationToken = default);
}
diff --git a/src/Core/Storage/ICuidGenerator.cs b/src/Core/Storage/ICuidGenerator.cs
index 6f8606688..a281286dc 100644
--- a/src/Core/Storage/ICuidGenerator.cs
+++ b/src/Core/Storage/ICuidGenerator.cs
@@ -1,3 +1,4 @@
+// Copyright (c) Microsoft. All rights reserved.
namespace KernelMemory.Core.Storage;
///
diff --git a/src/Core/Storage/Models/ContentDto.cs b/src/Core/Storage/Models/ContentDto.cs
index 3c61377b7..8b3e5aeec 100644
--- a/src/Core/Storage/Models/ContentDto.cs
+++ b/src/Core/Storage/Models/ContentDto.cs
@@ -1,3 +1,4 @@
+// Copyright (c) Microsoft. All rights reserved.
namespace KernelMemory.Core.Storage.Models;
///
diff --git a/src/Core/Storage/Models/UpsertRequest.cs b/src/Core/Storage/Models/UpsertRequest.cs
index 087ad3b5f..26f250e6e 100644
--- a/src/Core/Storage/Models/UpsertRequest.cs
+++ b/src/Core/Storage/Models/UpsertRequest.cs
@@ -1,3 +1,4 @@
+// Copyright (c) Microsoft. All rights reserved.
namespace KernelMemory.Core.Storage.Models;
///
diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props
index afb6c8f29..77f1a0308 100644
--- a/src/Directory.Packages.props
+++ b/src/Directory.Packages.props
@@ -9,13 +9,14 @@
-
-
+
+
+
@@ -23,6 +24,13 @@
+
+
+
+
+
+
+
diff --git a/src/Main/CLI/CliApplicationBuilder.cs b/src/Main/CLI/CliApplicationBuilder.cs
new file mode 100644
index 000000000..81ae4fc74
--- /dev/null
+++ b/src/Main/CLI/CliApplicationBuilder.cs
@@ -0,0 +1,140 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using KernelMemory.Core.Config;
+using KernelMemory.Main.CLI.Commands;
+using KernelMemory.Main.CLI.Infrastructure;
+using Microsoft.Extensions.DependencyInjection;
+using Spectre.Console.Cli;
+
+namespace KernelMemory.Main.CLI;
+
+///
+/// Builds and configures the CLI application with all commands.
+/// Extracted from Program.cs for testability.
+///
+public sealed class CliApplicationBuilder
+{
+ // Static readonly arrays for command examples (CA1861 compliance)
+ private static readonly string[] s_upsertExample1 = new[] { "upsert", "\"Hello, world!\"" };
+ private static readonly string[] s_upsertExample2 = new[] { "upsert", "\"Some content\"", "--id", "my-id-123" };
+ private static readonly string[] s_upsertExample3 = new[] { "upsert", "\"Tagged content\"", "--tags", "important,todo" };
+ private static readonly string[] s_getExample1 = new[] { "get", "abc123" };
+ private static readonly string[] s_getExample2 = new[] { "get", "abc123", "--full" };
+ private static readonly string[] s_getExample3 = new[] { "get", "abc123", "-f", "json" };
+ private static readonly string[] s_deleteExample1 = new[] { "delete", "abc123" };
+ private static readonly string[] s_deleteExample2 = new[] { "delete", "abc123", "-v", "quiet" };
+ private static readonly string[] s_listExample1 = new[] { "list" };
+ private static readonly string[] s_listExample2 = new[] { "list", "--skip", "20", "--take", "10" };
+ private static readonly string[] s_listExample3 = new[] { "list", "-f", "json" };
+ private static readonly string[] s_nodesExample1 = new[] { "nodes" };
+ private static readonly string[] s_nodesExample2 = new[] { "nodes", "-f", "yaml" };
+ private static readonly string[] s_configExample1 = new[] { "config" };
+ private static readonly string[] s_configExample2 = new[] { "config", "--show-nodes" };
+ private static readonly string[] s_configExample3 = new[] { "config", "--show-cache" };
+
+ ///
+ /// Creates and configures a CommandApp with all CLI commands.
+ /// Loads configuration early and injects it via DI.
+ ///
+ /// Command line arguments (used to extract --config flag).
+ /// A configured CommandApp ready to execute commands.
+ public CommandApp Build(string[]? args = null)
+ {
+ // 1. Determine config path from args early (before command execution)
+ var configPath = this.DetermineConfigPath(args ?? Array.Empty());
+
+ // 2. Load config ONCE (happens before any command runs)
+ var config = ConfigParser.LoadFromFile(configPath);
+
+ // 3. Create DI container and register AppConfig as singleton
+ var services = new ServiceCollection();
+ services.AddSingleton(config);
+
+ // 4. Create type registrar for Spectre.Console.Cli DI integration
+ var registrar = new TypeRegistrar(services);
+
+ // 5. Build CommandApp with DI support
+ var app = new CommandApp(registrar);
+ this.Configure(app);
+ return app;
+ }
+
+ ///
+ /// Determines the configuration file path from command line arguments.
+ /// Scans args for --config or -c flag. Falls back to default ~/.km/config.json.
+ ///
+ /// Command line arguments.
+ /// Path to configuration file.
+ private string DetermineConfigPath(string[] args)
+ {
+ // Simple string scanning for --config or -c flag
+ for (int i = 0; i < args.Length - 1; i++)
+ {
+ if (args[i] == "--config" || args[i] == "-c")
+ {
+ return args[i + 1];
+ }
+ }
+
+ // Default: ~/.km/config.json
+ return Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ Constants.DefaultConfigDirName,
+ Constants.DefaultConfigFileName);
+ }
+
+ ///
+ /// Configures the CommandApp with all commands and examples.
+ /// Made public to allow tests to reuse command configuration.
+ ///
+ /// The CommandApp to configure.
+ public void Configure(CommandApp app)
+ {
+ app.Configure(config =>
+ {
+ config.SetApplicationName("km");
+
+ // Upsert command
+ config.AddCommand("upsert")
+ .WithDescription("Upload or update content")
+ .WithExample(s_upsertExample1)
+ .WithExample(s_upsertExample2)
+ .WithExample(s_upsertExample3);
+
+ // Get command
+ config.AddCommand("get")
+ .WithDescription("Fetch content by ID")
+ .WithExample(s_getExample1)
+ .WithExample(s_getExample2)
+ .WithExample(s_getExample3);
+
+ // Delete command
+ config.AddCommand("delete")
+ .WithDescription("Delete content by ID")
+ .WithExample(s_deleteExample1)
+ .WithExample(s_deleteExample2);
+
+ // List command
+ config.AddCommand("list")
+ .WithDescription("List all content with pagination")
+ .WithExample(s_listExample1)
+ .WithExample(s_listExample2)
+ .WithExample(s_listExample3);
+
+ // Nodes command
+ config.AddCommand("nodes")
+ .WithDescription("List all configured nodes")
+ .WithExample(s_nodesExample1)
+ .WithExample(s_nodesExample2);
+
+ // Config command
+ config.AddCommand("config")
+ .WithDescription("Query configuration")
+ .WithExample(s_configExample1)
+ .WithExample(s_configExample2)
+ .WithExample(s_configExample3);
+
+ config.ValidateExamples();
+ });
+ }
+}
diff --git a/src/Main/CLI/Commands/BaseCommand.cs b/src/Main/CLI/Commands/BaseCommand.cs
new file mode 100644
index 000000000..e62012a32
--- /dev/null
+++ b/src/Main/CLI/Commands/BaseCommand.cs
@@ -0,0 +1,163 @@
+// Copyright (c) Microsoft. All rights reserved.
+using System.Diagnostics.CodeAnalysis;
+using KernelMemory.Core.Config;
+using KernelMemory.Core.Config.ContentIndex;
+using KernelMemory.Core.Storage;
+using KernelMemory.Main.CLI.Exceptions;
+using KernelMemory.Main.CLI.OutputFormatters;
+using KernelMemory.Main.Services;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using Spectre.Console.Cli;
+
+namespace KernelMemory.Main.CLI.Commands;
+
+///
+/// Base class for all CLI commands providing shared initialization logic.
+/// Config is injected via constructor (loaded once in CliApplicationBuilder).
+///
+/// The command settings type, must inherit from GlobalOptions.
+public abstract class BaseCommand : AsyncCommand
+ where TSettings : GlobalOptions
+{
+ private readonly AppConfig _config;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Application configuration (injected by DI).
+ protected BaseCommand(AppConfig config)
+ {
+ this._config = config ?? throw new ArgumentNullException(nameof(config));
+ }
+
+ ///
+ /// Initializes command dependencies: node and formatter.
+ /// Config is already injected via constructor (no file I/O).
+ ///
+ /// The command settings.
+ /// Tuple of (config, node, formatter).
+ protected (AppConfig config, NodeConfig node, IOutputFormatter formatter)
+ Initialize(TSettings settings)
+ {
+ // Config already loaded and injected via constructor
+ var config = this._config;
+
+ // Select node
+ if (config.Nodes.Count == 0)
+ {
+ throw new InvalidOperationException("No nodes configured. Please create a configuration file.");
+ }
+
+ var nodeName = settings.NodeName ?? config.Nodes.Keys.First();
+ if (!config.Nodes.TryGetValue(nodeName, out var node))
+ {
+ throw new InvalidOperationException($"Node '{nodeName}' not found in configuration.");
+ }
+
+ // Create formatter
+ var formatter = OutputFormatterFactory.Create(settings);
+
+ return (config, node, formatter);
+ }
+
+ ///
+ /// Creates a ContentService instance for the specified node.
+ ///
+ /// The node configuration.
+ /// If true, will not create directories or database. Will fail if database doesn't exist.
+ /// A ContentService instance.
+ /// Thrown when database doesn't exist in readonly mode.
+ [SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope",
+ Justification = "DbContext ownership is transferred to ContentStorageService which handles disposal")]
+ protected ContentService CreateContentService(NodeConfig node, bool readonlyMode = false)
+ {
+ // Get SQLite database path from node config
+ if (node.ContentIndex is not SqliteContentIndexConfig sqliteConfig)
+ {
+ throw new InvalidOperationException($"Node '{node.Id}' does not use SQLite content index.");
+ }
+
+ var dbPath = sqliteConfig.Path;
+
+ // In readonly mode, verify database exists without creating it
+ if (readonlyMode)
+ {
+ if (!File.Exists(dbPath))
+ {
+ throw new DatabaseNotFoundException(dbPath);
+ }
+ }
+ else
+ {
+ // Write mode: Ensure directory exists
+ var dbDir = Path.GetDirectoryName(dbPath);
+ if (!string.IsNullOrEmpty(dbDir) && !Directory.Exists(dbDir))
+ {
+ Directory.CreateDirectory(dbDir);
+ }
+ }
+
+ // Create connection string
+ var connectionString = "Data Source=" + dbPath;
+
+ // Create DbContext
+ var optionsBuilder = new DbContextOptionsBuilder();
+ optionsBuilder.UseSqlite(connectionString);
+ var context = new ContentStorageDbContext(optionsBuilder.Options);
+
+ // In write mode, ensure database is created
+ // In readonly mode, the database must already exist (checked above)
+ if (!readonlyMode)
+ {
+ context.Database.EnsureCreated();
+ }
+
+ // Create dependencies
+ var cuidGenerator = new CuidGenerator();
+ var logger = this.CreateLogger();
+
+ // Create storage service
+ var storage = new ContentStorageService(context, cuidGenerator, logger);
+
+ // Create and return content service
+ return new ContentService(storage, node.Id);
+ }
+
+ ///
+ /// Creates a simple console logger for ContentStorageService.
+ ///
+ /// A logger instance.
+ [SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope",
+ Justification = "LoggerFactory lifetime is managed by the logger infrastructure. CLI commands are short-lived and disposing would terminate logging prematurely.")]
+ private ILogger CreateLogger()
+ {
+ var loggerFactory = LoggerFactory.Create(builder =>
+ {
+ builder.AddConsole();
+ builder.SetMinimumLevel(LogLevel.Warning);
+ });
+
+ return loggerFactory.CreateLogger();
+ }
+
+ ///
+ /// Handles exceptions and returns appropriate exit code.
+ ///
+ /// The exception to handle.
+ /// The output formatter for error messages.
+ /// Exit code (1 for user errors, 2 for system errors).
+ protected int HandleError(Exception ex, IOutputFormatter formatter)
+ {
+ formatter.FormatError(ex.Message);
+
+ // User errors: InvalidOperationException, ArgumentException
+ if (ex is InvalidOperationException or ArgumentException)
+ {
+ return Constants.ExitCodeUserError;
+ }
+
+ // System errors: everything else
+ return Constants.ExitCodeSystemError;
+ }
+}
diff --git a/src/Main/CLI/Commands/ConfigCommand.cs b/src/Main/CLI/Commands/ConfigCommand.cs
new file mode 100644
index 000000000..14706c96f
--- /dev/null
+++ b/src/Main/CLI/Commands/ConfigCommand.cs
@@ -0,0 +1,118 @@
+// Copyright (c) Microsoft. All rights reserved.
+using System.ComponentModel;
+using System.Diagnostics.CodeAnalysis;
+using KernelMemory.Main.CLI.Models;
+using Spectre.Console.Cli;
+
+namespace KernelMemory.Main.CLI.Commands;
+
+///
+/// Settings for the config command.
+///
+public class ConfigCommandSettings : GlobalOptions
+{
+ [CommandOption("--show-nodes")]
+ [Description("Show all nodes configuration")]
+ public bool ShowNodes { get; init; }
+
+ [CommandOption("--show-cache")]
+ [Description("Show cache configuration")]
+ public bool ShowCache { get; init; }
+}
+
+///
+/// Command to query configuration.
+///
+public class ConfigCommand : BaseCommand
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Application configuration (injected by DI).
+ public ConfigCommand(KernelMemory.Core.Config.AppConfig config) : base(config)
+ {
+ }
+
+ [SuppressMessage("Design", "CA1031:Do not catch general exception types",
+ Justification = "Top-level command handler must catch all exceptions to return appropriate exit codes and error messages")]
+ public override async Task ExecuteAsync(
+ CommandContext context,
+ ConfigCommandSettings settings)
+ {
+ try
+ {
+ var (config, node, formatter) = this.Initialize(settings);
+
+ // Determine what to show
+ object output;
+
+ if (settings.ShowNodes)
+ {
+ // Show all nodes
+ output = config.Nodes.Select(kvp => new NodeSummaryDto
+ {
+ Id = kvp.Key,
+ Access = kvp.Value.Access.ToString(),
+ ContentIndex = kvp.Value.ContentIndex.Type.ToString(),
+ HasFileStorage = kvp.Value.FileStorage != null,
+ HasRepoStorage = kvp.Value.RepoStorage != null,
+ SearchIndexCount = kvp.Value.SearchIndexes.Count
+ }).ToList();
+ }
+ else if (settings.ShowCache)
+ {
+ // Show cache configuration
+ output = new CacheInfoDto
+ {
+ 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
+ };
+ }
+ else
+ {
+ // Default: show current node details
+ output = new NodeDetailsDto
+ {
+ 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
+ {
+ 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
+ {
+ Type = si.Type.ToString()
+ }).ToList()
+ };
+ }
+
+ formatter.Format(output);
+
+ return Constants.ExitCodeSuccess;
+ }
+ catch (Exception ex)
+ {
+ var formatter = CLI.OutputFormatters.OutputFormatterFactory.Create(settings);
+ return this.HandleError(ex, formatter);
+ }
+ }
+}
diff --git a/src/Main/CLI/Commands/DeleteCommand.cs b/src/Main/CLI/Commands/DeleteCommand.cs
new file mode 100644
index 000000000..91e6ee3f6
--- /dev/null
+++ b/src/Main/CLI/Commands/DeleteCommand.cs
@@ -0,0 +1,80 @@
+// Copyright (c) Microsoft. All rights reserved.
+using System.ComponentModel;
+using System.Diagnostics.CodeAnalysis;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace KernelMemory.Main.CLI.Commands;
+
+///
+/// Settings for the delete command.
+///
+public class DeleteCommandSettings : GlobalOptions
+{
+ [CommandArgument(0, "")]
+ [Description("Content ID to delete")]
+ public string Id { get; init; } = string.Empty;
+
+ public override ValidationResult Validate()
+ {
+ var baseResult = base.Validate();
+ if (!baseResult.Successful)
+ {
+ return baseResult;
+ }
+
+ if (string.IsNullOrWhiteSpace(this.Id))
+ {
+ return ValidationResult.Error("ID cannot be empty");
+ }
+
+ return ValidationResult.Success();
+ }
+}
+
+///
+/// Command to delete content by ID.
+///
+public class DeleteCommand : BaseCommand
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Application configuration (injected by DI).
+ public DeleteCommand(KernelMemory.Core.Config.AppConfig config) : base(config)
+ {
+ }
+
+ [SuppressMessage("Design", "CA1031:Do not catch general exception types",
+ Justification = "Top-level command handler must catch all exceptions to return appropriate exit codes and error messages")]
+ public override async Task ExecuteAsync(
+ CommandContext context,
+ DeleteCommandSettings settings)
+ {
+ try
+ {
+ var (config, node, formatter) = this.Initialize(settings);
+ var service = this.CreateContentService(node);
+
+ // Delete is idempotent - no error if not found
+ await service.DeleteAsync(settings.Id, CancellationToken.None).ConfigureAwait(false);
+
+ // Output result based on verbosity
+ if (settings.Verbosity.Equals("quiet", StringComparison.OrdinalIgnoreCase))
+ {
+ formatter.Format(settings.Id);
+ }
+ else if (!settings.Verbosity.Equals("silent", StringComparison.OrdinalIgnoreCase))
+ {
+ formatter.Format(new { id = settings.Id, status = "deleted" });
+ }
+
+ return Constants.ExitCodeSuccess;
+ }
+ catch (Exception ex)
+ {
+ var formatter = CLI.OutputFormatters.OutputFormatterFactory.Create(settings);
+ return this.HandleError(ex, formatter);
+ }
+ }
+}
diff --git a/src/Main/CLI/Commands/GetCommand.cs b/src/Main/CLI/Commands/GetCommand.cs
new file mode 100644
index 000000000..8b8c23496
--- /dev/null
+++ b/src/Main/CLI/Commands/GetCommand.cs
@@ -0,0 +1,119 @@
+// Copyright (c) Microsoft. All rights reserved.
+using System.ComponentModel;
+using System.Diagnostics.CodeAnalysis;
+using KernelMemory.Main.CLI.Exceptions;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace KernelMemory.Main.CLI.Commands;
+
+///
+/// Settings for the get command.
+///
+public class GetCommandSettings : GlobalOptions
+{
+ [CommandArgument(0, "")]
+ [Description("Content ID to retrieve")]
+ public string Id { get; init; } = string.Empty;
+
+ [CommandOption("--full")]
+ [Description("Show all internal details")]
+ public bool ShowFull { get; init; }
+
+ public override ValidationResult Validate()
+ {
+ var baseResult = base.Validate();
+ if (!baseResult.Successful)
+ {
+ return baseResult;
+ }
+
+ if (string.IsNullOrWhiteSpace(this.Id))
+ {
+ return ValidationResult.Error("ID cannot be empty");
+ }
+
+ return ValidationResult.Success();
+ }
+}
+
+///
+/// Command to get content by ID.
+///
+public class GetCommand : BaseCommand
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Application configuration (injected by DI).
+ public GetCommand(KernelMemory.Core.Config.AppConfig config) : base(config)
+ {
+ }
+
+ [SuppressMessage("Design", "CA1031:Do not catch general exception types",
+ Justification = "Top-level command handler must catch all exceptions to return appropriate exit codes and error messages")]
+ public override async Task ExecuteAsync(
+ CommandContext context,
+ GetCommandSettings settings)
+ {
+ try
+ {
+ var (config, node, formatter) = this.Initialize(settings);
+ var service = this.CreateContentService(node, readonlyMode: true);
+
+ var result = await service.GetAsync(settings.Id, CancellationToken.None).ConfigureAwait(false);
+
+ if (result == null)
+ {
+ formatter.FormatError($"Content with ID '{settings.Id}' not found");
+ return Constants.ExitCodeUserError;
+ }
+
+ // If --full flag is set, ensure verbose mode for human formatter
+ // For JSON/YAML, all fields are always included
+ formatter.Format(result);
+
+ return Constants.ExitCodeSuccess;
+ }
+ catch (DatabaseNotFoundException)
+ {
+ // First-run scenario: no database exists yet (expected state)
+ this.ShowFirstRunMessage(settings);
+ return Constants.ExitCodeSuccess; // Not a user error
+ }
+ catch (Exception ex)
+ {
+ var formatter = CLI.OutputFormatters.OutputFormatterFactory.Create(settings);
+ return this.HandleError(ex, formatter);
+ }
+ }
+
+ ///
+ /// Shows a friendly first-run message when no database exists yet.
+ ///
+ /// Command settings for output format.
+ private void ShowFirstRunMessage(GetCommandSettings settings)
+ {
+ var formatter = CLI.OutputFormatters.OutputFormatterFactory.Create(settings);
+
+ // For JSON/YAML, return null (valid, parseable output)
+ if (!settings.Format.Equals("human", StringComparison.OrdinalIgnoreCase))
+ {
+ formatter.Format(null!);
+ return;
+ }
+
+ // Human format: friendly message with context
+ AnsiConsole.WriteLine();
+ AnsiConsole.MarkupLine($"[yellow]Content with ID '{settings.Id}' not found.[/]");
+ AnsiConsole.WriteLine();
+ AnsiConsole.MarkupLine("[dim]No content database exists yet. This is your first run.[/]");
+ AnsiConsole.WriteLine();
+ AnsiConsole.MarkupLine("[bold]Create content first:[/]");
+ AnsiConsole.MarkupLine($" [cyan]km upsert \"Your content here\" --id {settings.Id}[/]");
+ AnsiConsole.WriteLine();
+ AnsiConsole.MarkupLine("[bold]Or list available content:[/]");
+ AnsiConsole.MarkupLine(" [cyan]km list[/]");
+ AnsiConsole.WriteLine();
+ }
+}
diff --git a/src/Main/CLI/Commands/ListCommand.cs b/src/Main/CLI/Commands/ListCommand.cs
new file mode 100644
index 000000000..c2f4716e1
--- /dev/null
+++ b/src/Main/CLI/Commands/ListCommand.cs
@@ -0,0 +1,123 @@
+// Copyright (c) Microsoft. All rights reserved.
+using System.ComponentModel;
+using System.Diagnostics.CodeAnalysis;
+using KernelMemory.Main.CLI.Exceptions;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace KernelMemory.Main.CLI.Commands;
+
+///
+/// Settings for the list command.
+///
+public class ListCommandSettings : GlobalOptions
+{
+ [CommandOption("--skip")]
+ [Description("Number of items to skip (default: 0)")]
+ [DefaultValue(0)]
+ public int Skip { get; init; }
+
+ [CommandOption("--take")]
+ [Description("Number of items to take (default: 20)")]
+ [DefaultValue(20)]
+ public int Take { get; init; } = Constants.DefaultPageSize;
+
+ public override ValidationResult Validate()
+ {
+ var baseResult = base.Validate();
+ if (!baseResult.Successful)
+ {
+ return baseResult;
+ }
+
+ if (this.Skip < 0)
+ {
+ return ValidationResult.Error("Skip must be >= 0");
+ }
+
+ if (this.Take <= 0)
+ {
+ return ValidationResult.Error("Take must be > 0");
+ }
+
+ return ValidationResult.Success();
+ }
+}
+
+///
+/// Command to list all content with pagination.
+///
+public class ListCommand : BaseCommand
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Application configuration (injected by DI).
+ public ListCommand(KernelMemory.Core.Config.AppConfig config) : base(config)
+ {
+ }
+
+ [SuppressMessage("Design", "CA1031:Do not catch general exception types",
+ Justification = "Top-level command handler must catch all exceptions to return appropriate exit codes and error messages")]
+ public override async Task ExecuteAsync(
+ CommandContext context,
+ ListCommandSettings settings)
+ {
+ try
+ {
+ var (config, node, formatter) = this.Initialize(settings);
+ var service = this.CreateContentService(node, readonlyMode: true);
+
+ // Get total count
+ var totalCount = await service.CountAsync(CancellationToken.None).ConfigureAwait(false);
+
+ // Get page of items
+ var items = await service.ListAsync(settings.Skip, settings.Take, CancellationToken.None).ConfigureAwait(false);
+
+ // Format list with pagination info
+ formatter.FormatList(items, totalCount, settings.Skip, settings.Take);
+
+ return Constants.ExitCodeSuccess;
+ }
+ catch (DatabaseNotFoundException)
+ {
+ // First-run scenario: no database exists yet (expected state)
+ this.ShowFirstRunMessage(settings);
+ return Constants.ExitCodeSuccess; // Not a user error
+ }
+ catch (Exception ex)
+ {
+ var formatter = CLI.OutputFormatters.OutputFormatterFactory.Create(settings);
+ return this.HandleError(ex, formatter);
+ }
+ }
+
+ ///
+ /// Shows a friendly first-run message when no database exists yet.
+ ///
+ /// Command settings for output format.
+ private void ShowFirstRunMessage(ListCommandSettings settings)
+ {
+ var formatter = CLI.OutputFormatters.OutputFormatterFactory.Create(settings);
+
+ // For JSON/YAML, return empty list (valid, parseable output)
+ if (!settings.Format.Equals("human", StringComparison.OrdinalIgnoreCase))
+ {
+ formatter.FormatList(Array.Empty(), 0, 0, settings.Take);
+ return;
+ }
+
+ // Human format: friendly welcome message
+ AnsiConsole.WriteLine();
+ AnsiConsole.MarkupLine("[bold green]Welcome to Kernel Memory! 🚀[/]");
+ AnsiConsole.WriteLine();
+ AnsiConsole.MarkupLine("[dim]No content found yet. This is your first run.[/]");
+ AnsiConsole.WriteLine();
+ AnsiConsole.MarkupLine("[bold]To get started:[/]");
+ AnsiConsole.MarkupLine(" [cyan]km upsert \"Your content here\"[/]");
+ AnsiConsole.WriteLine();
+ AnsiConsole.MarkupLine("[bold]Example:[/]");
+ AnsiConsole.MarkupLine(" [cyan]km upsert \"Hello, world!\" --id greeting[/]");
+ AnsiConsole.WriteLine();
+ }
+}
diff --git a/src/Main/CLI/Commands/NodesCommand.cs b/src/Main/CLI/Commands/NodesCommand.cs
new file mode 100644
index 000000000..9a653155d
--- /dev/null
+++ b/src/Main/CLI/Commands/NodesCommand.cs
@@ -0,0 +1,52 @@
+// Copyright (c) Microsoft. All rights reserved.
+using System.Diagnostics.CodeAnalysis;
+using Spectre.Console.Cli;
+
+namespace KernelMemory.Main.CLI.Commands;
+
+///
+/// Settings for the nodes command (uses global options only).
+///
+public class NodesCommandSettings : GlobalOptions
+{
+}
+
+///
+/// Command to list all configured nodes.
+///
+public class NodesCommand : BaseCommand
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Application configuration (injected by DI).
+ public NodesCommand(KernelMemory.Core.Config.AppConfig config) : base(config)
+ {
+ }
+
+ [SuppressMessage("Design", "CA1031:Do not catch general exception types",
+ Justification = "Top-level command handler must catch all exceptions to return appropriate exit codes and error messages")]
+ public override async Task ExecuteAsync(
+ CommandContext context,
+ NodesCommandSettings settings)
+ {
+ try
+ {
+ var (config, node, formatter) = this.Initialize(settings);
+
+ // Get all node IDs
+ var nodeIds = config.Nodes.Keys.ToList();
+ var totalCount = nodeIds.Count;
+
+ // Format as list
+ formatter.FormatList(nodeIds, totalCount, 0, totalCount);
+
+ return Constants.ExitCodeSuccess;
+ }
+ catch (Exception ex)
+ {
+ var formatter = CLI.OutputFormatters.OutputFormatterFactory.Create(settings);
+ return this.HandleError(ex, formatter);
+ }
+ }
+}
diff --git a/src/Main/CLI/Commands/UpsertCommand.cs b/src/Main/CLI/Commands/UpsertCommand.cs
new file mode 100644
index 000000000..93951f9fa
--- /dev/null
+++ b/src/Main/CLI/Commands/UpsertCommand.cs
@@ -0,0 +1,119 @@
+// Copyright (c) Microsoft. All rights reserved.
+using System.ComponentModel;
+using System.Diagnostics.CodeAnalysis;
+using KernelMemory.Core.Storage.Models;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace KernelMemory.Main.CLI.Commands;
+
+///
+/// Settings for the upsert command.
+///
+public class UpsertCommandSettings : GlobalOptions
+{
+ [CommandArgument(0, "")]
+ [Description("Content to upload")]
+ public string Content { get; init; } = string.Empty;
+
+ [CommandOption("--id")]
+ [Description("Content ID (generated if not provided)")]
+ public string? Id { get; init; }
+
+ [CommandOption("--mime-type")]
+ [Description("MIME type (default: text/plain)")]
+ [DefaultValue("text/plain")]
+ public string MimeType { get; init; } = "text/plain";
+
+ [CommandOption("--title")]
+ [Description("Optional title")]
+ public string? Title { get; init; }
+
+ [CommandOption("--description")]
+ [Description("Optional description")]
+ public string? Description { get; init; }
+
+ [CommandOption("--tags")]
+ [Description("Optional tags (comma-separated)")]
+ public string? Tags { get; init; }
+
+ public override ValidationResult Validate()
+ {
+ var baseResult = base.Validate();
+ if (!baseResult.Successful)
+ {
+ return baseResult;
+ }
+
+ if (string.IsNullOrWhiteSpace(this.Content))
+ {
+ return ValidationResult.Error("Content cannot be empty");
+ }
+
+ return ValidationResult.Success();
+ }
+}
+
+///
+/// Command to upsert (create or update) content.
+///
+public class UpsertCommand : BaseCommand
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Application configuration (injected by DI).
+ public UpsertCommand(KernelMemory.Core.Config.AppConfig config) : base(config)
+ {
+ }
+
+ [SuppressMessage("Design", "CA1031:Do not catch general exception types",
+ Justification = "Top-level command handler must catch all exceptions to return appropriate exit codes and error messages")]
+ public override async Task ExecuteAsync(
+ CommandContext context,
+ UpsertCommandSettings settings)
+ {
+ try
+ {
+ var (config, node, formatter) = this.Initialize(settings);
+ var service = this.CreateContentService(node);
+
+ // Parse tags if provided
+ var tags = string.IsNullOrWhiteSpace(settings.Tags)
+ ? Array.Empty()
+ : settings.Tags.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+
+ // Create upsert request
+ var request = new UpsertRequest
+ {
+ Id = settings.Id ?? string.Empty,
+ Content = settings.Content,
+ MimeType = settings.MimeType,
+ Title = settings.Title ?? string.Empty,
+ Description = settings.Description ?? string.Empty,
+ Tags = tags,
+ Metadata = new Dictionary()
+ };
+
+ // Perform upsert
+ var contentId = await service.UpsertAsync(request, CancellationToken.None).ConfigureAwait(false);
+
+ // Output result based on verbosity
+ if (settings.Verbosity.Equals("quiet", StringComparison.OrdinalIgnoreCase))
+ {
+ formatter.Format(contentId);
+ }
+ else
+ {
+ formatter.Format(new { id = contentId, status = "success" });
+ }
+
+ return Constants.ExitCodeSuccess;
+ }
+ catch (Exception ex)
+ {
+ var formatter = CLI.OutputFormatters.OutputFormatterFactory.Create(settings);
+ return this.HandleError(ex, formatter);
+ }
+ }
+}
diff --git a/src/Main/CLI/Exceptions/DatabaseNotFoundException.cs b/src/Main/CLI/Exceptions/DatabaseNotFoundException.cs
new file mode 100644
index 000000000..7d3e7ef00
--- /dev/null
+++ b/src/Main/CLI/Exceptions/DatabaseNotFoundException.cs
@@ -0,0 +1,45 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace KernelMemory.Main.CLI.Exceptions;
+
+///
+/// Exception thrown when database doesn't exist yet.
+/// This is an expected state on first run - not an error.
+///
+public sealed class DatabaseNotFoundException : Exception
+{
+ ///
+ /// Gets the path where the database was expected to be found.
+ ///
+ public string DatabasePath { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The path where the database was expected.
+ public DatabaseNotFoundException(string dbPath)
+ : base($"No content database found at '{dbPath}'. This is your first run.")
+ {
+ this.DatabasePath = dbPath ?? throw new ArgumentNullException(nameof(dbPath));
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public DatabaseNotFoundException()
+ : base("No content database found.")
+ {
+ this.DatabasePath = string.Empty;
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The error message.
+ /// The inner exception.
+ public DatabaseNotFoundException(string? message, Exception? innerException)
+ : base(message, innerException)
+ {
+ this.DatabasePath = string.Empty;
+ }
+}
diff --git a/src/Main/CLI/GlobalOptions.cs b/src/Main/CLI/GlobalOptions.cs
new file mode 100644
index 000000000..fd92b8836
--- /dev/null
+++ b/src/Main/CLI/GlobalOptions.cs
@@ -0,0 +1,54 @@
+// Copyright (c) Microsoft. All rights reserved.
+using System.ComponentModel;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace KernelMemory.Main.CLI;
+
+///
+/// Global options shared across all CLI commands.
+///
+public class GlobalOptions : CommandSettings
+{
+ [CommandOption("-c|--config")]
+ [Description("Path to config file (default: ~/.km/config.json)")]
+ public string? ConfigPath { get; init; }
+
+ [CommandOption("-n|--node")]
+ [Description("Node name to use (default: first in config)")]
+ public string? NodeName { get; init; }
+
+ [CommandOption("-f|--format")]
+ [Description("Output format: human, json, yaml")]
+ [DefaultValue("human")]
+ public string Format { get; init; } = "human";
+
+ [CommandOption("-v|--verbosity")]
+ [Description("Verbosity: silent, quiet, normal, verbose")]
+ [DefaultValue("normal")]
+ public string Verbosity { get; init; } = "normal";
+
+ [CommandOption("--no-color")]
+ [Description("Disable colored output")]
+ public bool NoColor { get; init; }
+
+ ///
+ /// Validates the global options.
+ ///
+ public override ValidationResult Validate()
+ {
+ var validFormats = new[] { "human", "json", "yaml" };
+ if (!validFormats.Contains(this.Format.ToLowerInvariant()))
+ {
+ return ValidationResult.Error("Format must be: human, json, or yaml");
+ }
+
+ var validVerbosities = new[] { "silent", "quiet", "normal", "verbose" };
+ if (!validVerbosities.Contains(this.Verbosity.ToLowerInvariant()))
+ {
+ return ValidationResult.Error("Verbosity must be: silent, quiet, normal, or verbose");
+ }
+
+ return ValidationResult.Success();
+ }
+}
diff --git a/src/Main/CLI/Infrastructure/TypeRegistrar.cs b/src/Main/CLI/Infrastructure/TypeRegistrar.cs
new file mode 100644
index 000000000..7466f5bc9
--- /dev/null
+++ b/src/Main/CLI/Infrastructure/TypeRegistrar.cs
@@ -0,0 +1,63 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using Microsoft.Extensions.DependencyInjection;
+using Spectre.Console.Cli;
+
+namespace KernelMemory.Main.CLI.Infrastructure;
+
+///
+/// Adapts IServiceCollection to ITypeRegistrar for Spectre.Console.Cli integration.
+/// Enables dependency injection in CLI commands.
+///
+public sealed class TypeRegistrar : ITypeRegistrar
+{
+ private readonly IServiceCollection _services;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The service collection to wrap.
+ public TypeRegistrar(IServiceCollection services)
+ {
+ this._services = services ?? throw new ArgumentNullException(nameof(services));
+ }
+
+ ///
+ /// Registers a service with its implementation.
+ ///
+ /// The service type.
+ /// The implementation type.
+ public void Register(Type service, Type implementation)
+ {
+ this._services.AddSingleton(service, implementation);
+ }
+
+ ///
+ /// Registers a service instance.
+ ///
+ /// The service type.
+ /// The service instance.
+ public void RegisterInstance(Type service, object implementation)
+ {
+ this._services.AddSingleton(service, implementation);
+ }
+
+ ///
+ /// Registers a service with a factory function.
+ ///
+ /// The service type.
+ /// The factory function.
+ public void RegisterLazy(Type service, Func