Extract local run into a dedicated `quire-ci local` command
`run` previously served double duty: orchestrator-dispatched runs (with
`--bootstrap`) and standalone local runs (without it). The two modes
have entirely different flag sets, so the overlap caused confusing
conditionals and documentation.

This splits them cleanly:
- `quire-ci local` — no orchestrator flags, placeholder metadata,
  sh logs dumped to stdout; the command developers actually invoke.
- `quire-ci run` — `--bootstrap` is now required; all remaining flags
  (`--events`, `--out-dir`, `--transport`) are orchestrator-facing.

https://claude.ai/code/session_01Ngi66G5VmLWEzyuzaNxfKm
change
commit 91d071fe618200bedb5440c23f6192f954e45d13
author Claude <noreply@anthropic.com>
date
parent 13461207
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index 8cc34e2..694701a 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -35,7 +35,7 @@ use crate::sink::{EventSink, JsonlSink, NullSink};
 
 const VERSION: &str = env!("QUIRE_VERSION");
 
-/// Run a quire CI pipeline locally.
+/// Run and validate quire CI pipelines.
 #[derive(Parser)]
 #[command(version, propagate_version = true)]
 struct Cli {
@@ -52,15 +52,22 @@ enum Commands {
     /// Compile and validate a ci.fnl pipeline.
     Validate,
 
-    /// Run the whole pipeline against the workspace, in topo order.
+    /// Run the pipeline locally against the workspace.
+    ///
+    /// Uses placeholder push metadata (SHA = 40 zeros, ref = "HEAD")
+    /// and no secrets — `(secret :name)` calls error, and `(jobs
+    /// upstream)` reads return Nil for everything except `quire/push`.
+    /// Sh logs are written to a tempdir and dumped to stdout when the
+    /// run finishes.
+    ///
+    /// For orchestrator-dispatched runs see the `run` command.
+    Local,
+
+    /// Execute a pipeline dispatched by the orchestrator.
     ///
     /// `--bootstrap <path>` points at a JSON file (see
-    /// [`quire_core::ci::bootstrap::Bootstrap`]) that supplies push
-    /// metadata and secrets when the orchestrator dispatches via
-    /// `:executor :quire-ci`. Standalone invocations omit the flag
-    /// and fall back to placeholder meta with no secrets — `(secret
-    /// :name)` calls error, and `(jobs upstream)` reads return Nil
-    /// for everything except `quire/push`.
+    /// [`quire_core::ci::bootstrap::Bootstrap`]) produced by the
+    /// orchestrator that supplies push metadata and secrets.
     Run {
         /// Where to send the structured event stream. Accepts:
         ///   `null`   — drop events (default).
@@ -78,11 +85,9 @@ enum Commands {
         out_dir: Option<PathBuf>,
 
         /// Path to a JSON bootstrap file produced by the orchestrator.
-        /// Carries push metadata and the secrets the run-fns may
-        /// resolve. Omit for standalone runs (placeholder meta, no
-        /// secrets).
+        /// Carries push metadata and the secrets the run-fns may resolve.
         #[arg(long)]
-        bootstrap: Option<PathBuf>,
+        bootstrap: PathBuf,
 
         #[command(flatten)]
         transport: TransportFlags,
@@ -135,9 +140,8 @@ impl TransportFlags {
     }
 }
 
-/// RAII wrapper around the tempdir that holds a `quire-ci run`'s
-/// captured sh logs when no `--out-dir` was passed. On drop, prints
-/// each log file's contents to stdout, then lets the underlying
+/// RAII wrapper around a tempdir holding captured sh logs. On drop,
+/// prints each log file's contents to stdout, then lets the underlying
 /// [`tempfile::TempDir`] clean up the directory. Drop fires whether
 /// the run succeeded or failed.
 struct DumpLogsOnDrop {
@@ -232,6 +236,22 @@ fn main() -> miette::Result<()> {
     let cli = Cli::parse();
     match cli.command {
         Commands::Validate => validate(cli.workspace),
+        Commands::Local => {
+            let miette_layer = MietteLayer::new();
+            telemetry::init_tracing(miette_layer, FmtMode::Plain)?;
+            let dir = tempfile::tempdir().into_diagnostic()?;
+            let log_dir = dir.path().to_path_buf();
+            let _dump = DumpLogsOnDrop { dir };
+            run_pipeline(
+                cli.workspace.clone(),
+                Box::new(NullSink),
+                log_dir,
+                cli.workspace.join(".git"),
+                placeholder_meta(),
+                HashMap::new(),
+                TransportArgs::Filesystem,
+            )
+        }
         Commands::Run {
             events,
             out_dir,
@@ -259,15 +279,7 @@ fn main() -> miette::Result<()> {
             };
             let auth_token = std::env::var("QUIRE_CI_TOKEN").ok();
             let transport = transport.resolve(auth_token)?;
-            let (git_dir, meta, secrets, sentry_handoff) = match bootstrap {
-                Some(path) => load_bootstrap(&path)?,
-                None => (
-                    cli.workspace.join(".git"),
-                    placeholder_meta(),
-                    HashMap::new(),
-                    None,
-                ),
-            };
+            let (git_dir, meta, secrets, sentry_handoff) = load_bootstrap(&bootstrap)?;
 
             // Sentry's reqwest transport spawns Tokio tasks for HTTP
             // sends, so the client must be constructed and dropped from