//! Headless CLI parsing: output format, prompt sources, permission rules, and agent args. use std::path::{Path, PathBuf}; use agent_client_protocol as acp; use clap::ValueEnum; #[derive(Copy, Clone, Debug, Default, PartialEq, Eq, ValueEnum)] pub enum OutputFormat { #[default] Plain, Json, /// NDJSON of the agent native ACP session updates. #[value(name = "streaming-json")] StreamingJson, /// NDJSON in the Anthropic Messages API wire format. #[value(name = "streaming-messages-json")] StreamingMessagesJson, } pub fn parse_json_schema(input: &str) -> anyhow::Result { let schema: serde_json::Value = serde_json::from_str(input) .map_err(|e| anyhow::anyhow!("--json-schema: invalid JSON: {e}"))?; if !schema.is_object() { anyhow::bail!("--json-schema: must be a JSON object describing a JSON Schema"); } Ok(schema) } #[derive(Debug, Clone)] pub enum HeadlessPrompt { Text(String), Blocks(Vec), } impl HeadlessPrompt { /// Build from mutually-exclusive CLI prompt args. `None` = interactive mode. pub fn from_args( single: Option<&str>, prompt_json: Option<&str>, prompt_file: Option<&Path>, ) -> anyhow::Result> { if let Some(text) = single { Self::from_text(text) .map(Some) .map_err(|e| anyhow::anyhow!("--single: {e}")) } else if let Some(json_str) = prompt_json { Self::from_json(json_str) .map(Some) .map_err(|e| anyhow::anyhow!("--prompt-json: {e}")) } else if let Some(path) = prompt_file { Self::from_file(path).map(Some) } else { Ok(None) } } /// `.json` files are parsed as content blocks, everything else as text. pub fn from_file(path: &Path) -> anyhow::Result { let content = std::fs::read_to_string(path) .map_err(|e| anyhow::anyhow!("Failed to read '{}': {e}", path.display()))?; let context = |e| anyhow::anyhow!("'{}': {e}", path.display()); if path.extension().and_then(|e| e.to_str()) == Some("json") { Self::from_json(&content).map_err(context) } else { Self::from_text(&content).map_err(context) } } fn from_text(text: &str) -> anyhow::Result { let trimmed = text.trim(); if trimmed.is_empty() { anyhow::bail!("prompt is empty"); } Ok(Self::Text(trimmed.to_string())) } fn from_json(json_str: &str) -> anyhow::Result { let blocks = parse_prompt_json(json_str)?; Ok(Self::Blocks(blocks)) } pub fn into_content_blocks(self) -> Vec { match self { Self::Text(text) => vec![acp::ContentBlock::Text(acp::TextContent::new(text))], Self::Blocks(blocks) => blocks, } } } /// Parse ACP content blocks from an array (`[...]`) or typed wrapper (`{"type":"acp","content":[...]}`). fn parse_prompt_json(json_str: &str) -> anyhow::Result> { let value: serde_json::Value = serde_json::from_str(json_str).map_err(|e| anyhow::anyhow!("Invalid JSON: {e}"))?; let blocks: Vec = match value { serde_json::Value::Array(_) => serde_json::from_value(value) .map_err(|e| anyhow::anyhow!("Invalid ACP content blocks: {e}"))?, serde_json::Value::Object(ref map) => { let format_type = map.get("type").and_then(|v| v.as_str()).ok_or_else(|| { anyhow::anyhow!( "JSON object must have a \"type\" field \ (e.g., {{\"type\": \"acp\", \"content\": [...]}})" ) })?; let content = map .get("content") .ok_or_else(|| anyhow::anyhow!("JSON object must have a \"content\" field"))?; match format_type { "acp" => serde_json::from_value(content.clone()).map_err(|e| { anyhow::anyhow!("Invalid ACP content blocks in \"content\": {e}") })?, other => anyhow::bail!( "Unsupported prompt format type: \"{other}\". Supported types: \"acp\"" ), } } _ => { anyhow::bail!("Expected JSON array or {{\"type\": \"...\", \"content\": [...]}} object") } }; if blocks.is_empty() { anyhow::bail!("content blocks array is empty"); } Ok(blocks) } /// Parse a comma-separated list into a vec, or None if empty. pub(crate) fn parse_comma_list(s: Option<&str>) -> Option> { s.and_then(|s| { let v: Vec = s .split(',') .map(|t| t.trim().to_string()) .filter(|t| !t.is_empty()) .collect(); if v.is_empty() { None } else { Some(v) } }) } pub fn parse_permission_rules_strict( allow: &[String], deny: &[String], ) -> anyhow::Result> { let (rules, errors) = parse_permission_rules_inner(allow, deny); if !errors.is_empty() { let msgs: Vec = errors .into_iter() .map(|(flag, rule, err)| format!("{flag} \"{rule}\": {err}")) .collect(); anyhow::bail!("{}", msgs.join("; ")); } Ok(rules) } pub fn parse_permission_rules_lenient( allow: &[String], deny: &[String], ) -> Vec { let (rules, errors) = parse_permission_rules_inner(allow, deny); for (flag, rule, err) in errors { eprintln!("warning: {flag} \"{rule}\": {err}, skipping"); } rules } // Deny before allow is cosmetic: the policy evaluator is order-independent (deny > ask > allow). pub(crate) fn parse_permission_rules_inner( allow: &[String], deny: &[String], ) -> ( Vec, Vec<(&'static str, String, String)>, ) { use xai_grok_workspace::permission::rules::parse_permission_rule; use xai_grok_workspace::permission::types::RuleAction; let mut rules = Vec::new(); let mut errors = Vec::new(); for rule_str in deny { match parse_permission_rule(rule_str, RuleAction::Deny) { Ok(rule) => rules.push(rule), Err(e) => errors.push(("--deny", rule_str.clone(), e.to_string())), } } for rule_str in allow { match parse_permission_rule(rule_str, RuleAction::Allow) { Ok(rule) => rules.push(rule), Err(e) => errors.push(("--allow", rule_str.clone(), e.to_string())), } } (rules, errors) } pub(crate) enum ResolvedAgent { FilePath(PathBuf), Name(String), } pub(crate) fn resolve_agent_arg(agent: &str) -> ResolvedAgent { let path = std::path::Path::new(agent); if path.exists() && path.is_file() { ResolvedAgent::FilePath(dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())) } else { ResolvedAgent::Name(agent.to_string()) } } pub(crate) fn parse_cli_agents( json: &str, ) -> anyhow::Result> { let map: std::collections::HashMap = serde_json::from_str(json).map_err(|e| anyhow::anyhow!("--agents: invalid JSON: {e}"))?; let mut agents = Vec::with_capacity(map.len()); for (name, mut value) in map { if let serde_json::Value::Object(ref mut obj) = value { if !obj.contains_key("promptBody") && let Some(prompt) = obj.remove("prompt") { obj.insert("promptBody".to_string(), prompt); } obj.entry("name".to_string()) .or_insert_with(|| serde_json::Value::String(name.clone())); obj.entry("description".to_string()) .or_insert_with(|| serde_json::Value::String(name.clone())); } let mut def = xai_grok_shell::agent::config::AgentDefinition::from_json(&value) .map_err(|e| anyhow::anyhow!("--agents: failed to parse '{name}': {e}"))?; def.name = name; agents.push(def); } Ok(agents) } pub(crate) fn apply_agent_flag( agent: &Option, config: &mut xai_grok_shell::agent::config::Config, ) { if let Some(agent) = agent { match resolve_agent_arg(agent) { ResolvedAgent::FilePath(path) => config.agent_profile_path = Some(path), ResolvedAgent::Name(name) => config.agent.name = Some(name), } } }