mod mcp;
mod error;
mod bluesky;
mod tools;
mod http;
mod cli;
mod car;
mod auth;
use anyhow::Result;
use clap::Parser;
use cli::{Cli, Commands};
use tracing::info;
#[tokio::main]
async fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
if args.len() > 1 {
run_cli_mode().await
} else {
run_mcp_mode().await
}
}
async fn run_cli_mode() -> Result<()> {
let cli = Cli::parse();
let log_level = if cli.quiet {
"error"
} else if cli.verbose {
"debug"
} else {
"info"
};
tracing_subscriber::fmt()
.with_env_filter(log_level)
.with_writer(std::io::stderr) .init();
let result = match cli.command {
Some(Commands::Profile(args)) => {
execute_profile_cli(args).await
}
Some(Commands::Search(args)) => {
execute_search_cli(args).await
}
Some(Commands::Login(args)) => {
execute_login_cli(args).await
}
None => {
eprintln!("Error: No command specified. Use --help for usage information.");
std::process::exit(1);
}
};
match result {
Ok(output) => {
println!("{}", output);
Ok(())
}
Err(e) => {
eprintln!("Error: {}", e);
std::process::exit(get_exit_code(&e));
}
}
}
async fn execute_profile_cli(args: cli::ProfileArgs) -> Result<String> {
use tokio::time::{timeout, Duration};
let result = timeout(Duration::from_secs(120), tools::profile::execute_profile(args)).await;
match result {
Ok(Ok(tool_result)) => {
Ok(tool_result.content.first()
.map(|c| c.text.clone())
.unwrap_or_default())
}
Ok(Err(e)) => Err(anyhow::anyhow!(e.message())),
Err(_) => Err(anyhow::anyhow!("Request exceeded 120 second timeout")),
}
}
async fn execute_search_cli(args: cli::SearchArgs) -> Result<String> {
use tokio::time::{timeout, Duration};
let result = timeout(Duration::from_secs(120), tools::search::execute_search(args)).await;
match result {
Ok(Ok(tool_result)) => {
Ok(tool_result.content.first()
.map(|c| c.text.clone())
.unwrap_or_default())
}
Ok(Err(e)) => Err(anyhow::anyhow!(e.message())),
Err(_) => Err(anyhow::anyhow!("Request exceeded 120 second timeout")),
}
}
async fn try_oauth_login(handle: &str, storage: &auth::CredentialStorage) -> Result<auth::Session> {
use auth::{AtProtoOAuthManager, CallbackServer, CallbackResult, Credentials};
info!("Starting atproto OAuth browser flow...");
let callback_server = CallbackServer::new()
.map_err(|e| anyhow::anyhow!("Failed to start callback server: {}", e))?;
let mut oauth_manager = AtProtoOAuthManager::new()?;
oauth_manager.set_redirect_uri(callback_server.callback_url());
info!("Resolving handle and discovering authorization server...");
let flow_state = oauth_manager.start_browser_flow(handle).await?;
info!("OAuth callback server started on {}", callback_server.callback_url());
info!("Authorization URL: {}", flow_state.auth_url);
if webbrowser::open(&flow_state.auth_url).is_ok() {
info!("Opened browser for authorization");
} else {
eprintln!("\nPlease visit this URL in your browser:");
eprintln!("{}\n", flow_state.auth_url);
}
info!("Waiting for authorization callback...");
let callback_result = callback_server
.wait_for_callback(std::time::Duration::from_secs(300))
.await
.map_err(|e| anyhow::anyhow!("OAuth callback failed: {}", e))?;
match callback_result {
CallbackResult::Success { code, state } => {
if state != flow_state.state {
return Err(anyhow::anyhow!("State parameter mismatch - possible CSRF attack"));
}
info!("Authorization successful, exchanging code for tokens...");
let session = oauth_manager.complete_flow(&code, &flow_state).await?;
info!("OAuth authentication successful!");
let oauth_credentials = Credentials::with_service(
&session.did, &session.refresh_jwt, &session.service,
);
storage.store_credentials_with_fallback(handle, oauth_credentials)?;
Ok(session)
}
CallbackResult::Error { error, description } => {
let desc = description.unwrap_or_else(|| "No description provided".to_string());
Err(anyhow::anyhow!("OAuth authorization failed: {} - {}", error, desc))
}
}
}
async fn execute_login_cli(args: cli::LoginCommand) -> Result<String> {
use auth::{Credentials, CredentialStorage, SessionManager};
use std::io::{self, Write};
let storage = CredentialStorage::new()?;
match args.command {
Some(cli::LoginSubcommands::List) => {
let accounts = storage.list_accounts()?;
let default_account = storage.get_default_account()?;
if accounts.is_empty() {
return Ok("No accounts stored. Use 'autoreply login' to add an account.".to_string());
}
let mut output = format!("Authenticated accounts ({}):\n", accounts.len());
for account in accounts {
let marker = if Some(&account) == default_account.as_ref() {
" (default)"
} else {
""
};
output.push_str(&format!(" • @{}{}\n", account, marker));
}
return Ok(output);
}
Some(cli::LoginSubcommands::Default { handle }) => {
storage.get_credentials(&handle)?;
storage.set_default_account(&handle)?;
return Ok(format!("✓ Set @{} as default account", handle));
}
Some(cli::LoginSubcommands::Delete { handle }) => {
let handle_to_delete = if let Some(h) = handle {
h
} else {
storage.get_default_account()?
.ok_or_else(|| anyhow::anyhow!("No default account set. Specify --handle"))?
};
storage.remove_account(&handle_to_delete)?;
return Ok(format!("✓ Deleted account @{}", handle_to_delete));
}
None => {
}
}
let handle = if let Some(h) = args.handle {
h
} else {
print!("Handle (e.g., alice.bsky.social): ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
input.trim().to_string()
};
if handle.is_empty() {
return Err(anyhow::anyhow!("Handle is required"));
}
let session = if args.password.is_some() {
let password = match args.password.as_deref() {
Some("") | None => {
print!("App password: ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
input.trim().to_string()
}
Some(p) => p.to_string(),
};
if password.is_empty() {
return Err(anyhow::anyhow!("Password is required for app password authentication"));
}
let credentials = if let Some(service) = args.service {
Credentials::with_service(&handle, &password, service)
} else {
Credentials::new(&handle, &password)
};
info!("Authenticating with app password...");
let manager = SessionManager::new()?;
let session = manager.login(&credentials).await?;
storage.store_credentials_with_fallback(&handle, credentials)?;
session
} else {
match try_oauth_login(&handle, &storage).await {
Ok(session) => session,
Err(oauth_error) => {
tracing::warn!("OAuth authentication failed: {}", oauth_error);
eprintln!("OAuth authentication failed: {}", oauth_error);
eprintln!("Falling back to app password authentication...\n");
print!("App password: ");
io::stdout().flush()?;
let mut password = String::new();
io::stdin().read_line(&mut password)?;
let password = password.trim().to_string();
if password.is_empty() {
return Err(anyhow::anyhow!("Password is required for app password authentication"));
}
let credentials = if let Some(service) = args.service {
Credentials::with_service(&handle, &password, service)
} else {
Credentials::new(&handle, &password)
};
info!("Authenticating with app password...");
let manager = SessionManager::new()?;
let session = manager.login(&credentials).await?;
storage.store_credentials_with_fallback(&handle, credentials)?;
session
}
}
};
storage.store_session(&handle, session.clone())?;
let accounts = storage.list_accounts()?;
if accounts.len() == 1 || storage.get_default_account()?.is_none() {
storage.set_default_account(&handle)?;
}
let auth_method = if args.password.is_some() {
"app password"
} else {
"OAuth (browser)"
};
Ok(format!(
"✓ Successfully authenticated as @{}\n DID: {}\n Method: {}\n Storage: {}",
session.handle,
session.did,
auth_method,
match storage.backend() {
auth::StorageBackend::Keyring => "OS keyring",
auth::StorageBackend::File => "file",
}
))
}
fn get_exit_code(err: &anyhow::Error) -> i32 {
let err_str = err.to_string().to_lowercase();
if err_str.contains("invalid") || err_str.contains("usage") {
1 } else if err_str.contains("network") || err_str.contains("connection") {
2 } else if err_str.contains("not found") {
3 } else if err_str.contains("timeout") {
4 } else {
5 }
}
async fn run_mcp_mode() -> Result<()> {
tracing_subscriber::fmt::init();
info!("Starting autoreply MCP Server");
mcp::handle_stdio().await?;
Ok(())
}