Skip to main content

outrig_cli/cli/
app.rs

1use clap::{ArgAction, Args, Parser, Subcommand};
2use std::path::PathBuf;
3use std::process::ExitCode;
4use tracing_subscriber::EnvFilter;
5
6use crate::cli::build::{self, BuildArgs};
7use crate::cli::clean::{self, CleanArgs};
8use crate::cli::design_prompt::{self, DesignArgs};
9use crate::cli::discard::{self, DiscardArgs};
10use crate::cli::logs::{self, LogsArgs};
11use crate::cli::ls::{self, LsArgs};
12use crate::cli::mcp::{self, McpArgs};
13use crate::cli::mcp_self as mcp_self_cli;
14use crate::cli::run::{self, RunArgs};
15use crate::error::Result;
16use crate::paths::{global_config_path, resolve_repo_config, resolve_repo_config_optional};
17use crate::{config_init, image_setup, init};
18
19#[derive(Debug, Parser)]
20#[command(
21    name = "outrig",
22    version,
23    about = "Run LLM agents with podman-isolated MCP servers."
24)]
25struct Cli {
26    /// Path to the repo `config.toml`. Defaults to walking up from cwd.
27    #[arg(long, global = true, value_name = "PATH")]
28    config: Option<PathBuf>,
29
30    /// Path to the global config. Defaults to `~/.outrig/config.toml`.
31    #[arg(long = "global-config", global = true, value_name = "PATH")]
32    global_config: Option<PathBuf>,
33
34    /// Override the session root for this invocation. Default cascade:
35    /// flag > config's `session-root` > `<XDG_DATA_HOME>/outrig/sessions/`.
36    #[arg(long = "session-root", global = true, value_name = "PATH")]
37    session_root: Option<PathBuf>,
38
39    /// Show buildah/podman transcripts. Repeat for trace-level outrig logs.
40    #[arg(short = 'v', long = "verbose", global = true, action = ArgAction::Count)]
41    verbose: u8,
42
43    #[command(subcommand)]
44    cmd: Cmd,
45}
46
47#[derive(Debug, Subcommand)]
48enum Cmd {
49    /// Start an interactive agent session.
50    Run(RunArgs),
51    /// Serve the configured backing MCPs as a single MCP server over stdio.
52    Mcp(McpArgs),
53    /// Generate prompts and setup snippets for AI-assisted design.
54    Design(DesignArgs),
55    /// Build (or cache-hit) one or more image-config images.
56    Build(BuildArgs),
57    /// Read or write outrig's configuration files.
58    Config(ConfigArgs),
59    /// Interactively set up global + repo config.
60    Init {
61        /// Overwrite existing files. Propagates to `config init` and `image add`.
62        #[arg(long)]
63        force: bool,
64    },
65    /// Manage image-configs.
66    Image(ImageArgs),
67    /// List sessions newest-first under the session root.
68    Ls(LsArgs),
69    /// Print or follow a session's MCP-server stderr.
70    Logs(LogsArgs),
71    /// Delete a session's on-disk record.
72    Discard(DiscardArgs),
73    /// Delete old stopped session records.
74    Clean(CleanArgs),
75}
76
77#[derive(Debug, Args)]
78struct ConfigArgs {
79    #[command(subcommand)]
80    cmd: ConfigCmd,
81}
82
83#[derive(Debug, Subcommand)]
84enum ConfigCmd {
85    /// Interactively write the global config (`~/.outrig/config.toml`).
86    Init {
87        /// Overwrite an existing global config.
88        #[arg(long)]
89        force: bool,
90    },
91}
92
93#[derive(Debug, Args)]
94struct ImageArgs {
95    #[command(subcommand)]
96    cmd: ImageCmd,
97}
98
99#[derive(Debug, Subcommand)]
100enum ImageCmd {
101    /// Scaffold a new image-config (Dockerfile + `[images.<name>]`).
102    Add {
103        /// Image-config name. Prompted if omitted.
104        name: Option<String>,
105        /// Overwrite an existing Dockerfile / config block of this name.
106        #[arg(long)]
107        force: bool,
108    },
109    /// Scaffold a standalone image project (Dockerfile + image.toml + README).
110    Init {
111        /// Project directory. Defaults to the current directory; its name
112        /// becomes the image ref.
113        dir: Option<PathBuf>,
114        /// Overwrite the generated files if they already exist.
115        #[arg(long)]
116        force: bool,
117    },
118    /// Build a standalone image project and validate the built image.
119    Build {
120        /// Project directory holding `image.toml`. Defaults to the current
121        /// directory.
122        dir: Option<PathBuf>,
123        /// Tag the build output as this ref instead of `[image].ref`. Does not
124        /// rewrite `image.toml`.
125        #[arg(long, value_name = "REF")]
126        tag: Option<String>,
127        /// Skip the live MCP server test. Still validates the embedded
128        /// `image.toml`.
129        #[arg(long = "no-test")]
130        no_test: bool,
131        /// Force a clean build (passes `--no-cache` to buildah).
132        #[arg(long = "no-cache")]
133        no_cache: bool,
134    },
135    /// Inspect an image's OutRig labels without starting it.
136    Inspect {
137        /// Inspect the registry ref with skopeo instead of the local image store.
138        #[arg(long)]
139        remote: bool,
140        /// Image ref to inspect. Local mode never pulls; remote mode reads registry metadata.
141        #[arg(value_name = "REF")]
142        image_ref: String,
143    },
144}
145
146pub fn run() -> ExitCode {
147    let cli = Cli::parse();
148    init_tracing(cli.verbose);
149
150    outrig::container::install_panic_hook();
151
152    tracing::debug!("outrig starting");
153    match dispatch(&cli) {
154        Ok(0) => ExitCode::SUCCESS,
155        Ok(code) => ExitCode::from(code.clamp(0, 255) as u8),
156        Err(e) => {
157            eprintln!("error: {e}");
158            ExitCode::from(1)
159        }
160    }
161}
162
163fn dispatch(cli: &Cli) -> Result<i32> {
164    match &cli.cmd {
165        Cmd::Run(args) => {
166            let (repo_config, global_config, runtime) = repo_cmd_ctx(cli, false)?;
167            runtime.block_on(run::execute(
168                &repo_config,
169                &global_config,
170                cli.session_root.as_deref(),
171                args,
172                cli.verbose,
173            ))
174        }
175        Cmd::Mcp(args) => {
176            if args.is_self_description() {
177                let runtime = tokio::runtime::Builder::new_current_thread()
178                    .enable_all()
179                    .build()?;
180                return runtime.block_on(mcp_self_cli::execute(args));
181            }
182            let (repo_config, global_config, runtime) = repo_cmd_ctx(cli, false)?;
183            runtime.block_on(mcp::execute(
184                &repo_config,
185                &global_config,
186                cli.session_root.as_deref(),
187                args,
188                cli.verbose,
189            ))
190        }
191        Cmd::Design(args) => design_prompt::execute(args),
192        Cmd::Build(args) => {
193            let (repo_config, global_config, runtime) = repo_cmd_ctx(cli, true)?;
194            runtime.block_on(build::execute(&repo_config, &global_config, args))
195        }
196        Cmd::Config(args) => match &args.cmd {
197            ConfigCmd::Init { force } => {
198                let runtime = tokio::runtime::Builder::new_current_thread()
199                    .enable_all()
200                    .build()?;
201                runtime.block_on(config_init::run(*force, cli.global_config.as_deref()))?;
202                Ok(0)
203            }
204        },
205        Cmd::Init { force } => {
206            let runtime = tokio::runtime::Builder::new_current_thread()
207                .enable_all()
208                .build()?;
209            runtime.block_on(init::run(*force, cli.global_config.as_deref()))?;
210            Ok(0)
211        }
212        Cmd::Image(args) => match &args.cmd {
213            ImageCmd::Add { name, force } => {
214                let cwd = std::env::current_dir()?;
215                let runtime = tokio::runtime::Builder::new_current_thread()
216                    .enable_all()
217                    .build()?;
218                runtime.block_on(image_setup::add::run(
219                    &cwd,
220                    cli.global_config.as_deref(),
221                    name.clone(),
222                    *force,
223                ))?;
224                Ok(0)
225            }
226            ImageCmd::Init { dir, force } => {
227                let cwd = std::env::current_dir()?;
228                image_setup::init::run(&cwd, dir.as_deref(), *force)?;
229                Ok(0)
230            }
231            ImageCmd::Build {
232                dir,
233                tag,
234                no_test,
235                no_cache,
236            } => {
237                let cwd = std::env::current_dir()?;
238                let runtime = tokio::runtime::Builder::new_current_thread()
239                    .enable_all()
240                    .build()?;
241                runtime.block_on(image_setup::build::run(
242                    &cwd,
243                    dir.as_deref(),
244                    tag.as_deref(),
245                    *no_test,
246                    *no_cache,
247                ))?;
248                Ok(0)
249            }
250            ImageCmd::Inspect { remote, image_ref } => {
251                let runtime = tokio::runtime::Builder::new_current_thread()
252                    .enable_all()
253                    .build()?;
254                runtime.block_on(image_setup::inspect::run(image_ref, *remote))?;
255                Ok(0)
256            }
257        },
258        Cmd::Ls(args) => {
259            let (cwd, global, runtime) = session_cmd_ctx(cli)?;
260            let session_root = cli.session_root.as_deref();
261            let repo_cfg = cli.config.as_deref();
262            runtime.block_on(ls::execute(args, session_root, repo_cfg, &global, &cwd))
263        }
264        Cmd::Logs(args) => {
265            let (cwd, global, runtime) = session_cmd_ctx(cli)?;
266            let session_root = cli.session_root.as_deref();
267            let repo_cfg = cli.config.as_deref();
268            runtime.block_on(logs::execute(args, session_root, repo_cfg, &global, &cwd))
269        }
270        Cmd::Discard(args) => {
271            let (cwd, global, runtime) = session_cmd_ctx(cli)?;
272            let session_root = cli.session_root.as_deref();
273            let repo_cfg = cli.config.as_deref();
274            runtime.block_on(discard::execute(
275                args,
276                session_root,
277                repo_cfg,
278                &global,
279                &cwd,
280            ))
281        }
282        Cmd::Clean(args) => {
283            let (cwd, global, runtime) = session_cmd_ctx(cli)?;
284            let session_root = cli.session_root.as_deref();
285            let repo_cfg = cli.config.as_deref();
286            runtime.block_on(clean::execute(args, session_root, repo_cfg, &global, &cwd))
287        }
288    }
289}
290
291fn init_tracing(verbose: u8) {
292    let outrig_log = std::env::var("OUTRIG_LOG").ok();
293    let rust_log = std::env::var("RUST_LOG").ok();
294    let mut filter =
295        EnvFilter::try_new(log_filter_spec(outrig_log.as_deref(), rust_log.as_deref()))
296            .unwrap_or_else(|_| EnvFilter::new("info"));
297    if verbose >= 2 {
298        filter = filter.add_directive(
299            "outrig=trace"
300                .parse()
301                .expect("hard-coded outrig trace directive must parse"),
302        );
303    }
304    tracing_subscriber::fmt()
305        .with_env_filter(filter)
306        .with_writer(std::io::stderr)
307        .init();
308    if verbose >= 2 {
309        tracing::trace!(target: "outrig", "verbose tracing enabled");
310    }
311}
312
313fn log_filter_spec<'a>(outrig_log: Option<&'a str>, rust_log: Option<&'a str>) -> &'a str {
314    outrig_log.or(rust_log).unwrap_or("info")
315}
316
317/// Shared preamble for `ls`/`logs`/`discard`: cwd, the resolved global
318/// config path, and a current-thread tokio runtime ready to drive the
319/// async `execute` form of each subcommand. The repo config is resolved
320/// inside each handler because session lookups can substring-match across
321/// repos and shouldn't fail on a missing repo config.
322fn session_cmd_ctx(cli: &Cli) -> Result<(PathBuf, PathBuf, tokio::runtime::Runtime)> {
323    let cwd = std::env::current_dir()?;
324    let global = global_config_path(cli.global_config.as_deref());
325    let runtime = tokio::runtime::Builder::new_current_thread()
326        .enable_all()
327        .build()?;
328    Ok((cwd, global, runtime))
329}
330
331/// Shared preamble for `run`/`mcp`/`build`: the resolved repo config, the
332/// resolved global config, and a current-thread tokio runtime. With
333/// `require_config` (build), errors if no repo config can be located;
334/// otherwise (run/mcp) a missing config falls back to the current directory
335/// as repo root, merged over the global config.
336fn repo_cmd_ctx(
337    cli: &Cli,
338    require_config: bool,
339) -> Result<(PathBuf, PathBuf, tokio::runtime::Runtime)> {
340    let cwd = std::env::current_dir()?;
341    let repo_config = if require_config {
342        resolve_repo_config(cli.config.as_deref(), &cwd)?
343    } else {
344        resolve_repo_config_optional(cli.config.as_deref(), &cwd)
345    };
346    let global_config = global_config_path(cli.global_config.as_deref());
347    let runtime = tokio::runtime::Builder::new_current_thread()
348        .enable_all()
349        .build()?;
350    Ok((repo_config, global_config, runtime))
351}
352
353#[cfg(test)]
354mod tests {
355    use clap::Parser;
356
357    use super::{Cli, Cmd, ImageCmd, log_filter_spec};
358
359    #[test]
360    fn outrig_log_wins_over_rust_log() {
361        assert_eq!(
362            log_filter_spec(Some("outrig=trace"), Some("debug")),
363            "outrig=trace"
364        );
365    }
366
367    #[test]
368    fn rust_log_is_used_when_outrig_log_is_unset() {
369        assert_eq!(log_filter_spec(None, Some("debug")), "debug");
370    }
371
372    #[test]
373    fn log_filter_defaults_to_info() {
374        assert_eq!(log_filter_spec(None, None), "info");
375    }
376
377    #[test]
378    fn image_inspect_defaults_to_local() {
379        let cli =
380            Cli::try_parse_from(["outrig", "image", "inspect", "rust-dev"]).expect("arg parses");
381
382        let Cmd::Image(args) = cli.cmd else {
383            panic!("expected image command");
384        };
385        let ImageCmd::Inspect { remote, image_ref } = args.cmd else {
386            panic!("expected image inspect command");
387        };
388
389        assert!(!remote);
390        assert_eq!(image_ref, "rust-dev");
391    }
392
393    #[test]
394    fn image_inspect_remote_arg_parses() {
395        let cli = Cli::try_parse_from([
396            "outrig",
397            "image",
398            "inspect",
399            "--remote",
400            "quay.io/acme/rust-dev:latest",
401        ])
402        .expect("arg parses");
403
404        let Cmd::Image(args) = cli.cmd else {
405            panic!("expected image command");
406        };
407        let ImageCmd::Inspect { remote, image_ref } = args.cmd else {
408            panic!("expected image inspect command");
409        };
410
411        assert!(remote);
412        assert_eq!(image_ref, "quay.io/acme/rust-dev:latest");
413    }
414}