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 pub task_id: Option<String>,
45}
46
47pub async fn run_non_interactive(
50 config: Config,
51 cwd: PathBuf,
52 model_id: String,
53 prompt: String,
54) -> Result<RunResult> {
55 run_non_interactive_with(config, cwd, model_id, prompt, RunOptions::default()).await
56}
57
58pub async fn run_non_interactive_with(
61 config: Config,
62 cwd: PathBuf,
63 model_id: String,
64 prompt: String,
65 opts: RunOptions,
66) -> Result<RunResult> {
67 let providers = std::sync::Arc::new(crate::providers::ProviderFactory::new(config.clone()));
68 let tools = if opts.no_execute {
71 std::sync::Arc::new(ToolRegistry::new())
72 } else {
73 ToolRegistry::build(
74 &config,
75 crate::providers::TuiMode::Headless,
76 providers.clone(),
77 )
78 };
79 let (mut runner, mut msg_rx) =
80 EffectRunner::pair_from_with_task(cwd.clone(), providers, tools, opts.task_id.clone());
81 runner = runner.without_terminal_title();
82
83 let mut state = State::new(config.clone(), cwd, model_id);
84 let mut lifecycle = RuntimeLifecycle::new();
85
86 if !config.mcp_servers.is_empty() && !opts.no_execute {
97 runner.dispatch(crate::domain::Cmd::InitMcpServers(
98 config.mcp_servers.clone(),
99 ));
100 }
101
102 let seed = Msg::SubmitPrompt {
104 text: prompt,
105 attachment_ids: vec![],
106 };
107 let (new_state, cmds) = update(state, seed);
108 state = new_state;
109 for cmd in cmds {
110 runner.dispatch(cmd);
111 }
112
113 let deadline = Duration::from_secs(20 * 60);
114
115 let drive = async {
116 while !matches!(state.turn, TurnState::Idle) || !state.ui.queued_messages.is_empty() {
117 let msg = tokio::select! {
118 m = msg_rx.recv() => match m {
119 Some(m) => m,
120 None => break,
121 },
122 s = lifecycle.next_msg() => match s {
123 Some(s) => s,
124 None => continue,
125 },
126 };
127 let (new_state, cmds) = update(state, msg);
128 state = new_state;
129 for cmd in cmds {
130 runner.dispatch(cmd);
131 }
132 if state.should_exit {
133 break;
134 }
135 }
136 state
137 };
138
139 let final_state = timeout(deadline, drive).await.map_err(|_| {
140 anyhow::anyhow!(
141 "non-interactive run exceeded {} seconds",
142 deadline.as_secs()
143 )
144 })?;
145
146 runner.shutdown().await;
147 Ok(build_result(&final_state))
148}
149
150fn build_result(state: &State) -> RunResult {
153 let mut out = RunResult {
154 total_tokens: state.session.cumulative_token_usage.total_tokens,
155 ..RunResult::default()
156 };
157
158 for msg in state.session.messages() {
159 for action in &msg.actions {
160 if let crate::domain::ActionResult::Error { error } = &action.result {
161 out.errors
162 .push(format!("{}: {}", action.action_type, error));
163 }
164 }
165 }
166
167 if let Some(last) = state
168 .session
169 .messages()
170 .iter()
171 .rev()
172 .find(|m| m.role == MessageRole::Assistant)
173 {
174 out.response = last.content.clone();
175 out.reasoning = last.thinking.clone();
176 }
177
178 out
179}
180
181pub fn format_result(result: &RunResult, format: OutputFormat) -> String {
183 match format {
184 OutputFormat::Text => {
185 if result.response.is_empty() && !result.errors.is_empty() {
186 result.errors.join("\n")
187 } else {
188 result.response.clone()
189 }
190 },
191 OutputFormat::Markdown => {
192 let mut out = result.response.clone();
193 if !result.errors.is_empty() {
194 out.push_str("\n\n---\n\n## Errors\n\n");
195 for e in &result.errors {
196 out.push_str(&format!("- {}\n", e));
197 }
198 }
199 out
200 },
201 OutputFormat::Json => {
202 let json = serde_json::json!({
203 "response": result.response,
204 "reasoning": result.reasoning,
205 "total_tokens": result.total_tokens,
206 "errors": result.errors,
207 });
208 serde_json::to_string_pretty(&json).unwrap_or_default()
209 },
210 }
211}