diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/IInteractiveFlow.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/IInteractiveFlow.cs
new file mode 100644
index 0000000..991a5df
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/IInteractiveFlow.cs
@@ -0,0 +1,26 @@
+using Spectre.Console;
+
+namespace ZB.MOM.WW.Secrets.Cli.Interactive;
+
+///
+/// One menu action of the interactive console. Implementations are UI-owning (they render to the
+/// supplied ) but store-logic-free — they operate through the seams on the
+/// the shell hands them and never open stores or resolve keys themselves.
+///
+public interface IInteractiveFlow
+{
+ /// The menu label shown for this flow in the shell's action list.
+ string Title { get; }
+
+ ///
+ /// Whether the flow needs a decrypt/seal-capable session; the shell upgrades a degraded session
+ /// (by prompting the operator for a KEK) before dispatch when this is .
+ ///
+ bool RequiresKek { get; }
+
+ /// Runs the flow against the current session, rendering its own UI to .
+ /// The console the flow renders to (the shell owns lifecycle; flows only draw).
+ /// The open session — guaranteed KEK-capable when is .
+ /// A token to cancel the flow.
+ Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct);
+}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/InteractiveShell.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/InteractiveShell.cs
new file mode 100644
index 0000000..b7e4491
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/InteractiveShell.cs
@@ -0,0 +1,328 @@
+using System.Data.Common;
+using Spectre.Console;
+using ZB.MOM.WW.Secrets.Abstractions;
+using ZB.MOM.WW.Secrets.MasterKey;
+
+namespace ZB.MOM.WW.Secrets.Cli.Interactive;
+
+///
+/// The interactive secret console: pick a store target, open a session, then loop a header +
+/// action menu that dispatches to pluggable s. This is the ONLY class
+/// that touches — services stay UI-free and flows, though UI-owning, carry
+/// no store logic. Flow failures are contained (rendered as an error panel) so one bad action never
+/// tears down the session; a degraded (no-KEK) session is upgraded on demand before a KEK-requiring
+/// flow runs.
+///
+public sealed class InteractiveShell
+{
+ private const string EnterPathLabel = "Enter an appsettings.json path…";
+ private const string ManualEntryLabel = "Manual entry (SQLite db + KEK env var)…";
+ private const string SwitchLabel = "Switch store target";
+ private const string QuitLabel = "Quit";
+ private const string PasteKeyLabel = "Paste base64 key";
+ private const string KeyFileLabel = "Key file path";
+ private const string CancelLabel = "Cancel";
+
+ private readonly IAnsiConsole _console;
+ private readonly RecentTargetsStore _recents;
+ private readonly TargetConfigReader _reader;
+ private readonly SecretsSessionFactory _sessions;
+ private readonly IReadOnlyList _flows;
+
+ /// Creates the shell over its console, recents store, target reader, session factory, and flow set.
+ /// The single console surface the shell (and its flows) render to.
+ /// Recently-used target store, offered first in the picker and re-stamped on selection.
+ /// Composes a target's Secrets configuration from appsettings or manual input.
+ /// Opens (and, on KEK upgrade, re-opens) a for a target.
+ /// The pluggable menu actions, listed in order above the built-in switch/quit entries.
+ public InteractiveShell(
+ IAnsiConsole console,
+ RecentTargetsStore recents,
+ TargetConfigReader reader,
+ SecretsSessionFactory sessions,
+ IReadOnlyList flows)
+ {
+ ArgumentNullException.ThrowIfNull(console);
+ ArgumentNullException.ThrowIfNull(recents);
+ ArgumentNullException.ThrowIfNull(reader);
+ ArgumentNullException.ThrowIfNull(sessions);
+ ArgumentNullException.ThrowIfNull(flows);
+
+ _console = console;
+ _recents = recents;
+ _reader = reader;
+ _sessions = sessions;
+ _flows = flows;
+ }
+
+ /// Runs the pick-target → menu loop until the operator quits.
+ /// A token to cancel session opens and flow dispatch.
+ /// 0 on a clean quit.
+ public async Task RunAsync(CancellationToken ct)
+ {
+ while (true)
+ {
+ StoreTarget? target = PickTarget();
+ if (target is null)
+ {
+ return 0; // operator cancelled the picker (Ctrl-C) → exit
+ }
+
+ SecretsSession session = await _sessions.OpenAsync(target, overrideKek: null, ct).ConfigureAwait(false);
+ RenderWarnings(session);
+
+ if (await RunMenuLoopAsync(session, ct).ConfigureAwait(false) == MenuOutcome.Quit)
+ {
+ return 0;
+ }
+
+ // MenuOutcome.SwitchTarget → fall through and re-open the picker.
+ }
+ }
+
+ // The outcome of the inner menu loop: the operator either quit outright or asked to switch targets.
+ private enum MenuOutcome
+ {
+ Quit,
+ SwitchTarget,
+ }
+
+ private async Task RunMenuLoopAsync(SecretsSession session, CancellationToken ct)
+ {
+ while (true)
+ {
+ RenderHeader(session);
+
+ string choice;
+ try
+ {
+ choice = _console.Prompt(BuildMenu());
+ }
+ catch (OperationCanceledException)
+ {
+ return MenuOutcome.Quit; // Ctrl-C at the menu → clean exit
+ }
+
+ switch (choice)
+ {
+ case QuitLabel:
+ return MenuOutcome.Quit;
+ case SwitchLabel:
+ return MenuOutcome.SwitchTarget;
+ }
+
+ IInteractiveFlow flow = _flows.First(f => f.Title == choice);
+ try
+ {
+ if (flow.RequiresKek && !session.KekAvailable)
+ {
+ SecretsSession? upgraded = await TryUpgradeSessionAsync(session, ct).ConfigureAwait(false);
+ if (upgraded is null)
+ {
+ continue; // cancelled or key rejected → back to the menu
+ }
+
+ session = upgraded; // keep the upgraded session for the rest of the loop
+ }
+
+ await flow.RunAsync(_console, session, ct).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ // Ctrl-C mid-flow → abandon the action and return to the menu.
+ }
+ catch (Exception ex) when (IsContainable(ex))
+ {
+ RenderErrorPanel(ex);
+ }
+ }
+ }
+
+ // The store/key faults a flow may realistically surface. SecretDecryptionException and
+ // MasterKeyUnavailableException both derive from InvalidOperationException; DbException (base of both
+ // Microsoft.Data.Sqlite and Microsoft.Data.SqlClient exceptions) covers store-provider faults. A
+ // blanket catch is deliberately avoided so genuinely unexpected faults still surface.
+ private static bool IsContainable(Exception ex) =>
+ ex is InvalidOperationException or IOException or ArgumentException or DbException;
+
+ // Presents the target picker: recents newest-first, then the two entry options. Returns the resolved
+ // target, or null if the operator cancelled the selection (Ctrl-C). Re-prompts on a recoverable
+ // read error so a bad path does not drop the operator out of the console.
+ private StoreTarget? PickTarget()
+ {
+ while (true)
+ {
+ IReadOnlyList recents = _recents.Load();
+
+ List choices = [];
+ Dictionary byLabel = new(StringComparer.Ordinal);
+ foreach (RecentTargetsStore.RecentTarget recent in recents)
+ {
+ string label = $"{recent.Name} — {recent.AppSettingsPath}";
+ if (byLabel.ContainsKey(label))
+ {
+ continue; // Load() already de-dups by path, but guard against colliding labels.
+ }
+
+ choices.Add(label);
+ byLabel[label] = recent;
+ }
+
+ choices.Add(EnterPathLabel);
+ choices.Add(ManualEntryLabel);
+
+ string picked;
+ try
+ {
+ picked = _console.Prompt(new SelectionPrompt()
+ .Title("Select a store target")
+ .AddChoices(choices));
+ }
+ catch (OperationCanceledException)
+ {
+ return null;
+ }
+
+ try
+ {
+ if (picked == ManualEntryLabel)
+ {
+ return ReadManualTarget();
+ }
+
+ string appSettingsPath = picked == EnterPathLabel
+ ? _console.Prompt(new TextPrompt("appsettings.json path:"))
+ : byLabel[picked].AppSettingsPath;
+
+ StoreTarget target = _reader.Read(appSettingsPath);
+ _recents.Touch(target.Name, target.AppSettingsPath!); // Read targets always carry a path
+ return target;
+ }
+ catch (OperationCanceledException)
+ {
+ return null;
+ }
+ catch (Exception ex) when (IsContainable(ex))
+ {
+ RenderErrorPanel(ex); // e.g. FileNotFoundException — show it and re-open the picker
+ }
+ }
+ }
+
+ private StoreTarget ReadManualTarget()
+ {
+ string dbPath = _console.Prompt(new TextPrompt("SQLite database path:"));
+ string envVar = _console.Prompt(new TextPrompt("Master-key environment variable name:")
+ .DefaultValue("ZB_SECRETS_MASTER_KEY"));
+
+ // Manual targets have no appsettings.json, so they are not remembered as recents.
+ return _reader.Manual(dbPath, new MasterKeyOptions
+ {
+ Source = MasterKeySource.Environment,
+ EnvVarName = envVar,
+ });
+ }
+
+ // Prompts the operator for a KEK to lift a degraded session, re-opening it with the supplied key.
+ // Returns the upgraded (KEK-capable) session, or null if the operator cancelled or the key was
+ // rejected — in which case the caller stays on the menu.
+ private async Task TryUpgradeSessionAsync(SecretsSession session, CancellationToken ct)
+ {
+ _console.MarkupLine("[yellow]This action needs a KEK, but the session is degraded.[/]");
+
+ string source;
+ try
+ {
+ source = _console.Prompt(new SelectionPrompt()
+ .Title("Provide the key")
+ .AddChoices(PasteKeyLabel, KeyFileLabel, CancelLabel));
+ }
+ catch (OperationCanceledException)
+ {
+ return null;
+ }
+
+ if (source == CancelLabel)
+ {
+ return null;
+ }
+
+ IMasterKeyProvider provider;
+ try
+ {
+ if (source == PasteKeyLabel)
+ {
+ string base64 = _console.Prompt(new TextPrompt("Paste base64 KEK:").Secret());
+ provider = new LiteralMasterKeyProvider(base64);
+ }
+ else
+ {
+ string filePath = _console.Prompt(new TextPrompt("Key file path:"));
+ provider = MasterKeyProviderFactory.Create(new MasterKeyOptions
+ {
+ Source = MasterKeySource.File,
+ FilePath = filePath,
+ });
+ }
+ }
+ catch (ArgumentException ex)
+ {
+ // LiteralMasterKeyProvider rejects a bad paste eagerly; name the problem and stay on the menu.
+ _console.MarkupLineInterpolated($"[red]Invalid key: {ex.Message}[/]");
+ return null;
+ }
+
+ SecretsSession upgraded = await _sessions.OpenAsync(session.Target, provider, ct).ConfigureAwait(false);
+ if (!upgraded.KekAvailable)
+ {
+ RenderWarnings(upgraded);
+ _console.MarkupLine("[red]That key did not unlock the session.[/]");
+ return null;
+ }
+
+ return upgraded;
+ }
+
+ private SelectionPrompt BuildMenu()
+ {
+ List actions = [];
+ actions.AddRange(_flows.Select(f => f.Title));
+ actions.Add(SwitchLabel);
+ actions.Add(QuitLabel);
+
+ return new SelectionPrompt()
+ .Title("Action")
+ .AddChoices(actions);
+ }
+
+ private void RenderHeader(SecretsSession session)
+ {
+ string kek = session.KekAvailable
+ ? "[green]KEK OK[/]"
+ : "[red]KEK UNAVAILABLE (degraded)[/]";
+
+ Markup body = new(
+ $"[bold]{Markup.Escape(session.Target.Name)}[/] " +
+ $"store: {Markup.Escape(session.StoreKind)} {kek}");
+
+ _console.Write(new Panel(body).Header("Target"));
+ }
+
+ private void RenderWarnings(SecretsSession session)
+ {
+ foreach (string warning in session.Warnings)
+ {
+ _console.MarkupLineInterpolated($"[yellow]{warning}[/]");
+ }
+ }
+
+ private void RenderErrorPanel(Exception ex)
+ {
+ Markup body = new(
+ $"[red]{Markup.Escape(ex.Message)}[/]\n\nIf this is a key problem, run the KEK doctor.");
+
+ _console.Write(new Panel(body)
+ .Header("[red]Action failed[/]")
+ .BorderColor(Color.Red));
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/InteractiveShellTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/InteractiveShellTests.cs
new file mode 100644
index 0000000..5e61497
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/InteractiveShellTests.cs
@@ -0,0 +1,284 @@
+using System.Security.Cryptography;
+using Spectre.Console;
+using Spectre.Console.Testing;
+using ZB.MOM.WW.Secrets.Abstractions;
+using ZB.MOM.WW.Secrets.Cli.Interactive;
+using ZB.MOM.WW.Secrets.MasterKey;
+
+namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive;
+
+///
+/// Drives end-to-end through a scripted : the
+/// target picker (recents-first + manual entry), the header/menu loop, flow dispatch, the degraded-session
+/// KEK-upgrade prompt, and the error-containment panel. The shell is the only UI-owning class, so these
+/// tests assert on rendered and on what a fake flow observed.
+///
+public sealed class InteractiveShellTests : IDisposable
+{
+ private readonly string _dir =
+ Path.Combine(Path.GetTempPath(), $"zb-secrets-shell-{Guid.NewGuid():N}");
+ private readonly List _envVars = [];
+
+ public InteractiveShellTests() => Directory.CreateDirectory(_dir);
+
+ public void Dispose()
+ {
+ foreach (string name in _envVars)
+ {
+ Environment.SetEnvironmentVariable(name, null);
+ }
+
+ if (Directory.Exists(_dir))
+ {
+ try { Directory.Delete(_dir, recursive: true); } catch (IOException) { /* best-effort temp cleanup */ }
+ }
+ }
+
+ private string DbPath => Path.Combine(_dir, "secrets.db");
+
+ private static string NewKeyBase64()
+ {
+ byte[] key = new byte[32];
+ RandomNumberGenerator.Fill(key);
+ return Convert.ToBase64String(key);
+ }
+
+ private string SetTempKeyEnv()
+ {
+ string name = $"ZB_TEST_KEK_{Guid.NewGuid():N}";
+ Environment.SetEnvironmentVariable(name, NewKeyBase64());
+ _envVars.Add(name);
+ return name;
+ }
+
+ private string UnsetEnvVarName()
+ {
+ string name = $"ZB_TEST_UNSET_{Guid.NewGuid():N}";
+ _envVars.Add(name); // ensure cleanup even though never set
+ return name;
+ }
+
+ private string WriteAppSettings(string envVarName)
+ {
+ string path = Path.Combine(_dir, "appsettings.json");
+ File.WriteAllText(path, $$"""
+ {
+ "Secrets": {
+ "SqlitePath": "secrets.db",
+ "MasterKey": { "Source": "Environment", "EnvVarName": "{{envVarName}}" }
+ }
+ }
+ """);
+ return path;
+ }
+
+ private static TestConsole NewConsole() => new TestConsole().Interactive();
+
+ private static InteractiveShell NewShell(
+ TestConsole console, RecentTargetsStore recents, params IInteractiveFlow[] flows) =>
+ new(console, recents, new TargetConfigReader(), new SecretsSessionFactory(), flows);
+
+ private RecentTargetsStore NewRecents(TimeProvider? clock = null) =>
+ new(Path.Combine(_dir, "recents.json"), clock);
+
+ /// Records what the shell dispatched to it without touching store logic.
+ private sealed class FakeFlow(string title, bool requiresKek = false, Action? onRun = null)
+ : IInteractiveFlow
+ {
+ public string Title { get; } = title;
+ public bool RequiresKek { get; } = requiresKek;
+ public int InvocationCount { get; private set; }
+ public SecretsSession? LastSession { get; private set; }
+
+ public Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct)
+ {
+ InvocationCount++;
+ LastSession = session;
+ onRun?.Invoke(session);
+ return Task.CompletedTask;
+ }
+ }
+
+ // --- Picker input helpers (SelectionPrompt: DownArrow to move, Enter to accept) ---
+
+ private static void SelectIndex(TestConsole console, int index)
+ {
+ for (int i = 0; i < index; i++)
+ {
+ console.Input.PushKey(ConsoleKey.DownArrow);
+ }
+
+ console.Input.PushKey(ConsoleKey.Enter);
+ }
+
+ // Manual-entry target: with no recents the picker is [Enter appsettings(0), Manual entry(1)].
+ private void ScriptManualEntry(TestConsole console, string envVarName)
+ {
+ SelectIndex(console, 1); // "Manual entry…"
+ console.Input.PushTextWithEnter(DbPath);
+ console.Input.PushTextWithEnter(envVarName);
+ }
+
+ [Fact]
+ public async Task Shell_shows_target_picker_then_menu_and_quits()
+ {
+ TestConsole console = NewConsole();
+ InteractiveShell shell = NewShell(console, NewRecents());
+
+ ScriptManualEntry(console, UnsetEnvVarName());
+ // No flows: menu is [Switch store target(0), Quit(1)].
+ SelectIndex(console, 1); // Quit
+
+ int result = await shell.RunAsync(CancellationToken.None);
+
+ Assert.Equal(0, result);
+ Assert.Contains("Manual entry", console.Output, StringComparison.Ordinal);
+ Assert.Contains("appsettings", console.Output, StringComparison.Ordinal);
+ Assert.Contains("Switch store target", console.Output, StringComparison.Ordinal);
+ Assert.Contains("Quit", console.Output, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task Recents_appear_first_in_target_picker_and_selection_opens_session()
+ {
+ string envVar = SetTempKeyEnv();
+ string appSettings = WriteAppSettings(envVar);
+
+ var clock = new MutableClock(DateTimeOffset.Parse("2026-01-01T00:00:00Z"));
+ RecentTargetsStore recents = NewRecents(clock);
+ recents.Touch("prod-app", appSettings);
+ DateTimeOffset original = recents.Load()[0].LastUsedUtc;
+
+ clock.Advance(TimeSpan.FromHours(1));
+
+ TestConsole console = NewConsole();
+ InteractiveShell shell = NewShell(console, recents);
+
+ SelectIndex(console, 0); // recent is first
+ SelectIndex(console, 1); // menu (no flows): Quit
+
+ int result = await shell.RunAsync(CancellationToken.None);
+
+ Assert.Equal(0, result);
+ Assert.Contains("prod-app", console.Output, StringComparison.Ordinal);
+
+ IReadOnlyList after = recents.Load();
+ Assert.Equal(appSettings, after[0].AppSettingsPath); // still first
+ Assert.True(after[0].LastUsedUtc > original); // re-Touched with a newer stamp
+ }
+
+ [Fact]
+ public async Task Menu_lists_registered_flows_by_title_plus_builtins()
+ {
+ var alpha = new FakeFlow("Alpha flow");
+ var beta = new FakeFlow("Beta flow");
+
+ TestConsole console = NewConsole();
+ InteractiveShell shell = NewShell(console, NewRecents(), alpha, beta);
+
+ ScriptManualEntry(console, UnsetEnvVarName());
+ // Menu is [Alpha(0), Beta(1), Switch(2), Quit(3)].
+ SelectIndex(console, 3); // Quit
+
+ int result = await shell.RunAsync(CancellationToken.None);
+
+ Assert.Equal(0, result);
+ Assert.Contains("Alpha flow", console.Output, StringComparison.Ordinal);
+ Assert.Contains("Beta flow", console.Output, StringComparison.Ordinal);
+ Assert.Contains("Switch store target", console.Output, StringComparison.Ordinal);
+ Assert.Contains("Quit", console.Output, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task Degraded_session_flow_requiring_kek_prompts_for_key_first()
+ {
+ var flow = new FakeFlow("Seal something", requiresKek: true);
+ string pastedKey = NewKeyBase64();
+
+ TestConsole console = NewConsole();
+ InteractiveShell shell = NewShell(console, NewRecents(), flow);
+
+ ScriptManualEntry(console, UnsetEnvVarName()); // degraded session
+ // Menu is [Seal something(0), Switch(1), Quit(2)].
+ SelectIndex(console, 0); // choose the KEK-requiring flow
+ SelectIndex(console, 0); // key source: "Paste base64 key"
+ console.Input.PushTextWithEnter(pastedKey);
+ SelectIndex(console, 2); // back at the menu → Quit
+
+ int result = await shell.RunAsync(CancellationToken.None);
+
+ Assert.Equal(0, result);
+ Assert.Equal(1, flow.InvocationCount);
+ Assert.NotNull(flow.LastSession);
+ Assert.True(flow.LastSession!.KekAvailable); // session was upgraded before dispatch
+ Assert.DoesNotContain(pastedKey, console.Output, StringComparison.Ordinal); // masked
+ }
+
+ [Fact]
+ public async Task Flow_not_requiring_kek_runs_on_degraded_session()
+ {
+ var flow = new FakeFlow("List metadata", requiresKek: false);
+
+ TestConsole console = NewConsole();
+ InteractiveShell shell = NewShell(console, NewRecents(), flow);
+
+ ScriptManualEntry(console, UnsetEnvVarName()); // degraded session
+ SelectIndex(console, 0); // run the flow
+ SelectIndex(console, 2); // menu [flow(0), Switch(1), Quit(2)] → Quit
+
+ int result = await shell.RunAsync(CancellationToken.None);
+
+ Assert.Equal(0, result);
+ Assert.Equal(1, flow.InvocationCount);
+ Assert.NotNull(flow.LastSession);
+ Assert.False(flow.LastSession!.KekAvailable); // ran on the degraded session
+ }
+
+ [Fact]
+ public async Task Flow_exception_renders_error_panel_and_returns_to_menu()
+ {
+ var flow = new FakeFlow(
+ "Boom",
+ requiresKek: false,
+ onRun: _ => throw new SecretDecryptionException("tag mismatch"));
+
+ TestConsole console = NewConsole();
+ InteractiveShell shell = NewShell(console, NewRecents(), flow);
+
+ ScriptManualEntry(console, UnsetEnvVarName());
+ SelectIndex(console, 0); // run the throwing flow
+ SelectIndex(console, 2); // loop returned to menu → Quit
+
+ int result = await shell.RunAsync(CancellationToken.None);
+
+ Assert.Equal(0, result);
+ Assert.Equal(1, flow.InvocationCount);
+ Assert.Contains("KEK doctor", console.Output, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task Switch_target_reopens_picker()
+ {
+ TestConsole console = NewConsole();
+ InteractiveShell shell = NewShell(console, NewRecents());
+
+ ScriptManualEntry(console, UnsetEnvVarName()); // first target
+ SelectIndex(console, 0); // menu [Switch(0), Quit(1)] → Switch
+ ScriptManualEntry(console, UnsetEnvVarName()); // picker reopened → second target
+ SelectIndex(console, 1); // Quit
+
+ int result = await shell.RunAsync(CancellationToken.None);
+
+ Assert.Equal(0, result);
+ }
+
+ /// A hand-advanced clock so a re-Touch produces a strictly newer timestamp.
+ private sealed class MutableClock(DateTimeOffset start) : TimeProvider
+ {
+ private DateTimeOffset _now = start;
+
+ public void Advance(TimeSpan by) => _now += by;
+
+ public override DateTimeOffset GetUtcNow() => _now;
+ }
+}