use crate::config::Config;
use crate::error::Result;
use crate::generator::TweetTemplates;
use crate::scheduler::Scheduler;
use crate::twitter::TwitterClient;
use crate::ui;
use chant::{choose, input};
use glyphs::{style, Color};
pub async fn run(config: &Config) -> Result<()> {
ui::welcome();
loop {
let action = choose(&[
"📝 Create a tweet",
"📦 Announce a release",
"🎯 Use a template",
"📅 Schedule a tweet",
"📬 View queue",
"⚙️ Configure",
"🚪 Exit",
])
.cursor("❯ ")
.run();
match action.as_deref() {
Some("📝 Create a tweet") => create_tweet(config).await?,
Some("📦 Announce a release") => announce_release(config).await?,
Some("🎯 Use a template") => use_template(config).await?,
Some("📅 Schedule a tweet") => schedule_tweet(config).await?,
Some("📬 View queue") => view_queue(config).await?,
Some("⚙️ Configure") => configure(config).await?,
Some("🚪 Exit") | None => {
println!();
println!(
" {} {}",
style("👋").fg(Color::White),
style("See you next time!").fg(Color::Rgb { r: 150, g: 150, b: 150 })
);
println!();
break;
}
_ => {}
}
println!();
}
Ok(())
}
async fn create_tweet(config: &Config) -> Result<()> {
ui::header("Create a Tweet");
let content = input("Tweet:")
.placeholder("What's happening?")
.char_limit(280)
.run();
if content.is_empty() {
ui::warning("Tweet cancelled");
return Ok(());
}
let char_count = content.chars().count();
ui::tweet_preview(&content, char_count);
if char_count > 280 {
ui::error("Tweet is too long! Please shorten it.");
return Ok(());
}
let action = choose(&[
"🚀 Post now",
"📅 Schedule for later",
"📋 Copy to clipboard",
"❌ Cancel",
])
.header("What next?")
.cursor("▸ ")
.run();
match action.as_deref() {
Some("🚀 Post now") => {
if !config.twitter.is_configured() {
ui::error("Twitter not configured. Run 'herald init' first.");
return Ok(());
}
let client = TwitterClient::new(config.twitter.clone())?;
let posted = ui::with_spinner_async("Posting...", client.post_tweet(&content)).await?;
ui::tweet_posted(&posted.url);
}
Some("📅 Schedule for later") => {
let scheduler = Scheduler::new(config.schedule.clone())?;
let scheduled = scheduler.schedule_text(&content, None)?;
ui::success(&format!("Scheduled for {}", scheduled.scheduled_for.format("%Y-%m-%d %H:%M UTC")));
}
Some("📋 Copy to clipboard") => {
copy_to_clipboard(&content);
ui::success("Copied to clipboard!");
}
_ => {
ui::info("Cancelled");
}
}
Ok(())
}
async fn announce_release(config: &Config) -> Result<()> {
ui::header("Announce a Release");
let project = input("Project name:")
.placeholder("e.g., herald")
.run();
if project.is_empty() {
ui::warning("Cancelled");
return Ok(());
}
let version = input("Version:")
.placeholder("e.g., 1.0.0")
.run();
if version.is_empty() {
ui::warning("Cancelled");
return Ok(());
}
let tagline = input("Tagline:")
.placeholder("What makes it awesome?")
.run();
if tagline.is_empty() {
ui::warning("Cancelled");
return Ok(());
}
let platform = choose(&[
"📦 crates.io (Rust)",
"📦 npm (JavaScript)",
"🐙 GitHub Release",
"🔗 Custom URL",
])
.header("Where is it published?")
.cursor("▸ ")
.run();
let url = match platform.as_deref() {
Some("📦 crates.io (Rust)") => format!("https://crates.io/crates/{}", project),
Some("📦 npm (JavaScript)") => format!("https://www.npmjs.com/package/{}", project),
Some("🐙 GitHub Release") => {
let repo = input("GitHub repo (user/repo):")
.placeholder("e.g., moltenlabs/herald")
.run();
format!("https://github.com/{}/releases/tag/v{}", repo, version)
}
Some("🔗 Custom URL") => {
input("URL:")
.placeholder("https://...")
.run()
}
_ => return Ok(()),
};
let tweet = TweetTemplates::crate_release(&project, &version, &tagline, &url);
let char_count = tweet.chars().count();
ui::tweet_preview(&tweet, char_count);
let action = choose(&[
"🚀 Post now",
"📅 Schedule for later",
"📋 Copy to clipboard",
"✏️ Edit manually",
"❌ Cancel",
])
.header("What next?")
.cursor("▸ ")
.run();
match action.as_deref() {
Some("🚀 Post now") => {
if !config.twitter.is_configured() {
ui::error("Twitter not configured. Run 'herald init' first.");
return Ok(());
}
let client = TwitterClient::new(config.twitter.clone())?;
let posted = ui::with_spinner_async("Posting...", client.post_tweet(&tweet)).await?;
ui::tweet_posted(&posted.url);
}
Some("📅 Schedule for later") => {
let scheduler = Scheduler::new(config.schedule.clone())?;
let scheduled = scheduler.schedule_text(&tweet, None)?;
ui::success(&format!("Scheduled for {}", scheduled.scheduled_for.format("%Y-%m-%d %H:%M UTC")));
}
Some("📋 Copy to clipboard") => {
copy_to_clipboard(&tweet);
ui::success("Copied to clipboard!");
}
Some("✏️ Edit manually") => {
let edited = input("Edit tweet:")
.default(&tweet)
.char_limit(280)
.run();
if !edited.is_empty() {
copy_to_clipboard(&edited);
ui::success("Copied to clipboard!");
}
}
_ => {
ui::info("Cancelled");
}
}
Ok(())
}
async fn use_template(config: &Config) -> Result<()> {
ui::header("Tweet Templates");
let template = choose(&[
"📦 Crate/Package Release",
"🌟 Open Source Announcement",
"✨ Feature Announcement",
"🎉 Milestone Celebration",
"🧵 Thread Starter",
"⬅️ Back",
])
.header("Choose a template:")
.cursor("▸ ")
.run();
let tweet = match template.as_deref() {
Some("📦 Crate/Package Release") => {
let name = input("Name:").placeholder("project name").run();
if name.is_empty() { return Ok(()); }
let version = input("Version:").placeholder("1.0.0").run();
if version.is_empty() { return Ok(()); }
let tagline = input("Tagline:").placeholder("what it does").run();
if tagline.is_empty() { return Ok(()); }
TweetTemplates::crate_release(&name, &version, &tagline, &format!("https://crates.io/crates/{}", name))
}
Some("🌟 Open Source Announcement") => {
let name = input("Name:").placeholder("project name").run();
if name.is_empty() { return Ok(()); }
let desc = input("Description:").placeholder("what it does").run();
if desc.is_empty() { return Ok(()); }
let url = input("URL:").placeholder("https://github.com/...").run();
if url.is_empty() { return Ok(()); }
TweetTemplates::open_source(&name, &desc, &url)
}
Some("✨ Feature Announcement") => {
let name = input("Project:").placeholder("project name").run();
if name.is_empty() { return Ok(()); }
let feature = input("Feature:").placeholder("new feature").run();
if feature.is_empty() { return Ok(()); }
let benefit = input("Benefit:").placeholder("why it's great").run();
if benefit.is_empty() { return Ok(()); }
TweetTemplates::feature(&name, &feature, &benefit)
}
Some("🎉 Milestone Celebration") => {
let name = input("Project:").placeholder("project name").run();
if name.is_empty() { return Ok(()); }
let metric = input("Metric:").placeholder("downloads, stars, etc").run();
if metric.is_empty() { return Ok(()); }
let value = input("Value:").placeholder("10,000").run();
if value.is_empty() { return Ok(()); }
TweetTemplates::milestone(&name, &metric, &value)
}
Some("🧵 Thread Starter") => {
let topic = input("Topic:").placeholder("what's the thread about?").run();
if topic.is_empty() { return Ok(()); }
let count = input("Number of points:").placeholder("5").run();
let count: usize = count.parse().unwrap_or(5);
TweetTemplates::thread_opener(&topic, count)
}
_ => return Ok(()),
};
let char_count = tweet.chars().count();
ui::tweet_preview(&tweet, char_count);
let action = choose(&[
"🚀 Post now",
"📅 Schedule",
"📋 Copy",
"❌ Cancel",
])
.cursor("▸ ")
.run();
match action.as_deref() {
Some("🚀 Post now") => {
if !config.twitter.is_configured() {
ui::error("Twitter not configured. Run 'herald init' first.");
return Ok(());
}
let client = TwitterClient::new(config.twitter.clone())?;
let posted = ui::with_spinner_async("Posting...", client.post_tweet(&tweet)).await?;
ui::tweet_posted(&posted.url);
}
Some("📅 Schedule") => {
let scheduler = Scheduler::new(config.schedule.clone())?;
let scheduled = scheduler.schedule_text(&tweet, None)?;
ui::success(&format!("Scheduled for {}", scheduled.scheduled_for.format("%Y-%m-%d %H:%M UTC")));
}
Some("📋 Copy") => {
copy_to_clipboard(&tweet);
ui::success("Copied to clipboard!");
}
_ => {}
}
Ok(())
}
async fn schedule_tweet(config: &Config) -> Result<()> {
ui::header("Schedule a Tweet");
let content = input("Tweet:")
.placeholder("What's happening?")
.char_limit(280)
.run();
if content.is_empty() {
ui::warning("Cancelled");
return Ok(());
}
let char_count = content.chars().count();
ui::tweet_preview(&content, char_count);
if char_count > 280 {
ui::error("Tweet is too long!");
return Ok(());
}
let timing = choose(&[
"⏰ Next available slot",
"🌅 Tomorrow morning (9 AM)",
"🌆 Tomorrow afternoon (3 PM)",
"📅 Custom time",
"❌ Cancel",
])
.header("When to post?")
.cursor("▸ ")
.run();
let scheduled_time = match timing.as_deref() {
Some("⏰ Next available slot") => None,
Some("🌅 Tomorrow morning (9 AM)") => {
let tomorrow = chrono::Utc::now() + chrono::Duration::days(1);
Some(tomorrow.date_naive().and_hms_opt(9, 0, 0).unwrap().and_utc())
}
Some("🌆 Tomorrow afternoon (3 PM)") => {
let tomorrow = chrono::Utc::now() + chrono::Duration::days(1);
Some(tomorrow.date_naive().and_hms_opt(15, 0, 0).unwrap().and_utc())
}
Some("📅 Custom time") => {
let time_str = input("Time (YYYY-MM-DD HH:MM):")
.placeholder("2024-01-15 09:00")
.run();
if time_str.is_empty() {
return Ok(());
}
chrono::NaiveDateTime::parse_from_str(&time_str, "%Y-%m-%d %H:%M")
.ok()
.map(|dt| dt.and_utc())
}
_ => return Ok(()),
};
let scheduler = Scheduler::new(config.schedule.clone())?;
let scheduled = scheduler.schedule_text(&content, scheduled_time)?;
ui::success("Tweet scheduled!");
ui::kv("Time", &scheduled.scheduled_for.format("%Y-%m-%d %H:%M UTC").to_string());
ui::kv("ID", &scheduled.id[..8]);
Ok(())
}
async fn view_queue(config: &Config) -> Result<()> {
let scheduler = Scheduler::new(config.schedule.clone())?;
loop {
ui::header("Tweet Queue");
let stats = scheduler.stats()?;
ui::queue_stats(stats.pending, stats.posted, stats.failed, stats.cancelled);
println!();
let action = choose(&[
"📋 List pending tweets",
"🗑️ Cancel a tweet",
"🧹 Clean up old tweets",
"⬅️ Back",
])
.cursor("▸ ")
.run();
match action.as_deref() {
Some("📋 List pending tweets") => {
let pending = scheduler.pending()?;
if pending.is_empty() {
ui::info("No pending tweets");
} else {
let tweets: Vec<(String, String, String, String)> = pending
.iter()
.map(|t| (
t.id.clone(),
t.scheduled_for.format("%Y-%m-%d %H:%M").to_string(),
format!("{:?}", t.status),
t.content.clone(),
))
.collect();
ui::scheduled_tweets_table(&tweets);
}
}
Some("🗑️ Cancel a tweet") => {
let pending = scheduler.pending()?;
if pending.is_empty() {
ui::info("No pending tweets to cancel");
} else {
let options: Vec<String> = pending
.iter()
.map(|t| format!("{} - {}...", &t.id[..8], t.content.chars().take(30).collect::<String>()))
.collect();
if let Some(selected) = choose(&options).header("Select tweet to cancel:").cursor("▸ ").run() {
let id = selected.split(" - ").next().unwrap();
if let Some(tweet) = pending.iter().find(|t| t.id.starts_with(id)) {
if chant::confirm(&format!("Cancel tweet '{}'?", &tweet.content.chars().take(30).collect::<String>())).run() {
scheduler.cancel(&tweet.id)?;
ui::success("Tweet cancelled");
}
}
}
}
}
Some("🧹 Clean up old tweets") => {
let removed = scheduler.cleanup()?;
ui::success(&format!("Removed {} old tweets", removed));
}
Some("⬅️ Back") | None => break,
_ => {}
}
}
Ok(())
}
async fn configure(config: &Config) -> Result<()> {
let mut config = config.clone();
loop {
ui::header("Configuration");
let config_path = Config::default_path()?;
ui::kv("Config file", &config_path.display().to_string());
ui::kv("Twitter configured", if config.twitter.is_configured() { "✓ Yes" } else { "✗ No" });
ui::kv("LLM configured", if !config.llm.api_key.is_empty() { "✓ Yes" } else { "✗ No" });
ui::kv("LLM provider", &config.llm.provider);
ui::kv("Default tone", &config.defaults.tone);
println!();
let action = choose(&[
"🔑 Set Twitter credentials",
"🤖 Set LLM API key",
"🎨 Set tweet defaults",
"📝 Edit config file",
"📋 Show full config",
"⬅️ Back",
])
.cursor("▸ ")
.run();
match action.as_deref() {
Some("🔑 Set Twitter credentials") => {
setup_twitter(&mut config).await?;
}
Some("🤖 Set LLM API key") => {
setup_llm(&mut config).await?;
}
Some("🎨 Set tweet defaults") => {
setup_defaults(&mut config).await?;
}
Some("📝 Edit config file") => {
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".to_string());
ui::info(&format!("Opening in {}...", editor));
std::process::Command::new(&editor)
.arg(&config_path)
.status()
.ok();
}
Some("📋 Show full config") => {
let toml = toml::to_string_pretty(&config).unwrap_or_default();
println!();
println!("{}", style("─".repeat(50)).fg(Color::BrightBlack));
println!("{}", toml);
println!("{}", style("─".repeat(50)).fg(Color::BrightBlack));
println!();
ui::info("Press Enter to continue...");
let _ = input("").run();
}
Some("⬅️ Back") | None => break,
_ => {}
}
}
Ok(())
}
async fn setup_twitter(config: &mut Config) -> Result<()> {
ui::header("Twitter API Setup");
println!("{}", style("Go to: https://developer.twitter.com/en/portal/projects").fg(Color::Cyan).bold());
println!();
println!("{}", style("Step 1: Get Consumer Keys").fg(Color::Yellow).bold());
ui::list_item("•", "Go to your App → Keys and tokens");
ui::list_item("•", "Under 'Consumer Keys', copy API Key and API Secret");
println!();
let api_key = input("API Key:")
.placeholder("e.g. AhzM2iI2j3ytCe28AWsMrry8T")
.run();
if api_key.is_empty() {
ui::warning("Cancelled");
return Ok(());
}
if api_key.len() < 20 || api_key.len() > 30 {
ui::warning(&format!("API Key looks unusual (length: {}). Expected ~25 chars.", api_key.len()));
if !chant::confirm("Continue anyway?").run() {
return Ok(());
}
}
let api_secret = input("API Secret:")
.placeholder("e.g. GWZ8hLGh1KdmSFvo...")
.run();
if api_secret.is_empty() {
ui::warning("Cancelled");
return Ok(());
}
if api_secret.len() < 40 || api_secret.len() > 60 {
ui::warning(&format!("API Secret looks unusual (length: {}). Expected ~50 chars.", api_secret.len()));
if !chant::confirm("Continue anyway?").run() {
return Ok(());
}
}
println!();
println!("{}", style("Step 2: Get Access Token & Secret").fg(Color::Yellow).bold());
println!("{}", style("⚠️ NOT the Bearer Token! Scroll down to 'Authentication Tokens'").fg(Color::Red));
ui::list_item("•", "Under 'Authentication Tokens' section");
ui::list_item("•", "Click 'Generate' next to 'Access Token and Secret'");
ui::list_item("•", "Make sure you have Read and Write permissions!");
println!();
let access_token = input("Access Token:")
.placeholder("e.g. 1234567890-AbCdEf...")
.run();
if access_token.is_empty() {
ui::warning("Cancelled");
return Ok(());
}
if access_token.starts_with("AAAA") {
ui::error("That looks like a Bearer Token, not an Access Token!");
ui::info("The Access Token starts with numbers like '1234567890-...'");
ui::info("Look under 'Authentication Tokens', not 'Bearer Token'");
return Ok(());
}
if !access_token.contains('-') {
ui::warning("Access Token usually contains a dash (e.g. 1234567890-AbCdEf...)");
if !chant::confirm("Continue anyway?").run() {
return Ok(());
}
}
let access_token_secret = input("Access Token Secret:")
.placeholder("e.g. xYz123AbC...")
.run();
if access_token_secret.is_empty() {
ui::warning("Cancelled");
return Ok(());
}
if access_token_secret.contains('%') || access_token_secret.len() > 60 {
ui::error("That doesn't look like an Access Token Secret!");
ui::info("The Access Token Secret is about 45 characters, no special URL encoding");
return Ok(());
}
config.twitter.api_key = api_key;
config.twitter.api_secret = api_secret;
config.twitter.access_token = access_token;
config.twitter.access_token_secret = access_token_secret;
config.save()?;
println!();
ui::success("Twitter credentials saved!");
ui::kv("Config", &Config::default_path()?.display().to_string());
Ok(())
}
async fn setup_llm(config: &mut Config) -> Result<()> {
ui::header("LLM API Setup");
let provider = choose(&[
"🟠 Anthropic (Claude)",
"🟢 OpenAI (GPT-4)",
"🦙 Ollama (Local)",
"⬅️ Cancel",
])
.header("Choose your LLM provider:")
.cursor("▸ ")
.run();
match provider.as_deref() {
Some("🟠 Anthropic (Claude)") => {
ui::info("Get your API key from https://console.anthropic.com");
println!();
let api_key = input("Anthropic API Key:")
.placeholder("sk-ant-...")
.run();
if api_key.is_empty() {
ui::warning("Cancelled");
return Ok(());
}
config.llm.provider = "anthropic".to_string();
config.llm.api_key = api_key;
config.llm.model = "claude-sonnet-4-20250514".to_string();
config.save()?;
ui::success("Anthropic API key saved!");
}
Some("🟢 OpenAI (GPT-4)") => {
ui::info("Get your API key from https://platform.openai.com");
println!();
let api_key = input("OpenAI API Key:")
.placeholder("sk-...")
.run();
if api_key.is_empty() {
ui::warning("Cancelled");
return Ok(());
}
config.llm.provider = "openai".to_string();
config.llm.api_key = api_key;
config.llm.model = "gpt-4".to_string();
config.save()?;
ui::success("OpenAI API key saved!");
}
Some("🦙 Ollama (Local)") => {
ui::info("Make sure Ollama is running locally");
println!();
let model = input("Model name:")
.placeholder("llama2")
.default("llama2")
.run();
config.llm.provider = "ollama".to_string();
config.llm.api_key = String::new();
config.llm.model = model;
config.llm.base_url = Some("http://localhost:11434/api/generate".to_string());
config.save()?;
ui::success("Ollama configured!");
}
_ => {}
}
Ok(())
}
async fn setup_defaults(config: &mut Config) -> Result<()> {
ui::header("Tweet Defaults");
let tone = choose(&[
"😎 Casual - Developer-friendly, conversational",
"👔 Professional - Informative, credible",
"🚀 Hype - Exciting, high energy",
"🔧 Technical - Focus on technical details",
])
.header("Default tone:")
.cursor("▸ ")
.run();
match tone.as_deref() {
Some(t) if t.contains("Casual") => config.defaults.tone = "casual".to_string(),
Some(t) if t.contains("Professional") => config.defaults.tone = "professional".to_string(),
Some(t) if t.contains("Hype") => config.defaults.tone = "hype".to_string(),
Some(t) if t.contains("Technical") => config.defaults.tone = "technical".to_string(),
_ => return Ok(()),
}
let emojis = choose(&[
"✅ Yes - Include emojis",
"❌ No - No emojis",
])
.header("Include emojis?")
.cursor("▸ ")
.run();
config.defaults.emojis = emojis.as_deref().map(|e| e.contains("Yes")).unwrap_or(true);
let hashtags = choose(&[
"❌ No - No hashtags (recommended)",
"✅ Yes - Include hashtags",
])
.header("Include hashtags?")
.cursor("▸ ")
.run();
config.defaults.hashtags = hashtags.as_deref().map(|h| h.contains("Yes")).unwrap_or(false);
config.save()?;
ui::success("Defaults saved!");
Ok(())
}
fn copy_to_clipboard(text: &str) {
#[cfg(target_os = "macos")]
{
use std::process::{Command, Stdio};
use std::io::Write;
if let Ok(mut child) = Command::new("pbcopy")
.stdin(Stdio::piped())
.spawn()
{
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(text.as_bytes()).ok();
}
child.wait().ok();
}
}
#[cfg(target_os = "linux")]
{
use std::process::{Command, Stdio};
use std::io::Write;
let result = Command::new("xclip")
.args(["-selection", "clipboard"])
.stdin(Stdio::piped())
.spawn();
if let Ok(mut child) = result {
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(text.as_bytes()).ok();
}
child.wait().ok();
}
}
#[cfg(target_os = "windows")]
{
use std::process::{Command, Stdio};
use std::io::Write;
if let Ok(mut child) = Command::new("clip")
.stdin(Stdio::piped())
.spawn()
{
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(text.as_bytes()).ok();
}
child.wait().ok();
}
}
}