use clap::{Parser, Subcommand};
use std::path::PathBuf;
use tsk_core::{send_request, socket_path, Priority};
#[derive(Parser)]
#[command(name = "tsk", about = "tsk — work with a clear context", version = env!("CARGO_PKG_VERSION"))]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
Thread {
#[command(subcommand)]
action: ThreadCommands,
},
Task {
#[command(subcommand)]
action: TaskCommands,
},
Context,
}
#[derive(Subcommand)]
enum ThreadCommands {
Create {
slug: String,
priority: String,
description: String,
},
List,
SwitchTo {
id: String,
},
Wait {
id: String,
reason: Option<String>,
},
Resume {
id: String,
note: Option<String>,
},
Update {
id: String,
#[arg(long)]
slug: Option<String>,
#[arg(long)]
description: Option<String>,
#[arg(long)]
priority: Option<String>,
},
}
#[derive(Subcommand)]
enum TaskCommands {
Create {
description: String,
#[arg(long)]
due_by: Option<String>,
#[arg(long)]
thread: Option<String>,
},
List {
#[arg(long)]
thread: Option<String>,
},
Start {
id: String,
#[arg(long)]
thread: Option<String>,
},
Block {
id: String,
reason: String,
#[arg(long)]
thread: Option<String>,
},
Complete {
id: String,
#[arg(long)]
thread: Option<String>,
},
Cancel {
id: String,
#[arg(long)]
thread: Option<String>,
},
Update {
id: String,
#[arg(long)]
description: Option<String>,
#[arg(long)]
due_by: Option<String>,
#[arg(long)]
seq: Option<u32>,
#[arg(long)]
thread: Option<String>,
},
}
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() == 1 {
if let Err(e) = tui::run() {
eprintln!("TUI error: {}", e);
std::process::exit(1);
}
return;
}
let cli = Cli::parse();
if let Err(e) = run_cli(cli) {
eprintln!("{}", e);
std::process::exit(1);
}
}
fn project_root() -> PathBuf {
std::env::var("TSK_PROJECT_ROOT")
.map(PathBuf::from)
.unwrap_or_else(|_| std::env::current_dir().expect("Cannot determine current directory"))
}
fn run_cli(cli: Cli) -> Result<(), String> {
let root = project_root();
let sock = socket_path(&root);
match cli.command {
Some(Commands::Thread { action }) => match action {
ThreadCommands::Create {
slug,
priority,
description,
} => {
let _: Priority = priority.parse()?;
let result = send_request(
&sock,
"thread.create",
serde_json::json!({
"slug": slug,
"priority": priority,
"description": description,
}),
)?;
println!("{}", serde_json::to_string_pretty(&result).unwrap());
Ok(())
}
ThreadCommands::List => {
let result = send_request(&sock, "thread.list", serde_json::json!({}))?;
println!("{}", serde_json::to_string_pretty(&result).unwrap());
Ok(())
}
ThreadCommands::SwitchTo { id } => {
let result = send_request(
&sock,
"thread.switch_to",
serde_json::json!({ "id": id }),
)?;
println!("{}", serde_json::to_string_pretty(&result).unwrap());
Ok(())
}
ThreadCommands::Wait { id, reason } => {
let mut params = serde_json::json!({ "id": id });
if let Some(r) = reason { params["reason"] = r.into(); }
let result = send_request(&sock, "thread.wait", params)?;
println!("{}", serde_json::to_string_pretty(&result).unwrap());
Ok(())
}
ThreadCommands::Resume { id, note } => {
let mut params = serde_json::json!({ "id": id });
if let Some(n) = note { params["note"] = n.into(); }
let result = send_request(&sock, "thread.resume", params)?;
println!("{}", serde_json::to_string_pretty(&result).unwrap());
Ok(())
}
ThreadCommands::Update { id, slug, description, priority } => {
if let Some(ref p) = priority {
let _: Priority = p.parse()?;
}
let mut params = serde_json::json!({ "id": id });
if let Some(s) = slug { params["slug"] = s.into(); }
if let Some(d) = description { params["description"] = d.into(); }
if let Some(p) = priority { params["priority"] = p.into(); }
let result = send_request(&sock, "thread.update", params)?;
println!("{}", serde_json::to_string_pretty(&result).unwrap());
Ok(())
}
},
Some(Commands::Task { action }) => match action {
TaskCommands::Create { description, due_by, thread } => {
let mut params = serde_json::json!({ "description": description });
if let Some(d) = due_by { params["due_by"] = d.into(); }
if let Some(t) = thread { params["thread"] = t.into(); }
let result = send_request(&sock, "task.create", params)?;
println!("{}", serde_json::to_string_pretty(&result).unwrap());
Ok(())
}
TaskCommands::List { thread } => {
let mut params = serde_json::json!({});
if let Some(t) = thread { params["thread"] = t.into(); }
let result = send_request(&sock, "task.list", params)?;
println!("{}", serde_json::to_string_pretty(&result).unwrap());
Ok(())
}
TaskCommands::Start { id, thread } => {
let mut params = serde_json::json!({ "id": id });
if let Some(t) = thread { params["thread"] = t.into(); }
let result = send_request(&sock, "task.start", params)?;
println!("{}", serde_json::to_string_pretty(&result).unwrap());
Ok(())
}
TaskCommands::Block { id, reason, thread } => {
let mut params = serde_json::json!({ "id": id, "reason": reason });
if let Some(t) = thread { params["thread"] = t.into(); }
let result = send_request(&sock, "task.block", params)?;
println!("{}", serde_json::to_string_pretty(&result).unwrap());
Ok(())
}
TaskCommands::Complete { id, thread } => {
let mut params = serde_json::json!({ "id": id });
if let Some(t) = thread { params["thread"] = t.into(); }
let result = send_request(&sock, "task.complete", params)?;
println!("{}", serde_json::to_string_pretty(&result).unwrap());
Ok(())
}
TaskCommands::Cancel { id, thread } => {
let mut params = serde_json::json!({ "id": id });
if let Some(t) = thread { params["thread"] = t.into(); }
let result = send_request(&sock, "task.cancel", params)?;
println!("{}", serde_json::to_string_pretty(&result).unwrap());
Ok(())
}
TaskCommands::Update { id, description, due_by, seq, thread } => {
let mut params = serde_json::json!({ "id": id });
if let Some(d) = description { params["description"] = d.into(); }
if let Some(d) = due_by { params["due_by"] = d.into(); }
if let Some(s) = seq { params["seq"] = s.into(); }
if let Some(t) = thread { params["thread"] = t.into(); }
let result = send_request(&sock, "task.update", params)?;
println!("{}", serde_json::to_string_pretty(&result).unwrap());
Ok(())
}
},
Some(Commands::Context) => {
const AGENT_CONTEXT: &str = include_str!("agent-context.md");
print!("{}", AGENT_CONTEXT);
match send_request(&sock, "thread.list", serde_json::json!({})) {
Ok(result) => {
let threads = result["threads"].as_array().cloned().unwrap_or_default();
if threads.is_empty() {
println!("No threads exist yet.");
} else {
for t in &threads {
let id = t["id"].as_u64().unwrap_or(0);
let slug = t["slug"].as_str().unwrap_or("?");
let state = t["state"].as_str().unwrap_or("?");
let priority = t["priority"].as_str().unwrap_or("?");
let description = t["description"].as_str().unwrap_or("");
println!(
"- {:04} {:20} {:6} {:6} {}",
id, slug, priority, state, description
);
}
}
}
Err(_) => {
println!("(tskd is not running — start it with: tskd)");
}
}
Ok(())
}
None => Ok(()),
}
}
mod tui;
#[cfg(test)]
mod tests {
use super::*;
use tsk_core::{Thread, ThreadState};
#[test]
fn no_args_would_launch_tui() {
let cli = Cli::try_parse_from(["tsk", "thread", "list"]);
assert!(cli.is_ok());
let cli = Cli::try_parse_from(["tsk", "thread", "create", "fix-login", "PRIO", "Fix it"]);
assert!(cli.is_ok());
let cli = Cli::try_parse_from(["tsk", "thread", "switch-to", "a3f1b2c"]);
assert!(cli.is_ok());
}
#[test]
fn cli_validates_priority() {
assert!("BG".parse::<Priority>().is_ok());
assert!("PRIO".parse::<Priority>().is_ok());
assert!("INC".parse::<Priority>().is_ok());
assert!("NORMAL".parse::<Priority>().is_err());
}
#[test]
fn scroll_down_clamps_to_max() {
assert_eq!(tui::scroll_down(0, 10, 24, 5), 0);
assert_eq!(tui::scroll_down(0, 30, 24, 10), 6);
assert_eq!(tui::scroll_down(4, 30, 24, 10), 6);
}
#[test]
fn scroll_down_advances_by_amount() {
assert_eq!(tui::scroll_down(0, 30, 24, 3), 3);
assert_eq!(tui::scroll_down(2, 30, 24, 3), 5);
}
#[test]
fn scroll_bottom_goes_to_last_page() {
assert_eq!(tui::scroll_bottom(30, 24), 6);
assert_eq!(tui::scroll_bottom(10, 24), 0); }
#[test]
fn count_rows_empty_threads() {
assert_eq!(tui::count_rows(&[]), 0);
}
#[test]
fn count_rows_one_active() {
let t = Thread {
id: 1,
slug: "foo".to_string(),
state: ThreadState::Active,
priority: Priority::Priority,
description: "".to_string(),
};
assert_eq!(tui::count_rows(&[t]), 4);
}
#[test]
fn count_rows_two_sections() {
let active = Thread {
id: 1, slug: "a".to_string(), state: ThreadState::Active,
priority: Priority::Priority, description: "".to_string(),
};
let paused = Thread {
id: 2, slug: "b".to_string(), state: ThreadState::Paused,
priority: Priority::Background, description: "".to_string(),
};
assert_eq!(tui::count_rows(&[active, paused]), 9);
}
}