diff --git a/ZB.MOM.WW.Secrets/Directory.Packages.props b/ZB.MOM.WW.Secrets/Directory.Packages.props
index 84dca02..e8fbd27 100644
--- a/ZB.MOM.WW.Secrets/Directory.Packages.props
+++ b/ZB.MOM.WW.Secrets/Directory.Packages.props
@@ -22,6 +22,7 @@
+
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 44ac559..465639d 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,3 +1,152 @@
-// Placeholder entry point for the ZB.MOM.WW.Secrets CLI. Real commands are added
-// in a later task; for now it is a no-op that exits successfully.
-return 0;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using ZB.MOM.WW.Secrets.Abstractions;
+using ZB.MOM.WW.Secrets.Cli;
+using ZB.MOM.WW.Secrets.DependencyInjection;
+using ZB.MOM.WW.Secrets.Sqlite;
+
+// Headless `secret` CLI: set / get / list / rm / rotate over the ZB.MOM.WW secrets store.
+// The heavy lifting lives in SecretCommands; Program only builds the host, runs the schema
+// migration, parses the verb + positional args, and dispatches.
+
+HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
+builder.Configuration
+ .AddJsonFile("appsettings.json", optional: true)
+ .AddEnvironmentVariables();
+builder.Services.AddZbSecrets(builder.Configuration, "Secrets");
+
+using IHost host = builder.Build();
+
+// Ensure the schema exists before any store operation.
+await host.Services.GetRequiredService()
+ .MigrateAsync(CancellationToken.None);
+
+var commands = new SecretCommands(
+ host.Services.GetRequiredService(),
+ host.Services.GetRequiredService(),
+ host.Services.GetRequiredService(),
+ Console.Out);
+
+string actor = string.IsNullOrWhiteSpace(Environment.UserName) ? "cli" : Environment.UserName;
+CancellationToken ct = CancellationToken.None;
+
+if (args.Length == 0)
+{
+ return Usage();
+}
+
+try
+{
+ switch (args[0])
+ {
+ case "set":
+ {
+ if (args.Length < 3)
+ return Usage("set requires and .");
+ (SecretContentType contentType, string? description) = ParseSetOptions(args, startIndex: 3);
+ return await commands.SetAsync(args[1], args[2], contentType, description, actor, ct);
+ }
+
+ case "rotate":
+ {
+ if (args.Length < 3)
+ return Usage("rotate requires and .");
+ (SecretContentType contentType, string? description) = ParseSetOptions(args, startIndex: 3);
+ return await commands.RotateAsync(args[1], args[2], contentType, description, actor, ct);
+ }
+
+ case "get":
+ {
+ if (args.Length < 2)
+ return Usage("get requires .");
+ return await commands.GetAsync(args[1], ct);
+ }
+
+ case "list":
+ {
+ bool includeDeleted = args.Skip(1).Any(a =>
+ string.Equals(a, "--include-deleted", StringComparison.Ordinal));
+ return await commands.ListAsync(includeDeleted, ct);
+ }
+
+ case "rm":
+ {
+ if (args.Length < 2)
+ return Usage("rm requires .");
+ return await commands.RemoveAsync(args[1], actor, ct);
+ }
+
+ default:
+ return Usage($"Unknown command '{args[0]}'.");
+ }
+}
+catch (ArgumentException ex)
+{
+ // Invalid secret name, bad --content-type value, unknown option: report without a stack trace.
+ Console.Error.WriteLine(ex.Message);
+ return 2;
+}
+
+// --- helpers ---------------------------------------------------------------
+
+// Parses the optional flags shared by `set` and `rotate`:
+// [--content-type text|connection-string|json|binary-base64] [--description "..."]
+static (SecretContentType contentType, string? description) ParseSetOptions(string[] args, int startIndex)
+{
+ SecretContentType contentType = SecretContentType.Text;
+ string? description = null;
+
+ for (int i = startIndex; i < args.Length; i++)
+ {
+ switch (args[i])
+ {
+ case "--content-type":
+ if (i + 1 >= args.Length)
+ throw new ArgumentException("--content-type requires a value.");
+ contentType = ParseContentType(args[++i]);
+ break;
+
+ case "--description":
+ if (i + 1 >= args.Length)
+ throw new ArgumentException("--description requires a value.");
+ description = args[++i];
+ break;
+
+ default:
+ throw new ArgumentException($"Unknown option '{args[i]}'.");
+ }
+ }
+
+ return (contentType, description);
+}
+
+// Maps the CLI content-type token (kebab-case) to the enum.
+static SecretContentType ParseContentType(string value) => value switch
+{
+ "text" => SecretContentType.Text,
+ "connection-string" => SecretContentType.ConnectionString,
+ "json" => SecretContentType.Json,
+ "binary-base64" => SecretContentType.BinaryBase64,
+ _ => throw new ArgumentException(
+ $"Unknown --content-type '{value}'; expected text|connection-string|json|binary-base64."),
+};
+
+// Prints usage (optionally preceded by an error line) and returns the standard usage exit code.
+int Usage(string? error = null)
+{
+ if (error is not null)
+ Console.Error.WriteLine(error);
+
+ Console.Error.WriteLine(
+ """
+ Usage: secret [args]
+
+ set [--content-type text|connection-string|json|binary-base64] [--description "..."]
+ rotate [--content-type ...] [--description "..."]
+ get
+ list [--include-deleted]
+ rm
+ """);
+ return 2;
+}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/SecretCommands.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/SecretCommands.cs
new file mode 100644
index 0000000..5a6ede4
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/SecretCommands.cs
@@ -0,0 +1,174 @@
+using System.Text.Json;
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.Secrets.Cli;
+
+///
+/// The thin, front-end-agnostic command layer behind the secret CLI verbs
+/// (set / get / list / rm / rotate). Each verb is an
+/// -returning task (0 on success, non-zero on error) and writes ALL output to the
+/// injected — never to directly — so the layer is
+/// fully testable and can be hosted under any front-end. Confirmation and listing output is JSON
+/// metadata only and NEVER carries a secret value; the sole value-exposing surface is
+/// , which intentionally prints the decrypted plaintext (see its remarks).
+///
+public sealed class SecretCommands
+{
+ private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = false };
+
+ private readonly ISecretStore _store;
+ private readonly ISecretCipher _cipher;
+ private readonly ISecretResolver _resolver;
+ private readonly TextWriter _output;
+
+ /// Creates the command set over the supplied core seams and output sink.
+ /// The encrypted store (upsert / delete / list).
+ /// The envelope cipher used to seal values on set / rotate.
+ /// The read seam used by get to resolve current plaintext.
+ /// Sink for all command output (JSON confirmations, listings, values).
+ public SecretCommands(ISecretStore store, ISecretCipher cipher, ISecretResolver resolver, TextWriter output)
+ {
+ _store = store ?? throw new ArgumentNullException(nameof(store));
+ _cipher = cipher ?? throw new ArgumentNullException(nameof(cipher));
+ _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
+ _output = output ?? throw new ArgumentNullException(nameof(output));
+ }
+
+ ///
+ /// set: seals under a fresh per-secret DEK and upserts it (create or
+ /// overwrite-in-place). Prints a JSON confirmation carrying only the name and action — never the
+ /// value.
+ ///
+ /// The secret name.
+ /// The plaintext to seal.
+ /// How consumers should interpret the plaintext.
+ /// Optional human-readable description.
+ /// Principal recorded as created_by / updated_by, if known.
+ /// A token to cancel the operation.
+ /// 0 on success.
+ public async Task SetAsync(
+ string name, string value, SecretContentType contentType, string? description, string? actor, CancellationToken ct)
+ {
+ var secretName = new SecretName(name);
+ await UpsertSealedAsync(secretName, value, contentType, description, actor, ct).ConfigureAwait(false);
+ WriteJson(new { name = secretName.Value, action = "set" });
+ return 0;
+ }
+
+ ///
+ /// rotate: like , but REQUIRES the secret to already exist (and not be
+ /// tombstoned). Rotating a missing secret prints a not-found error and returns non-zero without
+ /// creating anything. The confirmation carries only the name and action — never the value.
+ ///
+ /// The existing secret to rotate.
+ /// The new plaintext to seal in place.
+ /// How consumers should interpret the plaintext.
+ /// Optional human-readable description.
+ /// Principal recorded as updated_by, if known.
+ /// A token to cancel the operation.
+ /// 0 on success; non-zero if the secret does not exist.
+ public async Task RotateAsync(
+ string name, string value, SecretContentType contentType, string? description, string? actor, CancellationToken ct)
+ {
+ var secretName = new SecretName(name);
+
+ StoredSecret? existing = await _store.GetAsync(secretName, ct).ConfigureAwait(false);
+ if (existing is null || existing.IsDeleted)
+ {
+ WriteJson(new { name = secretName.Value, error = "not-found" });
+ return 1;
+ }
+
+ await UpsertSealedAsync(secretName, value, contentType, description, actor, ct).ConfigureAwait(false);
+ WriteJson(new { name = secretName.Value, action = "rotate" });
+ return 0;
+ }
+
+ ///
+ /// get: resolves and prints the current DECRYPTED plaintext for to the
+ /// output sink.
+ ///
+ ///
+ /// This deliberately exposes the secret value. It is the one command whose whole purpose
+ /// is to surface plaintext (to stdout), so an operator can pipe or capture it. Every other
+ /// command emits metadata only. A missing or tombstoned secret prints a JSON not-found
+ /// error and returns non-zero.
+ ///
+ /// The secret to resolve.
+ /// A token to cancel the operation.
+ /// 0 when the value was printed; non-zero when the secret is absent.
+ public async Task GetAsync(string name, CancellationToken ct)
+ {
+ var secretName = new SecretName(name);
+
+ string? value = await _resolver.GetAsync(secretName, ct).ConfigureAwait(false);
+ if (value is null)
+ {
+ WriteJson(new { error = "not-found" });
+ return 1;
+ }
+
+ _output.WriteLine(value);
+ return 0;
+ }
+
+ ///
+ /// list: prints a JSON array of the safe metadata projection for every secret — name,
+ /// description, content type, KEK id, revision, tombstone flag, and update stamps. NEVER a value.
+ ///
+ /// When , tombstoned rows are included.
+ /// A token to cancel the operation.
+ /// 0 on success.
+ public async Task ListAsync(bool includeDeleted, CancellationToken ct)
+ {
+ IReadOnlyList items = await _store.ListAsync(includeDeleted, ct).ConfigureAwait(false);
+
+ var projection = items.Select(m => new
+ {
+ name = m.Name.Value,
+ description = m.Description,
+ contentType = m.ContentType.ToString(),
+ kekId = m.KekId,
+ revision = m.Revision,
+ isDeleted = m.IsDeleted,
+ updatedUtc = m.UpdatedUtc,
+ updatedBy = m.UpdatedBy,
+ });
+
+ WriteJson(projection);
+ return 0;
+ }
+
+ ///
+ /// rm: soft-deletes (tombstones) and prints a JSON confirmation with a
+ /// found flag.
+ ///
+ /// The secret to tombstone.
+ /// Principal recorded as updated_by, if known.
+ /// A token to cancel the operation.
+ /// 0 if a row was tombstoned; non-zero if no such row existed.
+ public async Task RemoveAsync(string name, string? actor, CancellationToken ct)
+ {
+ var secretName = new SecretName(name);
+
+ bool removed = await _store.DeleteAsync(secretName, actor, ct).ConfigureAwait(false);
+ WriteJson(new { name = secretName.Value, action = "removed", found = removed });
+ return removed ? 0 : 1;
+ }
+
+ /// Seals a value under the cipher and upserts it, carrying description + actor stamps.
+ private async Task UpsertSealedAsync(
+ SecretName name, string value, SecretContentType contentType, string? description, string? actor, CancellationToken ct)
+ {
+ StoredSecret row = _cipher.Encrypt(name, value, contentType) with
+ {
+ Description = description,
+ CreatedBy = actor,
+ UpdatedBy = actor,
+ };
+ await _store.UpsertAsync(row, ct).ConfigureAwait(false);
+ }
+
+ /// Serializes to compact JSON on its own line.
+ private void WriteJson(object value) => _output.WriteLine(JsonSerializer.Serialize(value, JsonOptions));
+}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/ZB.MOM.WW.Secrets.Cli.csproj b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/ZB.MOM.WW.Secrets.Cli.csproj
index 063ae94..3302fcc 100644
--- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/ZB.MOM.WW.Secrets.Cli.csproj
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/ZB.MOM.WW.Secrets.Cli.csproj
@@ -8,8 +8,18 @@
false
+
+
+
+
+
+
+ PreserveNewest
+
+
+
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/appsettings.json b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/appsettings.json
new file mode 100644
index 0000000..8f8dcf5
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/appsettings.json
@@ -0,0 +1,10 @@
+{
+ "Secrets": {
+ "SqlitePath": "secrets.db",
+ "MasterKey": {
+ "Source": "Environment",
+ "EnvVarName": "ZB_SECRETS_MASTER_KEY"
+ },
+ "RunMigrationsOnStartup": true
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/SecretCommandsTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/SecretCommandsTests.cs
new file mode 100644
index 0000000..5285464
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/SecretCommandsTests.cs
@@ -0,0 +1,156 @@
+using ZB.MOM.WW.Audit;
+using ZB.MOM.WW.Secrets.Abstractions;
+using ZB.MOM.WW.Secrets.Cli;
+using ZB.MOM.WW.Secrets.Crypto;
+using ZB.MOM.WW.Secrets.Sqlite;
+using ZB.MOM.WW.Secrets.Tests.Fakes;
+
+namespace ZB.MOM.WW.Secrets.Tests.Cli;
+
+///
+/// Exercises the headless layer over a real migrated SQLite store,
+/// the real envelope cipher, and the real resolver — asserting round-trips, exit codes, and the
+/// hard invariant that list/set output never carries a plaintext value.
+///
+public sealed class SecretCommandsTests : IAsyncLifetime, IDisposable
+{
+ private readonly string _dbPath =
+ Path.Combine(Path.GetTempPath(), $"zb-secrets-cli-{Guid.NewGuid():N}.db");
+
+ private readonly SecretsSqliteConnectionFactory _factory;
+ private readonly SqliteSecretStore _store;
+ private readonly AesGcmEnvelopeCipher _cipher;
+ private readonly DefaultSecretResolver _resolver;
+
+ public SecretCommandsTests()
+ {
+ _factory = new SecretsSqliteConnectionFactory(_dbPath);
+ _store = new SqliteSecretStore(_factory);
+ _cipher = new AesGcmEnvelopeCipher(new FakeMasterKeyProvider("kek-cli"));
+ // A near-zero TTL keeps the resolver from serving a stale plaintext across a rotate/remove.
+ _resolver = new DefaultSecretResolver(
+ _store, _cipher, NoOpAuditWriter.Instance, TimeSpan.Zero);
+ }
+
+ public async Task InitializeAsync() =>
+ await new SqliteSecretsStoreMigrator(_factory).MigrateAsync(CancellationToken.None);
+
+ public Task DisposeAsync() => Task.CompletedTask;
+
+ public void Dispose()
+ {
+ if (File.Exists(_dbPath))
+ {
+ try { File.Delete(_dbPath); } catch (IOException) { /* best-effort temp cleanup */ }
+ }
+ }
+
+ private (SecretCommands cmd, StringWriter output) NewSut()
+ {
+ var output = new StringWriter();
+ return (new SecretCommands(_store, _cipher, _resolver, output), output);
+ }
+
+ private static CancellationToken Ct => CancellationToken.None;
+
+ [Fact]
+ public async Task Set_Then_Get_RoundTrips()
+ {
+ var (cmd, setOut) = NewSut();
+
+ int setRc = await cmd.SetAsync(
+ "sql/db-conn", "hunter2", SecretContentType.ConnectionString, "prod db", "alice", Ct);
+
+ Assert.Equal(0, setRc);
+ // set confirmation must NOT leak the value.
+ Assert.DoesNotContain("hunter2", setOut.ToString());
+ Assert.Contains("\"action\":\"set\"", setOut.ToString());
+ Assert.Contains("sql/db-conn", setOut.ToString());
+
+ // A fresh writer for get so we can assert exactly the plaintext came back.
+ var getOut = new StringWriter();
+ var getCmd = new SecretCommands(_store, _cipher, _resolver, getOut);
+ int getRc = await getCmd.GetAsync("sql/db-conn", Ct);
+
+ Assert.Equal(0, getRc);
+ Assert.Equal("hunter2", getOut.ToString().TrimEnd('\r', '\n'));
+ }
+
+ [Fact]
+ public async Task List_PrintsMetadata_NoValues()
+ {
+ var (cmd, _) = NewSut();
+ await cmd.SetAsync("app/one", "value-one", SecretContentType.Text, null, "alice", Ct);
+ await cmd.SetAsync("app/two", "value-two", SecretContentType.Json, "second", "alice", Ct);
+
+ var listOut = new StringWriter();
+ var listCmd = new SecretCommands(_store, _cipher, _resolver, listOut);
+ int rc = await listCmd.ListAsync(includeDeleted: false, Ct);
+
+ string json = listOut.ToString();
+ Assert.Equal(0, rc);
+ Assert.Contains("app/one", json);
+ Assert.Contains("app/two", json);
+ Assert.DoesNotContain("value-one", json);
+ Assert.DoesNotContain("value-two", json);
+ }
+
+ [Fact]
+ public async Task Rm_Tombstones()
+ {
+ var (cmd, _) = NewSut();
+ await cmd.SetAsync("app/gone", "bye", SecretContentType.Text, null, "alice", Ct);
+
+ var rm1Out = new StringWriter();
+ int rc1 = await new SecretCommands(_store, _cipher, _resolver, rm1Out).RemoveAsync("app/gone", "alice", Ct);
+ Assert.Equal(0, rc1);
+ Assert.Contains("\"found\":true", rm1Out.ToString());
+
+ // Second remove: already tombstoned → not found.
+ var rm2Out = new StringWriter();
+ int rc2 = await new SecretCommands(_store, _cipher, _resolver, rm2Out).RemoveAsync("app/gone", "alice", Ct);
+ Assert.NotEqual(0, rc2);
+ Assert.Contains("\"found\":false", rm2Out.ToString());
+
+ // Get after removal → not-found.
+ var getOut = new StringWriter();
+ int getRc = await new SecretCommands(_store, _cipher, _resolver, getOut).GetAsync("app/gone", Ct);
+ Assert.NotEqual(0, getRc);
+ Assert.Contains("not-found", getOut.ToString());
+ }
+
+ [Fact]
+ public async Task Rotate_RequiresExisting_OverwritesInPlace()
+ {
+ // Rotate on a missing secret → non-zero, nothing created.
+ var missingOut = new StringWriter();
+ int missingRc = await new SecretCommands(_store, _cipher, _resolver, missingOut)
+ .RotateAsync("app/rot", "v1", SecretContentType.Text, null, "alice", Ct);
+ Assert.NotEqual(0, missingRc);
+
+ // After a set, rotate to a new value → 0, and get returns the new value.
+ await new SecretCommands(_store, _cipher, _resolver, new StringWriter())
+ .SetAsync("app/rot", "v1", SecretContentType.Text, null, "alice", Ct);
+
+ var rotateOut = new StringWriter();
+ int rotateRc = await new SecretCommands(_store, _cipher, _resolver, rotateOut)
+ .RotateAsync("app/rot", "v2", SecretContentType.Text, null, "bob", Ct);
+ Assert.Equal(0, rotateRc);
+ Assert.Contains("\"action\":\"rotate\"", rotateOut.ToString());
+ Assert.DoesNotContain("v2", rotateOut.ToString());
+
+ var getOut = new StringWriter();
+ await new SecretCommands(_store, _cipher, _resolver, getOut).GetAsync("app/rot", Ct);
+ Assert.Equal("v2", getOut.ToString().TrimEnd('\r', '\n'));
+ }
+
+ [Fact]
+ public async Task Get_MissingSecret_ReturnsNonZero()
+ {
+ var (cmd, output) = NewSut();
+ int rc = await cmd.GetAsync("nope/missing", Ct);
+
+ Assert.NotEqual(0, rc);
+ Assert.Contains("not-found", output.ToString());
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/ZB.MOM.WW.Secrets.Tests.csproj b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/ZB.MOM.WW.Secrets.Tests.csproj
index e96d479..5094891 100644
--- a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/ZB.MOM.WW.Secrets.Tests.csproj
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/ZB.MOM.WW.Secrets.Tests.csproj
@@ -21,6 +21,7 @@
+