use std::env::VarError;
use std::process::ExitCode;
use std::sync::Arc;
use std::time::Duration;
use cpm_planner::audit::{AuditSink, NullAuditSink};
use cpm_planner::earned_value::BaselineMode;
use cpm_planner::llm::LlmConfig;
use cpm_planner::planner::{DEFAULT_MAX_TTL, MAX_TTL_CEILING};
use cpm_planner::project::{PROJECT_ROOT_ENV, ProjectRoot};
use cpm_planner::{BasicCpmPlanner, PlanServer, SqlitePlanStore};
use tracing_subscriber::EnvFilter;
const USAGE: &str = "\
cpm-planner: Critical Path Method planner as an MCP server.
Usage:
cpm-planner Run the MCP server on stdio (how MCP clients start it)
cpm-planner skills <cmd> Install, uninstall or list the agent skills
cpm-planner --version Print the version
cpm-planner --help Print this help
";
fn main() -> ExitCode {
let args: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
if args.is_empty() {
return run_server();
}
let Some(args) = args
.into_iter()
.map(|a| a.into_string().ok())
.collect::<Option<Vec<String>>>()
else {
eprintln!("cpm-planner: arguments must be valid UTF-8");
return ExitCode::from(cpm_planner::skills::EXIT_USAGE);
};
match args[0].as_str() {
"--version" | "-V" if args.len() == 1 => {
println!("cpm-planner {}", env!("CARGO_PKG_VERSION"));
ExitCode::SUCCESS
}
"--help" | "-h" | "help" if args.len() == 1 => {
print!("{USAGE}\n{}", cpm_planner::skills::USAGE);
ExitCode::SUCCESS
}
"skills" => ExitCode::from(cpm_planner::skills::run(&args[1..])),
_ => {
eprintln!(
"cpm-planner: unexpected arguments `{}`\n\n{USAGE}",
args.join(" ")
);
ExitCode::from(cpm_planner::skills::EXIT_USAGE)
}
}
}
fn run_server() -> ExitCode {
let runtime = match tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(err) => {
eprintln!("Error: cannot start the async runtime: {err}");
return ExitCode::FAILURE;
}
};
match runtime.block_on(serve()) {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("Error: {err:?}");
ExitCode::FAILURE
}
}
}
async fn serve() -> anyhow::Result<()> {
init_tracing();
let audit: Arc<dyn AuditSink> = Arc::new(NullAuditSink);
let db_path = SqlitePlanStore::default_db_path()?;
tracing::info!(db = %db_path.display(), "opening planner database");
let store = SqlitePlanStore::open(&db_path)?;
let max_ttl = resolve_max_ttl()?;
tracing::info!(max_ttl_secs = max_ttl.as_secs(), "lease TTL ceiling");
let baseline_mode = resolve_baseline_mode()?;
tracing::info!(?baseline_mode, "earned-value baseline mode");
let project_root = discover_project_root();
match &project_root {
Some(root) => tracing::info!(root = %root.root().display(), "project root discovered"),
None => tracing::info!("no project root discovered; path-based tools disabled"),
}
let mut planner_builder = BasicCpmPlanner::with_store(store, audit)
.with_max_ttl(max_ttl)
.with_baseline_mode(baseline_mode);
if let Some(root) = project_root {
planner_builder = planner_builder.with_project_root(root);
}
let planner = Arc::new(planner_builder);
let llm_config = LlmConfig::from_env();
tracing::info!("starting cpm-planner stdio server");
PlanServer::new(planner)
.with_llm_config(llm_config)
.serve_stdio()
.await?;
Ok(())
}
fn discover_project_root() -> Option<ProjectRoot> {
if let Some(value) = std::env::var_os(PROJECT_ROOT_ENV).filter(|v| !v.is_empty()) {
return match ProjectRoot::from_path(std::path::Path::new(&value)) {
Ok(root) => Some(root),
Err(err) => {
tracing::warn!(error = %err, "{PROJECT_ROOT_ENV} is set but invalid; no project root");
None
}
};
}
match std::env::current_dir() {
Ok(cwd) => ProjectRoot::discover(&cwd),
Err(err) => {
tracing::warn!(error = %err, "cannot read the current directory; no project root");
None
}
}
}
fn resolve_max_ttl() -> anyhow::Result<Duration> {
parse_max_ttl(std::env::var("CPM_MAX_TTL_SECS"))
}
fn parse_max_ttl(raw: Result<String, VarError>) -> anyhow::Result<Duration> {
match raw {
Err(VarError::NotPresent) => Ok(DEFAULT_MAX_TTL),
Err(VarError::NotUnicode(_)) => {
anyhow::bail!("CPM_MAX_TTL_SECS is not valid unicode")
}
Ok(raw) => {
let secs: u64 = raw.trim().parse().map_err(|_| {
anyhow::anyhow!(
"CPM_MAX_TTL_SECS must be a positive integer number of seconds; got '{raw}'"
)
})?;
if secs == 0 {
anyhow::bail!("CPM_MAX_TTL_SECS must be >= 1; got 0");
}
if secs > MAX_TTL_CEILING.as_secs() {
anyhow::bail!("CPM_MAX_TTL_SECS must be <= 2592000 (30 days)");
}
Ok(Duration::from_secs(secs))
}
}
}
fn resolve_baseline_mode() -> anyhow::Result<BaselineMode> {
parse_baseline_mode(std::env::var("CPM_BASELINE_MODE"))
}
fn parse_baseline_mode(raw: Result<String, VarError>) -> anyhow::Result<BaselineMode> {
match raw {
Err(VarError::NotPresent) => Ok(BaselineMode::Auto),
Err(VarError::NotUnicode(_)) => anyhow::bail!("CPM_BASELINE_MODE is not valid unicode"),
Ok(raw) => match raw.trim() {
"auto" => Ok(BaselineMode::Auto),
"refuse" => Ok(BaselineMode::Refuse),
other => anyhow::bail!("CPM_BASELINE_MODE must be 'auto' or 'refuse'; got '{other}'"),
},
}
}
fn init_tracing() {
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
let _ = tracing_subscriber::fmt()
.with_env_filter(filter)
.with_writer(std::io::stderr)
.with_ansi(false)
.try_init();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unset_max_ttl_is_the_default() {
assert_eq!(
parse_max_ttl(Err(VarError::NotPresent)).unwrap(),
DEFAULT_MAX_TTL
);
}
#[test]
fn zero_max_ttl_is_rejected() {
assert!(parse_max_ttl(Ok("0".into())).is_err());
}
#[test]
fn non_numeric_max_ttl_is_rejected() {
assert!(parse_max_ttl(Ok("abc".into())).is_err());
}
#[test]
fn max_ttl_above_thirty_days_is_rejected() {
assert!(parse_max_ttl(Ok("2592001".into())).is_err());
}
#[test]
fn max_ttl_of_one_hour_parses() {
assert_eq!(
parse_max_ttl(Ok("3600".into())).unwrap(),
Duration::from_secs(3600)
);
}
#[test]
fn non_unicode_max_ttl_is_rejected() {
let bad = VarError::NotUnicode(std::ffi::OsString::from("x"));
assert!(parse_max_ttl(Err(bad)).is_err());
}
#[test]
fn unset_baseline_mode_is_auto() {
assert_eq!(
parse_baseline_mode(Err(VarError::NotPresent)).unwrap(),
BaselineMode::Auto
);
}
#[test]
fn explicit_auto_baseline_mode_parses() {
assert_eq!(
parse_baseline_mode(Ok("auto".into())).unwrap(),
BaselineMode::Auto
);
}
#[test]
fn refuse_baseline_mode_parses() {
assert_eq!(
parse_baseline_mode(Ok("refuse".into())).unwrap(),
BaselineMode::Refuse
);
}
#[test]
fn unknown_baseline_mode_is_rejected() {
assert!(parse_baseline_mode(Ok("sometimes".into())).is_err());
}
}