A Rust CLI for publishing thought records. Designed to work with thought.stream.
at main 6.1 kB view raw
1mod client; 2mod credentials; 3mod interactive; 4mod jetstream; 5mod tui; 6 7use anyhow::{Context, Result}; 8use clap::{Parser, Subcommand}; 9use std::io::{self, Write}; 10 11use client::AtProtoClient; 12use credentials::{CredentialStore, Credentials}; 13 14#[derive(Parser)] 15#[command(name = "thought")] 16#[command(about = "CLI tool for publishing stream.thought.blip records")] 17#[command(version = "0.1.0")] 18struct Cli { 19 #[command(subcommand)] 20 command: Option<Commands>, 21 22 /// Message to publish (if no subcommand is provided) 23 message: Option<String>, 24} 25 26#[derive(Subcommand)] 27enum Commands { 28 /// Login to Bluesky with credentials 29 Login, 30 /// Logout and clear stored credentials 31 Logout, 32 /// Enter interactive mode for rapid posting 33 Interactive { 34 /// Use TUI interface with live message feed 35 #[arg(long)] 36 tui: bool, 37 }, 38 /// Enter the thought stream (TUI mode with live feed) 39 Stream, 40} 41 42fn prompt_for_input(prompt: &str) -> Result<String> { 43 print!("{}", prompt); 44 io::stdout().flush().context("Failed to flush stdout")?; 45 46 let mut input = String::new(); 47 io::stdin() 48 .read_line(&mut input) 49 .context("Failed to read input")?; 50 51 Ok(input.trim().to_string()) 52} 53 54async fn handle_login() -> Result<()> { 55 let store = CredentialStore::new()?; 56 57 if store.exists() { 58 println!("You are already logged in. Use 'thought logout' to clear credentials first."); 59 return Ok(()); 60 } 61 62 println!("Login to Bluesky"); 63 println!("================"); 64 65 let username = prompt_for_input("Username (handle or email): ")?; 66 if username.is_empty() { 67 anyhow::bail!("Username cannot be empty"); 68 } 69 70 let password = rpassword::prompt_password("App Password: ") 71 .context("Failed to read password")?; 72 if password.is_empty() { 73 anyhow::bail!("Password cannot be empty"); 74 } 75 76 let pds_uri = prompt_for_input("PDS URI (press Enter for https://bsky.social): ")?; 77 let pds_uri = if pds_uri.is_empty() { 78 "https://bsky.social".to_string() 79 } else { 80 pds_uri 81 }; 82 83 // Test the credentials by attempting to authenticate 84 println!("Testing credentials..."); 85 let credentials = Credentials::new(username, password, pds_uri); 86 let mut client = AtProtoClient::new(&credentials.pds_uri); 87 88 client.login(&credentials).await 89 .context("Login failed. Please check your credentials.")?; 90 91 // Store credentials and session if login was successful 92 store.store(&credentials)?; 93 if let Some(session) = client.get_session() { 94 store.store_session(session)?; 95 } 96 97 Ok(()) 98} 99 100async fn handle_logout() -> Result<()> { 101 let store = CredentialStore::new()?; 102 store.clear()?; 103 Ok(()) 104} 105 106async fn handle_publish(message: &str) -> Result<()> { 107 let mut client = create_authenticated_client().await?; 108 let uri = client.publish_blip(message).await?; 109 110 // Update stored session if it was refreshed 111 let store = CredentialStore::new()?; 112 if let Some(session) = client.get_session() { 113 store.store_session(session)?; 114 } 115 116 println!("Published: {}", uri); 117 Ok(()) 118} 119 120async fn handle_interactive(use_tui: bool) -> Result<()> { 121 let mut client = create_authenticated_client().await?; 122 interactive::run_interactive_mode(&mut client, use_tui).await?; 123 124 // Update stored session after interactive mode ends in case it was refreshed 125 let store = CredentialStore::new()?; 126 if let Some(session) = client.get_session() { 127 store.store_session(session)?; 128 } 129 130 Ok(()) 131} 132 133async fn create_authenticated_client() -> Result<AtProtoClient> { 134 let store = CredentialStore::new()?; 135 136 let credentials = store.load()? 137 .context("Not logged in. Please run 'thought login' first.")?; 138 139 let mut client = AtProtoClient::new(&credentials.pds_uri); 140 141 // Try to load existing session first 142 if let Some(session) = store.load_session()? { 143 client.set_session(session); 144 145 // Try to refresh the session to validate it's still good 146 // If refresh fails, we'll fall back to full login 147 if let Err(_) = client.refresh_session().await { 148 // Session is invalid, clear it and perform full login 149 store.clear_session()?; 150 client.login(&credentials).await?; 151 152 // Save the new session 153 if let Some(session) = client.get_session() { 154 store.store_session(session)?; 155 } 156 } else { 157 // Session refresh succeeded, save the updated session 158 if let Some(session) = client.get_session() { 159 store.store_session(session)?; 160 } 161 } 162 } else { 163 // No existing session, perform full login 164 client.login(&credentials).await?; 165 166 // Save the new session 167 if let Some(session) = client.get_session() { 168 store.store_session(session)?; 169 } 170 } 171 172 Ok(client) 173} 174 175#[tokio::main] 176async fn main() -> Result<()> { 177 let cli = Cli::parse(); 178 179 match cli.command { 180 Some(Commands::Login) => handle_login().await?, 181 Some(Commands::Logout) => handle_logout().await?, 182 Some(Commands::Interactive { tui }) => handle_interactive(tui).await?, 183 Some(Commands::Stream) => handle_interactive(true).await?, 184 None => { 185 if let Some(message) = cli.message { 186 handle_publish(&message).await?; 187 } else { 188 println!("Usage:"); 189 println!(" thought login # Login to Bluesky"); 190 println!(" thought logout # Logout and clear credentials"); 191 println!(" thought interactive # Enter simple REPL mode"); 192 println!(" thought stream # Enter TUI mode with live feed"); 193 println!(" thought \"message\" # Publish a blip"); 194 } 195 } 196 } 197 198 Ok(()) 199}