Skip to main content

agent_commander/
lib.rs

1//! Agent Commander - Main library interface
2//! A Rust library to control agents enclosed in CLI commands
3//!
4//! Supports multiple CLI agents:
5//! - claude: Anthropic Claude Code CLI
6//! - codex: OpenAI Codex CLI
7//! - opencode: OpenCode CLI
8//! - agent: @link-assistant/agent (OpenCode fork with native permission modes)
9//! - qwen: Qwen Code CLI
10//! - gemini: Gemini CLI
11
12pub 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/// Agent options for creating a controller
63#[derive(Debug, Clone, Default)]
64pub struct AgentOptions {
65    /// CLI tool to use (e.g., 'claude', 'codex', 'opencode', 'agent', 'qwen', 'gemini')
66    pub tool: String,
67    /// Working directory for the agent
68    pub working_directory: String,
69    /// Prompt for the agent
70    pub prompt: Option<String>,
71    /// File containing prompt input for stdin-based tools
72    pub prompt_file: Option<String>,
73    /// System prompt for the agent
74    pub system_prompt: Option<String>,
75    /// Append to the default system prompt (tool-specific)
76    pub append_system_prompt: Option<String>,
77    /// Model to use (tool-specific)
78    pub model: Option<String>,
79    /// Fallback model to use when the primary model is overloaded (tool-specific)
80    pub fallback_model: Option<String>,
81    /// Isolation mode: 'none', 'screen', 'docker'
82    pub isolation: String,
83    /// Screen session name (for screen isolation)
84    pub screen_name: Option<String>,
85    /// Container name (for docker isolation)
86    pub container_name: Option<String>,
87    /// Enable JSON output mode
88    pub json: bool,
89    /// Resume a previous session (tool-specific)
90    pub resume: Option<String>,
91    /// Enable verbose output (tool-specific)
92    pub verbose: bool,
93    /// Re-emit user messages in streaming output (tool-specific)
94    pub replay_user_messages: bool,
95    /// Use a specific session ID (tool-specific)
96    pub session_id: Option<String>,
97    /// Fork a resumed session into a new session (tool-specific)
98    pub fork_session: bool,
99    /// Enforce native read-only/planning mode
100    pub read_only: bool,
101    /// Enforce native planning mode (where the tool distinguishes it)
102    pub plan_only: bool,
103    /// Approve each mutating command (ask mode), relayed over the tool's native
104    /// per-command JSON permission protocol (only `claude` and `agent`)
105    pub approve_each: bool,
106    /// Override the tool executable path/name
107    pub executable: Option<String>,
108    /// Extra raw arguments appended after typed tool arguments
109    pub extra_args: Vec<String>,
110    /// Extra environment variables applied to the tool executable
111    pub extra_env: Vec<(String, String)>,
112    /// Do not add default autonomous safety bypass flags
113    pub skip_default_safety_flags: bool,
114}
115
116/// Agent result from stop()
117#[derive(Debug, Clone, Default)]
118pub struct AgentResult {
119    /// Exit code from the process
120    pub exit_code: i32,
121    /// Plain text output (stdout + stderr)
122    pub plain_output: String,
123    /// Parsed JSON messages (if tool supports it)
124    pub parsed_output: Option<Vec<Value>>,
125    /// Session ID for resuming
126    pub session_id: Option<String>,
127    /// Aggregated stream token usage, when the tool exposes it
128    pub usage: Option<Value>,
129    /// Stable normalized metadata for caller reporting
130    pub metadata: ResultMetadata,
131}
132
133/// Agent start options
134#[derive(Debug, Clone, Default)]
135pub struct AgentStartOptions {
136    /// If true, just show the command
137    pub dry_run: bool,
138    /// Run in detached mode
139    pub detached: bool,
140    /// Stream output to console
141    pub attached: bool,
142}
143
144/// Agent stop options
145#[derive(Debug, Clone, Default)]
146pub struct AgentStopOptions {
147    /// If true, just show the command
148    pub dry_run: bool,
149}
150
151/// Agent controller
152pub 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    /// Create a new agent controller
239    ///
240    /// # Arguments
241    /// * `options` - Agent configuration
242    ///
243    /// # Returns
244    /// Result with Agent or error message
245    pub fn new(options: AgentOptions) -> Result<Self, String> {
246        // Validate required options
247        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    /// Start the agent (non-blocking)
324    ///
325    /// # Arguments
326    /// * `start_options` - Start options
327    ///
328    /// # Returns
329    /// Result indicating success or error
330    pub async fn start(&mut self, start_options: AgentStartOptions) -> Result<(), String> {
331        // Create output stream for JSON parsing if in JSON mode
332        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        // Build the command
347        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            // For detached mode, use execute_detached
393            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            // For attached mode, start command without waiting
409            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    /// Stop the agent and collect output
426    ///
427    /// # Arguments
428    /// * `stop_options` - Stop options
429    ///
430    /// # Returns
431    /// Result with agent output or error
432    pub async fn stop(&mut self, stop_options: AgentStopOptions) -> Result<AgentResult, String> {
433        // For isolation modes, send stop command
434        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        // For no isolation, wait for process to complete and collect output
499        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            // Wait for the process to exit
510            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            // Combine stdout and stderr for plain output
521            let plain_output = if stderr.is_empty() {
522                stdout.to_string()
523            } else {
524                format!("{}\n{}", stdout, stderr)
525            };
526
527            // Process output through stream if available
528            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            // Try to extract session ID
539            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    /// Get the current session ID (if available)
586    pub fn get_session_id(&self) -> Option<&String> {
587        self.session_id.as_ref()
588    }
589
590    /// Get all collected messages from the output stream
591    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
600/// Create an agent controller (convenience function)
601///
602/// # Arguments
603/// * `options` - Agent configuration
604///
605/// # Returns
606/// Result with Agent or error message
607pub fn agent(options: AgentOptions) -> Result<Agent, String> {
608    Agent::new(options)
609}
610
611// Tests are in rust/tests/lib_tests.rs