use std::path::PathBuf;
use clap::{Parser, Subcommand};
#[derive(Debug, Parser)]
#[command(
name = "render-session",
about = "render-session: MCP server + HTTP viewer for AI session output",
version
)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Debug, Subcommand)]
enum Commands {
Mcp,
Serve {
#[arg(long, default_value = "8000")]
port: u16,
#[arg(long)]
dir: String,
#[arg(long)]
watch_tick: Option<u64>,
},
Gen {
#[arg(long)]
project: String,
},
Report {
#[arg(long)]
dir: String,
#[arg(long)]
title: String,
},
Capture {
#[arg(long)]
project: std::path::PathBuf,
#[arg(long, default_value = "both")]
mode: String,
#[arg(long)]
slug: Option<String>,
#[arg(long, default_value_t = 5)]
n: usize,
#[arg(long, default_value_t = false)]
all: bool,
},
Config {
#[command(subcommand)]
subcommand: ConfigSubcommand,
},
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt::Subscriber::builder()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
let cli = Cli::parse();
match cli.command {
Commands::Mcp => run_mcp().await,
Commands::Serve {
port,
dir,
watch_tick,
} => run_serve(port, dir, watch_tick).await,
Commands::Gen { project } => run_gen(project).await,
Commands::Report { dir, title } => run_report(dir, title).await,
Commands::Capture {
project,
mode,
slug,
n,
all,
} => run_capture(project, mode, slug, n, all).await,
Commands::Config { subcommand } => run_config(subcommand).await,
}
}
async fn run_mcp() -> anyhow::Result<()> {
let exe_dir = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.to_path_buf()));
let mcp_path = exe_dir
.map(|d| d.join("render-session-mcp"))
.filter(|p| p.exists())
.unwrap_or_else(|| std::path::PathBuf::from("render-session-mcp"));
let status = tokio::process::Command::new(&mcp_path)
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.status()
.await
.map_err(|e| {
tracing::error!(
error = %e,
path = %mcp_path.display(),
"failed to spawn render-session-mcp"
);
e
})?;
if !status.success() {
let code = status.code().unwrap_or(-1);
tracing::error!(exit_code = code, "render-session-mcp exited non-zero");
anyhow::bail!("render-session-mcp exited with {}", status);
}
Ok(())
}
async fn run_serve(port: u16, dir: String, watch_tick: Option<u64>) -> anyhow::Result<()> {
tokio::spawn(render_session_core::child::watch_parent_death());
let watch_tick_dur = watch_tick.map(|t| {
let clamped = if t == 0 {
tracing::warn!("watch_tick=0 not allowed, clamping to 1");
1
} else {
t
};
std::time::Duration::from_secs(clamped)
});
render_session_core::serve::run(port, std::path::PathBuf::from(dir), watch_tick_dur).await?;
Ok(())
}
async fn run_gen(project: String) -> anyhow::Result<()> {
use anyhow::Context as _;
let project_path = std::path::PathBuf::from(&project);
let loaded =
render_session_core::config::load(&project_path).context("gen: failed to load config")?;
let list_path = render_session_core::gen::emit_list_json(&loaded.config, &project_path)
.map_err(|e| {
tracing::error!(error = %e, project = %project, "emit_list_json failed");
e
})?;
tracing::info!(file = %list_path.display(), "list.json emitted");
println!("{}", list_path.display());
let rules_path = render_session_core::gen::emit_rules_md(&loaded.config, &project_path)
.map_err(|e| {
tracing::error!(error = %e, project = %project, "emit_rules_md failed");
e
})?;
tracing::info!(file = %rules_path.display(), "render-lanes.md emitted");
println!("{}", rules_path.display());
Ok(())
}
async fn run_report(dir: String, title: String) -> anyhow::Result<()> {
use tokio::io::AsyncReadExt as _;
let mut body = String::new();
tokio::io::stdin()
.read_to_string(&mut body)
.await
.map_err(|e| {
tracing::error!(error = %e, "failed to read stdin for report");
e
})?;
let path = render_session_core::writer::write_report(std::path::Path::new(&dir), &title, &body)
.await
.map_err(|e| {
tracing::error!(error = %e, dir = %dir, title = %title, "write_report failed");
e
})?;
println!("{}", path.display());
Ok(())
}
async fn run_capture(
project: std::path::PathBuf,
mode: String,
slug: Option<String>,
n: usize,
all: bool,
) -> anyhow::Result<()> {
let claude_slug = match slug {
Some(s) => s,
None => derive_slug(&project).map_err(|e| {
tracing::error!(project = %project.display(), error = %e, "derive_slug failed");
e
})?,
};
let only_with_visual = !all;
let config = match render_session_core::config::load(&project) {
Ok(loaded) => loaded.config,
Err(e) => {
tracing::warn!(
project = %project.display(),
error = %e,
"capture: config load failed, using default"
);
render_session_core::config::Config::default()
}
};
let filter_chain_cfg = config
.categories
.get("recent")
.and_then(|c| c.filter.as_ref());
let filter_registry: Option<render_session_core::filters::FilterRegistry> =
filter_chain_cfg.map(|_| render_session_core::filters::FilterRegistry::with_builtins());
let filter_arg = match (filter_registry.as_ref(), filter_chain_cfg) {
(Some(reg), Some(cfg)) => Some((reg, cfg)),
_ => None,
};
match mode.as_str() {
"session" => {
if let Some(p) =
render_session_core::sources::session::capture_session(&project, &claude_slug)
.map_err(|e| {
tracing::error!(error = %e, "capture_session failed");
e
})?
{
println!("{}", p.display());
}
}
"recent" => {
for p in render_session_core::sources::session::capture_recent(
&project,
&claude_slug,
n,
only_with_visual,
filter_arg,
)
.map_err(|e| {
tracing::error!(error = %e, "capture_recent failed");
e
})? {
println!("{}", p.display());
}
}
"both" => {
if let Some(p) =
render_session_core::sources::session::capture_session(&project, &claude_slug)
.map_err(|e| {
tracing::error!(error = %e, "capture_session failed");
e
})?
{
println!("{}", p.display());
}
for p in render_session_core::sources::session::capture_recent(
&project,
&claude_slug,
n,
only_with_visual,
filter_arg,
)
.map_err(|e| {
tracing::error!(error = %e, "capture_recent failed");
e
})? {
println!("{}", p.display());
}
}
other => {
anyhow::bail!("invalid mode {:?}, expected session|recent|both", other);
}
}
Ok(())
}
fn derive_slug(project: &std::path::Path) -> anyhow::Result<String> {
let canonical = project.canonicalize().map_err(|e| {
tracing::error!(project = %project.display(), error = %e, "canonicalize failed");
e
})?;
Ok(canonical.to_string_lossy().replace('/', "-"))
}
#[derive(Debug, Subcommand)]
enum ConfigSubcommand {
Show {
#[arg(long)]
project: PathBuf,
#[arg(long, default_value_t = false)]
origin: bool,
},
Info {
#[arg(long)]
project: Option<PathBuf>,
},
Doctor {
#[arg(long)]
project: Option<PathBuf>,
},
}
struct DoctorCheck {
name: String,
ok: bool,
message: Option<String>,
}
async fn run_config(sub: ConfigSubcommand) -> anyhow::Result<()> {
match sub {
ConfigSubcommand::Show { project, origin } => handle_config_show(project, origin).await,
ConfigSubcommand::Info { project } => handle_config_info(project).await,
ConfigSubcommand::Doctor { project } => handle_config_doctor(project).await,
}
}
async fn handle_config_show(project: PathBuf, origin: bool) -> anyhow::Result<()> {
use anyhow::Context as _;
let loaded = render_session_core::config::load(&project)
.context("config show: failed to load config")?;
let yaml = serde_yaml::to_string(&loaded.config)
.context("config show: failed to serialize config as YAML")?;
if !origin {
print!("{yaml}");
return Ok(());
}
let value: serde_yaml::Value =
serde_yaml::from_str(&yaml).context("config show: failed to re-parse YAML for origin")?;
let keys = flatten_keys(&value, "");
println!("# Effective configuration (with provenance)");
print!("{yaml}");
if !keys.is_empty() {
println!("# --- origin annotations ---");
for key in &keys {
let origin_label = match loaded.origin_of(key) {
Some(src) => format!("{src}"),
None => "unknown".to_string(),
};
println!("# {key}: {origin_label}");
}
}
Ok(())
}
async fn handle_config_info(project: Option<PathBuf>) -> anyhow::Result<()> {
use anyhow::Context as _;
let project = resolve_project_dir(project);
let loaded = render_session_core::config::load(&project)
.context("config info: failed to load config")?;
println!("Layer paths:");
for (kind, path, status) in loaded.layer_paths() {
println!(" {kind:?}: {} [{status:?}]", path.display());
}
println!("\nEnv overrides:");
let overrides = loaded.env_overrides();
if overrides.is_empty() {
println!(" (none)");
} else {
for (name, value) in overrides {
println!(" {name} = ***** ({} chars)", value.len());
}
}
Ok(())
}
async fn handle_config_doctor(project: Option<PathBuf>) -> anyhow::Result<()> {
use anyhow::Context as _;
use render_session_core::config::LayerStatus;
let project = resolve_project_dir(project);
let loaded = render_session_core::config::load(&project)
.context("config doctor: failed to load config")?;
let mut checks: Vec<DoctorCheck> = Vec::new();
for (kind, path, status) in loaded.layer_paths() {
let name = format!("{kind:?} layer");
let (ok, message) = match status {
LayerStatus::Loaded => (true, format!("found: {}", path.display())),
LayerStatus::Absent => {
(true, format!("absent (optional): {}", path.display()))
}
LayerStatus::Failed(err) => (false, format!("parse error: {err}")),
};
checks.push(DoctorCheck {
name,
ok,
message: Some(message),
});
}
if let Ok(cfg_path) = std::env::var("RENDER_SESSION_CONFIG") {
if !cfg_path.is_empty() {
let p = std::path::Path::new(&cfg_path);
let (ok, msg) = if p.exists() {
(
true,
format!("RENDER_SESSION_CONFIG path exists: {cfg_path}"),
)
} else {
(
false,
format!("RENDER_SESSION_CONFIG path not found: {cfg_path}"),
)
};
checks.push(DoctorCheck {
name: "RENDER_SESSION_CONFIG path".to_string(),
ok,
message: Some(msg),
});
}
}
let override_count = loaded.env_overrides().len();
if override_count > 0 {
checks.push(DoctorCheck {
name: "env overrides parse".to_string(),
ok: true,
message: Some(format!(
"{override_count} active override(s) parsed successfully"
)),
});
}
let mut fail_count = 0usize;
for check in &checks {
let symbol = if check.ok { "OK" } else { "FAIL" };
let detail = check.message.as_deref().unwrap_or("no detail");
println!("[{symbol}] {}: {detail}", check.name);
if !check.ok {
fail_count += 1;
}
}
if fail_count > 0 {
tracing::warn!(fail_count, "config doctor found issue(s)");
println!("\ndoctor: {fail_count} issue(s) found");
std::process::exit(1);
} else {
println!("\ndoctor: all checks passed");
}
Ok(())
}
fn resolve_project_dir(project: Option<PathBuf>) -> PathBuf {
project.unwrap_or_else(|| {
std::env::current_dir().unwrap_or_else(|e| {
tracing::warn!(error = %e, "current_dir() failed, falling back to '.'");
PathBuf::from(".")
})
})
}
fn flatten_keys(v: &serde_yaml::Value, prefix: &str) -> Vec<String> {
let mut out = Vec::new();
if let serde_yaml::Value::Mapping(map) = v {
for (k, val) in map {
if let Some(key) = k.as_str() {
let full = if prefix.is_empty() {
key.to_string()
} else {
format!("{prefix}.{key}")
};
match val {
serde_yaml::Value::Mapping(_) => out.extend(flatten_keys(val, &full)),
_ => out.push(full),
}
}
}
}
out
}
#[cfg(test)]
mod tests {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_mcp_commands_status_await() {
let true_bin = if cfg!(target_os = "macos") || cfg!(target_os = "linux") {
"true"
} else {
"cmd"
};
let status = tokio::process::Command::new(true_bin)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.await
.expect("spawn `true`");
assert!(status.success(), "`true` must exit with success");
let child = tokio::process::Command::new("sleep")
.arg("30")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.kill_on_drop(false)
.spawn()
.expect("spawn `sleep 30`");
let pid = child.id().expect("child has pid") as libc::pid_t;
drop(child);
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
#[allow(unsafe_code)]
let probe = unsafe { libc::kill(pid, 0) };
assert_eq!(
probe, 0,
"child should still be alive after handle drop (kill_on_drop=false)"
);
#[allow(unsafe_code)]
unsafe {
libc::kill(pid, libc::SIGTERM);
}
}
}