mod init;
mod project;
mod store;
use clap::{Parser, Subcommand};
use store::{TaskEntry, TaskStore, gen_id};
#[derive(Parser)]
#[command(name = "task", about = "Lightweight task management for coding agents")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Create {
title: String,
description: Option<String>,
#[arg(long, default_value = "todo")]
status: String,
},
Update {
id: String,
status: String,
note: Option<String>,
#[arg(long)]
description: Option<String>,
},
List {
status: Option<String>,
#[arg(long)]
all: bool,
},
Get {
id: String,
},
Init {
#[arg(long)]
global: bool,
},
}
fn main() {
let cli = Cli::parse();
let store = TaskStore::default_path();
let project = project::get_project();
match cli.command {
Commands::Create {
title,
description,
status,
} => {
let id = gen_id();
store.append(&TaskEntry::new(
id.clone(),
project,
status,
title,
description.unwrap_or_default(),
String::new(),
));
println!("task created! ID: {id}");
println!("TASK_ADD_{id}");
}
Commands::Update {
id,
status,
note,
description,
} => {
if !store.id_exists(&id) {
eprintln!("Error: task '{id}' not found");
std::process::exit(1);
}
let prev = store.latest_entry(&id).unwrap();
let new_description = description.unwrap_or(prev.description);
store.append(&TaskEntry::new(
id.clone(),
project,
status.clone(),
prev.title,
new_description,
note.unwrap_or_default(),
));
println!("TASK_{}_{id}", status.to_uppercase());
}
Commands::List { status, all } => {
let project_filter = if all { None } else { Some(project.as_str()) };
let tasks = store.current_tasks(project_filter, status.as_deref());
if tasks.is_empty() {
return;
}
println!("{:<10} {:<8} {:<24} TITLE", "ID", "STATUS", "PROJECT");
for task in tasks {
println!(
"{:<10} {:<8} {:<24} {}",
task.id, task.status, project::short_project(&task.project), task.title
);
}
}
Commands::Get { id } => {
let entries = store.entries_for_id(&id);
if entries.is_empty() {
eprintln!("Error: task '{id}' not found");
std::process::exit(1);
}
let latest = entries.last().unwrap();
println!("{} | {} | {}", latest.id, latest.project, latest.title);
if !latest.description.is_empty() {
for line in latest.description.lines() {
println!(" {line}");
}
println!();
}
for entry in &entries {
if entry.note.is_empty() {
println!(" {:<28} {}", entry.ts, entry.status);
} else {
let note_display: String = entry
.note
.lines()
.enumerate()
.map(|(i, l)| {
if i == 0 {
l.to_string()
} else {
format!("\n{:>42}{l}", "")
}
})
.collect::<Vec<_>>()
.join("");
println!(" {:<28} {:<10} {}", entry.ts, entry.status, note_display);
}
}
}
Commands::Init { global } => {
let result = init::run_init(global);
if !result.injected.is_empty() {
for path in &result.injected {
println!("Injected: {path}");
}
} else if result.up_to_date > 0 {
println!("Already up-to-date.");
} else if !result.candidates.is_empty() {
println!(
"No instruction files found. Create one of these and run again:\n {}",
result.candidates.join(", ")
);
} else {
println!("Already up-to-date.");
}
}
}
}