use crate::error::{Error, Result};
use log::debug;
use std::path::PathBuf;
use std::process::Stdio;
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PermissionMode {
AcceptEdits,
BypassPermissions,
Default,
Delegate,
DontAsk,
Plan,
}
impl PermissionMode {
pub fn as_str(&self) -> &'static str {
match self {
PermissionMode::AcceptEdits => "acceptEdits",
PermissionMode::BypassPermissions => "bypassPermissions",
PermissionMode::Default => "default",
PermissionMode::Delegate => "delegate",
PermissionMode::DontAsk => "dontAsk",
PermissionMode::Plan => "plan",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputFormat {
Text,
StreamJson,
}
impl InputFormat {
pub fn as_str(&self) -> &'static str {
match self {
InputFormat::Text => "text",
InputFormat::StreamJson => "stream-json",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
Text,
Json,
StreamJson,
}
impl OutputFormat {
pub fn as_str(&self) -> &'static str {
match self {
OutputFormat::Text => "text",
OutputFormat::Json => "json",
OutputFormat::StreamJson => "stream-json",
}
}
}
#[derive(Debug, Clone)]
pub enum CliFlag {
AddDir(Vec<PathBuf>),
Agent(String),
Agents(String),
AllowDangerouslySkipPermissions,
AllowedTools(Vec<String>),
AppendSystemPrompt(String),
Betas(Vec<String>),
Chrome,
Continue,
DangerouslySkipPermissions,
Debug(Option<String>),
DebugFile(PathBuf),
DisableSlashCommands,
DisallowedTools(Vec<String>),
FallbackModel(String),
File(Vec<String>),
ForkSession,
FromPr(Option<String>),
IncludePartialMessages,
InputFormat(InputFormat),
JsonSchema(String),
MaxBudgetUsd(f64),
MaxThinkingTokens(u32),
McpConfig(Vec<String>),
McpDebug,
Model(String),
NoChrome,
NoSessionPersistence,
OutputFormat(OutputFormat),
PermissionMode(PermissionMode),
PermissionPromptTool(String),
PluginDir(Vec<PathBuf>),
Print,
ReplayUserMessages,
Resume(Option<String>),
SessionId(String),
SettingSources(String),
Settings(String),
StrictMcpConfig,
SystemPrompt(String),
Tools(Vec<String>),
Verbose,
}
impl CliFlag {
pub fn as_flag(&self) -> &'static str {
match self {
CliFlag::AddDir(_) => "--add-dir",
CliFlag::Agent(_) => "--agent",
CliFlag::Agents(_) => "--agents",
CliFlag::AllowDangerouslySkipPermissions => "--allow-dangerously-skip-permissions",
CliFlag::AllowedTools(_) => "--allowed-tools",
CliFlag::AppendSystemPrompt(_) => "--append-system-prompt",
CliFlag::Betas(_) => "--betas",
CliFlag::Chrome => "--chrome",
CliFlag::Continue => "--continue",
CliFlag::DangerouslySkipPermissions => "--dangerously-skip-permissions",
CliFlag::Debug(_) => "--debug",
CliFlag::DebugFile(_) => "--debug-file",
CliFlag::DisableSlashCommands => "--disable-slash-commands",
CliFlag::DisallowedTools(_) => "--disallowed-tools",
CliFlag::FallbackModel(_) => "--fallback-model",
CliFlag::File(_) => "--file",
CliFlag::ForkSession => "--fork-session",
CliFlag::FromPr(_) => "--from-pr",
CliFlag::IncludePartialMessages => "--include-partial-messages",
CliFlag::InputFormat(_) => "--input-format",
CliFlag::JsonSchema(_) => "--json-schema",
CliFlag::MaxBudgetUsd(_) => "--max-budget-usd",
CliFlag::MaxThinkingTokens(_) => "--max-thinking-tokens",
CliFlag::McpConfig(_) => "--mcp-config",
CliFlag::McpDebug => "--mcp-debug",
CliFlag::Model(_) => "--model",
CliFlag::NoChrome => "--no-chrome",
CliFlag::NoSessionPersistence => "--no-session-persistence",
CliFlag::OutputFormat(_) => "--output-format",
CliFlag::PermissionMode(_) => "--permission-mode",
CliFlag::PermissionPromptTool(_) => "--permission-prompt-tool",
CliFlag::PluginDir(_) => "--plugin-dir",
CliFlag::Print => "--print",
CliFlag::ReplayUserMessages => "--replay-user-messages",
CliFlag::Resume(_) => "--resume",
CliFlag::SessionId(_) => "--session-id",
CliFlag::SettingSources(_) => "--setting-sources",
CliFlag::Settings(_) => "--settings",
CliFlag::StrictMcpConfig => "--strict-mcp-config",
CliFlag::SystemPrompt(_) => "--system-prompt",
CliFlag::Tools(_) => "--tools",
CliFlag::Verbose => "--verbose",
}
}
pub fn to_args(&self) -> Vec<String> {
let flag = self.as_flag().to_string();
match self {
CliFlag::AllowDangerouslySkipPermissions
| CliFlag::Chrome
| CliFlag::Continue
| CliFlag::DangerouslySkipPermissions
| CliFlag::DisableSlashCommands
| CliFlag::ForkSession
| CliFlag::IncludePartialMessages
| CliFlag::McpDebug
| CliFlag::NoChrome
| CliFlag::NoSessionPersistence
| CliFlag::Print
| CliFlag::ReplayUserMessages
| CliFlag::StrictMcpConfig
| CliFlag::Verbose => vec![flag],
CliFlag::Debug(filter) => match filter {
Some(f) => vec![flag, f.clone()],
None => vec![flag],
},
CliFlag::FromPr(value) | CliFlag::Resume(value) => match value {
Some(v) => vec![flag, v.clone()],
None => vec![flag],
},
CliFlag::Agent(v)
| CliFlag::Agents(v)
| CliFlag::AppendSystemPrompt(v)
| CliFlag::FallbackModel(v)
| CliFlag::JsonSchema(v)
| CliFlag::Model(v)
| CliFlag::PermissionPromptTool(v)
| CliFlag::SessionId(v)
| CliFlag::SettingSources(v)
| CliFlag::Settings(v)
| CliFlag::SystemPrompt(v) => vec![flag, v.clone()],
CliFlag::InputFormat(f) => vec![flag, f.as_str().to_string()],
CliFlag::OutputFormat(f) => vec![flag, f.as_str().to_string()],
CliFlag::PermissionMode(m) => vec![flag, m.as_str().to_string()],
CliFlag::MaxBudgetUsd(amount) => vec![flag, amount.to_string()],
CliFlag::MaxThinkingTokens(tokens) => vec![flag, tokens.to_string()],
CliFlag::DebugFile(p) => vec![flag, p.to_string_lossy().to_string()],
CliFlag::AllowedTools(items)
| CliFlag::Betas(items)
| CliFlag::DisallowedTools(items)
| CliFlag::File(items)
| CliFlag::McpConfig(items)
| CliFlag::Tools(items) => {
let mut args = vec![flag];
args.extend(items.clone());
args
}
CliFlag::AddDir(paths) | CliFlag::PluginDir(paths) => {
let mut args = vec![flag];
args.extend(paths.iter().map(|p| p.to_string_lossy().to_string()));
args
}
}
}
pub fn all_flags() -> Vec<(&'static str, &'static str)> {
vec![
("AddDir", "--add-dir"),
("Agent", "--agent"),
("Agents", "--agents"),
(
"AllowDangerouslySkipPermissions",
"--allow-dangerously-skip-permissions",
),
("AllowedTools", "--allowed-tools"),
("AppendSystemPrompt", "--append-system-prompt"),
("Betas", "--betas"),
("Chrome", "--chrome"),
("Continue", "--continue"),
(
"DangerouslySkipPermissions",
"--dangerously-skip-permissions",
),
("Debug", "--debug"),
("DebugFile", "--debug-file"),
("DisableSlashCommands", "--disable-slash-commands"),
("DisallowedTools", "--disallowed-tools"),
("FallbackModel", "--fallback-model"),
("File", "--file"),
("ForkSession", "--fork-session"),
("FromPr", "--from-pr"),
("IncludePartialMessages", "--include-partial-messages"),
("InputFormat", "--input-format"),
("JsonSchema", "--json-schema"),
("MaxBudgetUsd", "--max-budget-usd"),
("MaxThinkingTokens", "--max-thinking-tokens"),
("McpConfig", "--mcp-config"),
("McpDebug", "--mcp-debug"),
("Model", "--model"),
("NoChrome", "--no-chrome"),
("NoSessionPersistence", "--no-session-persistence"),
("OutputFormat", "--output-format"),
("PermissionMode", "--permission-mode"),
("PermissionPromptTool", "--permission-prompt-tool"),
("PluginDir", "--plugin-dir"),
("Print", "--print"),
("ReplayUserMessages", "--replay-user-messages"),
("Resume", "--resume"),
("SessionId", "--session-id"),
("SettingSources", "--setting-sources"),
("Settings", "--settings"),
("StrictMcpConfig", "--strict-mcp-config"),
("SystemPrompt", "--system-prompt"),
("Tools", "--tools"),
("Verbose", "--verbose"),
]
}
}
#[derive(Debug, Clone)]
pub struct ClaudeCliBuilder {
command: PathBuf,
prompt: Option<String>,
debug: Option<String>,
verbose: bool,
dangerously_skip_permissions: bool,
allowed_tools: Vec<String>,
disallowed_tools: Vec<String>,
mcp_config: Vec<String>,
append_system_prompt: Option<String>,
permission_mode: Option<PermissionMode>,
continue_conversation: bool,
resume: Option<String>,
model: Option<String>,
fallback_model: Option<String>,
settings: Option<String>,
add_dir: Vec<PathBuf>,
ide: bool,
strict_mcp_config: bool,
session_id: Option<Uuid>,
oauth_token: Option<String>,
api_key: Option<String>,
permission_prompt_tool: Option<String>,
allow_recursion: bool,
max_thinking_tokens: Option<u32>,
}
impl Default for ClaudeCliBuilder {
fn default() -> Self {
Self::new()
}
}
impl ClaudeCliBuilder {
pub fn new() -> Self {
Self {
command: PathBuf::from("claude"),
prompt: None,
debug: None,
verbose: false,
dangerously_skip_permissions: false,
allowed_tools: Vec::new(),
disallowed_tools: Vec::new(),
mcp_config: Vec::new(),
append_system_prompt: None,
permission_mode: None,
continue_conversation: false,
resume: None,
model: None,
fallback_model: None,
settings: None,
add_dir: Vec::new(),
ide: false,
strict_mcp_config: false,
session_id: None,
oauth_token: None,
api_key: None,
permission_prompt_tool: None,
allow_recursion: false,
max_thinking_tokens: None,
}
}
pub fn command<P: Into<PathBuf>>(mut self, path: P) -> Self {
self.command = path.into();
self
}
pub fn prompt<S: Into<String>>(mut self, prompt: S) -> Self {
self.prompt = Some(prompt.into());
self
}
pub fn debug<S: Into<String>>(mut self, filter: Option<S>) -> Self {
self.debug = filter.map(|s| s.into());
self
}
pub fn verbose(mut self, verbose: bool) -> Self {
self.verbose = verbose;
self
}
pub fn dangerously_skip_permissions(mut self, skip: bool) -> Self {
self.dangerously_skip_permissions = skip;
self
}
pub fn allowed_tools<I, S>(mut self, tools: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.allowed_tools
.extend(tools.into_iter().map(|s| s.into()));
self
}
pub fn disallowed_tools<I, S>(mut self, tools: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.disallowed_tools
.extend(tools.into_iter().map(|s| s.into()));
self
}
pub fn mcp_config<I, S>(mut self, configs: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.mcp_config
.extend(configs.into_iter().map(|s| s.into()));
self
}
pub fn append_system_prompt<S: Into<String>>(mut self, prompt: S) -> Self {
self.append_system_prompt = Some(prompt.into());
self
}
pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
self.permission_mode = Some(mode);
self
}
pub fn continue_conversation(mut self, continue_conv: bool) -> Self {
self.continue_conversation = continue_conv;
self
}
pub fn resume<S: Into<String>>(mut self, session_id: Option<S>) -> Self {
self.resume = session_id.map(|s| s.into());
self
}
pub fn model<S: Into<String>>(mut self, model: S) -> Self {
self.model = Some(model.into());
self
}
pub fn fallback_model<S: Into<String>>(mut self, model: S) -> Self {
self.fallback_model = Some(model.into());
self
}
pub fn max_thinking_tokens(mut self, tokens: u32) -> Self {
self.max_thinking_tokens = Some(tokens);
self
}
pub fn settings<S: Into<String>>(mut self, settings: S) -> Self {
self.settings = Some(settings.into());
self
}
pub fn add_directories<I, P>(mut self, dirs: I) -> Self
where
I: IntoIterator<Item = P>,
P: Into<PathBuf>,
{
self.add_dir.extend(dirs.into_iter().map(|p| p.into()));
self
}
pub fn ide(mut self, ide: bool) -> Self {
self.ide = ide;
self
}
pub fn strict_mcp_config(mut self, strict: bool) -> Self {
self.strict_mcp_config = strict;
self
}
pub fn session_id(mut self, id: Uuid) -> Self {
self.session_id = Some(id);
self
}
pub fn oauth_token<S: Into<String>>(mut self, token: S) -> Self {
let token_str = token.into();
if !token_str.starts_with("sk-ant-oat") {
eprintln!("Warning: OAuth token should start with 'sk-ant-oat'");
}
self.oauth_token = Some(token_str);
self
}
pub fn api_key<S: Into<String>>(mut self, key: S) -> Self {
let key_str = key.into();
if !key_str.starts_with("sk-ant-api") {
eprintln!("Warning: API key should start with 'sk-ant-api'");
}
self.api_key = Some(key_str);
self
}
pub fn permission_prompt_tool<S: Into<String>>(mut self, tool: S) -> Self {
self.permission_prompt_tool = Some(tool.into());
self
}
#[cfg(feature = "integration-tests")]
pub fn allow_recursion(mut self) -> Self {
self.allow_recursion = true;
self
}
fn resolve_command(&self) -> Result<PathBuf> {
if self.command.is_absolute() {
return Ok(self.command.clone());
}
which::which(&self.command).map_err(|_| Error::BinaryNotFound {
name: self.command.display().to_string(),
})
}
fn build_args(&self) -> Vec<String> {
let mut args = vec![
"--print".to_string(),
"--verbose".to_string(),
"--output-format".to_string(),
"stream-json".to_string(),
"--input-format".to_string(),
"stream-json".to_string(),
];
if let Some(ref debug) = self.debug {
args.push("--debug".to_string());
if !debug.is_empty() {
args.push(debug.clone());
}
}
if self.dangerously_skip_permissions {
args.push("--dangerously-skip-permissions".to_string());
}
if !self.allowed_tools.is_empty() {
args.push("--allowed-tools".to_string());
args.extend(self.allowed_tools.clone());
}
if !self.disallowed_tools.is_empty() {
args.push("--disallowed-tools".to_string());
args.extend(self.disallowed_tools.clone());
}
if !self.mcp_config.is_empty() {
args.push("--mcp-config".to_string());
args.extend(self.mcp_config.clone());
}
if let Some(ref prompt) = self.append_system_prompt {
args.push("--append-system-prompt".to_string());
args.push(prompt.clone());
}
if let Some(ref mode) = self.permission_mode {
args.push("--permission-mode".to_string());
args.push(mode.as_str().to_string());
}
if self.continue_conversation {
args.push("--continue".to_string());
}
if let Some(ref session) = self.resume {
args.push("--resume".to_string());
args.push(session.clone());
}
if let Some(ref model) = self.model {
args.push("--model".to_string());
args.push(model.clone());
}
if let Some(ref model) = self.fallback_model {
args.push("--fallback-model".to_string());
args.push(model.clone());
}
if let Some(tokens) = self.max_thinking_tokens {
args.push("--max-thinking-tokens".to_string());
args.push(tokens.to_string());
}
if let Some(ref settings) = self.settings {
args.push("--settings".to_string());
args.push(settings.clone());
}
if !self.add_dir.is_empty() {
args.push("--add-dir".to_string());
for dir in &self.add_dir {
args.push(dir.to_string_lossy().to_string());
}
}
if self.ide {
args.push("--ide".to_string());
}
if self.strict_mcp_config {
args.push("--strict-mcp-config".to_string());
}
if let Some(ref tool) = self.permission_prompt_tool {
args.push("--permission-prompt-tool".to_string());
args.push(tool.clone());
}
if self.resume.is_none() && !self.continue_conversation {
args.push("--session-id".to_string());
let session_uuid = self.session_id.unwrap_or_else(|| {
let uuid = Uuid::new_v4();
debug!("[CLI] Generated session UUID: {}", uuid);
uuid
});
args.push(session_uuid.to_string());
}
if let Some(ref prompt) = self.prompt {
args.push(prompt.clone());
}
args
}
#[cfg(feature = "async-client")]
pub async fn spawn(self) -> Result<tokio::process::Child> {
let resolved = self.resolve_command()?;
let args = self.build_args();
debug!(
"[CLI] Executing command: {} {}",
resolved.display(),
args.join(" ")
);
let mut cmd = tokio::process::Command::new(&resolved);
cmd.args(&args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if self.allow_recursion {
cmd.env_remove("CLAUDECODE");
}
if let Some(ref token) = self.oauth_token {
cmd.env("CLAUDE_CODE_OAUTH_TOKEN", token);
}
if let Some(ref key) = self.api_key {
cmd.env("ANTHROPIC_API_KEY", key);
}
let child = cmd.spawn().map_err(Error::Io)?;
Ok(child)
}
#[cfg(feature = "async-client")]
pub fn build_command(self) -> Result<tokio::process::Command> {
let resolved = self.resolve_command()?;
let args = self.build_args();
let mut cmd = tokio::process::Command::new(&resolved);
cmd.args(&args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if self.allow_recursion {
cmd.env_remove("CLAUDECODE");
}
if let Some(ref token) = self.oauth_token {
cmd.env("CLAUDE_CODE_OAUTH_TOKEN", token);
}
if let Some(ref key) = self.api_key {
cmd.env("ANTHROPIC_API_KEY", key);
}
Ok(cmd)
}
pub fn spawn_sync(self) -> Result<std::process::Child> {
let resolved = self.resolve_command()?;
let args = self.build_args();
debug!(
"[CLI] Executing sync command: {} {}",
resolved.display(),
args.join(" ")
);
let mut cmd = std::process::Command::new(&resolved);
cmd.args(&args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if self.allow_recursion {
cmd.env_remove("CLAUDECODE");
}
if let Some(ref token) = self.oauth_token {
cmd.env("CLAUDE_CODE_OAUTH_TOKEN", token);
}
if let Some(ref key) = self.api_key {
cmd.env("ANTHROPIC_API_KEY", key);
}
cmd.spawn().map_err(Error::Io)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_streaming_flags_always_present() {
let builder = ClaudeCliBuilder::new();
let args = builder.build_args();
assert!(args.contains(&"--print".to_string()));
assert!(args.contains(&"--verbose".to_string())); assert!(args.contains(&"--output-format".to_string()));
assert!(args.contains(&"stream-json".to_string()));
assert!(args.contains(&"--input-format".to_string()));
}
#[test]
fn test_with_prompt() {
let builder = ClaudeCliBuilder::new().prompt("Hello, Claude!");
let args = builder.build_args();
assert_eq!(args.last().unwrap(), "Hello, Claude!");
}
#[test]
fn test_with_model() {
let builder = ClaudeCliBuilder::new()
.model("sonnet")
.fallback_model("opus");
let args = builder.build_args();
assert!(args.contains(&"--model".to_string()));
assert!(args.contains(&"sonnet".to_string()));
assert!(args.contains(&"--fallback-model".to_string()));
assert!(args.contains(&"opus".to_string()));
}
#[test]
fn test_with_debug() {
let builder = ClaudeCliBuilder::new().debug(Some("api"));
let args = builder.build_args();
assert!(args.contains(&"--debug".to_string()));
assert!(args.contains(&"api".to_string()));
}
#[test]
fn test_with_oauth_token() {
let valid_token = "sk-ant-oat-123456789";
let builder = ClaudeCliBuilder::new().oauth_token(valid_token);
let args = builder.clone().build_args();
assert!(!args.contains(&valid_token.to_string()));
assert_eq!(builder.oauth_token, Some(valid_token.to_string()));
}
#[test]
fn test_oauth_token_validation() {
let invalid_token = "invalid-token-123";
let builder = ClaudeCliBuilder::new().oauth_token(invalid_token);
assert_eq!(builder.oauth_token, Some(invalid_token.to_string()));
}
#[test]
fn test_with_api_key() {
let valid_key = "sk-ant-api-987654321";
let builder = ClaudeCliBuilder::new().api_key(valid_key);
let args = builder.clone().build_args();
assert!(!args.contains(&valid_key.to_string()));
assert_eq!(builder.api_key, Some(valid_key.to_string()));
}
#[test]
fn test_api_key_validation() {
let invalid_key = "invalid-api-key";
let builder = ClaudeCliBuilder::new().api_key(invalid_key);
assert_eq!(builder.api_key, Some(invalid_key.to_string()));
}
#[test]
fn test_both_auth_methods() {
let oauth = "sk-ant-oat-123";
let api_key = "sk-ant-api-456";
let builder = ClaudeCliBuilder::new().oauth_token(oauth).api_key(api_key);
assert_eq!(builder.oauth_token, Some(oauth.to_string()));
assert_eq!(builder.api_key, Some(api_key.to_string()));
}
#[test]
fn test_permission_prompt_tool() {
let builder = ClaudeCliBuilder::new().permission_prompt_tool("stdio");
let args = builder.build_args();
assert!(args.contains(&"--permission-prompt-tool".to_string()));
assert!(args.contains(&"stdio".to_string()));
}
#[test]
fn test_permission_prompt_tool_not_present_by_default() {
let builder = ClaudeCliBuilder::new();
let args = builder.build_args();
assert!(!args.contains(&"--permission-prompt-tool".to_string()));
}
#[test]
fn test_session_id_present_for_new_session() {
let builder = ClaudeCliBuilder::new();
let args = builder.build_args();
assert!(
args.contains(&"--session-id".to_string()),
"New sessions should have --session-id"
);
}
#[test]
fn test_session_id_not_present_with_resume() {
let builder = ClaudeCliBuilder::new().resume(Some("existing-uuid".to_string()));
let args = builder.build_args();
assert!(
args.contains(&"--resume".to_string()),
"Should have --resume flag"
);
assert!(
!args.contains(&"--session-id".to_string()),
"--session-id should NOT be present when resuming"
);
}
#[test]
fn test_session_id_not_present_with_continue() {
let builder = ClaudeCliBuilder::new().continue_conversation(true);
let args = builder.build_args();
assert!(
args.contains(&"--continue".to_string()),
"Should have --continue flag"
);
assert!(
!args.contains(&"--session-id".to_string()),
"--session-id should NOT be present when continuing"
);
}
}