use clap::{Parser, Subcommand};
const fn version_string() -> &'static str {
const HASH: &str = env!("TKT_GIT_HASH");
if HASH.is_empty() {
env!("CARGO_PKG_VERSION")
} else {
concat!(env!("CARGO_PKG_VERSION"), " (", env!("TKT_GIT_HASH"), ")")
}
}
#[derive(Parser)]
#[command(
name = "tkt",
about = "Git-native ticket CLI (.tickets/ contract)",
version = version_string(),
)]
struct Cli {
#[arg(short, long, global = true)]
quiet: bool,
#[arg(long, global = true)]
color: Option<String>,
#[arg(long, global = true)]
dry_run: bool,
#[arg(short = 'o', long = "output", global = true)]
output_format: Option<String>,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Ready {
#[arg(long)]
json: bool,
},
Init {
#[arg(long)]
write: Option<Option<String>>,
#[arg(long, value_parser = ["agents", "claude", "cursor", "kiro", "copilot", "windsurf"])]
target: Option<String>,
#[arg(long)]
all: bool,
#[arg(long)]
agent_only: bool,
},
Doctor {
path: Option<String>,
#[arg(long)]
fix: bool,
#[arg(long)]
strict: bool,
},
Lint {
#[arg(long)]
check: bool,
ids: Vec<String>,
},
New {
slug: String,
#[arg(long)]
title: Option<String>,
#[arg(long)]
spec: Option<String>,
#[arg(long)]
env: Option<String>,
#[arg(long)]
priority: Option<String>,
#[arg(long)]
status: Option<String>,
#[arg(long, value_delimiter = ',')]
blocked_by: Option<Vec<String>>,
#[arg(long = "validation", visible_alias = "vc", num_args = 1, action = clap::ArgAction::Append)]
validation_criteria: Vec<String>,
},
Batch {
#[arg(required = true)]
items: Vec<String>,
#[arg(long)]
spec: Option<String>,
#[arg(long)]
env: Option<String>,
#[arg(long)]
priority: Option<String>,
#[arg(long)]
status: Option<String>,
#[arg(long, value_delimiter = ',')]
blocked_by: Option<Vec<String>>,
#[arg(long = "validation", visible_alias = "vc", num_args = 1, action = clap::ArgAction::Append)]
validation_criteria: Vec<String>,
},
Claim { id: String },
Close {
id: String,
#[arg(long)]
note: Option<String>,
#[arg(long, conflicts_with = "note")]
resolution: Option<String>,
#[arg(long, value_delimiter = ',')]
ac: Option<Vec<u32>>,
#[arg(long)]
check_all: bool,
#[arg(long)]
force: bool,
#[arg(long, num_args = 1, action = clap::ArgAction::Append)]
evidence: Vec<String>,
},
Edit {
id: String,
#[arg(long)]
title: Option<String>,
#[arg(long)]
blocked_by: Option<String>,
#[arg(long)]
env: Option<String>,
#[arg(long)]
spec: Option<String>,
#[arg(long)]
priority: Option<String>,
#[arg(long)]
status: Option<String>,
#[arg(long, value_delimiter = ',')]
ac: Option<Vec<u32>>,
#[arg(long = "validation", visible_alias = "vc", num_args = 1, action = clap::ArgAction::Append)]
validation_criteria: Vec<String>,
},
Renumber {
old_id: String,
new_id: String,
#[arg(long)]
file: Option<String>,
},
SyncPlan {
#[arg(long, group = "mode")]
check: bool,
#[arg(long, group = "mode")]
fix: bool,
#[arg(long)]
strict: bool,
#[arg(long)]
brief: bool,
plan: Option<String>,
},
Validate {
#[arg(long)]
strict: bool,
#[arg(long)]
brief: bool,
#[arg(long)]
fix: bool,
#[arg(long)]
dry_run: bool,
},
Query {
#[arg(long)]
status: Option<String>,
#[arg(long)]
priority: Option<String>,
},
Blocked,
Capabilities,
Rebase {
#[arg(long)]
dry_run: bool,
},
Audit {
#[arg(long)]
strict: bool,
#[arg(long)]
brief: bool,
},
Config {
#[arg(long)]
set: Option<String>,
#[arg(long)]
get: Option<String>,
#[arg(long)]
unset: Option<String>,
#[arg(long)]
list: bool,
#[arg(long)]
show: bool,
},
Telemetry {
#[arg(long)]
enable: bool,
#[arg(long)]
disable: bool,
#[arg(long)]
status: bool,
#[arg(long)]
show: bool,
#[arg(long)]
clear: bool,
},
}
pub fn run() -> i32 {
let start = std::time::Instant::now();
let cli = Cli::parse();
let cmd_name = command_name(&cli.command);
let dbg = crate::telemetry::debug_mode();
let session = crate::telemetry::generate_session_id();
let project = std::env::current_dir()
.ok()
.and_then(|cwd| crate::git::repo_root(&cwd).ok())
.map(|root| crate::telemetry::project_slug(&root))
.unwrap_or_else(|| "unknown".to_string());
crate::telemetry::debug_event(
dbg,
&session,
&project,
&format!("session={} project={} cmd={}", session, project, cmd_name),
);
crate::QUIET.store(cli.quiet, std::sync::atomic::Ordering::Relaxed);
crate::DRY_RUN.store(cli.dry_run, std::sync::atomic::Ordering::Relaxed);
let json_output = cli
.output_format
.as_deref()
.map(|f| f.eq_ignore_ascii_case("json"))
.unwrap_or(false);
crate::JSON_OUTPUT.store(json_output, std::sync::atomic::Ordering::Relaxed);
crate::color::init(cli.color.as_deref());
let result = match cli.command {
Commands::Ready { json } => crate::commands::ready::run(json || json_output),
Commands::Init {
write,
target,
all,
agent_only,
} => crate::commands::init::run(write, target.as_deref(), all, agent_only),
Commands::Doctor { path, fix, strict } => {
crate::commands::doctor::run(path.as_deref(), fix, strict)
}
Commands::Lint { check, ids } => crate::commands::lint::run(check, &ids),
Commands::New {
slug,
title,
spec,
env,
priority,
status,
blocked_by,
validation_criteria,
} => crate::commands::new::run(
&slug,
title.as_deref(),
spec.as_deref(),
env.as_deref(),
priority.as_deref(),
status.as_deref(),
&blocked_by.unwrap_or_default(),
&validation_criteria,
),
Commands::Batch {
items,
spec,
env,
priority,
status,
blocked_by,
validation_criteria,
} => crate::commands::batch::run(
&items,
spec.as_deref(),
env.as_deref(),
priority.as_deref(),
status.as_deref(),
&blocked_by.unwrap_or_default(),
&validation_criteria,
),
Commands::Claim { id } => crate::commands::claim::run(&id),
Commands::Close {
id,
note,
resolution,
ac,
check_all,
force,
evidence,
} => {
let text = resolution.or(note);
crate::commands::close::run(
&id,
text.as_deref(),
&ac.unwrap_or_default(),
check_all,
force,
&evidence,
)
}
Commands::Edit {
id,
title,
blocked_by,
env,
spec,
priority,
status,
ac,
validation_criteria,
} => crate::commands::edit::run(
&id,
title.as_deref(),
blocked_by.as_deref(),
env.as_deref(),
spec.as_deref(),
priority.as_deref(),
status.as_deref(),
&ac.unwrap_or_default(),
if validation_criteria.is_empty() {
None
} else {
Some(&validation_criteria)
},
),
Commands::Renumber {
old_id,
new_id,
file,
} => crate::commands::renumber::run(&old_id, &new_id, file.as_deref()),
Commands::SyncPlan {
check,
fix,
strict,
brief,
plan,
} => crate::commands::sync_plan::run(check, fix, strict, brief, plan.as_deref()),
Commands::Validate {
strict,
brief,
fix,
dry_run,
} => {
if fix || dry_run {
crate::commands::validate::run_with_fix(strict, brief, dry_run)
} else {
crate::commands::validate::run(strict, brief)
}
}
Commands::Query { status, priority } => {
crate::commands::query::run(status.as_deref(), priority.as_deref())
}
Commands::Blocked => crate::commands::blocked::run(),
Commands::Capabilities => crate::commands::capabilities::run(),
Commands::Rebase { dry_run } => crate::commands::rebase::run(dry_run),
Commands::Audit { strict, brief } => crate::commands::audit::run(strict, brief),
Commands::Config {
set,
get,
unset,
list,
show,
} => crate::commands::config::run(
set.as_deref(),
get.as_deref(),
unset.as_deref(),
list,
show,
),
Commands::Telemetry {
enable,
disable,
status,
show,
clear,
} => crate::commands::telemetry::run(enable, disable, status, show, clear),
};
let exit_code = match result {
Ok(code) => code,
Err(e) => {
if let Some(de) = e.downcast_ref::<crate::DomainError>() {
let code = de.kind.exit_code();
if crate::JSON_OUTPUT.load(std::sync::atomic::Ordering::Relaxed) {
emit_json_error(de);
}
eprintln!("tkt: {} {}", crate::color::sym_err(), de.message);
code
} else {
if crate::JSON_OUTPUT.load(std::sync::atomic::Ordering::Relaxed) {
let envelope = format!(
"{{\"ok\":false,\"error\":{{\"kind\":\"io\",\"message\":{}}},\"exit_code\":2}}",
json_escape(&e.to_string())
);
eprintln!("{}", envelope);
}
eprintln!("tkt: {} crash: {}", crate::color::sym_err(), e);
2
}
}
};
record_telemetry(&cmd_name, exit_code, start.elapsed().as_millis() as u64);
crate::telemetry::debug_event(
dbg,
&session,
&project,
&format!(
"exit={} duration={:.1}s",
exit_code,
start.elapsed().as_secs_f64()
),
);
exit_code
}
fn command_name(cmd: &Commands) -> String {
match cmd {
Commands::Ready { .. } => "ready",
Commands::Init { .. } => "init",
Commands::Doctor { .. } => "doctor",
Commands::Lint { .. } => "lint",
Commands::New { .. } => "new",
Commands::Batch { .. } => "batch",
Commands::Claim { .. } => "claim",
Commands::Close { .. } => "close",
Commands::Edit { .. } => "edit",
Commands::Renumber { .. } => "renumber",
Commands::SyncPlan { .. } => "sync-plan",
Commands::Validate { .. } => "validate",
Commands::Query { .. } => "query",
Commands::Blocked => "blocked",
Commands::Capabilities => "capabilities",
Commands::Rebase { .. } => "rebase",
Commands::Audit { .. } => "audit",
Commands::Config { .. } => "config",
Commands::Telemetry { .. } => "telemetry",
}
.to_string()
}
fn record_telemetry(cmd: &str, exit_code: i32, duration_ms: u64) {
use crate::telemetry;
let (consent, _) = telemetry::check_consent();
if consent != telemetry::Consent::Enabled {
return;
}
let project = std::env::current_dir()
.ok()
.and_then(|cwd| crate::git::repo_root(&cwd).ok())
.map(|root| telemetry::project_slug(&root))
.unwrap_or_else(|| "unknown".to_string());
static SESSION: std::sync::LazyLock<String> =
std::sync::LazyLock::new(telemetry::generate_session_id);
let event = telemetry::Event {
ts: telemetry::iso_timestamp(),
session: SESSION.clone(),
project,
cmd: cmd.to_string(),
exit_code,
duration_ms,
version: env!("CARGO_PKG_VERSION").to_string(),
os: telemetry::os_string().to_string(),
arch: telemetry::arch_string().to_string(),
};
telemetry::record_event(&event);
}
fn emit_json_error(de: &crate::DomainError) {
let hint_part = match &de.hint {
Some(h) => format!(",\"hint\":{}", json_escape(h)),
None => String::new(),
};
let envelope = format!(
"{{\"ok\":false,\"error\":{{\"kind\":\"{}\",\"message\":{}{}}},\"exit_code\":{}}}",
de.kind.as_str(),
json_escape(&de.message),
hint_part,
de.kind.exit_code()
);
eprintln!("{}", envelope);
}
fn json_escape(s: &str) -> String {
serde_json::to_string(s).unwrap_or_else(|_| format!("\"{}\"", s))
}
pub(crate) fn emit_json_success(result: &str) {
println!("{{\"ok\":true,\"result\":{}}}", json_escape(result));
}