Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion .github/instructions/plugins.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>`). |
| `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 <plugin-name>` 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.
Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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

- **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
Expand Down
1 change: 1 addition & 0 deletions Spectara.Revela.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
<Folder Name="/tests/">
<Project Path="tests/Core/Core.csproj" />
<Project Path="tests/Commands/Commands.csproj" />
<Project Path="tests/Cli/Cli.csproj" />
<Project Path="tests/Integration/Integration.csproj" />
<Project Path="tests/Shared/Shared.csproj" />
</Folder>
Expand Down
7 changes: 7 additions & 0 deletions src/Cli.Embedded/Cli.Embedded.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@
<Compile Include="..\Cli\Hosting\**\*.cs" LinkBase="Hosting" />
</ItemGroup>

<!-- Marker read at runtime by BuildInfo to expose HostKind = Embedded
(both Cli and Cli.Embedded produce 'revela' as AssemblyName, so
assembly-name-based detection is impossible). -->
<ItemGroup>
<AssemblyMetadata Include="Revela.HostKind" Value="Embedded" />
</ItemGroup>

<!-- Core + Commands (same as Cli) -->
<ItemGroup>
<ProjectReference Include="..\Core\Core.csproj" />
Expand Down
142 changes: 142 additions & 0 deletions src/Cli/Hosting/BuildInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
using System.Globalization;
using System.Reflection;
using System.Runtime.InteropServices;

using Spectara.Revela.Sdk.Hosting;

namespace Spectara.Revela.Cli.Hosting;

/// <summary>
/// Default <see cref="IBuildInfo"/> implementation.
/// </summary>
/// <remarks>
/// <para>
/// Detects <see cref="HostKind"/> by reading the <c>Revela.HostKind</c>
/// <see cref="AssemblyMetadataAttribute"/> from the entry assembly. Default
/// when the attribute is absent is <see cref="HostKind.Standalone"/>.
/// </para>
/// <para>
/// Both Cli and Cli.Embedded produce an executable named <c>revela</c>, so
/// assembly-name-based detection is impossible. The metadata attribute is the
/// single source of truth, set in <c>Cli.Embedded.csproj</c> via:
/// </para>
/// <code>
/// &lt;ItemGroup&gt;
/// &lt;AssemblyMetadata Include="Revela.HostKind" Value="Embedded" /&gt;
/// &lt;/ItemGroup&gt;
/// </code>
/// </remarks>
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())
{
}

/// <summary>
/// Test seam: construct from a specific assembly, bypassing entry-assembly
/// auto-detection. Used by BuildInfoTests.
/// </summary>
internal BuildInfo(Assembly entry)
{
Kind = DetectHostKind(entry);
InformationalVersion = DetectInformationalVersion(entry);
Version = StripBuildMetadata(InformationalVersion);
Framework = RuntimeInformation.FrameworkDescription;
Configuration = BuildConfiguration;
RuntimeIdentifier = RuntimeInformation.RuntimeIdentifier;
}

/// <summary>
/// Test seam: construct directly from raw values, fully bypassing assembly
/// inspection. Used by BuildInfoTests for FormatVersionLine assertions
/// across both <see cref="HostKind"/> values.
/// </summary>
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<AssemblyMetadataAttribute>()
.FirstOrDefault(a => string.Equals(a.Key, HostKindMetadataKey, StringComparison.Ordinal))
?.Value;

return string.Equals(value, nameof(HostKind.Embedded), StringComparison.Ordinal)
? HostKind.Embedded
: HostKind.Standalone;
}

/// <summary>Test seam — exposes <see cref="DetectHostKind"/>.</summary>
internal static HostKind DetectHostKindForTesting(Assembly entry) => DetectHostKind(entry);

private static string DetectInformationalVersion(Assembly entry)
{
var info = entry.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.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];
}

/// <summary>Test seam — exposes <see cref="StripBuildMetadata"/>.</summary>
internal static string StripBuildMetadataForTesting(string informational) => StripBuildMetadata(informational);
}
3 changes: 3 additions & 0 deletions src/Cli/Hosting/CommandGroupRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ internal static class CommandGroups
/// <summary>Plugin addons and optional features.</summary>
public const string Addons = "Addons";

/// <summary>Diagnostic / about commands (main menu, bottom).</summary>
public const string Info = "Info";

// Config submenu groups

/// <summary>Core project configuration commands (config submenu).</summary>
Expand Down
28 changes: 28 additions & 0 deletions src/Cli/Hosting/CommandOrderRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ internal sealed class CommandOrderRegistry
private readonly HashSet<Command> noProjectRequired = [];
private readonly HashSet<Command> hideWhenProjectExists = [];
private readonly HashSet<Command> pipelineSteps = [];
private readonly Dictionary<Command, string> inlinedParents = [];

/// <summary>
/// Registers the display order for a command.
Expand Down Expand Up @@ -86,6 +87,33 @@ internal sealed class CommandOrderRegistry
/// <returns>True if the command is a pipeline step.</returns>
public bool IsPipelineStep(Command command) => pipelineSteps.Contains(command);

/// <summary>
/// Marks a command as an inlined parent for the interactive menu.
/// </summary>
/// <remarks>
/// 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 <paramref name="defaultActionLabel"/>) and lists the
/// command's visible subcommands directly under the same group label.
/// CLI behavior is unaffected.
/// </remarks>
/// <param name="command">The parent command to render inline.</param>
/// <param name="defaultActionLabel">Display label for the virtual default-action entry.</param>
public void RegisterInlinedParent(Command command, string defaultActionLabel) =>
inlinedParents[command] = defaultActionLabel;

/// <summary>
/// Gets whether a command is registered as an inlined parent.
/// </summary>
public bool IsInlinedParent(Command command) => inlinedParents.ContainsKey(command);

/// <summary>
/// Gets the display label for an inlined parent's default-action entry,
/// or <c>null</c> if the command is not registered as inlined.
/// </summary>
public string? GetInlineDefaultActionLabel(Command command) =>
inlinedParents.TryGetValue(command, out var label) ? label : null;

/// <summary>
/// Gets the display order for a command.
/// </summary>
Expand Down
60 changes: 16 additions & 44 deletions src/Cli/Hosting/ConsoleUI.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
using System.Reflection;

using Spectara.Revela.Sdk;

using Spectre.Console;
Expand Down Expand Up @@ -30,68 +28,42 @@ internal static class ConsoleUI
/// </summary>
internal static readonly Style GroupHeaderStyle = new(Color.Grey);

private static readonly string[] LogoLines =
[
@" ____ _ ",
@" | _ \ _____ _____| | __ _ ",
@" | |_) / _ \ \ / / _ \ |/ _` |",
@" | _ < __/\ V / __/ | (_| |",
@" |_| \_\___| \_/ \___|_|\__,_|",
];

/// <summary>
/// Clears the console and displays the Revela ASCII logo.
/// Clears the console (no banner).
/// </summary>
public static void ClearAndShowLogo()
{
AnsiConsole.Clear();
ShowLogo();
}
/// <remarks>
/// Version data is no longer shown at startup — use <c>revela info</c>
/// (CLI) or the <c>Info</c> menu group (TUI) for version and host details.
/// </remarks>
public static void ClearConsole() => AnsiConsole.Clear();

/// <summary>
/// Displays the Revela ASCII logo.
/// Displays a compact welcome panel with optional project context.
/// </summary>
public static void ShowLogo()
{
foreach (var line in LogoLines)
{
AnsiConsole.MarkupLine("[cyan1]" + line + "[/]");
}

AnsiConsole.WriteLine();
}

/// <summary>
/// Displays a welcome panel with version and optional project context.
/// </summary>
/// <param name="projectName">Project name to display (null for no project context)</param>
/// <param name="folderName">Folder name to display when no project name is set</param>
/// <param name="projectName">Project name to display (null for no project context).</param>
/// <param name="folderName">Folder name to display when no project name is set.</param>
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<string>
{
$"[bold]Version {version}[/]",
"[dim]Modern static site generator for photographers[/]"
};
var lines = new List<string>();

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);
Expand Down
Loading
Loading