mermaid_cli/app/
run_non_interactive.rs1use std::path::PathBuf;
10use std::time::Duration;
11
12use anyhow::Result;
13use tokio::time::timeout;
14
15use crate::app::Config;
16use crate::app::lifecycle::RuntimeLifecycle;
17use crate::cli::OutputFormat;
18use crate::domain::{Msg, State, TurnState, update};
19use crate::effect::EffectRunner;
20use crate::models::MessageRole;
21use crate::providers::ToolRegistry;
22
23#[derive(Debug, Default)]
25pub struct RunResult {
26 pub response: String,
27 pub reasoning: Option<String>,
28 pub total_tokens: usize,
29 pub errors: Vec<String>,
30}
31
32#[derive(Debug, Default, Clone)]
37pub struct RunOptions {
38 pub no_execute: bool,
42}
43
44pub async fn run_non_interactive(
47 config: Config,
48 cwd: PathBuf,
49 model_id: String,
50 prompt: String,
51) -> Result<RunResult> {
52 run_non_interactive_with(config, cwd, model_id, prompt, RunOptions::default()).await
53}
54
55pub async fn run_non_interactive_with(
58 config: Config,
59 cwd: PathBuf,
60 model_id: String,
61 prompt: String,
62 opts: RunOptions,
63) -> Result<RunResult> {
64 let providers = std::sync::Arc::new(crate::providers::ProviderFactory::new(config.clone()));
65 let tools = if opts.no_execute {
68 std::sync::Arc::new(ToolRegistry::new())
69 } else {
70 ToolRegistry::build(
71 &config,
72 crate::providers::TuiMode::Headless,
73 providers.clone(),
74 )
75 };
76 let (mut runner, mut msg_rx) = EffectRunner::pair_from(cwd.clone(), providers, tools);
77
78 let mut state = State::new(config.clone(), cwd, model_id);
79 let mut lifecycle = RuntimeLifecycle::new();
80
81 if !config.mcp_servers.is_empty() && !opts.no_execute {
92 runner.dispatch(crate::domain::Cmd::InitMcpServers(
93 config.mcp_servers.clone(),
94 ));
95 }
96
97 let seed = Msg::SubmitPrompt {
99 text: prompt,
100 attachment_ids: vec![],
101 };
102 let (new_state, cmds) = update(state, seed);
103 state = new_state;
104 for cmd in cmds {
105 runner.dispatch(cmd);
106 }
107
108 let deadline = Duration::from_secs(20 * 60);
109
110 let drive = async {
111 while !matches!(state.turn, TurnState::Idle) || !state.ui.queued_messages.is_empty() {
112 let msg = tokio::select! {
113 m = msg_rx.recv() => match m {
114 Some(m) => m,
115 None => break,
116 },
117 s = lifecycle.next_msg() => match s {
118 Some(s) => s,
119 None => continue,
120 },
121 };
122 let (new_state, cmds) = update(state, msg);
123 state = new_state;
124 for cmd in cmds {
125 runner.dispatch(cmd);
126 }
127 if state.should_exit {
128 break;
129 }
130 }
131 state
132 };
133
134 let final_state = timeout(deadline, drive).await.map_err(|_| {
135 anyhow::anyhow!(
136 "non-interactive run exceeded {} seconds",
137 deadline.as_secs()
138 )
139 })?;
140
141 runner.shutdown().await;
142 Ok(build_result(&final_state))
143}
144
145fn build_result(state: &State) -> RunResult {
148 let mut out = RunResult {
149 total_tokens: state.session.cumulative_token_usage.total_tokens,
150 ..RunResult::default()
151 };
152
153 for msg in state.session.messages() {
154 for action in &msg.actions {
155 if let crate::domain::ActionResult::Error { error } = &action.result {
156 out.errors
157 .push(format!("{}: {}", action.action_type, error));
158 }
159 }
160 }
161
162 if let Some(last) = state
163 .session
164 .messages()
165 .iter()
166 .rev()
167 .find(|m| m.role == MessageRole::Assistant)
168 {
169 out.response = last.content.clone();
170 out.reasoning = last.thinking.clone();
171 }
172
173 out
174}
175
176pub fn format_result(result: &RunResult, format: OutputFormat) -> String {
178 match format {
179 OutputFormat::Text => {
180 if result.response.is_empty() && !result.errors.is_empty() {
181 result.errors.join("\n")
182 } else {
183 result.response.clone()
184 }
185 },
186 OutputFormat::Markdown => {
187 let mut out = result.response.clone();
188 if !result.errors.is_empty() {
189 out.push_str("\n\n---\n\n## Errors\n\n");
190 for e in &result.errors {
191 out.push_str(&format!("- {}\n", e));
192 }
193 }
194 out
195 },
196 OutputFormat::Json => {
197 let json = serde_json::json!({
198 "response": result.response,
199 "reasoning": result.reasoning,
200 "total_tokens": result.total_tokens,
201 "errors": result.errors,
202 });
203 serde_json::to_string_pretty(&json).unwrap_or_default()
204 },
205 }
206}