A Rust CLI for publishing thought records. Designed to work with thought.stream.
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}