From 9db74fac1b8aa76456b136a7686897ff8207f345 Mon Sep 17 00:00:00 2001
From: Kirsten Kluge <15778270+kirkone@users.noreply.github.com>
Date: Thu, 14 May 2026 20:01:24 +0200
Subject: [PATCH 1/4] feat(sdk): add IBuildInfo + HostKind for build identity
(#23)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
New SDK service `Spectara.Revela.Sdk.Hosting.IBuildInfo` exposes the
immutable build-time identity of the running host (Standalone vs.
Embedded) plus version, framework, configuration, and runtime
identifier. Single source of truth for `--version`, the upcoming
`revela info` command, and any plugin that needs to branch on host
kind (e.g. self-update plugins hiding themselves in embedded builds).
Detection mechanism: `Revela.HostKind` assembly metadata attribute
set in `Cli.Embedded.csproj`. Both Cli and Cli.Embedded produce an
assembly named `revela`, so name-based detection is impossible —
the metadata attribute sidesteps that collision and makes adding a
third host variant (e.g. Cli.Embedded.Ai) a 3-line change.
System.CommandLine's default `--version` action is replaced with a
human-readable, host-kind-aware renderer:
revela 1.0.0 (.NET 10.0.7)
revela 1.0.0 (.NET 10.0.7) — embedded build
Same string is reused as the first line of `revela info`, so both
surfaces report the same identifier — no drift possible.
Conceptual note: `IBuildInfo` is complementary to .NET's
`IHostEnvironment`. The latter describes the runtime *deployment*
environment (Dev/Staging/Prod, overridable via DOTNET_ENVIRONMENT);
`IBuildInfo` describes the immutable *build-time* identity. The
two answer different questions and don't overlap.
Tests cover detection, version-string stripping, and FormatVersionLine
for both HostKind values.
---
Spectara.Revela.slnx | 1 +
src/Cli.Embedded/Cli.Embedded.csproj | 7 ++
src/Cli/Hosting/BuildInfo.cs | 142 +++++++++++++++++++++++++++
src/Cli/Hosting/HostBootstrap.cs | 32 ++++++
src/Sdk/Hosting/IBuildInfo.cs | 75 ++++++++++++++
tests/Cli/AssemblyInfo.cs | 2 +
tests/Cli/Cli.csproj | 33 +++++++
tests/Cli/Hosting/BuildInfoTests.cs | 103 +++++++++++++++++++
8 files changed, 395 insertions(+)
create mode 100644 src/Cli/Hosting/BuildInfo.cs
create mode 100644 src/Sdk/Hosting/IBuildInfo.cs
create mode 100644 tests/Cli/AssemblyInfo.cs
create mode 100644 tests/Cli/Cli.csproj
create mode 100644 tests/Cli/Hosting/BuildInfoTests.cs
diff --git a/Spectara.Revela.slnx b/Spectara.Revela.slnx
index 092957fd..6621a6fa 100644
--- a/Spectara.Revela.slnx
+++ b/Spectara.Revela.slnx
@@ -33,6 +33,7 @@
+
diff --git a/src/Cli.Embedded/Cli.Embedded.csproj b/src/Cli.Embedded/Cli.Embedded.csproj
index af23f2e3..00b9ec3e 100644
--- a/src/Cli.Embedded/Cli.Embedded.csproj
+++ b/src/Cli.Embedded/Cli.Embedded.csproj
@@ -20,6 +20,13 @@
+
+
+
+
+
diff --git a/src/Cli/Hosting/BuildInfo.cs b/src/Cli/Hosting/BuildInfo.cs
new file mode 100644
index 00000000..463a4264
--- /dev/null
+++ b/src/Cli/Hosting/BuildInfo.cs
@@ -0,0 +1,142 @@
+using System.Globalization;
+using System.Reflection;
+using System.Runtime.InteropServices;
+
+using Spectara.Revela.Sdk.Hosting;
+
+namespace Spectara.Revela.Cli.Hosting;
+
+///
+/// Default implementation.
+///
+///
+///
+/// Detects by reading the Revela.HostKind
+/// from the entry assembly. Default
+/// when the attribute is absent is .
+///
+///
+/// Both Cli and Cli.Embedded produce an executable named revela, so
+/// assembly-name-based detection is impossible. The metadata attribute is the
+/// single source of truth, set in Cli.Embedded.csproj via:
+///
+///
+/// <ItemGroup>
+/// <AssemblyMetadata Include="Revela.HostKind" Value="Embedded" />
+/// </ItemGroup>
+///
+///
+internal sealed class BuildInfo : IBuildInfo
+{
+ private const string HostKindMetadataKey = "Revela.HostKind";
+
+ private const string BuildConfiguration =
+#if DEBUG
+ "Debug";
+#else
+ "Release";
+#endif
+
+ public BuildInfo()
+ : this(Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly())
+ {
+ }
+
+ ///
+ /// Test seam: construct from a specific assembly, bypassing entry-assembly
+ /// auto-detection. Used by BuildInfoTests.
+ ///
+ internal BuildInfo(Assembly entry)
+ {
+ Kind = DetectHostKind(entry);
+ InformationalVersion = DetectInformationalVersion(entry);
+ Version = StripBuildMetadata(InformationalVersion);
+ Framework = RuntimeInformation.FrameworkDescription;
+ Configuration = BuildConfiguration;
+ RuntimeIdentifier = RuntimeInformation.RuntimeIdentifier;
+ }
+
+ ///
+ /// Test seam: construct directly from raw values, fully bypassing assembly
+ /// inspection. Used by BuildInfoTests for FormatVersionLine assertions
+ /// across both values.
+ ///
+ internal BuildInfo(
+ HostKind kind,
+ string version,
+ string informationalVersion,
+ string framework,
+ string configuration,
+ string runtimeIdentifier)
+ {
+ Kind = kind;
+ Version = version;
+ InformationalVersion = informationalVersion;
+ Framework = framework;
+ Configuration = configuration;
+ RuntimeIdentifier = runtimeIdentifier;
+ }
+
+ public HostKind Kind { get; }
+
+ public string Version { get; }
+
+ public string InformationalVersion { get; }
+
+ public string Framework { get; }
+
+ public string Configuration { get; }
+
+ public string RuntimeIdentifier { get; }
+
+ public string FormatVersionLine()
+ {
+ var suffix = Kind switch
+ {
+ HostKind.Embedded => " \u2014 embedded build",
+ HostKind.Standalone => string.Empty,
+ _ => string.Empty,
+ };
+ return string.Create(
+ CultureInfo.InvariantCulture,
+ $"revela {Version} ({Framework}){suffix}");
+ }
+
+ private static HostKind DetectHostKind(Assembly entry)
+ {
+ var value = entry
+ .GetCustomAttributes()
+ .FirstOrDefault(a => string.Equals(a.Key, HostKindMetadataKey, StringComparison.Ordinal))
+ ?.Value;
+
+ return string.Equals(value, nameof(HostKind.Embedded), StringComparison.Ordinal)
+ ? HostKind.Embedded
+ : HostKind.Standalone;
+ }
+
+ /// Test seam — exposes .
+ internal static HostKind DetectHostKindForTesting(Assembly entry) => DetectHostKind(entry);
+
+ private static string DetectInformationalVersion(Assembly entry)
+ {
+ var info = entry.GetCustomAttribute()?.InformationalVersion;
+ if (!string.IsNullOrWhiteSpace(info))
+ {
+ return info;
+ }
+
+ var version = entry.GetName().Version;
+ return version is null
+ ? "0.0.0"
+ : version.ToString(3);
+ }
+
+ private static string StripBuildMetadata(string informational)
+ {
+ var plus = informational.IndexOf('+', StringComparison.Ordinal);
+ return plus < 0 ? informational : informational[..plus];
+ }
+
+ /// Test seam — exposes .
+ internal static string StripBuildMetadataForTesting(string informational) => StripBuildMetadata(informational);
+}
diff --git a/src/Cli/Hosting/HostBootstrap.cs b/src/Cli/Hosting/HostBootstrap.cs
index 14ff305d..53023b35 100644
--- a/src/Cli/Hosting/HostBootstrap.cs
+++ b/src/Cli/Hosting/HostBootstrap.cs
@@ -1,11 +1,16 @@
+using System.CommandLine;
+using System.CommandLine.Invocation;
+using System.CommandLine.Parsing;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Spectara.Revela.Commands;
using Spectara.Revela.Core.Configuration;
using Spectara.Revela.Sdk;
using Spectara.Revela.Sdk.Abstractions;
using Spectara.Revela.Sdk.Configuration;
+using Spectara.Revela.Sdk.Hosting;
namespace Spectara.Revela.Cli.Hosting;
@@ -50,6 +55,11 @@ public static HostApplicationBuilder ConfigureRevela(
builder.Services.AddInteractiveMode();
builder.Services.AddPackages(packageSource, builder.Configuration, args);
+ // Build identity (HostKind, Version, Framework, ...) — single source
+ // of truth for `--version` and `revela info`. Idempotent registration
+ // so tests can override.
+ builder.Services.TryAddSingleton();
+
// Register ProjectEnvironment (runtime info about project location)
builder.Services.AddOptions()
.Configure((env, host) => env.Path = host.ContentRootPath);
@@ -67,6 +77,13 @@ public static async Task RunRevelaAsync(this IHost host, string[] args)
{
var rootCommand = host.UseRevelaCommands();
+ // Replace System.CommandLine's default --version action with one that
+ // prints the human-readable, host-kind-aware identifier. Same string
+ // is used as the first line of `revela info`.
+ var buildInfo = host.Services.GetRequiredService();
+ var versionOption = rootCommand.Options.OfType().FirstOrDefault();
+ versionOption?.Action = new BuildInfoVersionAction(buildInfo);
+
// Detect interactive mode: no arguments AND interactive terminal
var isInteractiveMode = args.Length == 0
&& !Console.IsInputRedirected
@@ -82,4 +99,19 @@ public static async Task RunRevelaAsync(this IHost host, string[] args)
return await rootCommand.Parse(args).InvokeAsync();
}
+
+ ///
+ /// Synchronous --version action that prints
+ /// .
+ ///
+ private sealed class BuildInfoVersionAction(IBuildInfo buildInfo) : SynchronousCommandLineAction
+ {
+ public override bool ClearsParseErrors => true;
+
+ public override int Invoke(ParseResult parseResult)
+ {
+ parseResult.InvocationConfiguration.Output.WriteLine(buildInfo.FormatVersionLine());
+ return 0;
+ }
+ }
}
diff --git a/src/Sdk/Hosting/IBuildInfo.cs b/src/Sdk/Hosting/IBuildInfo.cs
new file mode 100644
index 00000000..65a932ca
--- /dev/null
+++ b/src/Sdk/Hosting/IBuildInfo.cs
@@ -0,0 +1,75 @@
+namespace Spectara.Revela.Sdk.Hosting;
+
+///
+/// Immutable build-time identity of the running Revela host.
+///
+///
+///
+/// Single source of truth for version data exposed via revela --version,
+/// revela info, and any plugin that needs to branch on host kind.
+///
+///
+/// Note: describes the build-time
+/// identity of the host (Standalone vs. Embedded), determined at compile time
+/// via the Revela.HostKind assembly metadata attribute.
+/// It is not user-overridable.
+///
+///
+/// This is conceptually distinct from
+/// Microsoft.Extensions.Hosting.IHostEnvironment, which describes the
+/// runtime deployment environment (Development/Staging/Production)
+/// and is overridable via DOTNET_ENVIRONMENT. The two are
+/// complementary: IHostEnvironment answers "where am I running?",
+/// IBuildInfo answers "what build am I?".
+///
+///
+public interface IBuildInfo
+{
+ /// Build variant of the running host.
+ HostKind Kind { get; }
+
+ /// Clean semantic version, e.g. "1.0.0".
+ string Version { get; }
+
+ ///
+ /// Full informational version including build metadata,
+ /// e.g. "1.0.0+abc1234". Use this in bug reports.
+ ///
+ string InformationalVersion { get; }
+
+ /// .NET runtime description, e.g. ".NET 10.0.4".
+ string Framework { get; }
+
+ /// Build configuration: "Debug" or "Release".
+ string Configuration { get; }
+
+ /// Runtime identifier, e.g. "linux-x64".
+ string RuntimeIdentifier { get; }
+
+ ///
+ /// Single-line human-readable summary used by both --version
+ /// and the first line of revela info.
+ ///
+ ///
+ /// revela 1.0.0 (.NET 10.0.4) — embedded build
+ ///
+ string FormatVersionLine();
+}
+
+///
+/// Build variant of the running Revela host.
+///
+public enum HostKind
+{
+ ///
+ /// Standard standalone CLI build with dynamic plugin loading
+ /// (the revela dotnet tool).
+ ///
+ Standalone,
+
+ ///
+ /// Self-contained build with all plugins and themes statically linked.
+ /// No plugin management commands are available.
+ ///
+ Embedded,
+}
diff --git a/tests/Cli/AssemblyInfo.cs b/tests/Cli/AssemblyInfo.cs
new file mode 100644
index 00000000..a9b5e291
--- /dev/null
+++ b/tests/Cli/AssemblyInfo.cs
@@ -0,0 +1,2 @@
+// Enable parallel test execution at assembly level
+[assembly: Parallelize(Workers = 0, Scope = ExecutionScope.MethodLevel)]
diff --git a/tests/Cli/Cli.csproj b/tests/Cli/Cli.csproj
new file mode 100644
index 00000000..62777754
--- /dev/null
+++ b/tests/Cli/Cli.csproj
@@ -0,0 +1,33 @@
+
+
+
+ net10.0
+ Exe
+ enable
+ enable
+ false
+
+
+ true
+ true
+
+
+ $(NoWarn);CA1515
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Cli/Hosting/BuildInfoTests.cs b/tests/Cli/Hosting/BuildInfoTests.cs
new file mode 100644
index 00000000..61053882
--- /dev/null
+++ b/tests/Cli/Hosting/BuildInfoTests.cs
@@ -0,0 +1,103 @@
+using System.Reflection;
+
+using Spectara.Revela.Cli.Hosting;
+using Spectara.Revela.Sdk.Hosting;
+
+namespace Spectara.Revela.Tests.Cli.Hosting;
+
+[TestClass]
+[TestCategory("Unit")]
+public sealed class BuildInfoTests
+{
+ [TestMethod]
+ public void FormatVersionLine_Standalone_OmitsHostSuffix()
+ {
+ var info = new BuildInfo(
+ HostKind.Standalone,
+ version: "1.2.3",
+ informationalVersion: "1.2.3+abc1234",
+ framework: ".NET 10.0.4",
+ configuration: "Release",
+ runtimeIdentifier: "linux-x64");
+
+ var line = info.FormatVersionLine();
+
+ Assert.AreEqual("revela 1.2.3 (.NET 10.0.4)", line);
+ }
+
+ [TestMethod]
+ public void FormatVersionLine_Embedded_AppendsEmbeddedSuffix()
+ {
+ var info = new BuildInfo(
+ HostKind.Embedded,
+ version: "1.2.3",
+ informationalVersion: "1.2.3",
+ framework: ".NET 10.0.4",
+ configuration: "Release",
+ runtimeIdentifier: "linux-x64");
+
+ var line = info.FormatVersionLine();
+
+ Assert.AreEqual("revela 1.2.3 (.NET 10.0.4) \u2014 embedded build", line);
+ }
+
+ [TestMethod]
+ [DataRow("1.0.0", "1.0.0")]
+ [DataRow("1.0.0+abc1234", "1.0.0")]
+ [DataRow("1.0.0-rc.1+abc1234", "1.0.0-rc.1")]
+ [DataRow("2.5.0+", "2.5.0")]
+ public void StripBuildMetadata_RemovesEverythingFromFirstPlus(string input, string expected)
+ {
+ var actual = BuildInfo.StripBuildMetadataForTesting(input);
+
+ Assert.AreEqual(expected, actual);
+ }
+
+ [TestMethod]
+ public void DetectHostKind_AssemblyWithoutMetadata_ReturnsStandalone()
+ {
+ // The test runner assembly itself has no Revela.HostKind metadata.
+ var asm = Assembly.GetExecutingAssembly();
+
+ var kind = BuildInfo.DetectHostKindForTesting(asm);
+
+ Assert.AreEqual(HostKind.Standalone, kind);
+ }
+
+ [TestMethod]
+ public void DetectHostKind_CliEmbeddedAssembly_ReturnsEmbedded()
+ {
+ // Locate Cli.Embedded's revela.dll relative to this test assembly.
+ // Layout: artifacts/bin/Tests.Cli/Debug/net10.0/ → ../../../Cli.Embedded/Debug/net10.0/revela.dll
+ var testAsmDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!;
+ var embeddedDll = Path.GetFullPath(Path.Combine(
+ testAsmDir, "..", "..", "..", "Cli.Embedded", "Debug", "net10.0", "revela.dll"));
+
+ if (!File.Exists(embeddedDll))
+ {
+ Assert.Inconclusive($"Cli.Embedded build artifact not found at '{embeddedDll}'. Run a full solution build first.");
+ }
+
+ var asm = Assembly.LoadFile(embeddedDll);
+
+ var kind = BuildInfo.DetectHostKindForTesting(asm);
+
+ Assert.AreEqual(HostKind.Embedded, kind);
+ }
+
+ [TestMethod]
+ public void Constructor_FromTestRunnerAssembly_FillsAllProperties()
+ {
+ var info = new BuildInfo(Assembly.GetExecutingAssembly());
+
+ Assert.AreEqual(HostKind.Standalone, info.Kind);
+ Assert.IsFalse(string.IsNullOrEmpty(info.Version));
+ Assert.IsFalse(string.IsNullOrEmpty(info.Framework));
+ Assert.IsFalse(string.IsNullOrEmpty(info.RuntimeIdentifier));
+ // Configuration is build-time; either Debug or Release depending on test run config.
+ Assert.IsTrue(
+ info.Configuration is "Debug" or "Release",
+ $"Expected Debug or Release, got '{info.Configuration}'.");
+ Assert.IsFalse(info.Version.Contains('+', StringComparison.Ordinal));
+ }
+}
From 42f35a4363557e65fd91322098f9ab9a3220d24b Mon Sep 17 00:00:00 2001
From: Kirsten Kluge <15778270+kirkone@users.noreply.github.com>
Date: Thu, 14 May 2026 20:01:55 +0200
Subject: [PATCH 2/4] feat(cli): add revela info command tree with TUI inlining
(#23)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds three new diagnostic commands plus the inlining mechanism that
lets a parent command render flat in the interactive menu while
keeping a clean nested CLI surface.
Commands:
revela info Revela summary panel (default action)
revela info plugins Lists installed plugins
revela info plugins Plugin detail (Phase 2, plugin-provided)
revela info themes Lists installed themes (active marker)
revela info themes Theme detail (Phase 2, theme-provided)
TUI rendering:
Info
Revela ← virtual default-action entry
Plugins → ← only shown when plugins register sub-subcommands
Themes → ← only shown when themes register sub-subcommands
> Exit
The menu group is registered with order 90 (bottom of main menu, above
Exit). Plugins/themes contribute per-package detail subcommands via the
existing multi-level `ParentCommand` mechanism ("info plugins" or
"info themes") — no SDK API change required.
Mechanism (CommandDescriptor + CommandOrderRegistry + MenuChoice):
- New opt-in fields `InlineInMenu` and `InlineDefaultActionLabel`
on CommandDescriptor (default false → zero impact on existing
descriptors).
- InteractiveMenuService.BuildGroupedSelectionPrompt expands inlined
parents into a virtual default-action entry plus visible sub-
subcommands, each with absolute path overrides so CLI dispatch
remains correct.
- Inlined subcommands without nested children are filtered out — a
leaf entry with no extension provides no menu value beyond the
parent's default action.
Bug fix bundled in:
ShowMainMenuAsync had its own dispatch switch that hardcoded
`[selection.Command.Name]` for the args path, ignoring
CommandPathOverride from inlined entries. Clicking an inlined
subcommand at top level produced `Unrecognized command or argument`
from System.CommandLine. The top-level menu now routes through the
same HandleMenuActionAsync as nested menus, honoring the override.
Plugin convention documented in .github/instructions/plugins.instructions.md:
info subcommands are read-only diagnostics (no prompts, compact
output suitable for bug-report copy-paste, safe without project).
---
.github/instructions/plugins.instructions.md | 27 ++++-
src/Cli/Hosting/CommandGroupRegistry.cs | 3 +
src/Cli/Hosting/CommandOrderRegistry.cs | 28 +++++
src/Cli/Hosting/CoreCommandProvider.cs | 23 ++++
src/Cli/Hosting/HostExtensions.cs | 12 ++
src/Cli/Hosting/InteractiveMenuService.cs | 112 ++++++++++++++++--
src/Cli/Hosting/MenuChoice.cs | 47 +++++++-
src/Commands/Info/InfoCommand.cs | 82 +++++++++++++
src/Commands/Info/InfoPluginsCommand.cs | 82 +++++++++++++
src/Commands/Info/InfoThemesCommand.cs | 95 +++++++++++++++
src/Commands/ServiceCollectionExtensions.cs | 8 +-
src/Sdk/Abstractions/CommandDescriptor.cs | 23 +++-
tests/Commands/Info/InfoCommandTests.cs | 62 ++++++++++
.../Commands/Info/InfoPluginsCommandTests.cs | 46 +++++++
tests/Commands/Info/InfoThemesCommandTests.cs | 54 +++++++++
15 files changed, 688 insertions(+), 16 deletions(-)
create mode 100644 src/Commands/Info/InfoCommand.cs
create mode 100644 src/Commands/Info/InfoPluginsCommand.cs
create mode 100644 src/Commands/Info/InfoThemesCommand.cs
create mode 100644 tests/Commands/Info/InfoCommandTests.cs
create mode 100644 tests/Commands/Info/InfoPluginsCommandTests.cs
create mode 100644 tests/Commands/Info/InfoThemesCommandTests.cs
diff --git a/.github/instructions/plugins.instructions.md b/.github/instructions/plugins.instructions.md
index 940f0999..540a7516 100644
--- a/.github/instructions/plugins.instructions.md
+++ b/.github/instructions/plugins.instructions.md
@@ -56,12 +56,37 @@ public sealed class MyFeaturePlugin : IPlugin
| Param | Meaning |
|-------|---------|
| `Command` | The `System.CommandLine.Command` instance (from `MyCommand.Create()`) |
-| `ParentCommand` | `null` = root level, `"source"`/`"generate"`/etc. = subcommand. Parent created automatically if missing. |
+| `ParentCommand` | `null` = root level, `"source"`/`"generate"`/etc. = subcommand. Parent created automatically if missing. **Multi-level paths supported** (`"info plugins"` → `revela info plugins `). |
| `Order` | Sort order within parent (default 50; lower = earlier) |
| `Group` | Display group label in interactive menu |
| `RequiresProject` | `false` = available without `project.json` (e.g. `init`, `setup`) |
| `HideWhenProjectExists` | `true` = hidden inside a project (e.g. setup wizards) |
| `IsSequentialStep` | `true` = picked up by CLI `generate all` discovery. Pair with `IPipelineStep` for engine/MCP. |
+| `InlineInMenu` | Host-only flag (`info` command tree). Plugins should not need this. |
+| `InlineDefaultActionLabel` | Required when `InlineInMenu = true`. Plugins should not need this. |
+
+## `info` Subcommands — Convention for Plugins
+Plugins **may** contribute one read-only diagnostic subcommand under
+`revela info plugins ` by registering with
+`ParentCommand: "info plugins"`. This is opt-in; nothing breaks if you skip it.
+
+```csharp
+yield return new CommandDescriptor(
+ myInfoCommand.Create(),
+ ParentCommand: "info plugins",
+ Order: 10);
+```
+
+Hard rules for `info` subcommands:
+- **Read-only.** No prompts, no writes, no network calls that mutate state.
+- **Compact.** Output sized for bug-report copy-paste — typically a single
+ Spectre `Panel` with key/value lines. No tables that scroll.
+- **Fast.** No long-running work; user expects a tap-and-read response.
+- **Safe without context.** Must not crash when invoked without an active
+ project (e.g. report "no project loaded" instead of throwing).
+- **No side effects on cache, auth, or files.** This is diagnostics, not
+ troubleshooting tooling. Use a dedicated `doctor` or `check` command if
+ you need active probing.
## Plugin Configuration
1. Create config class with `[RevelaConfig("Spectara.Revela.Plugins.MyFeature")]` — full package ID is the JSON section name.
diff --git a/src/Cli/Hosting/CommandGroupRegistry.cs b/src/Cli/Hosting/CommandGroupRegistry.cs
index 7718683b..03ce378e 100644
--- a/src/Cli/Hosting/CommandGroupRegistry.cs
+++ b/src/Cli/Hosting/CommandGroupRegistry.cs
@@ -19,6 +19,9 @@ internal static class CommandGroups
/// Plugin addons and optional features.
public const string Addons = "Addons";
+ /// Diagnostic / about commands (main menu, bottom).
+ public const string Info = "Info";
+
// Config submenu groups
/// Core project configuration commands (config submenu).
diff --git a/src/Cli/Hosting/CommandOrderRegistry.cs b/src/Cli/Hosting/CommandOrderRegistry.cs
index b2d1c305..22d9d04b 100644
--- a/src/Cli/Hosting/CommandOrderRegistry.cs
+++ b/src/Cli/Hosting/CommandOrderRegistry.cs
@@ -24,6 +24,7 @@ internal sealed class CommandOrderRegistry
private readonly HashSet noProjectRequired = [];
private readonly HashSet hideWhenProjectExists = [];
private readonly HashSet pipelineSteps = [];
+ private readonly Dictionary inlinedParents = [];
///
/// Registers the display order for a command.
@@ -86,6 +87,33 @@ internal sealed class CommandOrderRegistry
/// True if the command is a pipeline step.
public bool IsPipelineStep(Command command) => pipelineSteps.Contains(command);
+ ///
+ /// Marks a command as an inlined parent for the interactive menu.
+ ///
+ ///
+ /// Inlined parents are not rendered as a single navigable entry. Instead
+ /// the menu renders a virtual entry for the command's default action
+ /// (labeled by ) and lists the
+ /// command's visible subcommands directly under the same group label.
+ /// CLI behavior is unaffected.
+ ///
+ /// The parent command to render inline.
+ /// Display label for the virtual default-action entry.
+ public void RegisterInlinedParent(Command command, string defaultActionLabel) =>
+ inlinedParents[command] = defaultActionLabel;
+
+ ///
+ /// Gets whether a command is registered as an inlined parent.
+ ///
+ public bool IsInlinedParent(Command command) => inlinedParents.ContainsKey(command);
+
+ ///
+ /// Gets the display label for an inlined parent's default-action entry,
+ /// or null if the command is not registered as inlined.
+ ///
+ public string? GetInlineDefaultActionLabel(Command command) =>
+ inlinedParents.TryGetValue(command, out var label) ? label : null;
+
///
/// Gets the display order for a command.
///
diff --git a/src/Cli/Hosting/CoreCommandProvider.cs b/src/Cli/Hosting/CoreCommandProvider.cs
index 978e128d..213d6de1 100644
--- a/src/Cli/Hosting/CoreCommandProvider.cs
+++ b/src/Cli/Hosting/CoreCommandProvider.cs
@@ -1,5 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using Spectara.Revela.Commands.Config;
+using Spectara.Revela.Commands.Info;
using Spectara.Revela.Features.Generate.Commands;
using Spectara.Revela.Features.Theme.Commands;
using Spectara.Revela.Sdk.Abstractions;
@@ -94,6 +95,28 @@ public IEnumerable GetCommands(IServiceProvider services)
Group: CommandGroups.Addons,
RequiresProject: false);
+ // ── Info group (TUI rendered inline: Revela / Plugins → / Themes →) ──
+ var infoCommand = services.GetRequiredService();
+ yield return new CommandDescriptor(
+ infoCommand.Create(),
+ Order: 10,
+ Group: CommandGroups.Info,
+ RequiresProject: false,
+ InlineInMenu: true,
+ InlineDefaultActionLabel: "Revela");
+
+ var infoPluginsCommand = services.GetRequiredService();
+ yield return new CommandDescriptor(
+ infoPluginsCommand.Create(),
+ ParentCommand: "info",
+ Order: 20);
+
+ var infoThemesCommand = services.GetRequiredService();
+ yield return new CommandDescriptor(
+ infoThemesCommand.Create(),
+ ParentCommand: "info",
+ Order: 30);
+
// Restore, Plugin, and Packages commands are provided by PackagesCommandProvider
// (only available in Cli, not in Cli.Embedded)
diff --git a/src/Cli/Hosting/HostExtensions.cs b/src/Cli/Hosting/HostExtensions.cs
index d8777e97..845df13c 100644
--- a/src/Cli/Hosting/HostExtensions.cs
+++ b/src/Cli/Hosting/HostExtensions.cs
@@ -60,6 +60,7 @@ public static RootCommand UseRevelaCommands(
groupRegistry.Register(CommandGroups.Content, 20);
groupRegistry.Register(CommandGroups.Setup, 30);
groupRegistry.Register(CommandGroups.Addons, 40);
+ groupRegistry.Register(CommandGroups.Info, 90);
// Create root command
var rootCommand = new RootCommand(description);
@@ -95,6 +96,17 @@ void OnCommandRegistered(Command cmd, CommandDescriptor desc)
pipelineOrderProvider.Register(desc.ParentCommand, cmd.Name, desc.Order);
}
}
+
+ if (desc.InlineInMenu)
+ {
+ if (string.IsNullOrEmpty(desc.InlineDefaultActionLabel))
+ {
+ throw new InvalidOperationException(
+ $"Command '{cmd.Name}' has InlineInMenu=true but no InlineDefaultActionLabel. " +
+ "Provide a label for the virtual default-action menu entry.");
+ }
+ orderRegistry.RegisterInlinedParent(cmd, desc.InlineDefaultActionLabel);
+ }
}
// Core commands (via CoreCommandProvider — uses same registration as plugins
diff --git a/src/Cli/Hosting/InteractiveMenuService.cs b/src/Cli/Hosting/InteractiveMenuService.cs
index dc030c33..e3db8bc9 100644
--- a/src/Cli/Hosting/InteractiveMenuService.cs
+++ b/src/Cli/Hosting/InteractiveMenuService.cs
@@ -72,7 +72,7 @@ public async Task RunAsync(CancellationToken cancellationToken = default)
private async Task HandleFirstRunAsync(CancellationToken cancellationToken)
{
- ConsoleUI.ClearAndShowLogo();
+ ConsoleUI.ClearConsole();
ConsoleUI.ShowFirstRunPanel();
var wizard = setupWizards.FirstOrDefault();
@@ -111,7 +111,7 @@ private async Task HandleFirstRunAsync(CancellationToken cancellationToken)
private async Task HandleNoProjectAsync(CancellationToken cancellationToken)
{
- ConsoleUI.ClearAndShowLogo();
+ ConsoleUI.ClearConsole();
var folderName = projectEnvironment.Value.FolderName;
@@ -175,7 +175,7 @@ private void ShowWelcomeBanner()
bannerShown = true;
- ConsoleUI.ClearAndShowLogo();
+ ConsoleUI.ClearConsole();
// Show project name if initialized, otherwise show folder name
var projectName = projectConfig.CurrentValue.Name;
@@ -208,14 +208,15 @@ private async Task ShowMainMenuAsync(CancellationToken cancellationT
var selection = AnsiConsole.Prompt(prompt);
+ // Top-level dispatch: Exit/Back/Wizard handled here; Navigate/Execute
+ // delegate to HandleMenuActionAsync so CommandPathOverride from inlined
+ // entries (e.g. "Plugins" → ["info","plugins"]) is honored.
return selection.Action switch
{
MenuAction.Exit => new MenuResult(true, 0),
MenuAction.Back => new MenuResult(false, 0),
- MenuAction.Navigate => await NavigateToCommandAsync(selection.Command!, [selection.Command!.Name], cancellationToken),
- MenuAction.Execute => await ExecuteCommandAsync(selection.Command!, [selection.Command!.Name], cancellationToken),
MenuAction.RunSetupWizard => await RunSetupWizardAsync(cancellationToken),
- _ => new MenuResult(false, 0),
+ MenuAction.Navigate or MenuAction.Execute or _ => await HandleMenuActionAsync(selection, [], cancellationToken),
};
}
@@ -295,7 +296,9 @@ private async Task HandleMenuActionAsync(
return await RunSetupWizardAsync(cancellationToken);
}
- var extendedPath = new List(commandPath) { selection.Command!.Name };
+ var extendedPath = selection.CommandPathOverride is not null
+ ? [.. selection.CommandPathOverride]
+ : new List(commandPath) { selection.Command!.Name };
return selection.Action switch
{
@@ -388,7 +391,7 @@ private SelectionPrompt BuildGroupedSelectionPrompt(
// Calculate column width from longest command name (+ 2 for " →" arrow)
var allCommands = grouped.SelectMany(g => g.Commands).ToList();
var nameWidth = allCommands.Count > 0
- ? allCommands.Max(c => c.Name.Length + (c.Subcommands.Any(s => !s.Hidden) ? 2 : 0)) + 2
+ ? allCommands.Max(c => MaxRenderedNameLength(c)) + 2
: 0;
if (hasGroups)
@@ -406,7 +409,11 @@ private SelectionPrompt BuildGroupedSelectionPrompt(
{
// Create group header as MenuChoice (will be non-selectable due to Leaf mode)
var groupChoice = new MenuChoice(groupName, Action: MenuAction.Navigate);
- var commandChoices = groupCommands.Select(c => MenuChoice.FromCommand(c, orderRegistry.IsPipelineStep(c), nameWidth)).ToList();
+ var commandChoices = new List();
+ foreach (var cmd in groupCommands)
+ {
+ AddGroupCommandChoices(commandChoices, cmd, nameWidth);
+ }
// Add Wizard to the Addons group (as last item)
if (includeSetupWizard && groupName == CommandGroups.Addons)
@@ -421,7 +428,7 @@ private SelectionPrompt BuildGroupedSelectionPrompt(
// Ungrouped commands - add directly
foreach (var cmd in groupCommands)
{
- prompt.AddChoice(MenuChoice.FromCommand(cmd, orderRegistry.IsPipelineStep(cmd), nameWidth));
+ AddUngroupedCommandChoices(prompt, cmd, nameWidth);
}
}
}
@@ -444,6 +451,91 @@ private SelectionPrompt BuildGroupedSelectionPrompt(
return prompt;
}
+ ///
+ /// Adds menu choices for a command appearing in a grouped section.
+ /// Inlined parents are expanded into a virtual default-action entry plus
+ /// each visible subcommand (with absolute path overrides).
+ ///
+ private void AddGroupCommandChoices(List choices, Command cmd, int nameWidth)
+ {
+ if (TryAddInlined(cmd, nameWidth, choices.Add))
+ {
+ return;
+ }
+ choices.Add(MenuChoice.FromCommand(cmd, orderRegistry.IsPipelineStep(cmd), nameWidth));
+ }
+
+ ///
+ /// Adds menu choices for an ungrouped command (rare).
+ /// Inlined parents behave the same way as in grouped sections.
+ ///
+ private void AddUngroupedCommandChoices(SelectionPrompt prompt, Command cmd, int nameWidth)
+ {
+ if (TryAddInlined(cmd, nameWidth, choice => prompt.AddChoice(choice)))
+ {
+ return;
+ }
+ prompt.AddChoice(MenuChoice.FromCommand(cmd, orderRegistry.IsPipelineStep(cmd), nameWidth));
+ }
+
+ private bool TryAddInlined(Command cmd, int nameWidth, Action add)
+ {
+ var label = orderRegistry.GetInlineDefaultActionLabel(cmd);
+ if (label is null)
+ {
+ return false;
+ }
+
+ // Virtual default-action entry: label runs the parent without subcommand
+ add(MenuChoice.CreateInlinedDefaultAction(cmd, label, nameWidth));
+
+ // Each visible subcommand that itself has at least one visible sub-subcommand
+ // (extension entries registered via ParentCommand: " ").
+ // Subcommands without any extension are skipped — the parent's default
+ // action already covers their content (e.g. count/list summary).
+ // Absolute path override (parent + sub) ensures correct CLI dispatch.
+ foreach (var sub in cmd.Subcommands
+ .Where(IsInlinableSub)
+ .OrderBy(orderRegistry.GetOrder)
+ .ThenBy(s => s.Name, StringComparer.OrdinalIgnoreCase))
+ {
+ add(MenuChoice.FromCommand(
+ sub,
+ orderRegistry.IsPipelineStep(sub),
+ nameWidth,
+ commandPathOverride: [cmd.Name, sub.Name]));
+ }
+ return true;
+ }
+
+ private static bool IsInlinableSub(Command sub) =>
+ !sub.Hidden && sub.Subcommands.Any(s => !s.Hidden);
+
+ ///
+ /// Computes the rendered name length used for column alignment, accounting
+ /// for the inline expansion of parents into a default-action label plus
+ /// subcommand entries (each with the " →" arrow when navigable).
+ ///
+ private int MaxRenderedNameLength(Command cmd)
+ {
+ var label = orderRegistry.GetInlineDefaultActionLabel(cmd);
+ if (label is null)
+ {
+ return cmd.Name.Length + (cmd.Subcommands.Any(s => !s.Hidden) ? 2 : 0);
+ }
+
+ var max = label.Length;
+ foreach (var sub in cmd.Subcommands.Where(IsInlinableSub))
+ {
+ var len = sub.Name.Length + 2; // always navigable when shown
+ if (len > max)
+ {
+ max = len;
+ }
+ }
+ return max;
+ }
+
[LoggerMessage(Level = LogLevel.Error, Message = "RootCommand not set")]
private static partial void LogRootCommandNotSet(ILogger logger);
diff --git a/src/Cli/Hosting/MenuChoice.cs b/src/Cli/Hosting/MenuChoice.cs
index e5d1089b..33354a45 100644
--- a/src/Cli/Hosting/MenuChoice.cs
+++ b/src/Cli/Hosting/MenuChoice.cs
@@ -8,10 +8,16 @@ namespace Spectara.Revela.Cli.Hosting;
/// The text shown in the menu.
/// The associated command, if any.
/// The action type for this choice.
+///
+/// When set, replaces the default extended path (current path + selected
+/// command name) with this absolute path. Used by inlined parent rendering
+/// where a top-level menu entry actually points at a nested subcommand.
+///
internal sealed record MenuChoice(
string DisplayName,
Command? Command = null,
- MenuAction Action = MenuAction.Navigate)
+ MenuAction Action = MenuAction.Navigate,
+ IReadOnlyList? CommandPathOverride = null)
{
///
/// Creates a "Back" menu choice for navigation.
@@ -33,14 +39,49 @@ public static MenuChoice CreateWizard(int nameColumnWidth = 0)
return new($"{paddedName} [dim]Install themes and plugins[/]", Action: MenuAction.RunSetupWizard);
}
+ ///
+ /// Creates the virtual default-action menu entry for an inlined parent.
+ ///
+ /// The inlined parent command.
+ /// Display label (e.g. "Revela").
+ /// Width to pad the name column to for alignment.
+ ///
+ /// A choice that, when selected, invokes the parent command with no
+ /// subcommand arguments — triggering its default action.
+ ///
+ public static MenuChoice CreateInlinedDefaultAction(Command parent, string label, int nameColumnWidth = 0)
+ {
+ var paddedName = nameColumnWidth > 0 ? label.PadRight(nameColumnWidth) : label;
+ var description = string.IsNullOrWhiteSpace(parent.Description)
+ ? string.Empty
+ : $" [dim]{parent.Description}[/]";
+
+ // Path override = just the parent name, so CommandExecutor invokes
+ // the parent without any subcommand → default action runs.
+ return new MenuChoice(
+ DisplayName: $"{paddedName}{description}",
+ Command: parent,
+ Action: MenuAction.Execute,
+ CommandPathOverride: [parent.Name]);
+ }
+
///
/// Creates a menu choice from a command.
///
/// The command to create a choice for.
/// Whether this command is a pipeline step (shown with marker).
/// Width to pad the name column to for alignment.
+ ///
+ /// Optional absolute path to use when this choice is selected. Used for
+ /// inlined subcommands where the menu position differs from the CLI tree
+ /// position.
+ ///
/// A menu choice with the command's name and description.
- public static MenuChoice FromCommand(Command cmd, bool isPipelineStep = false, int nameColumnWidth = 0)
+ public static MenuChoice FromCommand(
+ Command cmd,
+ bool isPipelineStep = false,
+ int nameColumnWidth = 0,
+ IReadOnlyList? commandPathOverride = null)
{
var hasVisibleSubcommands = cmd.Subcommands.Any(c => !c.Hidden);
var arrow = hasVisibleSubcommands ? " →" : "";
@@ -56,7 +97,7 @@ public static MenuChoice FromCommand(Command cmd, bool isPipelineStep = false, i
? MenuAction.Navigate
: MenuAction.Execute;
- return new MenuChoice($"{paddedName}{marker}{description}", cmd, action);
+ return new MenuChoice($"{paddedName}{marker}{description}", cmd, action, commandPathOverride);
}
///
diff --git a/src/Commands/Info/InfoCommand.cs b/src/Commands/Info/InfoCommand.cs
new file mode 100644
index 00000000..a7b05c01
--- /dev/null
+++ b/src/Commands/Info/InfoCommand.cs
@@ -0,0 +1,82 @@
+using System.CommandLine;
+using System.Globalization;
+
+using Microsoft.Extensions.Options;
+
+using Spectara.Revela.Sdk;
+using Spectara.Revela.Sdk.Abstractions;
+using Spectara.Revela.Sdk.Configuration;
+using Spectara.Revela.Sdk.Hosting;
+
+using Spectre.Console;
+
+namespace Spectara.Revela.Commands.Info;
+
+///
+/// Parent command for Revela diagnostic information.
+///
+///
+///
+/// Default action prints a compact Revela summary (version, framework, host
+/// kind, plugin/theme counts, active theme). Subcommands plugins and
+/// themes show detail tables. Plugins may register additional detail
+/// commands via ParentCommand: "info plugins".
+///
+///
+/// The first line of output is
+/// — the same string printed by revela --version.
+///
+///
+internal sealed class InfoCommand(
+ IBuildInfo buildInfo,
+ IPackageContext packageContext,
+ IOptionsMonitor themeConfig)
+{
+ /// Creates the command definition.
+ public Command Create()
+ {
+ var command = new Command("info", "Show Revela version and host info");
+ command.SetAction(_ => Execute());
+ return command;
+ }
+
+ private int Execute()
+ {
+ AnsiConsole.MarkupLine($"[bold]{Markup.Escape(buildInfo.FormatVersionLine())}[/]");
+
+ if (buildInfo.Kind == HostKind.Embedded)
+ {
+ AnsiConsole.MarkupLine(
+ "[dim]Plugin management: not available in embedded build (use the standalone CLI)[/]");
+ }
+
+ AnsiConsole.WriteLine();
+
+ var pluginCount = packageContext.Plugins.Count;
+ var themeCount = packageContext.Themes.Count;
+ var activeThemeName = themeConfig.CurrentValue.Name;
+
+ var summary = new List
+ {
+ $"[blue]Plugins:[/] {pluginCount.ToString(CultureInfo.InvariantCulture)}",
+ $"[blue]Themes:[/] {themeCount.ToString(CultureInfo.InvariantCulture)}",
+ string.IsNullOrEmpty(activeThemeName)
+ ? "[blue]Active theme:[/] [dim](no project)[/]"
+ : $"[blue]Active theme:[/] {Markup.Escape(activeThemeName)}",
+ string.Empty,
+ $"[dim]Build:[/] {Markup.Escape(buildInfo.Configuration)} ({Markup.Escape(buildInfo.RuntimeIdentifier)})",
+ $"[dim]Build id:[/] {Markup.Escape(buildInfo.InformationalVersion)}",
+ };
+
+ var panel = new Panel(new Markup(string.Join("\n", summary)))
+ .WithHeader("[bold]Revela[/]")
+ .WithInfoStyle();
+ panel.Padding = new Padding(1, 0, 1, 0);
+ AnsiConsole.Write(panel);
+
+ AnsiConsole.WriteLine();
+ AnsiConsole.MarkupLine("[dim]For details: [white]revela info plugins[/] · [white]revela info themes[/][/]");
+
+ return 0;
+ }
+}
diff --git a/src/Commands/Info/InfoPluginsCommand.cs b/src/Commands/Info/InfoPluginsCommand.cs
new file mode 100644
index 00000000..2e5628bd
--- /dev/null
+++ b/src/Commands/Info/InfoPluginsCommand.cs
@@ -0,0 +1,82 @@
+using System.CommandLine;
+using System.Globalization;
+
+using Spectara.Revela.Sdk;
+using Spectara.Revela.Sdk.Abstractions;
+
+using Spectre.Console;
+
+namespace Spectara.Revela.Commands.Info;
+
+///
+/// Lists installed plugins with version and source.
+///
+///
+/// Plugin authors may add per-plugin detail subcommands via
+/// ParentCommand: "info plugins"; those appear under revela info
+/// plugins <name> and as sub-entries in the TUI menu. They should
+/// be read-only diagnostics (no prompts), kept compact for bug-report
+/// copy-paste.
+///
+internal sealed class InfoPluginsCommand(IPackageContext packageContext)
+{
+ /// Creates the command definition.
+ public Command Create()
+ {
+ var command = new Command("plugins", "List installed plugins");
+ command.SetAction(_ => Execute());
+ return command;
+ }
+
+ private int Execute()
+ {
+ var plugins = packageContext.Plugins
+ .OrderBy(p => p.Plugin.Metadata.Name, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ if (plugins.Count == 0)
+ {
+ var emptyPanel = new Panel(new Markup("[dim]No plugins loaded.[/]"))
+ .WithHeader("[bold]Plugins[/] [dim](0)[/]")
+ .WithInfoStyle();
+ emptyPanel.Padding = new Padding(1, 0, 1, 0);
+ AnsiConsole.Write(emptyPanel);
+ return 0;
+ }
+
+ var lines = new List();
+ foreach (var info in plugins)
+ {
+ var meta = info.Plugin.Metadata;
+ var sourceMarkup = FormatSource(info.Source);
+ lines.Add($"[bold green]{Markup.Escape(meta.Name)}[/] [dim]v{Markup.Escape(meta.Version)}[/] {sourceMarkup}");
+ if (!string.IsNullOrWhiteSpace(meta.Description))
+ {
+ lines.Add($" [dim]{Markup.Escape(meta.Description)}[/]");
+ }
+ lines.Add($" [dim]{Markup.Escape(meta.Id)}[/]");
+ lines.Add(string.Empty);
+ }
+
+ // Drop trailing blank
+ if (lines.Count > 0 && lines[^1].Length == 0)
+ {
+ lines.RemoveAt(lines.Count - 1);
+ }
+
+ var panel = new Panel(new Markup(string.Join("\n", lines)))
+ .WithHeader($"[bold]Plugins[/] [dim]({plugins.Count.ToString(CultureInfo.InvariantCulture)})[/]")
+ .WithInfoStyle();
+ panel.Padding = new Padding(1, 0, 1, 0);
+ AnsiConsole.Write(panel);
+
+ return 0;
+ }
+
+ private static string FormatSource(PackageSource source) => source switch
+ {
+ PackageSource.Bundled => "[grey][[bundled]][/]",
+ PackageSource.Local => "[cyan][[local]][/]",
+ _ => $"[grey][[{source}]][/]",
+ };
+}
diff --git a/src/Commands/Info/InfoThemesCommand.cs b/src/Commands/Info/InfoThemesCommand.cs
new file mode 100644
index 00000000..c2e4ba99
--- /dev/null
+++ b/src/Commands/Info/InfoThemesCommand.cs
@@ -0,0 +1,95 @@
+using System.CommandLine;
+using System.Globalization;
+
+using Microsoft.Extensions.Options;
+
+using Spectara.Revela.Sdk;
+using Spectara.Revela.Sdk.Abstractions;
+using Spectara.Revela.Sdk.Configuration;
+
+using Spectre.Console;
+
+namespace Spectara.Revela.Commands.Info;
+
+///
+/// Lists installed themes with version, source, and the active marker.
+///
+///
+/// The active theme is read from and only
+/// highlighted when a project is loaded. Theme authors may add per-theme
+/// detail subcommands via ParentCommand: "info themes".
+///
+internal sealed class InfoThemesCommand(
+ IPackageContext packageContext,
+ IOptionsMonitor themeConfig)
+{
+ /// Creates the command definition.
+ public Command Create()
+ {
+ var command = new Command("themes", "List installed themes");
+ command.SetAction(_ => Execute());
+ return command;
+ }
+
+ private int Execute()
+ {
+ var themes = packageContext.Themes
+ .OrderBy(t => t.Theme.Metadata.Name, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ var activeName = themeConfig.CurrentValue.Name;
+
+ if (themes.Count == 0)
+ {
+ var emptyPanel = new Panel(new Markup("[dim]No themes loaded.[/]"))
+ .WithHeader("[bold]Themes[/] [dim](0)[/]")
+ .WithInfoStyle();
+ emptyPanel.Padding = new Padding(1, 0, 1, 0);
+ AnsiConsole.Write(emptyPanel);
+ return 0;
+ }
+
+ var lines = new List();
+ foreach (var info in themes)
+ {
+ var meta = info.Theme.Metadata;
+ var sourceMarkup = FormatSource(info.Source);
+ var isActive = !string.IsNullOrEmpty(activeName)
+ && string.Equals(meta.Name, activeName, StringComparison.OrdinalIgnoreCase);
+ var marker = isActive ? "[yellow]★[/] " : " ";
+
+ lines.Add($"{marker}[bold green]{Markup.Escape(meta.Name)}[/] [dim]v{Markup.Escape(meta.Version)}[/] {sourceMarkup}");
+ if (!string.IsNullOrWhiteSpace(meta.Description))
+ {
+ lines.Add($" [dim]{Markup.Escape(meta.Description)}[/]");
+ }
+ lines.Add($" [dim]{Markup.Escape(meta.Id)}[/]");
+ lines.Add(string.Empty);
+ }
+
+ if (lines.Count > 0 && lines[^1].Length == 0)
+ {
+ lines.RemoveAt(lines.Count - 1);
+ }
+
+ var panel = new Panel(new Markup(string.Join("\n", lines)))
+ .WithHeader($"[bold]Themes[/] [dim]({themes.Count.ToString(CultureInfo.InvariantCulture)})[/]")
+ .WithInfoStyle();
+ panel.Padding = new Padding(1, 0, 1, 0);
+ AnsiConsole.Write(panel);
+
+ if (string.IsNullOrEmpty(activeName))
+ {
+ AnsiConsole.WriteLine();
+ AnsiConsole.MarkupLine("[dim](no active theme — open a Revela project to see the active marker)[/]");
+ }
+
+ return 0;
+ }
+
+ private static string FormatSource(PackageSource source) => source switch
+ {
+ PackageSource.Bundled => "[grey][[bundled]][/]",
+ PackageSource.Local => "[cyan][[local]][/]",
+ _ => $"[grey][[{source}]][/]",
+ };
+}
diff --git a/src/Commands/ServiceCollectionExtensions.cs b/src/Commands/ServiceCollectionExtensions.cs
index 6bf5e4dd..3fc32048 100644
--- a/src/Commands/ServiceCollectionExtensions.cs
+++ b/src/Commands/ServiceCollectionExtensions.cs
@@ -1,6 +1,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Spectara.Revela.Commands.Config;
+using Spectara.Revela.Commands.Info;
using Spectara.Revela.Core.Services;
using Spectara.Revela.Features.Generate;
using Spectara.Revela.Features.Theme;
@@ -19,7 +20,7 @@ internal static class ServiceCollectionExtensions
/// Adds all Revela command services to the DI container.
///
///
- /// Registers host-owned commands (Config) and core features (Generate, Theme).
+ /// Registers host-owned commands (Config, Info) and core features (Generate, Theme).
/// Package management commands (Packages, Plugins, Restore) are registered separately
/// via AddPackageManagement() in the Packages feature — only loaded by Cli,
/// not by Cli.Embedded.
@@ -37,6 +38,11 @@ public static IServiceCollection AddRevelaCommands(this IServiceCollection servi
// Host-owned commands
services.AddConfigFeature();
+ // Info commands (always available, in both Cli and Cli.Embedded)
+ services.AddTransient();
+ services.AddTransient();
+ services.AddTransient();
+
// Core features — always available, not plugin-loaded
services.AddGenerateFeature();
services.AddThemeFeature();
diff --git a/src/Sdk/Abstractions/CommandDescriptor.cs b/src/Sdk/Abstractions/CommandDescriptor.cs
index fee118d1..02bfd77e 100644
--- a/src/Sdk/Abstractions/CommandDescriptor.cs
+++ b/src/Sdk/Abstractions/CommandDescriptor.cs
@@ -39,6 +39,20 @@ namespace Spectara.Revela.Sdk.Abstractions;
/// and by the "all" command to discover steps to run in Order sequence.
/// Default is false.
///
+///
+/// When true, the interactive menu does NOT render this command as a single
+/// entry. Instead it renders the command's default action as a virtual entry
+/// (labeled by ) followed by each of
+/// the command's visible subcommands directly under the group label. The CLI
+/// surface is unchanged. Only meaningful when the command has subcommands and
+/// is registered directly under root (with a ).
+/// Default is false.
+///
+///
+/// Display label for the virtual default-action entry generated when
+/// is true. Required when
+/// is true; ignored otherwise.
+///
///
///
/// // Register under "init" parent: revela init onedrive
@@ -57,6 +71,11 @@ namespace Spectara.Revela.Sdk.Abstractions;
/// // Sequential step: part of a pipeline that supports "all"
/// new CommandDescriptor(scanCmd, "generate", Order: 10,
/// IsSequentialStep: true)
+///
+/// // Inline a parent command flat under its group (e.g. info → Revela / Plugins → / Themes →)
+/// new CommandDescriptor(infoCmd, Order: 10, Group: "Info",
+/// RequiresProject: false,
+/// InlineInMenu: true, InlineDefaultActionLabel: "Revela")
///
///
public sealed record CommandDescriptor(
@@ -66,4 +85,6 @@ public sealed record CommandDescriptor(
string? Group = null,
bool RequiresProject = true,
bool HideWhenProjectExists = false,
- bool IsSequentialStep = false);
+ bool IsSequentialStep = false,
+ bool InlineInMenu = false,
+ string? InlineDefaultActionLabel = null);
diff --git a/tests/Commands/Info/InfoCommandTests.cs b/tests/Commands/Info/InfoCommandTests.cs
new file mode 100644
index 00000000..9f390455
--- /dev/null
+++ b/tests/Commands/Info/InfoCommandTests.cs
@@ -0,0 +1,62 @@
+using Microsoft.Extensions.Options;
+
+using NSubstitute;
+
+using Spectara.Revela.Commands.Info;
+using Spectara.Revela.Sdk.Abstractions;
+using Spectara.Revela.Sdk.Configuration;
+using Spectara.Revela.Sdk.Hosting;
+
+namespace Spectara.Revela.Tests.Commands.Info;
+
+[TestClass]
+[TestCategory("Unit")]
+public sealed class InfoCommandTests
+{
+ [TestMethod]
+ public void Create_ReturnsInfoCommandWithDescription()
+ {
+ var command = CreateCommand().Create();
+
+ Assert.AreEqual("info", command.Name);
+ Assert.IsFalse(string.IsNullOrEmpty(command.Description));
+ }
+
+ [TestMethod]
+ public void Create_HasNoSubcommandsByDefault()
+ {
+ // Subcommands are attached by HostExtensions via ParentCommand: "info"
+ // routing, not by InfoCommand itself.
+ var command = CreateCommand().Create();
+
+ Assert.IsEmpty(command.Subcommands);
+ }
+
+ [TestMethod]
+ public void Create_HasNoOptions()
+ {
+ // info default action takes no arguments.
+ var command = CreateCommand().Create();
+
+ Assert.IsEmpty(command.Arguments);
+ }
+
+ private static InfoCommand CreateCommand()
+ {
+ var buildInfo = Substitute.For();
+ buildInfo.Kind.Returns(HostKind.Standalone);
+ buildInfo.FormatVersionLine().Returns("revela 1.0.0 (.NET 10.0.4)");
+ buildInfo.InformationalVersion.Returns("1.0.0");
+ buildInfo.Configuration.Returns("Debug");
+ buildInfo.RuntimeIdentifier.Returns("linux-x64");
+
+ var packageContext = Substitute.For();
+ packageContext.Plugins.Returns([]);
+ packageContext.Themes.Returns([]);
+
+ var themeConfig = Substitute.For>();
+ themeConfig.CurrentValue.Returns(new ThemeConfig());
+
+ return new InfoCommand(buildInfo, packageContext, themeConfig);
+ }
+}
diff --git a/tests/Commands/Info/InfoPluginsCommandTests.cs b/tests/Commands/Info/InfoPluginsCommandTests.cs
new file mode 100644
index 00000000..9a134036
--- /dev/null
+++ b/tests/Commands/Info/InfoPluginsCommandTests.cs
@@ -0,0 +1,46 @@
+using NSubstitute;
+
+using Spectara.Revela.Commands.Info;
+using Spectara.Revela.Sdk.Abstractions;
+
+namespace Spectara.Revela.Tests.Commands.Info;
+
+[TestClass]
+[TestCategory("Unit")]
+public sealed class InfoPluginsCommandTests
+{
+ [TestMethod]
+ public void Create_ReturnsPluginsCommandWithDescription()
+ {
+ var command = CreateCommand([]).Create();
+
+ Assert.AreEqual("plugins", command.Name);
+ Assert.IsFalse(string.IsNullOrEmpty(command.Description));
+ }
+
+ [TestMethod]
+ public void Create_HasNoOptionsOrArguments()
+ {
+ // plugins listing takes no arguments — read-only diagnostic.
+ var command = CreateCommand([]).Create();
+
+ Assert.IsEmpty(command.Arguments);
+ }
+
+ [TestMethod]
+ public void Create_HasNoSubcommandsByDefault()
+ {
+ // Per-plugin detail subcommands are added by plugins themselves via
+ // ParentCommand: "info plugins" — not by this command.
+ var command = CreateCommand([]).Create();
+
+ Assert.IsEmpty(command.Subcommands);
+ }
+
+ private static InfoPluginsCommand CreateCommand(IReadOnlyList plugins)
+ {
+ var packageContext = Substitute.For();
+ packageContext.Plugins.Returns(plugins);
+ return new InfoPluginsCommand(packageContext);
+ }
+}
diff --git a/tests/Commands/Info/InfoThemesCommandTests.cs b/tests/Commands/Info/InfoThemesCommandTests.cs
new file mode 100644
index 00000000..63329f19
--- /dev/null
+++ b/tests/Commands/Info/InfoThemesCommandTests.cs
@@ -0,0 +1,54 @@
+using Microsoft.Extensions.Options;
+
+using NSubstitute;
+
+using Spectara.Revela.Commands.Info;
+using Spectara.Revela.Sdk.Abstractions;
+using Spectara.Revela.Sdk.Configuration;
+
+namespace Spectara.Revela.Tests.Commands.Info;
+
+[TestClass]
+[TestCategory("Unit")]
+public sealed class InfoThemesCommandTests
+{
+ [TestMethod]
+ public void Create_ReturnsThemesCommandWithDescription()
+ {
+ var command = CreateCommand([], activeThemeName: "").Create();
+
+ Assert.AreEqual("themes", command.Name);
+ Assert.IsFalse(string.IsNullOrEmpty(command.Description));
+ }
+
+ [TestMethod]
+ public void Create_HasNoOptionsOrArguments()
+ {
+ var command = CreateCommand([], activeThemeName: "").Create();
+
+ Assert.IsEmpty(command.Arguments);
+ }
+
+ [TestMethod]
+ public void Create_HasNoSubcommandsByDefault()
+ {
+ // Per-theme detail subcommands are added by themes themselves via
+ // ParentCommand: "info themes" — not by this command.
+ var command = CreateCommand([], activeThemeName: "").Create();
+
+ Assert.IsEmpty(command.Subcommands);
+ }
+
+ private static InfoThemesCommand CreateCommand(
+ IReadOnlyList themes,
+ string activeThemeName)
+ {
+ var packageContext = Substitute.For();
+ packageContext.Themes.Returns(themes);
+
+ var themeConfig = Substitute.For>();
+ themeConfig.CurrentValue.Returns(new ThemeConfig { Name = activeThemeName });
+
+ return new InfoThemesCommand(packageContext, themeConfig);
+ }
+}
From 5f856ee3c4dc1e5e40b333976553e8d4ee493637 Mon Sep 17 00:00:00 2001
From: Kirsten Kluge <15778270+kirkone@users.noreply.github.com>
Date: Thu, 14 May 2026 20:02:19 +0200
Subject: [PATCH 3/4] refactor(cli): drop ASCII logo, slim welcome panel (#23)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The block-letter ASCII art "Revela" rendered at startup was off-brand
vs. the actual aperture wordmark — generic figlet output that has
nothing to do with the project's visual identity. It also added ~6
lines of friction on every interactive menu render, pushing real
content offscreen on small terminals and SSH sessions.
Removed: `LogoLines` array, `ShowLogo()`, `ClearAndShowLogo()`.
Welcome panel slimmed:
- Header: "Welcome" → "Revela"
- Removed: redundant "Version" line (now in `revela info`)
- Removed: "Modern static site generator for photographers"
tagline (already known to the user invoking the binary)
- Kept: project/directory line and navigation hint
The first-run panel (`ShowFirstRunPanel`) is unchanged — that genuine
first-contact moment may welcome a future, brand-aligned visual
element, but the per-menu-render branding is gone.
Three call sites in InteractiveMenuService updated from
`ClearAndShowLogo()` to `ClearConsole()` were already shipped in the
previous commit alongside the menu-rendering refactor.
CHANGELOG documents all three #23 commits in one consolidated block.
Closes #23
---
CHANGELOG.md | 15 +++++++++
src/Cli/Hosting/ConsoleUI.cs | 60 ++++++++++--------------------------
2 files changed, 31 insertions(+), 44 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 68e3c7a0..4f7b7fa6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- **`revela info` command tree** — new diagnostic command tree exposing version data and installed packages. `revela info` (default action) prints a Revela summary panel; `revela info plugins` lists all installed plugins with version and source; `revela info themes` lists themes with the active one marked. Plugins/themes can contribute per-package detail subcommands by registering with `ParentCommand: "info plugins"` / `"info themes"` (read-only diagnostic convention documented in `plugins.instructions.md`). ([#23](https://github.com/Spectara/Revela/issues/23))
+- **`IBuildInfo` SDK service** — new `Spectara.Revela.Sdk.Hosting.IBuildInfo` interface (with `HostKind` enum: `Standalone` / `Embedded`) exposes the immutable build-time identity of the running host. Single source of truth for `--version`, `revela info`, and any plugin that needs to branch on host kind (e.g. self-update plugins hiding themselves in embedded builds). Detected via `Revela.HostKind` assembly metadata attribute set in `Cli.Embedded.csproj` — sidesteps the `AssemblyName="revela"` collision that makes name-based detection impossible. ([#23](https://github.com/Spectara/Revela/issues/23))
+- **`CommandDescriptor.InlineInMenu` + `InlineDefaultActionLabel`** — opt-in fields that flatten a parent command's inline appearance in the interactive menu while leaving CLI behavior unchanged. Used by `revela info` to render `Info → Revela / Plugins → / Themes →` instead of `Info → info → …`. Default `false`, zero impact on existing descriptors. ([#23](https://github.com/Spectara/Revela/issues/23))
+
+### Changed
+
+- **`revela --version` is now human-readable and host-kind aware** — the System.CommandLine default action is replaced with a renderer that prints `revela 1.0.0 (.NET 10.0.7)` (or `… — embedded build` for the standalone variant). Identical to the first line of `revela info`, so both surfaces report the same identifier. ([#23](https://github.com/Spectara/Revela/issues/23))
+- **Welcome panel slimmed down** — the ASCII-art Revela logo is removed from interactive startup (it was off-brand vs. the actual aperture wordmark and added ~6 lines of friction on every menu render). The welcome panel header is now `Revela`; the redundant `Version` line and the `Modern static site generator for photographers` tagline are gone. Version data lives in `revela info` (canonical for both CLI and TUI users). The first-run panel is unchanged. ([#23](https://github.com/Spectara/Revela/issues/23))
+
+### Fixed
+
+- **Inlined-parent menu entries dispatched to the wrong command** — clicking an inlined subcommand in the interactive main menu (e.g. `Plugins` under `Info`) built the args path from the leaf name only (`["plugins"]`) instead of the absolute path (`["info","plugins"]`), producing an `Unrecognized command or argument` error from System.CommandLine. The top-level menu now routes `Navigate`/`Execute` selections through the same dispatcher as nested menus, honoring `MenuChoice.CommandPathOverride`. ([#23](https://github.com/Spectara/Revela/issues/23))
+
## [0.0.1-beta.20] - 2026-05-06
### Fixed
diff --git a/src/Cli/Hosting/ConsoleUI.cs b/src/Cli/Hosting/ConsoleUI.cs
index eb3de7ed..00729af7 100644
--- a/src/Cli/Hosting/ConsoleUI.cs
+++ b/src/Cli/Hosting/ConsoleUI.cs
@@ -1,5 +1,3 @@
-using System.Reflection;
-
using Spectara.Revela.Sdk;
using Spectre.Console;
@@ -30,68 +28,42 @@ internal static class ConsoleUI
///
internal static readonly Style GroupHeaderStyle = new(Color.Grey);
- private static readonly string[] LogoLines =
- [
- @" ____ _ ",
- @" | _ \ _____ _____| | __ _ ",
- @" | |_) / _ \ \ / / _ \ |/ _` |",
- @" | _ < __/\ V / __/ | (_| |",
- @" |_| \_\___| \_/ \___|_|\__,_|",
- ];
-
///
- /// Clears the console and displays the Revela ASCII logo.
+ /// Clears the console (no banner).
///
- public static void ClearAndShowLogo()
- {
- AnsiConsole.Clear();
- ShowLogo();
- }
+ ///
+ /// Version data is no longer shown at startup — use revela info
+ /// (CLI) or the Info menu group (TUI) for version and host details.
+ ///
+ public static void ClearConsole() => AnsiConsole.Clear();
///
- /// Displays the Revela ASCII logo.
+ /// Displays a compact welcome panel with optional project context.
///
- public static void ShowLogo()
- {
- foreach (var line in LogoLines)
- {
- AnsiConsole.MarkupLine("[cyan1]" + line + "[/]");
- }
-
- AnsiConsole.WriteLine();
- }
-
- ///
- /// Displays a welcome panel with version and optional project context.
- ///
- /// Project name to display (null for no project context)
- /// Folder name to display when no project name is set
+ /// Project name to display (null for no project context).
+ /// Folder name to display when no project name is set.
public static void ShowWelcomePanel(string? projectName = null, string? folderName = null)
{
- var version = Assembly.GetEntryAssembly()?.GetName().Version?.ToString(3) ?? "1.0.0";
-
- var lines = new List
- {
- $"[bold]Version {version}[/]",
- "[dim]Modern static site generator for photographers[/]"
- };
+ var lines = new List();
if (!string.IsNullOrEmpty(projectName))
{
- lines.Add(string.Empty);
lines.Add($"[blue]Project:[/] {Markup.Escape(projectName)}");
}
else if (!string.IsNullOrEmpty(folderName))
{
- lines.Add(string.Empty);
lines.Add($"[dim]Directory:[/] {Markup.Escape(folderName)}");
}
- lines.Add(string.Empty);
+ if (lines.Count > 0)
+ {
+ lines.Add(string.Empty);
+ }
+
lines.Add("[blue]Navigate with[/] [bold]↑↓[/][blue], select with[/] [bold]Enter[/]");
var panel = new Panel(new Markup(string.Join("\n", lines)))
- .WithHeader("[cyan1]Welcome[/]")
+ .WithHeader("[cyan1]Revela[/]")
.WithInfoStyle();
AnsiConsole.Write(panel);
From 415d7cc62fcec46e19b76473b9618a679112c537 Mon Sep 17 00:00:00 2001
From: Kirsten Kluge <15778270+kirkone@users.noreply.github.com>
Date: Thu, 14 May 2026 20:14:46 +0200
Subject: [PATCH 4/4] docs(changelog): fix inverted host-kind suffix
description (#23)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The version-suffix " — embedded build" is appended for HostKind.Embedded
in BuildInfo.FormatVersionLine, not for Standalone. CHANGELOG had it
backwards.
Found in PR #71 review by copilot-pull-request-reviewer.
---
CHANGELOG.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4f7b7fa6..6085d9e4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,7 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
-- **`revela --version` is now human-readable and host-kind aware** — the System.CommandLine default action is replaced with a renderer that prints `revela 1.0.0 (.NET 10.0.7)` (or `… — embedded build` for the standalone variant). Identical to the first line of `revela info`, so both surfaces report the same identifier. ([#23](https://github.com/Spectara/Revela/issues/23))
+- **`revela --version` is now human-readable and host-kind aware** — the System.CommandLine default action is replaced with a renderer that prints `revela 1.0.0 (.NET 10.0.7)` (or `… — embedded build` for the embedded variant). Identical to the first line of `revela info`, so both surfaces report the same identifier. ([#23](https://github.com/Spectara/Revela/issues/23))
- **Welcome panel slimmed down** — the ASCII-art Revela logo is removed from interactive startup (it was off-brand vs. the actual aperture wordmark and added ~6 lines of friction on every menu render). The welcome panel header is now `Revela`; the redundant `Version` line and the `Modern static site generator for photographers` tagline are gone. Version data lives in `revela info` (canonical for both CLI and TUI users). The first-run panel is unchanged. ([#23](https://github.com/Spectara/Revela/issues/23))
### Fixed