Skip to content

Idea: make CLI extensibility plugin-based so products own their own commands #60

Description

@woksin

Status: idea / not committed. Captured for later consideration — no work planned, nothing blocked on it. Filed so the analysis isn't lost.

Problem / motivation

The CLI implements the commands for every Cratis product itself. Chronicle, Arc, Screenplay and Prologue commands all live in this repo, so the CLI has to know about all of them — their contracts, their package versions, and their dependency weight.

Rough shape of the current codebase (~20.7k LOC under Source/Cli):

Family LOC
Chronicle 10,587 (51%)
Screenplay + run 2,477
Prologue 913
Arc 331
Core / shared ~6,400

Two concrete costs today:

Dependency weight belongs to the wrong repo. The Screenplay commands are why the CLI carries Microsoft.Build.Locator, Microsoft.CodeAnalysis.Workspaces.MSBuild and Microsoft.CodeAnalysis.CSharp.Workspaces, and why Cli.csproj needs both IncludeAllContentForSelfExtract (Roslyn BuildHost in single-file publishes) and the Microsoft.CodeAnalysis.Workspaces.Common PrivateAssets="none" override. Every user pays that weight and that fragility for one command family. See also #50.

Version lockstep. Directory.Packages.props pins Cratis.Chronicle.Contracts 16.11.0 and Cratis.Arc.Screenplay 20.66.0. A contracts change in a product repo becomes a CLI change, authored by whoever happens to be holding this repo rather than by the person who made the change.

If each product owned its own CLI commands, that code and those dependencies would live next to the thing they describe.

Proposed solution

Extract a versioned core and let products contribute commands as plugin packages.

Most of the mechanism already exists. CliApp.Create() names no command:

config.SetApplicationName("cratis");
config.SetInterceptor(new EventStoreInterceptor());   // the one Chronicle leak
RegisterDiscoveredCommands(config);                    // source-generated

CliCommandGenerator discovers [CliBranch] / [CliCommand(Branch = typeof(...))] and emits both the Spectre registration tree and the llm-context descriptor. There is no hand-maintained registry. The coupling is at the csproj/compilation level, not in the code.

Three things block it, all contained:

  1. The generator only sees its own compilation. ForAttributeWithMetadataName walks syntax trees, so [CliCommand] types in a referenced assembly are invisible. Fix: add a pipeline stage off CompilationProvider that walks ReferencedAssemblySymbols for the same two attributes. Costs fine-grained incrementality, which is irrelevant at this size. This is the linchpin — worth spiking first, since everything else is mechanical extraction if it works and moot if it doesn't.

  2. No published core contract. A plugin needs GlobalSettings, OutputFormatter, OutputFormats, ExitCodes, CliConfiguration / CliContext, ConfirmationHelper, ConsoleInterrupt, and the registration attributes — roughly ten types. These would become a versioned Cratis.Cli.Extensibility package.

  3. Core knows about Chronicle in a few places. SetInterceptor(new EventStoreInterceptor()); the chronicle://localhost:35000 default in Program.cs; init generating CHRONICLE.md; and most significantly CliContext is entirely Chronicle-shaped (Server, EventStore, Namespace, ClientId, ClientSecret, AccessToken, TokenExpiry, LoggedInUser). Contexts would keep generic server/identity fields and let plugins contribute typed config sections.

Products would ship Cratis.Chronicle.Cli, Cratis.Arc.Cli, Cratis.Screenplay.Cli; this repo becomes core plus a thin shell that references them.

Worth adding alongside it: a PATH-based escape hatch. If the first arg matches no known branch, look for cratis-<name> on PATH and exec it — roughly 30 lines, and it gives third parties an extension story without any further machinery.

Alternatives considered

Runtime assembly plugins (~/.cratis/plugins, AssemblyLoadContext). Technically viable — publishes are self-contained but neither trimmed nor AOT, so reflection is fine. But every invocation would load every plugin just to build the help tree; llm-context and shell-completion generation would move from compile-time to runtime reflection; and transitive dependency conflicts between plugins land in one process. Significant machinery relative to the gain.

Out-of-process cratis-chronicle binaries (git/kubectl style). Perfect isolation and independent release cadence, but each product then owns four platform builds plus a Homebrew formula, and a manifest protocol has to be invented to recover help, completions, consistent --output and llm-context.

Compile-time plugins are preferred because they preserve single-file/native publish, add no startup cost, and keep the cross-cutting aggregators working unchanged — llm-context, the four shell completion generators, init, contexts, output formatting. Those aggregators are what make this feel like one product rather than a bag of tools, and both alternatives put them at risk.

Additional context

Known risk. Compile-time plugins create diamond-dependency exposure: Chronicle.Cli on one Cratis.Fundamentals and Arc.Cli on another surfaces as NU1605 in the shell project. Manageable, but it means this repo stays where version conflicts appear, which partly undercuts the "CLI never has to care" goal. Whether products coordinate on a shared Fundamentals floor should be decided early.

Suggested order if picked up. Spike the generator change against referenced assemblies → extract Cratis.Cli.Extensibility → move one small family first (Arc, at 331 LOC, is the natural candidate) → then Screenplay, which carries the largest dependency payoff.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions