use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
#[derive(Parser)]
#[command(name = "cryo-gh", about = "Cryochamber GitHub Discussion sync")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Init {
#[arg(long)]
repo: String,
#[arg(long)]
title: Option<String>,
},
Pull,
Push,
Sync {
#[arg(long)]
interval: Option<u64>,
},
Unsync,
Status,
#[command(hide = true)]
SyncDaemon {
#[arg(long)]
interval: Option<u64>,
},
}
fn gh_sync_path(dir: &Path) -> PathBuf {
dir.join("gh-sync.json")
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Init { repo, title } => cmd_gh_init(&repo, title.as_deref()),
Commands::Pull => cmd_gh_pull(),
Commands::Push => cmd_gh_push(),
Commands::Sync { interval } => cmd_gh_sync(interval),
Commands::Unsync => cmd_gh_unsync(),
Commands::Status => cmd_gh_status(),
Commands::SyncDaemon { interval } => cmd_gh_sync_daemon(interval),
}
}
fn cmd_gh_init(repo: &str, title: Option<&str>) -> Result<()> {
let dir = cryochamber::work_dir()?;
let (owner, repo_name) = repo
.split_once('/')
.context("--repo must be in 'owner/repo' format")?;
let default_title = format!(
"[Cryo] {}",
dir.file_name().unwrap_or_default().to_string_lossy()
);
let title = title.unwrap_or(&default_title);
let plan_content = std::fs::read_to_string(dir.join("plan.md")).unwrap_or_default();
let body = if plan_content.is_empty() {
"Cryochamber sync Discussion.".to_string()
} else {
format!("## Cryochamber Plan\n\n{plan_content}")
};
println!("Creating GitHub Discussion in {repo}...");
let (node_id, number) =
cryochamber::channel::github::create_discussion(owner, repo_name, title, &body)?;
println!("Created Discussion #{number}");
let self_login = cryochamber::channel::github::whoami().ok();
let sync_state = cryochamber::gh_sync::GhSyncState {
repo: repo.to_string(),
discussion_number: number,
discussion_node_id: node_id,
last_read_cursor: None,
self_login,
last_pushed_session: None,
};
cryochamber::gh_sync::save_sync_state(&gh_sync_path(&dir), &sync_state)?;
println!("Saved gh-sync.json");
Ok(())
}
fn cmd_gh_pull() -> Result<()> {
let dir = cryochamber::work_dir()?;
let mut sync_state = cryochamber::gh_sync::load_sync_state(&gh_sync_path(&dir))?
.context("gh-sync.json not found. Run 'cryo-gh init' first.")?;
let (owner, repo) = sync_state.owner_repo()?;
println!(
"Pulling comments from Discussion #{}...",
sync_state.discussion_number
);
let new_cursor = cryochamber::channel::github::pull_comments(
owner,
repo,
sync_state.discussion_number,
sync_state.last_read_cursor.as_deref(),
sync_state.self_login.as_deref(),
&dir,
)?;
if let Some(cursor) = new_cursor {
sync_state.last_read_cursor = Some(cursor);
cryochamber::gh_sync::save_sync_state(&gh_sync_path(&dir), &sync_state)?;
}
let inbox = cryochamber::message::read_inbox(&dir)?;
println!("Inbox: {} message(s)", inbox.len());
Ok(())
}
fn cmd_gh_push() -> Result<()> {
let dir = cryochamber::work_dir()?;
let mut sync_state = cryochamber::gh_sync::load_sync_state(&gh_sync_path(&dir))?
.context("gh-sync.json not found. Run 'cryo-gh init' first.")?;
let log = cryochamber::log::log_path(&dir);
let latest = cryochamber::log::read_latest_session(&log)?;
let Some(session_output) = latest else {
println!("No session log found. Nothing to push.");
return Ok(());
};
let state_file = cryochamber::state::state_path(&dir);
let session_num = cryochamber::state::load_state(&state_file)?
.map(|s| s.session_number)
.unwrap_or(0);
if sync_state.last_pushed_session == Some(session_num) {
println!("Session {session_num} already pushed. Skipping.");
return Ok(());
}
let comment = format!("## Session {session_num}\n\n```\n{session_output}\n```");
println!(
"Posting session summary to Discussion #{}...",
sync_state.discussion_number
);
cryochamber::channel::github::post_comment(&sync_state.discussion_node_id, &comment)?;
sync_state.last_pushed_session = Some(session_num);
cryochamber::gh_sync::save_sync_state(&gh_sync_path(&dir), &sync_state)?;
println!("Push complete.");
Ok(())
}
fn resolve_interval(interval_override: Option<u64>) -> Result<u64> {
let dir = cryochamber::work_dir()?;
let cfg = cryochamber::config::load_config(&cryochamber::config::config_path(&dir))?
.unwrap_or_default();
Ok(interval_override.unwrap_or(cfg.gh_poll_interval))
}
fn cmd_gh_sync(interval_override: Option<u64>) -> Result<()> {
let dir = cryochamber::work_dir()?;
if !cryochamber::config::config_path(&dir).exists() {
anyhow::bail!("No cryochamber project in this directory. Run `cryo init` first.");
}
let interval = resolve_interval(interval_override)?;
let sync_path = gh_sync_path(&dir);
let sync_state = cryochamber::gh_sync::load_sync_state(&sync_path)?
.context("gh-sync.json not found. Run 'cryo-gh init' first.")?;
cryochamber::message::ensure_dirs(&dir)?;
let exe = std::env::current_exe().context("Failed to resolve cryo-gh executable path")?;
let interval_str = interval.to_string();
let log_path = dir.join("cryo-gh-sync.log");
cryochamber::service::install(
"gh-sync",
&dir,
&exe,
&["sync-daemon", "--interval", &interval_str],
&log_path,
true,
)?;
println!(
"Sync service installed for Discussion #{} in {}",
sync_state.discussion_number, sync_state.repo
);
println!("Log: cryo-gh-sync.log");
println!("Survives reboot. Stop with: cryo-gh unsync");
Ok(())
}
fn cmd_gh_unsync() -> Result<()> {
let dir = cryochamber::work_dir()?;
if cryochamber::service::uninstall("gh-sync", &dir)? {
println!("Sync service stopped and removed.");
} else {
println!("No sync service installed for this directory.");
}
Ok(())
}
fn cmd_gh_sync_daemon(interval_override: Option<u64>) -> Result<()> {
let interval = resolve_interval(interval_override)?;
let dir = cryochamber::work_dir()?;
let sync_path = gh_sync_path(&dir);
eprintln!("Sync daemon started (PID {})", std::process::id());
let shutdown = Arc::new(AtomicBool::new(false));
signal_hook::flag::register(signal_hook::consts::SIGTERM, Arc::clone(&shutdown))?;
signal_hook::flag::register(signal_hook::consts::SIGINT, Arc::clone(&shutdown))?;
use notify::Watcher;
let (tx, rx) = std::sync::mpsc::channel();
let outbox_path = dir.join("messages").join("outbox");
let _watcher = {
let tx = tx.clone();
let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
if let Ok(event) = res {
if event.kind.is_create() {
let _ = tx.send(());
}
}
})
.context("Failed to create outbox watcher")?;
watcher
.watch(&outbox_path, notify::RecursiveMode::NonRecursive)
.context("Failed to watch messages/outbox/")?;
watcher
};
let shutdown_flag = Arc::clone(&shutdown);
std::thread::spawn(move || {
while !shutdown_flag.load(Ordering::Relaxed) {
std::thread::sleep(std::time::Duration::from_millis(250));
}
let _ = tx.send(()); });
let interval_dur = std::time::Duration::from_secs(interval);
loop {
if shutdown.load(Ordering::Relaxed) {
eprintln!("Sync: shutting down");
break;
}
let mut sync_state = cryochamber::gh_sync::load_sync_state(&sync_path)?
.context("gh-sync.json disappeared")?;
let (owner, repo) = sync_state.owner_repo()?;
match cryochamber::channel::github::pull_comments(
owner,
repo,
sync_state.discussion_number,
sync_state.last_read_cursor.as_deref(),
sync_state.self_login.as_deref(),
&dir,
) {
Ok(new_cursor) => {
if let Some(cursor) = new_cursor {
sync_state.last_read_cursor = Some(cursor);
cryochamber::gh_sync::save_sync_state(&sync_path, &sync_state)?;
}
}
Err(e) => eprintln!("Sync: pull error: {e}"),
}
if let Err(e) = push_outbox(&dir, &sync_state) {
eprintln!("Sync: push error: {e}");
}
match rx.recv_timeout(interval_dur) {
Ok(()) => {
std::thread::sleep(std::time::Duration::from_millis(200));
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
}
}
eprintln!("Sync: stopped");
Ok(())
}
fn push_outbox(dir: &Path, sync_state: &cryochamber::gh_sync::GhSyncState) -> Result<()> {
let messages = cryochamber::message::read_outbox(dir)?;
if messages.is_empty() {
return Ok(());
}
let outbox = dir.join("messages").join("outbox");
let archive = outbox.join("archive");
std::fs::create_dir_all(&archive)?;
for (filename, msg) in &messages {
let body = format!("**{}** ({})\n\n{}", msg.from, msg.subject, msg.body);
match cryochamber::channel::github::post_comment(&sync_state.discussion_node_id, &body) {
Ok(()) => {
eprintln!("Sync: posted outbox/{filename} to Discussion");
let src = outbox.join(filename);
let dst = archive.join(filename);
if src.exists() {
std::fs::rename(&src, &dst)?;
}
}
Err(e) => {
eprintln!("Sync: failed to post outbox/{filename}: {e}");
}
}
}
Ok(())
}
fn cmd_gh_status() -> Result<()> {
let dir = cryochamber::work_dir()?;
match cryochamber::gh_sync::load_sync_state(&gh_sync_path(&dir))? {
None => println!("GitHub sync not configured. Run 'cryo-gh init' first."),
Some(state) => {
println!("Repo: {}", state.repo);
println!("Discussion: #{}", state.discussion_number);
println!(
"Last read cursor: {}",
state
.last_read_cursor
.as_deref()
.unwrap_or("(none — will read all)")
);
}
}
Ok(())
}