diff --git a/Directory.Packages.props b/Directory.Packages.props
index 0e97cde..912f2ef 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -13,6 +13,8 @@
+
+
diff --git a/Documentation/reference/run.md b/Documentation/reference/run.md
new file mode 100644
index 0000000..3c9a7ce
--- /dev/null
+++ b/Documentation/reference/run.md
@@ -0,0 +1,51 @@
+# Run
+
+`cratis run` boots a local [Stage](https://github.com/Cratis/Stage) sandbox from the Screenplay (`.play`) files in a folder. It packages a Chronicle kernel, the Stage engine, and an in-memory event store into a single throwaway container so you can play with an event model straight from its `.play` source — no server to set up, nothing to clean up afterward.
+
+```bash
+cratis run [PATH]
+```
+
+You point it at a folder of `.play` files (or run it from inside one), and it hands that folder to the Stage container, which compiles every `.play` file it finds and exposes a live API you can drive.
+
+## Prerequisites
+
+- **Docker** must be installed and the `docker` command on your `PATH`. `cratis run` shells out to `docker run`.
+- The `cratis/stage` image is pulled automatically on first use.
+
+## Arguments
+
+| Argument | Description |
+|---|---|
+| `PATH` | Folder containing the Screenplay (`.play`) files to run. Searched recursively. Defaults to the current directory. |
+
+## Options
+
+| Option | Description |
+|---|---|
+| `--tag ` | The `cratis/stage` image tag to run. Default: `latest`. |
+| `--port ` | Host port to publish the Stage API on. Default: `9090`. |
+
+Global options such as `-o/--output` are also accepted — see [Global Options](global-options.md).
+
+## What it does
+
+The command mounts the folder into the Stage container and publishes the Stage API to your host:
+
+```bash
+docker run --rm -p 9090:9090 -v "$PWD":/eventmodel cratis/stage:latest
+```
+
+- The folder is mounted at `/eventmodel` inside the container; Stage globs `**/*.play` beneath it and compiles them.
+- The Stage API is published on `http://localhost:9090` (change the host side with `--port`).
+- `--rm` removes the container when it exits, so every run starts from a clean, in-memory store.
+
+The command streams the container's output and exits with the container's exit code. Stop the session with `Ctrl+C`.
+
+## Errors
+
+| Condition | Result |
+|---|---|
+| The folder contains no `.play` files | Validation error — nothing is started. |
+| The folder does not exist | Validation error. |
+| `docker` is not installed or not on `PATH` | Connection error with a hint to install Docker. |
diff --git a/Documentation/reference/toc.yml b/Documentation/reference/toc.yml
index c6b9bab..e1db2b3 100644
--- a/Documentation/reference/toc.yml
+++ b/Documentation/reference/toc.yml
@@ -1,3 +1,5 @@
+- name: Run
+ href: run.md
- name: Global Options
href: global-options.md
- name: Output Formats
diff --git a/Source/Cli.Specs/Usings.cs b/Source/Cli.Specs/Usings.cs
index efc5842..73859ae 100644
--- a/Source/Cli.Specs/Usings.cs
+++ b/Source/Cli.Specs/Usings.cs
@@ -7,5 +7,6 @@
global using Cratis.Cli.Commands.Chronicle.Observers;
global using Cratis.Cli.Commands.Completions;
global using Cratis.Cli.Commands.Init;
+global using Cratis.Cli.Commands.Run;
global using Cratis.Specifications;
global using Xunit;
diff --git a/Source/Cli.Specs/for_PlayFiles/given/a_temporary_folder.cs b/Source/Cli.Specs/for_PlayFiles/given/a_temporary_folder.cs
new file mode 100644
index 0000000..a9eced1
--- /dev/null
+++ b/Source/Cli.Specs/for_PlayFiles/given/a_temporary_folder.cs
@@ -0,0 +1,19 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Cli.for_PlayFiles.given;
+
+public class a_temporary_folder : Specification
+{
+ protected string _folder;
+
+ void Establish() => _folder = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString())).FullName;
+
+ void Destroy()
+ {
+ if (Directory.Exists(_folder))
+ {
+ Directory.Delete(_folder, true);
+ }
+ }
+}
diff --git a/Source/Cli.Specs/for_PlayFiles/when_checking_for_play_files/and_a_play_file_is_nested.cs b/Source/Cli.Specs/for_PlayFiles/when_checking_for_play_files/and_a_play_file_is_nested.cs
new file mode 100644
index 0000000..376dd99
--- /dev/null
+++ b/Source/Cli.Specs/for_PlayFiles/when_checking_for_play_files/and_a_play_file_is_nested.cs
@@ -0,0 +1,19 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Cli.for_PlayFiles.when_checking_for_play_files;
+
+public class and_a_play_file_is_nested : given.a_temporary_folder
+{
+ bool _result;
+
+ void Establish()
+ {
+ var nested = Directory.CreateDirectory(Path.Combine(_folder, "features", "invoicing")).FullName;
+ File.WriteAllText(Path.Combine(nested, "invoicing.play"), "module Invoicing");
+ }
+
+ void Because() => _result = PlayFiles.ExistIn(_folder);
+
+ [Fact] void should_find_the_nested_play_file() => _result.ShouldBeTrue();
+}
diff --git a/Source/Cli.Specs/for_PlayFiles/when_checking_for_play_files/and_a_play_file_is_present.cs b/Source/Cli.Specs/for_PlayFiles/when_checking_for_play_files/and_a_play_file_is_present.cs
new file mode 100644
index 0000000..358e785
--- /dev/null
+++ b/Source/Cli.Specs/for_PlayFiles/when_checking_for_play_files/and_a_play_file_is_present.cs
@@ -0,0 +1,15 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Cli.for_PlayFiles.when_checking_for_play_files;
+
+public class and_a_play_file_is_present : given.a_temporary_folder
+{
+ bool _result;
+
+ void Establish() => File.WriteAllText(Path.Combine(_folder, "demo.play"), "module Demo");
+
+ void Because() => _result = PlayFiles.ExistIn(_folder);
+
+ [Fact] void should_find_the_play_file() => _result.ShouldBeTrue();
+}
diff --git a/Source/Cli.Specs/for_PlayFiles/when_checking_for_play_files/and_no_play_files_are_present.cs b/Source/Cli.Specs/for_PlayFiles/when_checking_for_play_files/and_no_play_files_are_present.cs
new file mode 100644
index 0000000..8887a25
--- /dev/null
+++ b/Source/Cli.Specs/for_PlayFiles/when_checking_for_play_files/and_no_play_files_are_present.cs
@@ -0,0 +1,15 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Cli.for_PlayFiles.when_checking_for_play_files;
+
+public class and_no_play_files_are_present : given.a_temporary_folder
+{
+ bool _result;
+
+ void Establish() => File.WriteAllText(Path.Combine(_folder, "readme.md"), "not a play file");
+
+ void Because() => _result = PlayFiles.ExistIn(_folder);
+
+ [Fact] void should_not_find_any_play_files() => _result.ShouldBeFalse();
+}
diff --git a/Source/Cli.Specs/for_PlayFiles/when_checking_for_play_files/and_the_folder_does_not_exist.cs b/Source/Cli.Specs/for_PlayFiles/when_checking_for_play_files/and_the_folder_does_not_exist.cs
new file mode 100644
index 0000000..fbbf5a6
--- /dev/null
+++ b/Source/Cli.Specs/for_PlayFiles/when_checking_for_play_files/and_the_folder_does_not_exist.cs
@@ -0,0 +1,16 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Cli.for_PlayFiles.when_checking_for_play_files;
+
+public class and_the_folder_does_not_exist : Specification
+{
+ string _folder;
+ bool _result;
+
+ void Establish() => _folder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+
+ void Because() => _result = PlayFiles.ExistIn(_folder);
+
+ [Fact] void should_not_find_any_play_files() => _result.ShouldBeFalse();
+}
diff --git a/Source/Cli.Specs/for_StageContainer/when_building_run_arguments/with_a_custom_host_port.cs b/Source/Cli.Specs/for_StageContainer/when_building_run_arguments/with_a_custom_host_port.cs
new file mode 100644
index 0000000..2cdfe2f
--- /dev/null
+++ b/Source/Cli.Specs/for_StageContainer/when_building_run_arguments/with_a_custom_host_port.cs
@@ -0,0 +1,14 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Cli.for_StageContainer.when_building_run_arguments;
+
+public class with_a_custom_host_port : Specification
+{
+ IReadOnlyList _arguments;
+
+ void Because() => _arguments = StageContainer.BuildRunArguments("/work/space", "1.2.0", 9191);
+
+ [Fact] void should_publish_the_custom_host_port_to_the_api_port() => _arguments.ShouldContain("9191:9090");
+ [Fact] void should_run_the_requested_tag() => _arguments.ShouldContain("cratis/stage:1.2.0");
+}
diff --git a/Source/Cli.Specs/for_StageContainer/when_building_run_arguments/with_matching_ports.cs b/Source/Cli.Specs/for_StageContainer/when_building_run_arguments/with_matching_ports.cs
new file mode 100644
index 0000000..b1d52f7
--- /dev/null
+++ b/Source/Cli.Specs/for_StageContainer/when_building_run_arguments/with_matching_ports.cs
@@ -0,0 +1,17 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Cli.for_StageContainer.when_building_run_arguments;
+
+public class with_matching_ports : Specification
+{
+ IReadOnlyList _arguments;
+
+ void Because() => _arguments = StageContainer.BuildRunArguments("/work/space", "latest", 9090);
+
+ [Fact] void should_run_a_container() => _arguments.ShouldContain("run");
+ [Fact] void should_remove_the_container_on_exit() => _arguments.ShouldContain("--rm");
+ [Fact] void should_publish_the_host_port_to_the_api_port() => _arguments.ShouldContain("9090:9090");
+ [Fact] void should_mount_the_folder_at_the_model_path() => _arguments.ShouldContain("/work/space:/eventmodel");
+ [Fact] void should_run_the_tagged_image() => _arguments.ShouldContain("cratis/stage:latest");
+}
diff --git a/Source/Cli/Cli.csproj b/Source/Cli/Cli.csproj
index 219ff3f..70dc0e3 100644
--- a/Source/Cli/Cli.csproj
+++ b/Source/Cli/Cli.csproj
@@ -25,6 +25,8 @@
+
+
diff --git a/Source/Cli/Commands/Run/PlayFiles.cs b/Source/Cli/Commands/Run/PlayFiles.cs
new file mode 100644
index 0000000..cafdc3b
--- /dev/null
+++ b/Source/Cli/Commands/Run/PlayFiles.cs
@@ -0,0 +1,25 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Cli.Commands.Run;
+
+///
+/// Discovers Screenplay (.play) files within a folder tree.
+///
+public static class PlayFiles
+{
+ ///
+ /// The glob pattern that identifies a Screenplay file.
+ ///
+ public const string SearchPattern = "*.play";
+
+ ///
+ /// Determines whether the given folder contains at least one Screenplay (.play) file,
+ /// searching recursively through all subfolders.
+ ///
+ /// The folder to search.
+ /// True if one or more .play files are present; otherwise false.
+ public static bool ExistIn(string path) =>
+ Directory.Exists(path) &&
+ Directory.EnumerateFiles(path, SearchPattern, SearchOption.AllDirectories).Any();
+}
diff --git a/Source/Cli/Commands/Run/RunCommand.cs b/Source/Cli/Commands/Run/RunCommand.cs
new file mode 100644
index 0000000..abb2c47
--- /dev/null
+++ b/Source/Cli/Commands/Run/RunCommand.cs
@@ -0,0 +1,74 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.ComponentModel;
+using System.Diagnostics;
+
+namespace Cratis.Cli.Commands.Run;
+
+///
+/// Runs the Screenplay (.play) files in a folder in a local Stage sandbox using Docker.
+///
+[LlmDescription("Runs the current folder's Screenplay (.play) files in a local Stage sandbox via Docker. Errors if no .play files are present. The Stage API is published on the host (default port 9090).")]
+[CliCommand("run", "Run the Screenplay (.play) files in the current folder in a local Stage sandbox")]
+[CliExample("run")]
+[CliExample("run", "./screenplays")]
+[CliExample("run", "--port", "9191")]
+[LlmOption("--tag", "string", "The cratis/stage image tag to run (default: latest).")]
+[LlmOption("--port", "int", "Host port to publish the Stage API on (default: 9090).")]
+public class RunCommand : AsyncCommand
+{
+ ///
+ protected override async Task ExecuteAsync(CommandContext context, RunSettings settings, CancellationToken cancellationToken)
+ {
+ var format = settings.ResolveOutputFormat();
+ var path = Path.GetFullPath(settings.Path ?? Directory.GetCurrentDirectory());
+
+ if (!Directory.Exists(path))
+ {
+ OutputFormatter.WriteError(format, $"Folder '{path}' does not exist", "Run this command from a folder that contains one or more .play files, or pass the path to one", ExitCodes.ValidationErrorCode);
+ return ExitCodes.ValidationError;
+ }
+
+ if (!PlayFiles.ExistIn(path))
+ {
+ OutputFormatter.WriteError(format, "No Screenplay files (.play) found in the folder", "Run this command from a folder that contains one or more .play files, or pass the path to one", ExitCodes.ValidationErrorCode);
+ return ExitCodes.ValidationError;
+ }
+
+ var arguments = StageContainer.BuildRunArguments(path, settings.Tag, settings.Port);
+ var startInfo = new ProcessStartInfo { FileName = "docker" };
+ foreach (var argument in arguments)
+ {
+ startInfo.ArgumentList.Add(argument);
+ }
+
+ if (!string.Equals(format, OutputFormats.Quiet, StringComparison.Ordinal) &&
+ !string.Equals(format, OutputFormats.Json, StringComparison.Ordinal) &&
+ !string.Equals(format, OutputFormats.JsonCompact, StringComparison.Ordinal))
+ {
+ AnsiConsole.MarkupLine($" [{OutputFormatter.Muted.ToMarkup()}]Starting Stage from {path.EscapeMarkup()} on http://localhost:{settings.Port}[/]");
+ }
+
+ Process? process;
+ try
+ {
+ process = Process.Start(startInfo);
+ }
+ catch (Win32Exception)
+ {
+ OutputFormatter.WriteError(format, "Failed to start Docker", "Ensure Docker is installed and the 'docker' command is on your PATH", ExitCodes.ConnectionErrorCode);
+ return ExitCodes.ConnectionError;
+ }
+
+ if (process is null)
+ {
+ OutputFormatter.WriteError(format, "Failed to start Docker", "Ensure Docker is installed and the 'docker' command is on your PATH", ExitCodes.ConnectionErrorCode);
+ return ExitCodes.ConnectionError;
+ }
+
+ await process.WaitForExitAsync(cancellationToken);
+
+ return process.ExitCode == 0 ? ExitCodes.Success : ExitCodes.ServerError;
+ }
+}
diff --git a/Source/Cli/Commands/Run/RunSettings.cs b/Source/Cli/Commands/Run/RunSettings.cs
new file mode 100644
index 0000000..ca5a25f
--- /dev/null
+++ b/Source/Cli/Commands/Run/RunSettings.cs
@@ -0,0 +1,33 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Cli.Commands.Run;
+
+///
+/// Settings for the run command.
+///
+public class RunSettings : GlobalSettings
+{
+ ///
+ /// Gets or sets the folder containing the Screenplay files to run. Defaults to the current directory.
+ ///
+ [CommandArgument(0, "[PATH]")]
+ [Description("Folder containing the Screenplay (.play) files to run. Defaults to the current directory.")]
+ public string? Path { get; set; }
+
+ ///
+ /// Gets or sets the cratis/stage image tag to run.
+ ///
+ [CommandOption("--tag ")]
+ [Description("The cratis/stage image tag to run.")]
+ [DefaultValue("latest")]
+ public string Tag { get; set; } = "latest";
+
+ ///
+ /// Gets or sets the host port to publish the Stage API on.
+ ///
+ [CommandOption("--port ")]
+ [Description("Host port to publish the Stage API on.")]
+ [DefaultValue(9090)]
+ public int Port { get; set; } = 9090;
+}
diff --git a/Source/Cli/Commands/Run/StageContainer.cs b/Source/Cli/Commands/Run/StageContainer.cs
new file mode 100644
index 0000000..cc97293
--- /dev/null
+++ b/Source/Cli/Commands/Run/StageContainer.cs
@@ -0,0 +1,44 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Cli.Commands.Run;
+
+///
+/// Describes the Stage Docker container the run command launches and builds its invocation arguments.
+///
+public static class StageContainer
+{
+ ///
+ /// The Docker image name for the Stage sandbox.
+ ///
+ public const string Image = "cratis/stage";
+
+ ///
+ /// The port the Stage API listens on inside the container.
+ ///
+ public const int ApiPort = 9090;
+
+ ///
+ /// The path inside the container the folder of Screenplay files is mounted at.
+ ///
+ public const string MountPath = "/eventmodel";
+
+ ///
+ /// Builds the argument list for docker run that launches the Stage container with the given
+ /// folder mounted and the Stage API published on the host.
+ ///
+ /// The absolute path to the folder of Screenplay files to mount.
+ /// The image tag to run.
+ /// The host port to publish the Stage API on.
+ /// The ordered argument list to pass to the docker executable.
+ public static IReadOnlyList BuildRunArguments(string path, string tag, int hostPort) =>
+ [
+ "run",
+ "--rm",
+ "-p",
+ $"{hostPort}:{ApiPort}",
+ "-v",
+ $"{path}:{MountPath}",
+ $"{Image}:{tag}"
+ ];
+}