Spensara Spensara Knowledge Base & Documentation Repository

Repository Docs & Knowledge Base

Consolidated Spensara documentation repository (722 Markdown & architecture documents in ../repository)
Built Word Documents (.docx): Spensara Overview (.docx) Capability Assessment (.docx) Brink POS Ingestion Analysis (.docx) The Borg Ingestion Architecture (.docx)

Implementation Specs (157)

Detailed engineering and feature implementation specifications.
implementation-specs/specs/AnomalyDetection/TASK5.3.md 15,139 bytes · Updated Oct 23, 2026 10:44 AM EDT
Raw File

TASK 5.3: Add Enhanced CLI Options and Commands

Summary

Enhance the CLI commands with additional options for more flexible anomaly detection runs, including custom time ranges, output formats, run history, and statistics. This extends the base commands from TASK 5.1.

Dependencies

  • TASK 5.1 (Base CLI commands exist)
  • TASK 5.2 (DI wiring complete)
  • TASK 4.4 (IMonitorRunRepository for history/stats)

Files to Modify

1. backend/Kds.Ai.Cli/Commands/AnomalyDetector/RunAnomalyDetectorCommand.cs

Add enhanced options and new commands to the existing class:

csharp
using System.Text.Json;
using ConsoleAppFramework;
using Kds.Ai.AnomalyDetector.Orchestration;
using Kds.Ai.AnomalyDetector.Repositories;
using Kds.Ai.AnomalyDetector.Seeding;
using Kds.Ai.Data.Entities;
using Kds.Ai.Data.Enums;
using Kds.Ai.Data.Repositories;

namespace Kds.Ai.Cli.Commands.AnomalyDetector;

[RegisterCommands("anomaly")]
public class RunAnomalyDetectorCommand(
    IMonitorRunOrchestrator orchestrator,
    IMonitorSpecRepository specRepository,
    IMonitorRunRepository runRepository,
    MonitorSpecSeeder seeder)
{
    
    /// <summary>
    /// Run anomaly detection with full options
    /// </summary>
    /// <param name="name">-n|--name, Monitor spec name</param>
    /// <param name="specId">--spec-id, Monitor spec ID</param>
    /// <param name="analysisTime">-t|--time, Point in time for analysis</param>
    /// <param name="from">-f|--from, Analysis window start</param>
    /// <param name="to">--to, Analysis window end</param>
    /// <param name="dryRun">-d|--dry-run, Don't save results or emit alerts</param>
    /// <param name="verbose">-v|--verbose, Show detailed progress</param>
    /// <param name="outputFormat">-o|--output, Output format: text, json</param>
    /// <param name="suppressAlerts">--no-alerts, Suppress alert emission</param>
    /// <param name="timeout">--timeout, Execution timeout in seconds</param>
    [Command("run")]
    public async Task Run(
        string? name = null,
        Guid? specId = null,
        string? analysisTime = null,
        string? from = null,
        string? to = null,
        bool dryRun = false,
        bool verbose = false,
        string outputFormat = "text",
        bool suppressAlerts = false,
        int? timeout = null)
    {
        var output = CreateOutputHandler(outputFormat);
        
        try
        {
            if (name is null && specId is null)
            {
                output.WriteError("Specify either --name (-n) or --spec-id");
                return;
            }
            
            // Resolve spec
            var spec = specId.HasValue
                ? await specRepository.GetByIdAsync(specId.Value)
                : await specRepository.GetByNameAsync(name!);
            
            if (spec is null)
            {
                output.WriteError(specId.HasValue
                    ? $"Monitor spec {specId} not found"
                    : $"Monitor spec '{name}' not found");
                return;
            }
            
            // Parse times
            var time = ParseTime(analysisTime) ?? DateTimeOffset.UtcNow;
            var windowStart = ParseTime(from);
            var windowEnd = ParseTime(to);
            
            output.WriteInfo($"Monitor: {spec.Name} ({spec.Id})");
            output.WriteInfo($"State: {spec.State}");
            output.WriteInfo($"Analysis Time: {time:yyyy-MM-dd HH:mm:ss} UTC");
            
            // Build run options
            var options = new RunOptions
            {
                DryRun = dryRun,
                SuppressAlerts = suppressAlerts,
                WindowStart = windowStart,
                WindowEnd = windowEnd,
                Timeout = timeout.HasValue ? TimeSpan.FromSeconds(timeout.Value) : null,
                Progress = verbose ? new ConsoleProgress(output) : null
            };
            
            if (dryRun) output.WriteWarning("DRY RUN MODE");
            if (suppressAlerts) output.WriteWarning("ALERTS SUPPRESSED");
            
            // Execute
            var run = await orchestrator.ExecuteAsync(spec, time, options);
            
            // Output results
            output.WriteResults(run, verbose);
        }
        catch (Exception ex)
        {
            output.WriteError(ex.Message);
            if (verbose) output.WriteError(ex.ToString());
            Environment.ExitCode = 1;
        }
    }
    
    /// <summary>
    /// Show recent run history
    /// </summary>
    /// <param name="specId">--spec-id, Filter by monitor spec ID</param>
    /// <param name="limit">-n|--limit, Maximum runs to show</param>
    /// <param name="status">--status, Filter by status: completed, failed, all</param>
    /// <param name="outputFormat">-o|--output, Output format: text, json</param>
    [Command("history")]
    public async Task History(
        Guid? specId = null,
        int limit = 20,
        string status = "all",
        string outputFormat = "text")
    {
        var output = CreateOutputHandler(outputFormat);
        
        IReadOnlyList<MonitorRun> runs;
        
        if (specId.HasValue)
        {
            runs = await runRepository.GetByMonitorSpecAsync(specId.Value, limit);
        }
        else
        {
            runs = await runRepository.GetByTimeRangeAsync(
                DateTimeOffset.UtcNow.AddDays(-7),
                DateTimeOffset.UtcNow);
        }
        
        // Filter by status
        if (status != "all")
        {
            var statusEnum = Enum.Parse<MonitorRunStatus>(status, ignoreCase: true);
            runs = runs.Where(r => r.Status == statusEnum).ToList();
        }
        
        output.WriteRunHistory(runs.Take(limit).ToList());
    }
    
    /// <summary>
    /// Show statistics for monitor runs
    /// </summary>
    /// <param name="days">--days, Number of days to include</param>
    /// <param name="outputFormat">-o|--output, Output format: text, json</param>
    [Command("stats")]
    public async Task Stats(int days = 7, string outputFormat = "text")
    {
        var output = CreateOutputHandler(outputFormat);
        
        var from = DateTimeOffset.UtcNow.AddDays(-days);
        var stats = await runRepository.GetStatisticsAsync(from);
        
        output.WriteStatistics(stats, days);
    }
    
    // run-all, list, create, set-state defined in TASK 5.1
    // ... (see TASK 5.1 for base commands, which also don't use [Option] attributes)
    
    // === Helper Methods ===
    
    private static DateTimeOffset? ParseTime(string? timeStr)
    {
        if (string.IsNullOrEmpty(timeStr)) return null;
        
        // Support relative times
        if (timeStr.StartsWith("-"))
        {
            if (int.TryParse(timeStr[1..^1], out var value))
            {
                return timeStr[^1] switch
                {
                    'h' => DateTimeOffset.UtcNow.AddHours(-value),
                    'd' => DateTimeOffset.UtcNow.AddDays(-value),
                    'm' => DateTimeOffset.UtcNow.AddMinutes(-value),
                    _ => throw new ArgumentException($"Unknown time unit: {timeStr}")
                };
            }
        }
        
        if (DateTimeOffset.TryParse(timeStr, out var result))
            return result;
        
        throw new ArgumentException($"Invalid time format: {timeStr}");
    }
    
    private IOutputHandler CreateOutputHandler(string format) => format.ToLower() switch
    {
        "json" => new JsonOutputHandler(),
        _ => new TextOutputHandler()
    };
    
    private sealed class ConsoleProgress(IOutputHandler output) : IProgress<RunProgress>
    {
        public void Report(RunProgress value)
        {
            var prefix = value.Phase switch
            {
                RunPhase.Completed => "[DONE]",
                RunPhase.Failed => "[FAIL]",
                _ => "[....]"
            };
            
            output.WriteInfo($"{prefix} {value.Phase}: {value.Message}");
        }
    }
}

// === Output Handlers ===

public interface IOutputHandler
{
    void WriteInfo(string message);
    void WriteWarning(string message);
    void WriteError(string message);
    void WriteResults(MonitorRun run, bool verbose);
    void WriteRunHistory(IReadOnlyList<MonitorRun> runs);
    void WriteStatistics(RunStatistics stats, int days);
}

internal sealed class TextOutputHandler : IOutputHandler
{
    public void WriteInfo(string message) => Console.WriteLine(message);
    public void WriteWarning(string message) => Console.WriteLine($"[WARN] {message}");
    public void WriteError(string message) => Console.WriteLine($"[ERROR] {message}");
    
    public void WriteResults(MonitorRun run, bool verbose)
    {
        Console.WriteLine();
        Console.WriteLine("=== Results ===");
        Console.WriteLine($"Run ID: {run.Id}");
        Console.WriteLine($"Status: {run.Status}");
        Console.WriteLine($"Duration: {run.DurationMs}ms");
        Console.WriteLine($"Anomalies: {run.AnomaliesDetected}");
        
        if (run.AlertEvents.Count > 0)
        {
            Console.WriteLine($"Alerts: {run.AlertEvents.Count}");
            foreach (var alert in run.AlertEvents)
            {
                Console.WriteLine($"  - {alert.Id} ({alert.Severity})");
            }
        }
        
        if (!string.IsNullOrEmpty(run.ErrorMessage))
            Console.WriteLine($"Error: {run.ErrorMessage}");
        
        if (verbose && !string.IsNullOrEmpty(run.ResultSummary))
        {
            Console.WriteLine();
            Console.WriteLine("=== Details ===");
            Console.WriteLine(run.ResultSummary);
        }
    }
    
    public void WriteRunHistory(IReadOnlyList<MonitorRun> runs)
    {
        Console.WriteLine($"{"ID",-36} {"Status",-10} {"Anomalies",-10} {"Started",-20}");
        Console.WriteLine(new string('-', 80));
        
        foreach (var run in runs)
        {
            Console.WriteLine($"{run.Id,-36} {run.Status,-10} {run.AnomaliesDetected,-10} {run.StartedAt:yyyy-MM-dd HH:mm}");
        }
    }
    
    public void WriteStatistics(RunStatistics stats, int days)
    {
        Console.WriteLine($"Statistics for last {days} days:");
        Console.WriteLine($"  Total Runs: {stats.TotalRuns}");
        Console.WriteLine($"  Completed: {stats.CompletedRuns}");
        Console.WriteLine($"  Failed: {stats.FailedRuns}");
        Console.WriteLine($"  With Anomalies: {stats.RunsWithAnomalies}");
        Console.WriteLine($"  Total Anomalies: {stats.TotalAnomaliesDetected}");
        Console.WriteLine($"  Avg Duration: {stats.AverageRunDurationMs:F0}ms");
        Console.WriteLine($"  Total Tokens: {stats.TotalTokensUsed}");
    }
}

internal sealed class JsonOutputHandler : IOutputHandler
{
    public void WriteInfo(string message) { }
    public void WriteWarning(string message) { }
    public void WriteError(string message) => WriteJson(new { error = message });
    
    public void WriteResults(MonitorRun run, bool verbose) =>
        WriteJson(new
        {
            runId = run.Id,
            status = run.Status.ToString(),
            durationMs = run.DurationMs,
            anomaliesDetected = run.AnomaliesDetected,
            alertCount = run.AlertEvents.Count,
            error = run.ErrorMessage,
            resultSummary = verbose ? run.ResultSummary : null
        });
    
    public void WriteRunHistory(IReadOnlyList<MonitorRun> runs) =>
        WriteJson(runs.Select(r => new
        {
            id = r.Id,
            status = r.Status.ToString(),
            anomalies = r.AnomaliesDetected,
            started = r.StartedAt
        }));
    
    public void WriteStatistics(RunStatistics stats, int days) =>
        WriteJson(new { days, stats });
    
    private static void WriteJson(object obj) =>
        Console.WriteLine(JsonSerializer.Serialize(obj, new JsonSerializerOptions 
        { 
            WriteIndented = true 
        }));
}

Enhanced Command Options Summary

anomaly run Options

OptionShortDescription
--name-nMonitor spec name
--spec-idMonitor spec ID
--time-tAnalysis point in time (ISO or relative)
--from-fWindow start time
--toWindow end time
--dry-run-dDon't persist results
--verbose-vShow progress
--output-oOutput format (text/json)
--no-alertsSuppress alerts
--timeoutTimeout in seconds

Relative Time Syntax

bash
# 2 hours ago
anomaly run -n "Monitor Name" -t "-2h"

# 1 day ago
anomaly run -n "Monitor Name" -t "-1d"

# Custom range
anomaly run -n "Monitor Name" -f "-24h" --to "-1h"

Changes from Old Design

  • Removed --store option entirely (MonitorSpec has no StoreGuid)
  • Removed --skip-preflight (preflight was domain-specific, agent decides)
  • Removed --threshold and --lookback config overrides (no PrepTimeMonitorConfig typed config)
  • Removed ApplyConfigOverrides helper (no typed config to deserialize)
  • Removed toggle command with --enable/--disable (replaced by set-state in TASK 5.1)
  • history command filters by --spec-id instead of --store
  • All output handlers use run.AlertEvents collection instead of run.AlertEventId
  • Uses run.AlertEvents.Count instead of run.AlertEventId.HasValue
  • No GetByStoreAsync calls anywhere

Acceptance Criteria

  • [ ] All new options added to run command
  • [ ] history command shows run history (by spec, by time range)
  • [ ] stats command shows statistics
  • [ ] JSON output format works correctly
  • [ ] Relative time parsing (-2h, -1d) works
  • [ ] No [Option] attributes used (deprecated in ConsoleAppFramework v5)
  • [ ] Aliases defined in XML doc comments (e.g., -n|--limit in <param> tag)
  • [ ] No references to --store, --skip-preflight, --threshold, --lookback
  • [ ] No PrepTimeMonitorConfig deserialization
  • [ ] Uses run.AlertEvents (collection) not run.AlertEventId (single FK)
  • [ ] Uses spec.State (not spec.IsEnabled)
  • [ ] No GetByStoreAsync calls
  • [ ] Primary constructor used for RunAnomalyDetectorCommand (no _field pattern)
  • [ ] No inline fully-qualified types (Data.Entities.MonitorRun) — uses using directives
  • [ ] IMonitorSpecRepository imported from Kds.Ai.Data.Repositories
  • [ ] ConsoleProgress, TextOutputHandler, JsonOutputHandler are sealed
  • [ ] Implementation classes use internal sealed (interfaces stay public)
  • [ ] Solution builds without errors

Notes

  • IMonitorRunRepository injected for history/stats commands
  • No [Option] attributes needed - parameter names automatically become options
  • Aliases are defined in XML doc comments using pipe separator (e.g., -o|--output)
  • ConsoleAppFramework v5 deprecated [Option] in favor of automatic option generation
  • Consider adding --quiet option for scripting
  • Consider adding shell completion scripts
  • The output handlers can be extended with more formats later