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:
-
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.
-
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.
-
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.
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):runTwo concrete costs today:
Dependency weight belongs to the wrong repo. The Screenplay commands are why the CLI carries
Microsoft.Build.Locator,Microsoft.CodeAnalysis.Workspaces.MSBuildandMicrosoft.CodeAnalysis.CSharp.Workspaces, and whyCli.csprojneeds bothIncludeAllContentForSelfExtract(Roslyn BuildHost in single-file publishes) and theMicrosoft.CodeAnalysis.Workspaces.CommonPrivateAssets="none"override. Every user pays that weight and that fragility for one command family. See also #50.Version lockstep.
Directory.Packages.propspinsCratis.Chronicle.Contracts 16.11.0andCratis.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:CliCommandGeneratordiscovers[CliBranch]/[CliCommand(Branch = typeof(...))]and emits both the Spectre registration tree and thellm-contextdescriptor. There is no hand-maintained registry. The coupling is at the csproj/compilation level, not in the code.Three things block it, all contained:
The generator only sees its own compilation.
ForAttributeWithMetadataNamewalks syntax trees, so[CliCommand]types in a referenced assembly are invisible. Fix: add a pipeline stage offCompilationProviderthat walksReferencedAssemblySymbolsfor 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.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 versionedCratis.Cli.Extensibilitypackage.Core knows about Chronicle in a few places.
SetInterceptor(new EventStoreInterceptor()); thechronicle://localhost:35000default inProgram.cs;initgeneratingCHRONICLE.md; and most significantlyCliContextis 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>onPATHand 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-contextand 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-chroniclebinaries (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--outputandllm-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.Clion oneCratis.FundamentalsandArc.Clion 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 sharedFundamentalsfloor 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.