1pub mod cli_parser;
13pub mod command_builder;
14pub mod executor;
15pub mod permissions;
16pub mod result_metadata;
17pub mod streaming;
18pub mod tools;
19pub mod tui;
20
21use serde_json::{json, Value};
22use std::path::PathBuf;
23use std::time::{SystemTime, UNIX_EPOCH};
24use tokio::io::AsyncWriteExt;
25
26pub use cli_parser::{
27 parse_args, parse_start_agent_args, parse_stop_agent_args, show_start_agent_help,
28 show_stop_agent_help, validate_start_agent_options, validate_stop_agent_options,
29 StartAgentOptions, StopAgentOptions, ValidationResult,
30};
31
32pub use command_builder::{
33 build_agent_command, build_docker_stop_command, build_piped_command, build_screen_stop_command,
34 read_only_unsupported_error, supports_read_only, AgentCommandOptions,
35};
36
37pub use permissions::{
38 ask_scope, ask_unsupported_error, build_permission_response, normalize_permission_request,
39 permission_parity, supports_ask, NormalizedPermissionRequest, PermissionParityRow,
40 PermissionRelay, ASK_DECISIONS, ASK_SUPPORTED_TOOLS,
41};
42
43pub use executor::{
44 execute_command, execute_detached, setup_signal_handler, start_command, ExecutionResult,
45 ProcessHandle,
46};
47
48pub use result_metadata::{
49 build_normalized_result_metadata, BuildMetadataOptions, PricingInfo, ResultMetadata,
50};
51
52pub use streaming::{
53 create_input_stream, create_output_stream, parse_ndjson, parse_ndjson_line, stringify_ndjson,
54 stringify_ndjson_line, JsonInputStream, JsonOutputStream, ParseError,
55};
56
57pub use tools::{
58 get_tool, is_tool_supported, list_tools, AgentTool, ClaudeTool, CodexTool, OpencodeTool, Tool,
59 ToolRegistry,
60};
61
62#[derive(Debug, Clone, Default)]
64pub struct AgentOptions {
65 pub tool: String,
67 pub working_directory: String,
69 pub prompt: Option<String>,
71 pub prompt_file: Option<String>,
73 pub system_prompt: Option<String>,
75 pub append_system_prompt: Option<String>,
77 pub model: Option<String>,
79 pub fallback_model: Option<String>,
81 pub isolation: String,
83 pub screen_name: Option<String>,
85 pub container_name: Option<String>,
87 pub json: bool,
89 pub resume: Option<String>,
91 pub verbose: bool,
93 pub replay_user_messages: bool,
95 pub session_id: Option<String>,
97 pub fork_session: bool,
99 pub read_only: bool,
101 pub plan_only: bool,
103 pub approve_each: bool,
106 pub executable: Option<String>,
108 pub extra_args: Vec<String>,
110 pub extra_env: Vec<(String, String)>,
112 pub skip_default_safety_flags: bool,
114}
115
116#[derive(Debug, Clone, Default)]
118pub struct AgentResult {
119 pub exit_code: i32,
121 pub plain_output: String,
123 pub parsed_output: Option<Vec<Value>>,
125 pub session_id: Option<String>,
127 pub usage: Option<Value>,
129 pub metadata: ResultMetadata,
131}
132
133#[derive(Debug, Clone, Default)]
135pub struct AgentStartOptions {
136 pub dry_run: bool,
138 pub detached: bool,
140 pub attached: bool,
142}
143
144#[derive(Debug, Clone, Default)]
146pub struct AgentStopOptions {
147 pub dry_run: bool,
149}
150
151pub struct Agent {
153 options: AgentOptions,
154 process_handle: Option<ProcessHandle>,
155 output_stream: Option<JsonOutputStream>,
156 session_id: Option<String>,
157 prompt_temp_dir: Option<PathBuf>,
158}
159
160fn supports_prompt_file_input(tool: &str) -> bool {
161 matches!(
162 tool,
163 "claude" | "codex" | "opencode" | "agent" | "qwen" | "gemini"
164 )
165}
166
167fn build_prompt_file_content(
168 tool: &str,
169 prompt: Option<&str>,
170 system_prompt: Option<&str>,
171) -> String {
172 if tool == "claude" {
173 return prompt.unwrap_or_default().to_string();
174 }
175
176 match (system_prompt, prompt) {
177 (Some(system_prompt), Some(prompt)) => format!("{}\n\n{}", system_prompt, prompt),
178 (Some(system_prompt), None) => system_prompt.to_string(),
179 (None, Some(prompt)) => prompt.to_string(),
180 (None, None) => String::new(),
181 }
182}
183
184fn should_create_prompt_file(options: &AgentOptions, dry_run: bool) -> bool {
185 if dry_run || options.prompt_file.is_some() || !supports_prompt_file_input(&options.tool) {
186 return false;
187 }
188
189 if options.tool == "claude" {
190 return options.prompt.is_some();
191 }
192
193 options.prompt.is_some() || options.system_prompt.is_some()
194}
195
196fn extract_usage_value(tool: &str, output: &str) -> Option<Value> {
197 match tool {
198 "claude" => {
199 let usage = tools::claude::extract_usage(output);
200 Some(json!({
201 "inputTokens": usage.input_tokens,
202 "outputTokens": usage.output_tokens,
203 "cacheCreationTokens": usage.cache_creation_tokens,
204 "cacheReadTokens": usage.cache_read_tokens,
205 }))
206 }
207 "codex" => {
208 let usage = tools::codex::extract_usage(output);
209 Some(json!({
210 "inputTokens": usage.input_tokens,
211 "outputTokens": usage.output_tokens,
212 }))
213 }
214 "opencode" => {
215 let usage = tools::opencode::extract_usage(output);
216 Some(json!({
217 "inputTokens": usage.input_tokens,
218 "outputTokens": usage.output_tokens,
219 }))
220 }
221 "agent" => {
222 let usage = tools::agent::extract_usage(output);
223 Some(json!({
224 "inputTokens": usage.input_tokens,
225 "outputTokens": usage.output_tokens,
226 "reasoningTokens": usage.reasoning_tokens,
227 "cacheReadTokens": usage.cache_read_tokens,
228 "cacheWriteTokens": usage.cache_write_tokens,
229 "totalCost": usage.total_cost,
230 "stepCount": usage.step_count,
231 }))
232 }
233 _ => None,
234 }
235}
236
237impl Agent {
238 pub fn new(options: AgentOptions) -> Result<Self, String> {
246 if options.tool.is_empty() {
248 return Err("tool is required".to_string());
249 }
250 if options.working_directory.is_empty() {
251 return Err("working_directory is required".to_string());
252 }
253 if options.isolation == "screen" && options.screen_name.is_none() {
254 return Err("screen_name is required for screen isolation".to_string());
255 }
256 if options.isolation == "docker" && options.container_name.is_none() {
257 return Err("container_name is required for docker isolation".to_string());
258 }
259 if (options.read_only || options.plan_only) && !supports_read_only(&options.tool) {
260 return Err(read_only_unsupported_error(&options.tool));
261 }
262 if options.approve_each && !supports_ask(&options.tool) {
263 return Err(ask_unsupported_error(&options.tool));
264 }
265
266 Ok(Self {
267 options,
268 process_handle: None,
269 output_stream: None,
270 session_id: None,
271 prompt_temp_dir: None,
272 })
273 }
274
275 async fn cleanup_prompt_temp_dir(&mut self) {
276 if let Some(dir) = self.prompt_temp_dir.take() {
277 let _ = tokio::fs::remove_dir_all(dir).await;
278 }
279 }
280
281 async fn prepare_prompt_file(&mut self, dry_run: bool) -> Result<Option<String>, String> {
282 if !should_create_prompt_file(&self.options, dry_run) {
283 return Ok(self.options.prompt_file.clone());
284 }
285
286 let unique_id = SystemTime::now()
287 .duration_since(UNIX_EPOCH)
288 .map_err(|e| e.to_string())?
289 .as_nanos();
290 let temp_dir = std::env::temp_dir().join(format!(
291 "agent-commander-{}-{}",
292 std::process::id(),
293 unique_id
294 ));
295 tokio::fs::create_dir(&temp_dir)
296 .await
297 .map_err(|e| e.to_string())?;
298 self.prompt_temp_dir = Some(temp_dir.clone());
299 let prompt_file = temp_dir.join("prompt.txt");
300 let content = build_prompt_file_content(
301 &self.options.tool,
302 self.options.prompt.as_deref(),
303 self.options.system_prompt.as_deref(),
304 );
305
306 let mut open_options = tokio::fs::OpenOptions::new();
307 open_options.write(true).create_new(true);
308 #[cfg(unix)]
309 {
310 open_options.mode(0o600);
311 }
312 let mut file = open_options
313 .open(&prompt_file)
314 .await
315 .map_err(|e| e.to_string())?;
316 file.write_all(content.as_bytes())
317 .await
318 .map_err(|e| e.to_string())?;
319
320 Ok(Some(prompt_file.to_string_lossy().into_owned()))
321 }
322
323 pub async fn start(&mut self, start_options: AgentStartOptions) -> Result<(), String> {
331 if self.options.json {
333 self.output_stream = Some(create_output_stream());
334 }
335
336 let prepared_prompt_file = match self.prepare_prompt_file(start_options.dry_run).await {
337 Ok(prompt_file) => prompt_file,
338 Err(error) => {
339 self.cleanup_prompt_temp_dir().await;
340 return Err(error);
341 }
342 };
343 let prompt_handled_by_temp_file =
344 prepared_prompt_file.is_some() && self.options.prompt_file.is_none();
345
346 let command_options = AgentCommandOptions {
348 tool: self.options.tool.clone(),
349 working_directory: self.options.working_directory.clone(),
350 prompt: if prompt_handled_by_temp_file {
351 None
352 } else {
353 self.options.prompt.clone()
354 },
355 prompt_file: prepared_prompt_file,
356 system_prompt: if prompt_handled_by_temp_file && self.options.tool != "claude" {
357 None
358 } else {
359 self.options.system_prompt.clone()
360 },
361 append_system_prompt: self.options.append_system_prompt.clone(),
362 model: self.options.model.clone(),
363 fallback_model: self.options.fallback_model.clone(),
364 json: self.options.json,
365 verbose: self.options.verbose,
366 replay_user_messages: self.options.replay_user_messages,
367 resume: self.options.resume.clone(),
368 session_id: self.options.session_id.clone(),
369 fork_session: self.options.fork_session,
370 read_only: self.options.read_only,
371 plan_only: self.options.plan_only,
372 approve_each: self.options.approve_each,
373 executable: self.options.executable.clone(),
374 extra_args: self.options.extra_args.clone(),
375 extra_env: self.options.extra_env.clone(),
376 skip_default_safety_flags: self.options.skip_default_safety_flags,
377 isolation: self.options.isolation.clone(),
378 screen_name: self.options.screen_name.clone(),
379 container_name: self.options.container_name.clone(),
380 detached: start_options.detached,
381 };
382
383 let command = build_agent_command(&command_options);
384
385 if start_options.dry_run {
386 println!("Dry run - command that would be executed:");
387 println!("{}", command);
388 return Ok(());
389 }
390
391 if start_options.detached {
392 if let Err(error) = execute_detached(&command).await.map_err(|e| e.to_string()) {
394 self.cleanup_prompt_temp_dir().await;
395 return Err(error);
396 }
397 println!("Agent started in detached mode");
398 if self.options.isolation == "screen" {
399 if let Some(ref name) = self.options.screen_name {
400 println!("Screen session: {}", name);
401 }
402 } else if self.options.isolation == "docker" {
403 if let Some(ref name) = self.options.container_name {
404 println!("Container: {}", name);
405 }
406 }
407 } else {
408 let handle = match start_command(&command, start_options.attached)
410 .await
411 .map_err(|e| e.to_string())
412 {
413 Ok(handle) => handle,
414 Err(error) => {
415 self.cleanup_prompt_temp_dir().await;
416 return Err(error);
417 }
418 };
419 self.process_handle = Some(handle);
420 }
421
422 Ok(())
423 }
424
425 pub async fn stop(&mut self, stop_options: AgentStopOptions) -> Result<AgentResult, String> {
433 if self.options.isolation == "screen" || self.options.isolation == "docker" {
435 let stop_command = if self.options.isolation == "screen" {
436 let screen_name = self
437 .options
438 .screen_name
439 .as_ref()
440 .ok_or("screen_name is required to stop screen session")?;
441 build_screen_stop_command(screen_name)
442 } else {
443 let container_name = self
444 .options
445 .container_name
446 .as_ref()
447 .ok_or("container_name is required to stop docker container")?;
448 build_docker_stop_command(container_name)
449 };
450
451 if stop_options.dry_run {
452 println!("Dry run - command that would be executed:");
453 println!("{}", stop_command);
454 return Ok(AgentResult {
455 metadata: build_normalized_result_metadata(BuildMetadataOptions {
456 tool: &self.options.tool,
457 exit_code: 0,
458 plain_output: "",
459 parsed_output: None,
460 session_id: None,
461 usage: None,
462 }),
463 ..Default::default()
464 });
465 }
466
467 let result = match execute_command(&stop_command, false, true)
468 .await
469 .map_err(|e| e.to_string())
470 {
471 Ok(result) => result,
472 Err(error) => {
473 self.cleanup_prompt_temp_dir().await;
474 return Err(error);
475 }
476 };
477 self.cleanup_prompt_temp_dir().await;
478
479 let metadata = build_normalized_result_metadata(BuildMetadataOptions {
480 tool: &self.options.tool,
481 exit_code: result.exit_code,
482 plain_output: &result.stdout,
483 parsed_output: None,
484 session_id: None,
485 usage: None,
486 });
487
488 return Ok(AgentResult {
489 exit_code: result.exit_code,
490 plain_output: result.stdout,
491 parsed_output: None,
492 session_id: None,
493 usage: None,
494 metadata,
495 });
496 }
497
498 if self.options.isolation == "none" || self.options.isolation.is_empty() {
500 if self.process_handle.is_none() {
501 self.cleanup_prompt_temp_dir().await;
502 return Err("Agent not started or already stopped".to_string());
503 }
504 let handle = self
505 .process_handle
506 .as_mut()
507 .ok_or("Agent not started or already stopped")?;
508
509 let exit_code = match handle.wait_for_exit().await.map_err(|e| e.to_string()) {
511 Ok(exit_code) => exit_code,
512 Err(error) => {
513 self.cleanup_prompt_temp_dir().await;
514 return Err(error);
515 }
516 };
517
518 let (stdout, stderr, _) = handle.get_output();
519
520 let plain_output = if stderr.is_empty() {
522 stdout.to_string()
523 } else {
524 format!("{}\n{}", stdout, stderr)
525 };
526
527 let mut parsed_output = None;
529 if let Some(ref mut stream) = self.output_stream {
530 stream.process(stdout);
531 stream.flush();
532 let messages = stream.get_messages();
533 if !messages.is_empty() {
534 parsed_output = Some(messages.to_vec());
535 }
536 }
537
538 if is_tool_supported(&self.options.tool) {
540 match self.options.tool.as_str() {
541 "claude" => {
542 self.session_id = tools::claude::extract_session_id(&plain_output);
543 }
544 "codex" => {
545 self.session_id = tools::codex::extract_session_id(&plain_output);
546 }
547 "opencode" => {
548 self.session_id = tools::opencode::extract_session_id(&plain_output);
549 }
550 "agent" => {
551 self.session_id = tools::agent::extract_session_id(&plain_output);
552 }
553 _ => {}
554 }
555 }
556
557 let usage = extract_usage_value(&self.options.tool, &plain_output);
558 let metadata = build_normalized_result_metadata(BuildMetadataOptions {
559 tool: &self.options.tool,
560 exit_code,
561 plain_output: &plain_output,
562 parsed_output: parsed_output.as_deref(),
563 session_id: self.session_id.clone(),
564 usage: usage.clone(),
565 });
566
567 let result = AgentResult {
568 exit_code,
569 plain_output,
570 parsed_output,
571 session_id: self.session_id.clone(),
572 usage,
573 metadata,
574 };
575 self.cleanup_prompt_temp_dir().await;
576 return Ok(result);
577 }
578
579 Err(format!(
580 "Unsupported isolation mode: {}",
581 self.options.isolation
582 ))
583 }
584
585 pub fn get_session_id(&self) -> Option<&String> {
587 self.session_id.as_ref()
588 }
589
590 pub fn get_messages(&self) -> Vec<&Value> {
592 if let Some(ref stream) = self.output_stream {
593 stream.get_messages().iter().collect()
594 } else {
595 Vec::new()
596 }
597 }
598}
599
600pub fn agent(options: AgentOptions) -> Result<Agent, String> {
608 Agent::new(options)
609}
610
611