mod cli;
mod document;
mod export;
mod parser;
mod render;
mod server;
mod session;
mod unicode;
use clap::Parser;
use cli::{Cli, Command};
use session::{ChatMessage, ChatRole, Session};
fn main() {
let cli = Cli::parse();
if let Err(e) = run(cli.command) {
eprintln!("Error: {}", e);
std::process::exit(1);
}
}
fn run(command: Command) -> Result<(), Box<dyn std::error::Error>> {
match command {
Command::New { title } => cmd_new(&title),
Command::Step { title, latex } => cmd_step(&title, &latex),
Command::Eq { latex } => cmd_eq(&latex),
Command::Note { text } => cmd_note(&text),
Command::Text { text } => cmd_text(&text),
Command::Result { title, latex } => cmd_result(&title, &latex),
Command::Divider => cmd_divider(),
Command::Render { latex, output } => cmd_render(&latex, output.as_deref()),
Command::Stop => cmd_stop(),
Command::Export { output } => cmd_export(&output),
Command::Status => cmd_status(),
Command::Selection { json, latex } => cmd_selection(json, latex),
Command::Chat { all, step, json } => cmd_chat(all, step, json),
Command::Reply { step_id, text } => cmd_reply(step_id, text),
Command::Listen { json } => cmd_listen(json),
Command::Update { check } => cmd_update(check),
}
}
fn require_session() -> Result<Session, Box<dyn std::error::Error>> {
Session::find_current()
.ok_or_else(|| "No active session. Run `cliboard new \"title\"` first.".into())
}
fn count_steps(content: &str) -> usize {
content
.lines()
.filter(|line| line.starts_with("## "))
.count()
}
fn cmd_new(title: &str) -> Result<(), Box<dyn std::error::Error>> {
let session = Session::create(title)?;
let port = server::find_available_port(8377)?;
let url = format!("http://localhost:{}", port);
let _ = open::that(&url);
println!("Board live at {}", url);
server::start_server(&session, port)?;
Ok(())
}
fn cmd_step(title: &str, latex: &str) -> Result<(), Box<dyn std::error::Error>> {
let session = require_session()?;
let content = format!("\n## {}\n\n$${}$$\n", title, latex);
session.append(&content)?;
let board = session.read_board()?;
let n = count_steps(&board);
println!("Step {} added: \"{}\"", n, title);
Ok(())
}
fn cmd_eq(latex: &str) -> Result<(), Box<dyn std::error::Error>> {
let session = require_session()?;
let content = format!("\n$${}$$\n", latex);
session.append(&content)?;
println!("Equation added");
Ok(())
}
fn cmd_note(text: &str) -> Result<(), Box<dyn std::error::Error>> {
let session = require_session()?;
let content = format!("\n> {}\n", text);
session.append(&content)?;
println!("Note added");
Ok(())
}
fn cmd_text(text: &str) -> Result<(), Box<dyn std::error::Error>> {
let session = require_session()?;
let content = format!("\n{}\n", text);
session.append(&content)?;
println!("Text added");
Ok(())
}
fn cmd_result(title: &str, latex: &str) -> Result<(), Box<dyn std::error::Error>> {
let session = require_session()?;
let content = format!("\n## {} {{.result}}\n\n$${}$$\n", title, latex);
session.append(&content)?;
println!("Result added: \"{}\"", title);
Ok(())
}
fn cmd_divider() -> Result<(), Box<dyn std::error::Error>> {
let session = require_session()?;
session.append("\n---\n")?;
println!("Divider added");
Ok(())
}
fn cmd_render(latex: &str, output: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
let rendered = render::render_equation(latex)
.map_err(|e| format!("KaTeX error: {}", e))?;
let html = format!(
r#"<!DOCTYPE html>
<html><head>
<meta charset="UTF-8">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css">
<style>body {{ display:flex; justify-content:center; align-items:center; min-height:100vh; margin:0; background:#1a1a2e; color:#e0e0e0; }}</style>
</head><body>{}</body></html>"#,
rendered
);
match output {
Some(path) => {
std::fs::write(path, &html)?;
println!("Rendered to {}", path);
}
None => {
let tmp = tempfile::Builder::new()
.suffix(".html")
.tempfile()?;
let tmp_path = tmp.into_temp_path();
std::fs::write(&tmp_path, &html)?;
let _ = open::that(tmp_path.to_str().unwrap_or(""));
std::thread::sleep(std::time::Duration::from_secs(2));
println!("Opened in browser");
}
}
Ok(())
}
fn cmd_stop() -> Result<(), Box<dyn std::error::Error>> {
let session = require_session()?;
if let Some(pid) = session.read_pid() {
let _ = std::process::Command::new("kill")
.arg(pid.to_string())
.status();
session.remove_pid();
println!("Server stopped (PID {})", pid);
} else {
println!("No server PID found");
}
Ok(())
}
fn cmd_export(output: &str) -> Result<(), Box<dyn std::error::Error>> {
let session = require_session()?;
let content = session.read_board()?;
let doc = parser::parse(&content);
export::export_html(&doc, output)?;
println!("Exported to {}", output);
Ok(())
}
fn cmd_status() -> Result<(), Box<dyn std::error::Error>> {
match Session::find_current() {
Some(session) => {
let port = session.read_port();
let pid = session.read_pid();
let content = session.read_board().unwrap_or_default();
let steps = count_steps(&content);
let alive = pid
.map(is_pid_alive)
.unwrap_or(false);
if alive {
let port_str = port
.map(|p| format!(":{}", p))
.unwrap_or_else(|| "unknown port".to_string());
println!("Running on {}, {} steps", port_str, steps);
} else {
println!("Not running ({} steps in board)", steps);
}
}
None => {
println!("No active session");
}
}
Ok(())
}
fn cmd_selection(json: bool, latex: bool) -> Result<(), Box<dyn std::error::Error>> {
let session = require_session()?;
match session.read_selection() {
Some(selection) => {
if json {
let json_str = serde_json::to_string_pretty(&selection)?;
println!("{}", json_str);
} else if latex {
println!("{}", selection.latex);
} else {
println!("Step {}: {}", selection.step_id, selection.title);
println!("LaTeX: {}", selection.latex);
println!("Unicode: {}", selection.unicode);
}
}
None => {
println!("No selection yet. Click an equation on the board first.");
}
}
Ok(())
}
fn cmd_chat(all: bool, step: Option<usize>, json: bool) -> Result<(), Box<dyn std::error::Error>> {
let session = require_session()?;
let store = session.read_messages()?;
let messages = if let Some(step_id) = step {
store
.messages
.into_iter()
.filter(|m| m.step_id == step_id)
.collect()
} else if all {
store.messages
} else {
session.pending_messages()?
};
if json {
println!("{}", serde_json::to_string_pretty(&messages)?);
return Ok(());
}
if messages.is_empty() {
println!("No messages.");
return Ok(());
}
for msg in &messages {
let role_prefix = match msg.role {
ChatRole::User => "Q",
ChatRole::Assistant => "A",
};
println!("[Step {}] {}: {}", msg.step_id, role_prefix, msg.text);
if let Some(ctx) = &msg.context {
if let (Some(selected), Some(latex)) = (&ctx.selected, &ctx.latex) {
let title_part = ctx
.step_title
.as_deref()
.filter(|t| !t.is_empty())
.map(|t| format!(" ({})", t))
.unwrap_or_default();
println!(
" -> selected: \"{}\" in {}{}",
selected, latex, title_part
);
}
}
}
Ok(())
}
fn cmd_reply(step_id: usize, text: String) -> Result<(), Box<dyn std::error::Error>> {
let session = require_session()?;
let rendered = render::render_reply_content(&text, step_id);
let timestamp_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_millis();
let msg = ChatMessage {
id: format!("{:x}", timestamp_ms),
step_id,
role: ChatRole::Assistant,
text,
rendered,
timestamp: chrono::Local::now().to_rfc3339(),
context: None,
};
session.append_message(msg)?;
println!("Reply sent to step {}.", step_id);
Ok(())
}
fn cmd_listen(json: bool) -> Result<(), Box<dyn std::error::Error>> {
use notify::{EventKind, RecursiveMode, Watcher};
use std::collections::HashSet;
let session = require_session()?;
let messages_path = session.messages_path();
let mut seen: HashSet<String> = {
let store = session.read_messages()?;
store.messages.iter().map(|m| m.id.clone()).collect()
};
eprintln!("Listening for chat questions... (Ctrl+C to stop)");
let (tx, rx) = std::sync::mpsc::channel();
let mut watcher = notify::recommended_watcher(tx)?;
let watch_dir = messages_path
.parent()
.unwrap_or(std::path::Path::new("."));
watcher.watch(watch_dir, RecursiveMode::NonRecursive)?;
for event_result in rx {
match event_result {
Ok(event) => {
let dominated = matches!(
event.kind,
EventKind::Modify(_) | EventKind::Create(_)
);
let affects = event.paths.iter().any(|p| p == &messages_path);
if dominated && affects {
if let Ok(store) = session.read_messages() {
for msg in &store.messages {
if msg.role == ChatRole::User && !seen.contains(&msg.id) {
seen.insert(msg.id.clone());
if json {
println!("{}", serde_json::to_string(msg)?);
} else {
print!("[Step {}] {}", msg.step_id, msg.text);
if let Some(ctx) = &msg.context {
if let Some(sel) = &ctx.selected {
print!(" (re: \"{}\")", sel);
}
}
println!();
}
use std::io::Write;
let _ = std::io::stdout().flush();
}
}
}
}
}
Err(e) => eprintln!("Watch error: {}", e),
}
}
Ok(())
}
const GITHUB_REPO: &str = "maxwellsdm1867/cliboard";
fn cmd_update(check_only: bool) -> Result<(), Box<dyn std::error::Error>> {
let current = env!("CARGO_PKG_VERSION");
println!("cliboard v{}", current);
println!("Checking for updates...");
let output = std::process::Command::new("curl")
.args([
"-fsSL",
"-H",
"Accept: application/vnd.github+json",
&format!(
"https://api.github.com/repos/{}/releases/latest",
GITHUB_REPO
),
])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("404") || output.status.code() == Some(22) {
println!("No releases published yet.");
println!(
"Build from source: cargo install --path . (or cargo build --release)"
);
return Ok(());
}
return Err("Failed to check for updates. Check your internet connection.".into());
}
let body = String::from_utf8(output.stdout)?;
let release: serde_json::Value = serde_json::from_str(&body)?;
let latest = release["tag_name"]
.as_str()
.ok_or("Could not parse latest version")?
.trim_start_matches('v');
if latest == current {
println!("Already on the latest version.");
return Ok(());
}
println!("New version available: v{} -> v{}", current, latest);
if check_only {
println!(
"Run `cliboard update` to install, or visit:\n https://github.com/{}/releases/tag/v{}",
GITHUB_REPO, latest
);
return Ok(());
}
let target = target_triple();
let asset_name = format!("cliboard-{}.tar.gz", target);
let assets = release["assets"]
.as_array()
.ok_or("No assets in release")?;
let download_url = assets
.iter()
.find(|a| {
let name = a["name"].as_str().unwrap_or("");
name == asset_name || name.contains(target)
})
.and_then(|a| a["browser_download_url"].as_str())
.ok_or_else(|| {
format!(
"No prebuilt binary for your platform ({}).\n\
Install from source: cargo install --path .",
target
)
})?;
println!("Downloading...");
let tmp_dir = tempfile::tempdir()?;
let tmp_archive = tmp_dir.path().join("cliboard.tar.gz");
let status = std::process::Command::new("curl")
.args(["-fsSL", "-o"])
.arg(&tmp_archive)
.arg(download_url)
.status()?;
if !status.success() {
return Err("Download failed.".into());
}
let status = std::process::Command::new("tar")
.arg("xzf")
.arg(&tmp_archive)
.arg("-C")
.arg(tmp_dir.path())
.status()?;
if !status.success() {
return Err("Failed to extract archive.".into());
}
let new_bin = find_binary_in_dir(tmp_dir.path())
.ok_or("Could not find cliboard binary in downloaded archive.")?;
let current_exe = std::env::current_exe()?;
let backup = current_exe.with_extension("old");
std::fs::rename(¤t_exe, &backup)?;
match std::fs::copy(&new_bin, ¤t_exe) {
Ok(_) => {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(
¤t_exe,
std::fs::Permissions::from_mode(0o755),
)?;
}
let _ = std::fs::remove_file(&backup);
println!("Updated to v{}!", latest);
}
Err(e) => {
let _ = std::fs::rename(&backup, ¤t_exe);
return Err(format!("Install failed: {}. Restored previous version.", e).into());
}
}
Ok(())
}
fn find_binary_in_dir(dir: &std::path::Path) -> Option<std::path::PathBuf> {
let bin_name = if cfg!(windows) {
"cliboard.exe"
} else {
"cliboard"
};
let direct = dir.join(bin_name);
if direct.exists() {
return Some(direct);
}
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let nested = entry.path().join(bin_name);
if nested.exists() {
return Some(nested);
}
}
}
None
}
fn target_triple() -> &'static str {
#[cfg(all(target_arch = "aarch64", target_os = "macos"))]
{ "aarch64-apple-darwin" }
#[cfg(all(target_arch = "x86_64", target_os = "macos"))]
{ "x86_64-apple-darwin" }
#[cfg(all(target_arch = "x86_64", target_os = "linux"))]
{ "x86_64-unknown-linux-gnu" }
#[cfg(all(target_arch = "aarch64", target_os = "linux"))]
{ "aarch64-unknown-linux-gnu" }
#[cfg(all(target_arch = "x86_64", target_os = "windows"))]
{ "x86_64-pc-windows-msvc" }
#[cfg(all(target_arch = "aarch64", target_os = "windows"))]
{ "aarch64-pc-windows-msvc" }
#[cfg(not(any(
all(target_arch = "aarch64", target_os = "macos"),
all(target_arch = "x86_64", target_os = "macos"),
all(target_arch = "x86_64", target_os = "linux"),
all(target_arch = "aarch64", target_os = "linux"),
all(target_arch = "x86_64", target_os = "windows"),
all(target_arch = "aarch64", target_os = "windows"),
)))]
{ "unknown" }
}
fn is_pid_alive(pid: u32) -> bool {
std::process::Command::new("kill")
.args(["-0", &pid.to_string()])
.status()
.map(|s| s.success())
.unwrap_or(false)
}