Replace clap with figue in quire-ci; move credentials to QUIRE__* env vars
quire-ci now uses figue for argument parsing. Transport credentials and
telemetry settings move from CLI flags to QUIRE__* environment variables
so quire-server no longer passes --run-id/--server-url/--transport on the
command line:

  QUIRE__RUN_ID       (was --run-id)
  QUIRE__SERVER_URL   (was --server-url / QUIRE_SERVER_URL)
  QUIRE__AUTH_TOKEN   (was QUIRE_CI_TOKEN)
  QUIRE__TRANSPORT    (was --transport, omitted for filesystem default)
  QUIRE__SENTRY_DSN   (was SENTRY_DSN, now sourced from quire-server config
                       rather than the bootstrap file)

SentryHandoff.dsn is removed from the bootstrap wire format; the DSN
travels via QUIRE__SENTRY_DSN while the trace id (needed for distributed
tracing correlation) stays in the bootstrap file. The sensitive auth_token
field is redacted in figue's debug/help output automatically.

https://claude.ai/code/session_01NFGc15XXN1PGGa2hFom7RA
change
commit e11e2c5c5fe9fbeb58a718977c69b07f913a4490
author Claude <noreply@anthropic.com>
date
parent 48a7da72
diff --git a/quire-ci/Cargo.toml b/quire-ci/Cargo.toml
index 41e3bfc..41fcc05 100644
--- a/quire-ci/Cargo.toml
+++ b/quire-ci/Cargo.toml
@@ -4,7 +4,8 @@ version = "0.1.0"
 edition = "2024"
 
 [dependencies]
-clap = { workspace = true }
+facet = "*"
+figue = "*"
 fs-err = { workspace = true }
 jiff = { workspace = true }
 miette = { workspace = true, features = ["fancy"] }
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index fbb3b16..6441af5 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -5,7 +5,8 @@ use std::io;
 use std::path::PathBuf;
 use std::rc::Rc;
 
-use clap::Parser;
+use facet::Facet;
+use figue::{self as args, Driver, FigueBuiltins};
 use miette::{IntoDiagnostic, Result, miette};
 use quire_core::api::SecretResponse;
 use quire_core::ci::bootstrap::{Bootstrap, SentryHandoff};
@@ -38,27 +39,65 @@ use crate::sink::{EventSink, JsonlSink, NullSink};
 const VERSION: &str = env!("QUIRE_VERSION");
 
 /// Run and validate quire CI pipelines.
-#[derive(Parser)]
-#[command(version, propagate_version = true)]
+#[derive(Facet)]
 struct Cli {
     /// Workspace root containing .quire/ci.fnl. Defaults to cwd.
-    #[arg(short, long, default_value = ".", global = true)]
-    workspace: PathBuf,
+    #[facet(args::named, args::short = 'w', default = ".")]
+    workspace: String,
+
+    /// Transport credentials and telemetry settings for
+    /// orchestrator-dispatched runs, sourced from `QUIRE__*` env vars:
+    /// `QUIRE__RUN_ID`, `QUIRE__SERVER_URL`, `QUIRE__AUTH_TOKEN`,
+    /// `QUIRE__TRANSPORT`, `QUIRE__SENTRY_DSN`.
+    #[facet(args::config, args::env_prefix = "QUIRE")]
+    quire: QuireConfig,
+
+    #[facet(flatten)]
+    builtins: FigueBuiltins,
 
-    #[command(subcommand)]
+    #[facet(args::subcommand)]
     command: Commands,
 }
 
-#[derive(clap::Subcommand)]
+/// Transport credentials and telemetry settings sourced from `QUIRE__*`
+/// environment variables. Fields can also be overridden via
+/// `--quire.<field>` on the CLI.
+#[derive(Facet)]
+struct QuireConfig {
+    /// Run UUID assigned by the orchestrator (`QUIRE__RUN_ID`).
+    #[facet(default)]
+    run_id: String,
+
+    /// Base URL of quire-server, e.g. `http://127.0.0.1:3000`
+    /// (`QUIRE__SERVER_URL`).
+    #[facet(default)]
+    server_url: String,
+
+    /// Bearer token minted at run creation time (`QUIRE__AUTH_TOKEN`).
+    #[facet(sensitive, default)]
+    auth_token: String,
+
+    /// Transport mode: `filesystem` (default) or `api`
+    /// (`QUIRE__TRANSPORT`).
+    #[facet(default = "filesystem")]
+    transport: String,
+
+    /// Sentry DSN for error reporting (`QUIRE__SENTRY_DSN`).
+    #[facet(default)]
+    sentry_dsn: Option<String>,
+}
+
+#[derive(Facet)]
+#[repr(u8)]
 enum Commands {
     /// Compile and validate a ci.fnl pipeline.
     Validate,
 
     /// Execute a pipeline dispatched by the orchestrator.
     ///
-    /// `--bootstrap <path>` points at a JSON file (see
-    /// [`quire_core::ci::bootstrap::Bootstrap`]) produced by the
-    /// orchestrator that supplies push metadata.
+    /// Transport credentials and telemetry settings are supplied via
+    /// `QUIRE__*` environment variables (see top-level `--quire.*`
+    /// options).
     Run {
         /// Where to send the structured event stream. Accepts:
         ///   `null`   — drop events (default).
@@ -66,42 +105,22 @@ enum Commands {
         ///   `<path>` — write JSONL to this file. The orchestrator
         ///              reads the file post-run to populate `jobs`
         ///              and `sh_events` database rows.
-        #[arg(long, default_value = "null", value_parser = parse_events_target)]
-        events: EventsTarget,
+        #[facet(args::named, default = "null")]
+        events: String,
 
         /// Directory for per-sh CRI log files. Defaults to a fresh
         /// tempdir whose path is printed on stdout at the end of the
         /// run.
-        #[arg(long)]
-        out_dir: Option<PathBuf>,
+        #[facet(args::named, default)]
+        out_dir: Option<String>,
 
         /// Path to a JSON bootstrap file produced by the orchestrator.
-        /// Carries push metadata.
-        #[arg(long)]
-        bootstrap: PathBuf,
-
-        #[command(flatten)]
-        transport: TransportFlags,
+        /// Carries push metadata and the Sentry trace id.
+        #[facet(args::named)]
+        bootstrap: String,
     },
 }
 
-/// Session and transport flags for orchestrator-dispatched runs.
-#[derive(clap::Args, Debug)]
-struct TransportFlags {
-    /// Run ID assigned by the orchestrator.
-    #[arg(long)]
-    run_id: String,
-
-    /// Base URL of quire-server (e.g. `http://127.0.0.1:3000`).
-    /// Falls back to `QUIRE_SERVER_URL` if the flag is omitted.
-    #[arg(long, env = "QUIRE_SERVER_URL")]
-    server_url: String,
-
-    /// Transport for CI ↔ server communication.
-    #[arg(long, default_value = "filesystem")]
-    transport: TransportMode,
-}
-
 /// 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
@@ -223,15 +242,25 @@ impl ApiClient {
 
 fn main() -> Result<()> {
     miette::set_panic_hook();
-    let cli = Cli::parse();
+
+    let config = figue::builder::<Cli>()
+        .map_err(|e| miette!("{e}"))?
+        .cli(|cli| cli.args(std::env::args().skip(1)))
+        .env(|env| env)
+        .help(|h| h.program_name("quire-ci").version(VERSION))
+        .build();
+
+    let cli: Cli = Driver::new(config).run().unwrap();
+    let workspace = PathBuf::from(&cli.workspace);
+
     match cli.command {
-        Commands::Validate => validate(cli.workspace),
+        Commands::Validate => validate(workspace),
         Commands::Run {
             events,
             out_dir,
             bootstrap,
-            transport,
         } => {
+            let events = parse_events_target(&events).map_err(|e| miette!("{e}"))?;
             let sink: Box<dyn EventSink> = match events {
                 EventsTarget::Null => Box::new(NullSink),
                 EventsTarget::Stdout => Box::new(JsonlSink::new(io::stdout())),
@@ -242,6 +271,7 @@ fn main() -> Result<()> {
             };
             let (log_dir, _dump) = match out_dir {
                 Some(path) => {
+                    let path = PathBuf::from(path);
                     fs_err::create_dir_all(&path).into_diagnostic()?;
                     (path, None)
                 }
@@ -251,30 +281,14 @@ fn main() -> Result<()> {
                     (path, Some(DumpLogsOnDrop { dir }))
                 }
             };
-            let auth_token = std::env::var("QUIRE_CI_TOKEN")
-                .map_err(|_| miette!("QUIRE_CI_TOKEN env var is required"))?;
-            let transport = Transport {
-                session: ApiSession {
-                    run_id: transport.run_id,
-                    server_url: transport.server_url,
-                    auth_token,
-                },
-                mode: transport.transport,
-            };
-            let (git_dir, meta, bootstrap_sentry) = load_bootstrap(&bootstrap)?;
-            // SENTRY_DSN env var overrides (or supplies) the DSN; the
-            // trace id is still taken from the bootstrap handoff when
-            // present so both sides' events land on the same trace.
-            let sentry_handoff = std::env::var("SENTRY_DSN")
-                .ok()
-                .map(|dsn| SentryHandoff {
-                    dsn,
-                    trace_id: bootstrap_sentry
-                        .as_ref()
-                        .map(|h| h.trace_id.clone())
-                        .unwrap_or_default(),
-                })
-                .or(bootstrap_sentry);
+
+            let transport_mode = cli
+                .quire
+                .transport
+                .parse::<TransportMode>()
+                .map_err(|e| miette!("{e}"))?;
+
+            let (git_dir, meta, sentry_handoff) = load_bootstrap(&PathBuf::from(&bootstrap))?;
 
             // Sentry's reqwest transport spawns Tokio tasks for HTTP
             // sends, so the client must be constructed and dropped from
@@ -290,7 +304,9 @@ fn main() -> Result<()> {
 
             // Drop order: `_sentry` flushes first (still inside the
             // runtime), then `_enter`, then `rt`.
-            let _sentry = init_sentry(sentry_handoff.as_ref(), &meta);
+            let trace_id = sentry_handoff.as_ref().map(|h| h.trace_id.as_str());
+            let _sentry = init_sentry(cli.quire.sentry_dsn.as_deref(), trace_id, &meta);
+
             // No type registrations: quire-ci's user-level errors
             // (CompileError, JobError, FennelError) are no longer logged
             // at tracing::error, so the miette renderer would never fire
@@ -299,57 +315,62 @@ fn main() -> Result<()> {
             let miette_layer = MietteLayer::new();
             telemetry::init_tracing(miette_layer, FmtMode::Plain)?;
 
-            let client = ApiClient::new(transport.session.clone());
+            let session = ApiSession {
+                run_id: cli.quire.run_id,
+                server_url: cli.quire.server_url,
+                auth_token: cli.quire.auth_token,
+            };
+            let transport = Transport {
+                session: session.clone(),
+                mode: transport_mode,
+            };
+
+            let client = ApiClient::new(session);
             let registry = SecretRegistry::new(move |name| client.fetch_secret(name));
 
-            run_pipeline(
-                cli.workspace,
-                sink,
-                log_dir,
-                git_dir,
-                meta,
-                registry,
-                transport,
-            )
+            run_pipeline(workspace, sink, log_dir, git_dir, meta, registry, transport)
         }
     }
 }
 
-/// Initialize Sentry when the orchestrator passed a handoff. Tags
-/// the scope with `service=quire-ci` plus the run's sha and ref so
-/// events from this binary are distinguishable from quire-server's
-/// in the same project, and attaches the orchestrator's trace id so
-/// the two sides' events group on the same trace. A malformed
-/// trace_id (shouldn't happen — the orchestrator emits the canonical
-/// hex form) is logged and skipped rather than aborting Sentry init.
-fn init_sentry(handoff: Option<&SentryHandoff>, meta: &RunMeta) -> Option<sentry::ClientInitGuard> {
-    let handoff = handoff?;
-    let guard = sentry::init((
-        handoff.dsn.as_str(),
-        telemetry::sentry_client_options(VERSION),
-    ));
+/// Initialize Sentry when a DSN is provided. Tags the scope with
+/// `service=quire-ci` plus the run's sha and ref so events from this
+/// binary are distinguishable from quire-server's in the same project.
+/// When a trace id is also available (from the bootstrap handoff),
+/// attaches it so both sides' events group on the same trace.
+fn init_sentry(
+    dsn: Option<&str>,
+    trace_id: Option<&str>,
+    meta: &RunMeta,
+) -> Option<sentry::ClientInitGuard> {
+    let dsn = dsn?;
+    let guard = sentry::init((dsn, telemetry::sentry_client_options(VERSION)));
     sentry::configure_scope(|scope| {
         scope.set_tag("service", "quire-ci");
         scope.set_tag("sha", &meta.sha);
         scope.set_tag("ref", &meta.r#ref);
-        match handoff.trace_id.parse::<sentry::protocol::TraceId>() {
-            Ok(trace_id) => {
-                scope.set_context(
-                    "trace",
-                    sentry::protocol::Context::Trace(Box::new(sentry::protocol::TraceContext {
-                        trace_id,
-                        span_id: sentry::protocol::SpanId::default(),
-                        op: Some("quire.ci.run".into()),
-                        ..Default::default()
-                    })),
-                );
-            }
-            Err(e) => {
-                tracing::warn!(
-                    trace_id = %handoff.trace_id,
-                    error = %e,
-                    "malformed trace_id in bootstrap; quire-ci events won't link to orchestrator",
-                );
+        if let Some(tid) = trace_id {
+            match tid.parse::<sentry::protocol::TraceId>() {
+                Ok(trace_id) => {
+                    scope.set_context(
+                        "trace",
+                        sentry::protocol::Context::Trace(Box::new(
+                            sentry::protocol::TraceContext {
+                                trace_id,
+                                span_id: sentry::protocol::SpanId::default(),
+                                op: Some("quire.ci.run".into()),
+                                ..Default::default()
+                            },
+                        )),
+                    );
+                }
+                Err(e) => {
+                    tracing::warn!(
+                        trace_id = %tid,
+                        error = %e,
+                        "malformed trace_id in bootstrap; quire-ci events won't link to orchestrator",
+                    );
+                }
             }
         }
     });
diff --git a/quire-core/src/ci/bootstrap.rs b/quire-core/src/ci/bootstrap.rs
index 273783f..f8bc818 100644
--- a/quire-core/src/ci/bootstrap.rs
+++ b/quire-core/src/ci/bootstrap.rs
@@ -35,13 +35,13 @@ pub struct Bootstrap {
     pub sentry: Option<SentryHandoff>,
 }
 
-/// What quire-ci needs to mirror the orchestrator's Sentry context.
+/// What quire-ci needs to mirror the orchestrator's Sentry trace.
 ///
-/// `trace_id` is the hex form
-/// of [`sentry::protocol::TraceId`]; kept as a string here so
-/// `quire-core` doesn't grow a `sentry` dep.
-#[derive(Debug, Serialize, Deserialize)]
+/// `trace_id` is the hex form of [`sentry::protocol::TraceId`]; kept
+/// as a string here so `quire-core` doesn't grow a `sentry` dep.
+/// The DSN itself travels via the `QUIRE__SENTRY_DSN` environment
+/// variable, not this struct.
+#[derive(Clone, Debug, Serialize, Deserialize)]
 pub struct SentryHandoff {
-    pub dsn: String,
     pub trace_id: String,
 }
diff --git a/quire-core/src/ci/transport.rs b/quire-core/src/ci/transport.rs
index a302ede..92f4a44 100644
--- a/quire-core/src/ci/transport.rs
+++ b/quire-core/src/ci/transport.rs
@@ -2,8 +2,8 @@
 //!
 //! The on-the-wire pairing both sides agree on. The orchestrator
 //! constructs a `Transport` per run (minting the auth token and
-//! using the run's UUID); quire-ci reconstructs it from CLI flags
-//! plus `QUIRE_CI_TOKEN`.
+//! using the run's UUID); quire-ci reconstructs it from the
+//! `QUIRE__*` environment variables.
 
 /// Credentials and endpoint coordinates for a single CI run's API
 /// channel. Holds everything quire-ci needs to call back to the
diff --git a/quire-server/src/bin/quire/commands/ci.rs b/quire-server/src/bin/quire/commands/ci.rs
index 56d4971..cea5410 100644
--- a/quire-server/src/bin/quire/commands/ci.rs
+++ b/quire-server/src/bin/quire/commands/ci.rs
@@ -85,7 +85,7 @@ pub async fn run(quire: &Quire, maybe_sha: Option<&str>) -> Result<()> {
     let workspace = tmp.path().join("workspace");
     quire::ci::materialize_workspace(&repo_path.join(".git"), &commit.sha, &workspace)
         .into_diagnostic()?;
-    let exec_result = run.execute(&repo_path.join(".git"), &workspace, &meta, None, None);
+    let exec_result = run.execute(&repo_path.join(".git"), &workspace, &meta, None, None, None);
 
     // Print the combined quire-ci log regardless of outcome.
     let log_path = tmp.path().join(&run_id).join("quire-ci.log");
diff --git a/quire-server/src/ci/mod.rs b/quire-server/src/ci/mod.rs
index 278a60e..67249fb 100644
--- a/quire-server/src/ci/mod.rs
+++ b/quire-server/src/ci/mod.rs
@@ -188,8 +188,7 @@ fn run_ref(
     let sentry_handoff =
         ctx.sentry_dsn
             .as_ref()
-            .map(|dsn| quire_core::ci::bootstrap::SentryHandoff {
-                dsn: dsn.clone(),
+            .map(|_| quire_core::ci::bootstrap::SentryHandoff {
                 trace_id: trace_id.to_string(),
             });
     sentry::with_scope(
@@ -211,6 +210,7 @@ fn run_ref(
                 push_ref,
                 &transport,
                 sentry_handoff.as_ref(),
+                ctx.sentry_dsn.as_deref(),
             ) {
                 tracing::error!(
                     repo = %ctx.event_repo,
@@ -230,6 +230,7 @@ fn run_ref_inner(
     push_ref: &PushRef,
     transport: &Transport,
     sentry: Option<&quire_core::ci::bootstrap::SentryHandoff>,
+    sentry_dsn: Option<&str>,
 ) -> error::Result<()> {
     let ci = ctx.repo.ci();
 
@@ -258,7 +259,14 @@ fn run_ref_inner(
         Executor::Process => {
             // Compilation happens inside quire-ci so a malformed ci.fnl is
             // reported once, with the worker's trace context.
-            run.execute(&ctx.repo.path(), &workspace, &meta, sentry, Some(transport))?;
+            run.execute(
+                &ctx.repo.path(),
+                &workspace,
+                &meta,
+                sentry,
+                sentry_dsn,
+                Some(transport),
+            )?;
         }
     }
     Ok(())
@@ -509,6 +517,7 @@ exit 0
                 &push_ref,
                 &new_transport(TransportMode::Filesystem, 3000),
                 None,
+                None,
             )
         });
 
@@ -563,6 +572,7 @@ exit 0
                 &push_ref,
                 &new_transport(TransportMode::Filesystem, 3000),
                 None,
+                None,
             )
         });
 
@@ -606,6 +616,7 @@ exit 0
             &push_ref,
             &new_transport(TransportMode::Filesystem, 3000),
             None,
+            None,
         )
         .expect("should succeed without ci.fnl");
     }
diff --git a/quire-server/src/ci/run.rs b/quire-server/src/ci/run.rs
index 0d757d8..2857386 100644
--- a/quire-server/src/ci/run.rs
+++ b/quire-server/src/ci/run.rs
@@ -307,6 +307,7 @@ impl Run {
         workspace: &Path,
         meta: &RunMeta,
         sentry: Option<&quire_core::ci::bootstrap::SentryHandoff>,
+        sentry_dsn: Option<&str>,
         transport: Option<&Transport>,
     ) -> Result<()> {
         self.transition(RunState::Active, None)?;
@@ -330,34 +331,27 @@ impl Run {
         );
 
         let mut cmd = std::process::Command::new("quire-ci");
-        cmd.arg("run").arg("--workspace").arg(workspace);
-
-        match transport {
-            None => {
-                cmd.arg("--out-dir")
-                    .arg(&run_dir)
-                    .arg("--events")
-                    .arg(&events_path)
-                    .arg("--bootstrap")
-                    .arg(&bootstrap_path);
-            }
-            Some(t) => {
-                cmd.arg("--run-id")
-                    .arg(&t.session.run_id)
-                    .arg("--server-url")
-                    .arg(&t.session.server_url);
-                cmd.env("QUIRE_CI_TOKEN", &t.session.auth_token);
-                cmd.arg("--out-dir")
-                    .arg(&run_dir)
-                    .arg("--events")
-                    .arg(&events_path)
-                    .arg("--bootstrap")
-                    .arg(&bootstrap_path);
-                if t.mode == TransportMode::Api {
-                    cmd.arg("--transport").arg("api");
-                }
+        cmd.arg("run")
+            .arg("--workspace")
+            .arg(workspace)
+            .arg("--out-dir")
+            .arg(&run_dir)
+            .arg("--events")
+            .arg(&events_path)
+            .arg("--bootstrap")
+            .arg(&bootstrap_path);
+
+        if let Some(t) = transport {
+            cmd.env("QUIRE__RUN_ID", &t.session.run_id);
+            cmd.env("QUIRE__SERVER_URL", &t.session.server_url);
+            cmd.env("QUIRE__AUTH_TOKEN", &t.session.auth_token);
+            if t.mode == TransportMode::Api {
+                cmd.env("QUIRE__TRANSPORT", "api");
             }
         }
+        if let Some(dsn) = sentry_dsn {
+            cmd.env("QUIRE__SENTRY_DSN", dsn);
+        }
 
         let status = cmd
             .stdout(std::process::Stdio::from(log))
@@ -633,15 +627,12 @@ fn write_bootstrap(
     meta: &RunMeta,
     sentry: Option<&quire_core::ci::bootstrap::SentryHandoff>,
 ) -> Result<()> {
-    use quire_core::ci::bootstrap::{Bootstrap, SentryHandoff};
+    use quire_core::ci::bootstrap::Bootstrap;
 
     let bootstrap = Bootstrap {
         meta: meta.clone(),
         git_dir: git_dir.to_path_buf(),
-        sentry: sentry.map(|s| SentryHandoff {
-            dsn: s.dsn.clone(),
-            trace_id: s.trace_id.clone(),
-        }),
+        sentry: sentry.cloned(),
     };
     let json = serde_json::to_vec_pretty(&bootstrap).map_err(std::io::Error::other)?;