use clap::{Parser, Subcommand};
use molten_herald::{
ui, Config, EventDetector, EventType, Result,
Scheduler, TweetGenerator, TweetTemplates, TwitterClient,
};
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "herald")]
#[command(author = "Molten Labs")]
#[command(version)]
#[command(about = "📢 Automated viral tweet generation and scheduling for developers")]
#[command(long_about = None)]
struct Cli {
#[arg(short, long, global = true)]
config: Option<PathBuf>,
#[arg(short, long, global = true)]
verbose: bool,
#[arg(long, global = true)]
plain: bool,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
#[command(name = "i", alias = "interactive")]
Interactive,
Init {
#[arg(short, long)]
force: bool,
},
Generate {
#[arg(short, long)]
project: Option<String>,
#[arg(short, long)]
event: Option<String>,
#[arg(short = 'n', long, default_value = "1")]
count: usize,
#[arg(long)]
dry_run: bool,
},
Post {
text: String,
#[arg(long)]
thread: bool,
},
Schedule {
text: String,
#[arg(short, long)]
time: Option<String>,
},
Queue {
#[command(subcommand)]
action: Option<QueueAction>,
},
Process,
Config {
#[arg(long)]
example: bool,
},
Template {
#[command(subcommand)]
template: TemplateType,
},
Detect {
project: Option<String>,
},
Demo,
}
#[derive(Subcommand)]
enum QueueAction {
List,
Cancel { id: String },
Reschedule {
id: String,
#[arg(short, long)]
time: String,
},
Cleanup,
Stats,
}
#[derive(Subcommand)]
enum TemplateType {
CrateRelease {
name: String,
version: String,
tagline: String,
},
OpenSource {
name: String,
description: String,
url: String,
},
Feature {
name: String,
feature: String,
benefit: String,
},
Milestone {
name: String,
metric: String,
value: String,
},
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
let config = match &cli.config {
Some(path) => Config::load_from(path)?,
None => Config::load().unwrap_or_default(),
};
match cli.command {
Commands::Interactive => molten_herald::interactive::run(&config).await,
Commands::Init { force } => cmd_init(force).await,
Commands::Generate { project, event, count, dry_run } => {
cmd_generate(&config, project, event, count, dry_run).await
}
Commands::Post { text, thread } => cmd_post(&config, &text, thread).await,
Commands::Schedule { text, time } => cmd_schedule(&config, &text, time).await,
Commands::Queue { action } => cmd_queue(&config, action).await,
Commands::Process => cmd_process(&config).await,
Commands::Config { example } => cmd_config(&config, example),
Commands::Template { template } => cmd_template(template),
Commands::Detect { project } => cmd_detect(&config, project).await,
Commands::Demo => {
ui::demo();
Ok(())
}
}
}
async fn cmd_init(force: bool) -> Result<()> {
ui::banner();
let config_path = Config::default_path()?;
if config_path.exists() && !force {
ui::warning(&format!("Configuration already exists at: {}", config_path.display()));
ui::info("Use --force to overwrite");
return Ok(());
}
let config = Config::example();
config.save_to(&config_path)?;
ui::config_created(&config_path.display().to_string());
Ok(())
}
async fn cmd_generate(
config: &Config,
project: Option<String>,
event_type: Option<String>,
count: usize,
dry_run: bool,
) -> Result<()> {
ui::banner();
let generator = TweetGenerator::new(config.llm.clone(), config.defaults.clone());
let event = if let (Some(proj), Some(evt)) = (&project, &event_type) {
let event_type = match evt.as_str() {
"release" => EventType::Release,
"commit" => EventType::Commit,
"pr" | "pull_request" => EventType::PullRequest,
"feature" => EventType::MajorFeature,
_ => EventType::Custom(evt.clone()),
};
EventDetector::create_manual_event(
proj,
&format!("{} update", proj),
None,
None,
event_type,
)
} else {
ui::info("Detecting events from configured projects...");
let detector = EventDetector::new();
let mut events = Vec::new();
for proj in &config.projects {
if let Ok(mut detected) = ui::with_spinner_async(
&format!("Checking {}...", proj.name),
detector.detect(proj)
).await {
events.append(&mut detected);
}
}
events.into_iter().next().ok_or_else(|| {
molten_herald::HeraldError::NoEvents
})?
};
ui::subheader(&format!("Generating {} tweet(s) for: {}", count, event.title));
println!();
if count > 1 {
let tweets = ui::with_spinner_async(
"Generating variations...",
generator.generate_variations(&event, count)
).await?;
for (i, tweet) in tweets.iter().enumerate() {
ui::header(&format!("Variation {}", i + 1));
ui::generated_tweet(&tweet.content, tweet.length, Some(&format!("{:?}", event.event_type)));
}
} else {
let tweet = ui::with_spinner_async(
"Generating tweet...",
generator.generate(&event)
).await?;
ui::generated_tweet(&tweet.content, tweet.length, Some(&format!("{:?}", event.event_type)));
if !dry_run {
if ui::confirm_prompt("Post this tweet?") {
let client = TwitterClient::new(config.twitter.clone())?;
let posted = ui::with_spinner_async(
"Posting to Twitter...",
client.post_tweet(&tweet.content)
).await?;
ui::tweet_posted(&posted.url);
}
}
}
Ok(())
}
async fn cmd_post(config: &Config, text: &str, thread: bool) -> Result<()> {
ui::banner();
let client = TwitterClient::new(config.twitter.clone())?;
if thread {
let tweets: Vec<String> = text.split("---")
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
ui::info(&format!("Posting thread with {} tweets...", tweets.len()));
for (i, tweet) in tweets.iter().enumerate() {
ui::tweet_preview(tweet, tweet.chars().count());
if i < tweets.len() - 1 {
println!(" ↓");
}
}
if ui::confirm_prompt("Post this thread?") {
let posted = ui::with_spinner_async(
"Posting thread...",
client.post_thread(&tweets)
).await?;
let urls: Vec<String> = posted.iter().map(|p| p.url.clone()).collect();
ui::thread_posted(&urls);
}
} else {
ui::tweet_preview(text, text.chars().count());
if ui::confirm_prompt("Post this tweet?") {
let posted = ui::with_spinner_async(
"Posting to Twitter...",
client.post_tweet(text)
).await?;
ui::tweet_posted(&posted.url);
}
}
Ok(())
}
async fn cmd_schedule(config: &Config, text: &str, time: Option<String>) -> Result<()> {
ui::banner();
let scheduler = Scheduler::new(config.schedule.clone())?;
let scheduled_time = if let Some(ref t) = time {
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(t) {
dt.with_timezone(&chrono::Utc)
} else {
scheduler.next_available_slot()
}
} else {
scheduler.next_available_slot()
};
ui::tweet_preview(text, text.chars().count());
let scheduled = scheduler.schedule_text(text, Some(scheduled_time))?;
ui::success("Tweet scheduled!");
println!();
ui::kv("Time", &scheduled.scheduled_for.format("%Y-%m-%d %H:%M UTC").to_string());
ui::kv("ID", &scheduled.id[..8]);
Ok(())
}
async fn cmd_queue(config: &Config, action: Option<QueueAction>) -> Result<()> {
let scheduler = Scheduler::new(config.schedule.clone())?;
match action {
None | Some(QueueAction::List) => {
ui::banner();
let pending = scheduler.pending()?;
if pending.is_empty() {
ui::info("No scheduled tweets");
return Ok(());
}
let tweets: Vec<(String, String, String, String)> = pending
.iter()
.map(|t| (
t.id.clone(),
t.scheduled_for.format("%Y-%m-%d %H:%M UTC").to_string(),
format!("{:?}", t.status),
t.content.clone(),
))
.collect();
ui::scheduled_tweets_table(&tweets);
}
Some(QueueAction::Cancel { id }) => {
scheduler.cancel(&id)?;
ui::success(&format!("Cancelled tweet: {}", &id[..8.min(id.len())]));
}
Some(QueueAction::Reschedule { id, time }) => {
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&time) {
scheduler.reschedule(&id, dt.with_timezone(&chrono::Utc))?;
ui::success(&format!("Rescheduled {} to {}", &id[..8.min(id.len())], time));
} else {
ui::error("Invalid time format. Use ISO 8601 (e.g., 2024-01-15T09:00:00Z)");
}
}
Some(QueueAction::Cleanup) => {
let removed = scheduler.cleanup()?;
ui::success(&format!("Removed {} completed/failed tweets", removed));
}
Some(QueueAction::Stats) => {
ui::banner();
let stats = scheduler.stats()?;
ui::queue_stats(stats.pending, stats.posted, stats.failed, stats.cancelled);
}
}
Ok(())
}
async fn cmd_process(config: &Config) -> Result<()> {
let scheduler = Scheduler::new(config.schedule.clone())?;
let client = TwitterClient::new(config.twitter.clone())?;
let due = scheduler.due()?;
if due.is_empty() {
return Ok(());
}
for tweet in due {
match client.post_tweet(&tweet.content).await {
Ok(posted) => {
scheduler.mark_posted(&tweet.id, &posted.id)?;
ui::success(&format!("Posted: {}", posted.url));
}
Err(e) => {
scheduler.mark_failed(&tweet.id, &e.to_string())?;
ui::error(&format!("Failed to post {}: {}", &tweet.id[..8], e));
}
}
}
Ok(())
}
fn cmd_config(config: &Config, example: bool) -> Result<()> {
if example {
let example = Config::example();
let toml = toml::to_string_pretty(&example)
.map_err(|e| molten_herald::HeraldError::Config(e.to_string()))?;
println!("{}", toml);
} else {
let toml = toml::to_string_pretty(config)
.map_err(|e| molten_herald::HeraldError::Config(e.to_string()))?;
println!("{}", toml);
}
Ok(())
}
fn cmd_template(template: TemplateType) -> Result<()> {
let tweet = match template {
TemplateType::CrateRelease { name, version, tagline } => {
TweetTemplates::crate_release(&name, &version, &tagline, &format!("https://crates.io/crates/{}", name))
}
TemplateType::OpenSource { name, description, url } => {
TweetTemplates::open_source(&name, &description, &url)
}
TemplateType::Feature { name, feature, benefit } => {
TweetTemplates::feature(&name, &feature, &benefit)
}
TemplateType::Milestone { name, metric, value } => {
TweetTemplates::milestone(&name, &metric, &value)
}
};
ui::banner();
ui::generated_tweet(&tweet, tweet.chars().count(), None);
ui::info("Tip: Pipe to pbcopy (macOS) or xclip (Linux) to copy");
Ok(())
}
async fn cmd_detect(config: &Config, project: Option<String>) -> Result<()> {
ui::banner();
let detector = EventDetector::new();
let projects: Vec<_> = if let Some(ref name) = project {
config.projects.iter().filter(|p| p.name == *name).collect()
} else {
config.projects.iter().collect()
};
if projects.is_empty() {
ui::warning("No projects configured. Add projects to your config file.");
return Ok(());
}
for proj in projects {
ui::subheader(&format!("Detecting events for: {}", proj.name));
match ui::with_spinner_async(
"Fetching...",
detector.detect(proj)
).await {
Ok(events) => {
if events.is_empty() {
ui::info("No recent events found");
} else {
for event in events {
ui::list_item(
"•",
&format!("{:?}: {} ({})",
event.event_type,
event.title,
event.timestamp.format("%Y-%m-%d")
)
);
}
}
}
Err(e) => {
ui::error(&format!("Error: {}", e));
}
}
println!();
}
Ok(())
}