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 #[arg(long, global = true, value_name = "PATH")]
28 config: Option<PathBuf>,
29
30 #[arg(long = "global-config", global = true, value_name = "PATH")]
32 global_config: Option<PathBuf>,
33
34 #[arg(long = "session-root", global = true, value_name = "PATH")]
37 session_root: Option<PathBuf>,
38
39 #[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 Run(RunArgs),
51 Mcp(McpArgs),
53 Design(DesignArgs),
55 Build(BuildArgs),
57 Config(ConfigArgs),
59 Init {
61 #[arg(long)]
63 force: bool,
64 },
65 Image(ImageArgs),
67 Ls(LsArgs),
69 Logs(LogsArgs),
71 Discard(DiscardArgs),
73 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 Init {
87 #[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 Add {
103 name: Option<String>,
105 #[arg(long)]
107 force: bool,
108 },
109 Init {
111 dir: Option<PathBuf>,
114 #[arg(long)]
116 force: bool,
117 },
118 Build {
120 dir: Option<PathBuf>,
123 #[arg(long, value_name = "REF")]
126 tag: Option<String>,
127 #[arg(long = "no-test")]
130 no_test: bool,
131 #[arg(long = "no-cache")]
133 no_cache: bool,
134 },
135 Inspect {
137 #[arg(long)]
139 remote: bool,
140 #[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
317fn 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
331fn 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}