diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/InteractiveEntry.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/InteractiveEntry.cs
new file mode 100644
index 0000000..a51ad57
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/InteractiveEntry.cs
@@ -0,0 +1,62 @@
+using Spectre.Console;
+using ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
+
+namespace ZB.MOM.WW.Secrets.Cli.Interactive;
+
+///
+/// Composition root for the interactive console: the non-TTY gate that decides whether a bare
+/// secret invocation launches the console, and the default wiring (flow set, recents store,
+/// target reader, session factory) that hands to an .
+///
+public static class InteractiveEntry
+{
+ ///
+ /// Whether a bare secret invocation should launch the interactive console. True only when no
+ /// verb was supplied AND neither standard stream is redirected — a redirected stdin/stdout means the
+ /// invocation is scripted/piped, where the console's prompts would hang or corrupt the pipe, so the
+ /// caller falls through to headless usage instead.
+ ///
+ /// The process command-line arguments.
+ /// at process start.
+ /// at process start.
+ public static bool ShouldEnterInteractive(string[] args, bool inputRedirected, bool outputRedirected)
+ => args.Length == 0 && !inputRedirected && !outputRedirected;
+
+ ///
+ /// Builds the ordered flow set the shell is wired with — one instance of each menu action, in the
+ /// order they appear on the menu. Exposed (and used by ) so the composition
+ /// is assertable without running the shell.
+ ///
+ public static IReadOnlyList CreateFlows() =>
+ [
+ new ListSecretsFlow(),
+ new SetSecretFlow(),
+ new RevealSecretFlow(),
+ new DeleteSecretFlow(),
+ new ReferenceAuditFlow(),
+ new KekDoctorFlow(),
+ new BundleExportFlow(),
+ new BundleImportFlow(),
+ ];
+
+ ///
+ /// Wires an with its default collaborators: a per-user recents store
+ /// under ~/.zb-secrets/recent-targets.json, the target-config reader, the session factory, and
+ /// . The shell's own ctor re-validates flow-title uniqueness.
+ ///
+ /// The console surface the shell (and its flows) render to.
+ public static InteractiveShell CreateShell(IAnsiConsole console)
+ {
+ string recentsPath = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ ".zb-secrets",
+ "recent-targets.json");
+
+ return new InteractiveShell(
+ console,
+ new RecentTargetsStore(recentsPath),
+ new TargetConfigReader(),
+ new SecretsSessionFactory(),
+ CreateFlows());
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Program.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Program.cs
index 5423f81..223d255 100644
--- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Program.cs
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Program.cs
@@ -1,8 +1,10 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
+using Spectre.Console;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Cli;
+using ZB.MOM.WW.Secrets.Cli.Interactive;
using ZB.MOM.WW.Secrets.DependencyInjection;
using ZB.MOM.WW.Secrets.MasterKey;
@@ -10,6 +12,12 @@ using ZB.MOM.WW.Secrets.MasterKey;
// The heavy lifting lives in SecretCommands; Program only builds the host, runs the schema
// migration, parses the verb + positional args, and dispatches.
+// A bare `secret` on a real TTY launches the interactive console, which builds its OWN per-target
+// sessions — so this must run BEFORE the host build + schema migration below, which the headless
+// verbs (and only they) require against the CLI's own appsettings-configured store and KEK.
+if (InteractiveEntry.ShouldEnterInteractive(args, Console.IsInputRedirected, Console.IsOutputRedirected))
+ return await InteractiveEntry.CreateShell(AnsiConsole.Console).RunAsync(CancellationToken.None);
+
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Configuration
.AddJsonFile("appsettings.json", optional: true)
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/InteractiveEntryTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/InteractiveEntryTests.cs
new file mode 100644
index 0000000..6a33d60
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/InteractiveEntryTests.cs
@@ -0,0 +1,57 @@
+using Spectre.Console.Testing;
+using ZB.MOM.WW.Secrets.Cli.Interactive;
+
+namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive;
+
+///
+/// Pins the composition root of the interactive console: the non-TTY gate that decides whether a bare
+/// secret invocation launches the console, and the exact flow set (order + uniqueness) the shell
+/// is wired with. These are the two things Program.cs delegates to, so they are asserted here directly
+/// rather than by driving the whole shell.
+///
+public sealed class InteractiveEntryTests
+{
+ [Theory]
+ // Only a bare invocation on a real TTY (neither stream redirected) enters the interactive console.
+ [InlineData(new string[0], false, false, true)]
+ [InlineData(new string[0], true, false, false)]
+ [InlineData(new string[0], false, true, false)]
+ [InlineData(new string[0], true, true, false)]
+ [InlineData(new[] { "list" }, false, false, false)]
+ public void ShouldEnterInteractive_true_only_for_no_args_and_real_tty(
+ string[] args, bool inputRedirected, bool outputRedirected, bool expected)
+ {
+ Assert.Equal(
+ expected,
+ InteractiveEntry.ShouldEnterInteractive(args, inputRedirected, outputRedirected));
+ }
+
+ [Fact]
+ public void CreateShell_composes_all_flows_exactly_once()
+ {
+ // CreateShell must succeed — its InteractiveShell ctor re-validates flow-title uniqueness, so a
+ // duplicate or reserved collision would throw here.
+ InteractiveShell shell = InteractiveEntry.CreateShell(new TestConsole());
+ Assert.NotNull(shell);
+
+ // The composed flow set is asserted through the same seam CreateShell uses.
+ string[] titles = InteractiveEntry.CreateFlows().Select(f => f.Title).ToArray();
+
+ Assert.Equal(
+ new[]
+ {
+ "List secrets",
+ "Set / rotate a secret",
+ "Get (reveal) a secret",
+ "Delete a secret",
+ "Reference audit & seed",
+ "KEK doctor (lockout recovery)",
+ "Export bundle (ciphertext-only)",
+ "Import bundle",
+ },
+ titles);
+
+ // Each title appears exactly once.
+ Assert.Equal(titles.Length, titles.Distinct(StringComparer.Ordinal).Count());
+ }
+}