use std::path::Path;
use std::sync::Arc;
use clap::Parser;
use carryctx::adapter::config::ConfigLoader;
use carryctx::adapter::filesystem::AdmissionLock;
use carryctx::adapter::git::GitCli;
use carryctx::adapter::sqlite::ProjectDatabase;
use carryctx::adapter::sqlite_repos::*;
use carryctx::adapter::xdg::XdgPaths;
use carryctx::application::runtime::{InvocationContext, OutputFormat, ProjectRuntime};
use carryctx::domain::dependency::DependencyKind;
use carryctx::domain::task::{TaskPriority, TaskStatus};
use carryctx::error::{CarryCtxError, ExitCode};
use carryctx::output;
use carryctx::repository::*;
#[derive(Parser, Debug)]
#[command(name = "carryctx", version = env!("CARGO_PKG_VERSION"), about, long_about = None)]
pub struct Cli {
#[arg(long, global = true)]
pub project: Option<String>,
#[arg(long, global = true)]
pub config: Option<String>,
#[arg(long, global = true)]
pub profile: Option<String>,
#[arg(long, global = true, env = "CARRYCTX_AGENT", alias = "owner")]
pub agent: Option<String>,
#[arg(long, global = true, env = "CARRYCTX_SESSION")]
pub session: Option<String>,
#[arg(long, global = true, env = "CARRYCTX_TASK")]
pub task: Option<String>,
#[arg(long, global = true, value_parser = ["text", "json", "markdown"])]
pub format: Option<String>,
#[arg(long, global = true)]
pub json: bool,
#[arg(long, global = true)]
pub no_color: bool,
#[arg(long, global = true)]
pub quiet: bool,
#[arg(long, global = true, conflicts_with = "quiet")]
pub verbose: bool,
#[arg(long, global = true)]
pub yes: bool,
#[arg(long, global = true)]
pub dry_run: bool,
#[arg(long, global = true)]
pub non_interactive: bool,
#[arg(long, global = true, value_parser = ["error", "warn"])]
pub config_compat: Option<String>,
#[arg(long, global = true, value_delimiter = ',')]
pub fields: Option<Vec<String>>,
#[command(subcommand)]
pub command: Option<Commands>,
}
pub mod commands;
pub use commands::*;
#[derive(Parser, Debug)]
pub enum Commands {
Init(InitArgs),
Status(StatusArgs),
Resume(ResumeArgs),
Context(ContextArgs),
Checkpoint(CheckpointArgs),
Doctor(DoctorArgs),
Agent(AgentArgs),
Task(TaskArgs),
Team(TeamArgs),
Session(SessionArgs),
Progress(ProgressArgs),
Mcp(McpArgs),
Preset(PresetArgs),
Worktree(WorktreeArgs),
Event(EventArgs),
Config(ConfigArgs),
Project(ProjectArgs),
Decision(DecisionArgs),
Handoff(HandoffArgs),
Skill(SkillArgs),
Completions(CompletionsArgs),
Hooks(HooksArgs),
Sync(SyncArgs),
Stats(StatsArgs),
Graph(GraphArgs),
Search(SearchArgs),
}
fn main() {
install_broken_pipe_hook();
let _ = tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.with_target(true)
.try_init();
let cli = Cli::parse();
let result = run(cli);
match result {
Ok(exit_code) => std::process::exit(exit_code as i32),
Err(code) => std::process::exit(code as i32),
}
}
fn install_broken_pipe_hook() {
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let message = info
.payload()
.downcast_ref::<&str>()
.copied()
.or_else(|| info.payload().downcast_ref::<String>().map(|s| s.as_str()))
.unwrap_or("");
if message.contains("Broken pipe") {
std::process::exit(128 + 13);
}
default_hook(info);
}));
}
fn run(cli: Cli) -> Result<ExitCode, ExitCode> {
tracing::debug!(command = ?cli.command, "dispatching command");
let mut ctx = build_invocation_context(&cli)?;
ctx.read_only = matches!(&cli.command, Some(Commands::Team(args))
if matches!(&args.command, TeamCommand::Status { .. } | TeamCommand::Context { .. }));
let is_json = matches!(ctx.format, OutputFormat::Json);
let direct_lock = matches!(
&cli.command,
Some(Commands::Init(_))
| Some(Commands::Sync(_))
| Some(Commands::Project(ProjectArgs {
command: ProjectCommand::Restore { .. }
}))
);
if !ctx.read_only && !direct_lock {
let git = GitCli::new();
let project = git.discover(resolve_work_dir(&ctx)).map_err(|error| {
report_runtime_open_error("runtime.open", &error, is_json);
error.exit_code
})?;
let xdg = XdgPaths::new();
let lock = acquire_runtime_lock(&xdg.admission_lock_dir(&project.git_common_dir)).map_err(
|error| {
report_runtime_open_error("runtime.open", &error, is_json);
error.exit_code
},
)?;
ctx.admission_lock = Some(Arc::new(lock));
}
let mut pre_opened: Option<ProjectRuntime> = None;
if !direct_lock {
if let Ok(runtime) = try_open_runtime(&ctx) {
if let Some(agent_ref) = &ctx.agent {
if !agent_ref.trim().is_empty() {
if let Ok(agent_id) = resolve_agent_id(
&runtime.config.project.id,
agent_ref,
runtime.database.connection(),
) {
ctx.agent = Some(agent_id);
}
}
}
pre_opened = Some(runtime);
}
}
if let Some(Commands::Team(args)) = &cli.command {
if matches!(
&args.command,
TeamCommand::Status { .. } | TeamCommand::Context { .. }
) {
return handle_team(args, pre_opened.take(), &ctx, is_json);
}
}
match &cli.command {
Some(Commands::Init(args)) => handle_init(args, &ctx),
Some(Commands::Status(args)) => handle_status(args, pre_opened.take(), &ctx, is_json),
Some(Commands::Resume(args)) => handle_resume(args, pre_opened.take(), &ctx, is_json),
Some(Commands::Context(args)) => handle_context(args, pre_opened.take(), &ctx, is_json),
Some(Commands::Checkpoint(args)) => {
handle_checkpoint(args, pre_opened.take(), &ctx, is_json)
}
Some(Commands::Doctor(args)) => handle_doctor(args, pre_opened.take(), &ctx, is_json),
Some(Commands::Agent(args)) => handle_agent(args, pre_opened.take(), &ctx, is_json),
Some(Commands::Task(args)) => handle_task(args, pre_opened.take(), &ctx, is_json),
Some(Commands::Team(args)) => handle_team(args, pre_opened.take(), &ctx, is_json),
Some(Commands::Session(args)) => handle_session(args, pre_opened.take(), &ctx, is_json),
Some(Commands::Progress(args)) => handle_progress(args, pre_opened.take(), &ctx, is_json),
Some(Commands::Mcp(args)) => handle_mcp(args, &ctx),
Some(Commands::Preset(args)) => handle_preset(args, pre_opened.take(), &ctx, is_json),
Some(Commands::Worktree(args)) => handle_worktree(args, pre_opened.take(), &ctx, is_json),
Some(Commands::Event(args)) => handle_event(args, pre_opened.take(), &ctx, is_json),
Some(Commands::Config(args)) => handle_config(args, &ctx, is_json),
Some(Commands::Project(args)) => handle_project(args, pre_opened.take(), &ctx, is_json),
Some(Commands::Decision(args)) => handle_decision(args, pre_opened.take(), &ctx, is_json),
Some(Commands::Handoff(args)) => handle_handoff(args, pre_opened.take(), &ctx, is_json),
Some(Commands::Skill(args)) => handle_skill(args, &ctx, is_json),
Some(Commands::Completions(args)) => handle_completions(args),
Some(Commands::Hooks(args)) => handle_hooks(args, &ctx, is_json),
Some(Commands::Sync(args)) => handle_sync(args, &ctx, is_json),
Some(Commands::Stats(args)) => handle_stats(args, &ctx, is_json),
Some(Commands::Graph(args)) => handle_graph(args, pre_opened.take(), &ctx, is_json),
Some(Commands::Search(args)) => handle_search(args, pre_opened.take(), &ctx, is_json),
None => {
if !ctx.quiet {
println!(
"CarryCtx v{} — Local-first memory for coding agents",
env!("CARGO_PKG_VERSION")
);
println!("Use --help for usage information.");
}
Ok(ExitCode::Success)
}
}
}
pub fn build_invocation_context(cli: &Cli) -> Result<InvocationContext, ExitCode> {
let cwd = std::env::current_dir().map_err(|e| {
eprintln!("Failed to get current directory: {e}");
ExitCode::General
})?;
let is_json = cli.json || cli.format.as_deref() == Some("json");
let config_compat = match cli.config_compat.as_deref() {
Some("error") => carryctx::application::runtime::ConfigCompatMode::Error,
_ => carryctx::application::runtime::ConfigCompatMode::Warn,
};
InvocationContext::new(
cwd,
cli.project.clone(),
cli.config.clone(),
cli.profile.clone(),
cli.agent.clone(),
cli.session.clone(),
cli.task.clone(),
cli.format.clone(),
cli.json,
cli.no_color,
cli.quiet,
cli.verbose,
cli.dry_run,
cli.yes,
!cli.non_interactive,
cli.fields.clone(),
config_compat,
)
.map_err(|e| {
report_runtime_open_error("carryctx", &e, is_json);
e.exit_code
})
}
pub fn resolve_work_dir(ctx: &InvocationContext) -> &Path {
ctx.project.as_deref().map(Path::new).unwrap_or(&ctx.cwd)
}
fn report_runtime_open_error(command: &str, error: &CarryCtxError, is_json: bool) {
if is_json {
let (text, _, _) = output::render_json::<serde_json::Value>(command, Err(error), true);
eprintln!("{text}");
} else {
eprintln!("Error [{}]: {}", error.code, error.message);
}
}
pub fn open_runtime(ctx: &InvocationContext) -> Result<ProjectRuntime, CarryCtxError> {
let xdg = XdgPaths::new();
let cfg_loader = ConfigLoader::new(xdg.clone());
let work_dir = resolve_work_dir(ctx);
let mut config = cfg_loader.load(Some(work_dir))?;
{
let mut config_files: Vec<(&Path, &str)> = Vec::new();
let global_path = xdg.global_config();
if global_path.exists() {
config_files.push((global_path.as_path(), "global"));
}
let project_path = work_dir.join(".carryctx").join("config.toml");
if project_path.exists() {
config_files.push((project_path.as_path(), "project"));
}
carryctx::application::runtime::validate_config_compat(ctx.config_compat, &config_files)?;
}
let git = GitCli::new();
let git_project = git.discover(work_dir)?;
let db_path = xdg.project_db(&git_project.git_common_dir);
let admission_lock = if ctx.read_only {
None
} else if let Some(lock) = &ctx.admission_lock {
Some(lock.clone())
} else {
Some(Arc::new(acquire_runtime_lock(
&xdg.admission_lock_dir(&git_project.git_common_dir),
)?))
};
if !ctx.read_only {
carryctx::application::project_mgmt::recover_restore_journals(
&xdg,
&git_project.git_common_dir,
)?;
carryctx::application::project_mgmt::recover_sync_journals(
&xdg,
&git_project.git_common_dir,
)?;
carryctx::application::worktree::recover_worktree_create_journals(
&xdg,
&git_project.git_common_dir,
)?;
}
let database = if ctx.read_only {
let database = ProjectDatabase::open_readonly(&db_path)?;
database.is_up_to_date()?;
database
} else {
let mut database = ProjectDatabase::open(&db_path)?;
database.migrate()?;
database
};
if let Ok(mut stmt) = database
.connection()
.prepare("SELECT id, name, task_prefix FROM projects LIMIT 1")
{
if let Ok(row) = stmt.query_row([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
))
}) {
config.project.id = row.0;
if !row.1.is_empty() {
config.project.name = row.1;
}
if !row.2.is_empty() {
config.project.task_prefix = row.2;
}
}
}
Ok(ProjectRuntime {
git_project,
database,
config,
xdg,
db_path,
admission_lock,
})
}
pub fn try_open_runtime(ctx: &InvocationContext) -> Result<ProjectRuntime, ExitCode> {
open_runtime(ctx).map_err(|e| e.exit_code)
}
pub fn open_runtime_or_report(
ctx: &InvocationContext,
command: &str,
) -> Result<ProjectRuntime, ExitCode> {
match open_runtime(ctx) {
Ok(runtime) => Ok(runtime),
Err(error) => {
let is_json = matches!(ctx.format, OutputFormat::Json);
report_runtime_open_error(command, &error, is_json);
Err(error.exit_code)
}
}
}
const LOCK_RETRY_INITIAL_DELAY_MS: u64 = 25;
const LOCK_RETRY_MAX_DELAY_MS: u64 = 400;
const LOCK_RETRY_BUDGET_MS: u64 = 20_000;
fn next_backoff_delay_ms(attempt: u32, waited_ms: u64) -> u64 {
if waited_ms >= LOCK_RETRY_BUDGET_MS {
return 0;
}
let doubling = LOCK_RETRY_INITIAL_DELAY_MS
.checked_shl(attempt)
.unwrap_or(LOCK_RETRY_MAX_DELAY_MS);
doubling
.min(LOCK_RETRY_MAX_DELAY_MS)
.min(LOCK_RETRY_BUDGET_MS - waited_ms)
}
fn acquire_runtime_lock(path: &Path) -> Result<AdmissionLock, CarryCtxError> {
let operation_id = ulid::Ulid::generate().to_string();
let hostname = resolve_hostname();
let now = chrono::Utc::now().to_rfc3339();
let mut waited_ms: u64 = 0;
let mut attempt: u32 = 0;
loop {
match AdmissionLock::acquire(path, &operation_id, std::process::id(), &hostname, &now) {
Ok(lock) => return Ok(lock),
Err(error) if error.code == "STATE_CONFLICT" => {
let delay_ms = next_backoff_delay_ms(attempt, waited_ms);
if delay_ms == 0 {
return Err(error);
}
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
waited_ms += delay_ms;
attempt += 1;
}
Err(error) => return Err(error),
}
}
}
fn resolve_hostname() -> String {
#[cfg(unix)]
if let Some(host) = hostname_from_gethostname() {
return host;
}
if let Ok(content) = std::fs::read_to_string("/etc/hostname") {
if let Some(host) = first_hostname_line(&content) {
return host;
}
}
if let Ok(host) = std::env::var("HOSTNAME") {
let trimmed = host.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
}
"unknown".to_string()
}
#[cfg(unix)]
#[allow(unsafe_code)]
fn hostname_from_gethostname() -> Option<String> {
let mut buf = [0u8; 256];
let rc = unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) };
if rc != 0 {
return None;
}
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
let host = std::str::from_utf8(&buf[..end]).ok()?.trim();
(!host.is_empty()).then(|| host.to_string())
}
fn first_hostname_line(content: &str) -> Option<String> {
content
.lines()
.map(str::trim)
.find(|line| !line.is_empty() && !line.starts_with('#'))
.map(str::to_string)
}
pub fn render_and_print<T: serde::Serialize>(
command: &str,
result: Result<T, CarryCtxError>,
is_json: bool,
quiet: bool,
) -> Result<ExitCode, ExitCode> {
render_and_print_with_warnings(command, result, is_json, quiet, vec![])
}
pub fn render_and_print_with_warnings<T: serde::Serialize>(
command: &str,
result: Result<T, CarryCtxError>,
is_json: bool,
quiet: bool,
warnings: Vec<String>,
) -> Result<ExitCode, ExitCode> {
let (output, sink, exit_code) = match &result {
Ok(data) => output::render_json_with_warnings(command, Ok(data), is_json, warnings),
Err(err) => output::render_json_with_warnings::<serde_json::Value>(
command,
Err(err),
is_json,
vec![],
),
};
if !quiet || matches!(sink, output::OutputSink::Stderr) {
match sink {
output::OutputSink::Stdout => println!("{output}"),
output::OutputSink::Stderr => eprintln!("{output}"),
}
}
match exit_code {
ExitCode::Success => Ok(ExitCode::Success),
other => Err(other),
}
}
pub fn render_and_print_entity<T: serde::Serialize>(
command: &str,
result: Result<T, CarryCtxError>,
is_json: bool,
quiet: bool,
verbose: bool,
cli_fields: Option<&[String]>,
config_fields: Option<&std::collections::HashMap<String, Vec<String>>>,
) -> Result<ExitCode, ExitCode> {
render_and_print_entity_with_warnings(
command,
result,
is_json,
quiet,
verbose,
vec![],
cli_fields,
config_fields,
)
}
#[allow(clippy::too_many_arguments)]
pub fn render_and_print_entity_with_warnings<T: serde::Serialize>(
command: &str,
result: Result<T, CarryCtxError>,
is_json: bool,
quiet: bool,
verbose: bool,
warnings: Vec<String>,
cli_fields: Option<&[String]>,
config_fields: Option<&std::collections::HashMap<String, Vec<String>>>,
) -> Result<ExitCode, ExitCode> {
let (output, sink, exit_code) = match &result {
Ok(data) => output::render_entity(
command,
Ok(data),
is_json,
verbose,
cli_fields,
config_fields,
warnings,
),
Err(err) => output::render_entity::<serde_json::Value>(
command,
Err(err),
is_json,
verbose,
cli_fields,
config_fields,
vec![],
),
};
if !quiet || matches!(sink, output::OutputSink::Stderr) {
match sink {
output::OutputSink::Stdout => println!("{output}"),
output::OutputSink::Stderr => eprintln!("{output}"),
}
}
match exit_code {
ExitCode::Success => Ok(ExitCode::Success),
other => Err(other),
}
}
pub fn not_implemented(command: &str) -> ExitCode {
eprintln!("{command}: not yet implemented");
ExitCode::Unsupported
}
pub fn check_dry_run(
ctx: &InvocationContext,
description: &str,
) -> Option<Result<ExitCode, ExitCode>> {
if ctx.dry_run {
eprintln!("[dry-run] Would {description}");
Some(Ok(ExitCode::Success))
} else {
None
}
}
pub fn resolve_agent_id(
project_id: &str,
agent_ref: &str,
conn: &rusqlite::Connection,
) -> Result<String, CarryCtxError> {
let repo = SqliteAgentRepository::new(conn);
if let Some(agent) = repo.find_by_name(project_id, agent_ref)? {
return require_active_agent(agent).map(|agent| agent.id);
}
if let Some(agent) = repo.find_by_id(project_id, agent_ref)? {
return require_active_agent(agent).map(|agent| agent.id);
}
Err(CarryCtxError::resource_not_found(format!(
"Agent '{agent_ref}' not found."
)))
}
fn require_active_agent(
agent: carryctx::domain::agent::Agent,
) -> Result<carryctx::domain::agent::Agent, CarryCtxError> {
if agent.status == carryctx::domain::agent::AgentStatus::Active {
Ok(agent)
} else {
Err(CarryCtxError::permission_scope(format!(
"Agent '{}' is deactivated and cannot act.",
agent.name
)))
}
}
pub fn resolve_task_id(
project_id: &str,
task_ref: &str,
conn: &rusqlite::Connection,
) -> Result<String, CarryCtxError> {
let repo = SqliteTaskRepository::new(conn);
if let Some(task) = repo.find_by_display_id(project_id, task_ref)? {
return Ok(task.id);
}
if let Some(task) = repo.find_by_id(project_id, task_ref)? {
return Ok(task.id);
}
Err(CarryCtxError::resource_not_found(format!(
"Task '{task_ref}' not found."
)))
}
pub fn parse_task_status(s: &str) -> Result<TaskStatus, CarryCtxError> {
match s.to_ascii_lowercase().as_str() {
"planned" => Ok(TaskStatus::Planned),
"ready" => Ok(TaskStatus::Ready),
"in_progress" => Ok(TaskStatus::InProgress),
"blocked" => Ok(TaskStatus::Blocked),
"review" => Ok(TaskStatus::Review),
"completed" => Ok(TaskStatus::Completed),
"cancelled" => Ok(TaskStatus::Cancelled),
other => Err(CarryCtxError::invalid_arguments(format!(
"Unknown status: {other}"
))),
}
}
pub fn parse_task_priority(s: &str) -> Result<TaskPriority, CarryCtxError> {
match s.to_ascii_lowercase().as_str() {
"low" | "backlog" => Ok(TaskPriority::Low),
"normal" | "medium" => Ok(TaskPriority::Normal),
"high" => Ok(TaskPriority::High),
"urgent" | "critical" => Ok(TaskPriority::Urgent),
other => Err(CarryCtxError::invalid_arguments(format!(
"Unknown priority: {other}"
))),
}
}
pub fn parse_dependency_kind(s: &str) -> Result<DependencyKind, CarryCtxError> {
match s {
"strong" => Ok(DependencyKind::Strong),
"informational" | "info" => Ok(DependencyKind::Informational),
other => Err(CarryCtxError::invalid_arguments(format!(
"Unknown dependency kind: {other}"
))),
}
}
#[cfg(test)]
mod hostname_backoff_tests {
use super::*;
#[test]
fn backoff_doubles_then_caps_at_max_delay() {
assert_eq!(next_backoff_delay_ms(0, 0), 25);
assert_eq!(next_backoff_delay_ms(1, 25), 50);
assert_eq!(next_backoff_delay_ms(2, 75), 100);
assert_eq!(next_backoff_delay_ms(3, 175), 200);
assert_eq!(next_backoff_delay_ms(4, 375), 400);
assert_eq!(next_backoff_delay_ms(5, 775), 400);
assert_eq!(next_backoff_delay_ms(40, 15_000), 400);
}
#[test]
fn backoff_respects_total_budget_and_terminates() {
assert_eq!(next_backoff_delay_ms(5, LOCK_RETRY_BUDGET_MS - 10), 10);
assert_eq!(next_backoff_delay_ms(5, LOCK_RETRY_BUDGET_MS - 300), 300);
assert_eq!(
next_backoff_delay_ms(5, LOCK_RETRY_BUDGET_MS),
0,
"exhausted budget must stop the retry loop"
);
assert_eq!(
next_backoff_delay_ms(5, LOCK_RETRY_BUDGET_MS + 1),
0,
"overshoot must stop the retry loop"
);
}
#[test]
fn first_hostname_line_skips_blanks_and_comments() {
assert_eq!(first_hostname_line("myhost\n"), Some("myhost".into()));
assert_eq!(
first_hostname_line("\n \n# comment\nreal\n"),
Some("real".into())
);
assert_eq!(first_hostname_line(""), None);
assert_eq!(first_hostname_line(" \n#\n"), None);
}
#[test]
fn resolve_hostname_returns_a_usable_token() {
let host = resolve_hostname();
assert!(!host.is_empty(), "hostname resolution must never be empty");
assert!(!host.contains('\n'), "hostname must be a single token");
}
#[test]
fn parse_task_status_is_case_insensitive_like_priority() {
for (raw, expected) in [
("planned", "planned"),
("READY", "ready"),
("In_Progress", "inprogress"),
("BLOCKED", "blocked"),
("Review", "review"),
("COMPLETED", "completed"),
("Cancelled", "cancelled"),
] {
let parsed = parse_task_status(raw)
.unwrap_or_else(|e| panic!("status '{raw}' must parse case-insensitively: {e}"));
assert_eq!(format!("{parsed:?}").to_ascii_lowercase(), expected);
}
let err = parse_task_status("NOT_A_STATUS").unwrap_err();
assert_eq!(err.code, "INVALID_ARGUMENTS", "{err:?}");
}
}