use clap::{Parser, Subcommand};
pub fn resolve_zoom_thread(sock: &std::path::Path) -> Option<tsk_core::Thread> {
let mut dir = std::env::var("PWD")
.map(std::path::PathBuf::from)
.or_else(|_| std::env::current_dir())
.ok()?;
loop {
if dir.join("doc/tsk").is_dir() {
let path_str = dir.to_string_lossy().into_owned();
let result = tsk_core::send_request(
sock,
"thread.resolve_path",
serde_json::json!({"path": path_str}),
)
.ok()?;
if result.is_null() {
return None;
}
return serde_json::from_value(result).ok();
}
if !dir.pop() {
return None;
}
}
}
#[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 {
#[command(
about = "Start, pause, resume, detach, stop and list ledger threads, and resolve this session's or worktree's binding (runs without tskd)"
)]
Thread {
#[command(subcommand)]
action: thread::ThreadCommands,
},
#[command(about = "Fetch, locate and push the ledger branch (runs without tskd)")]
Ledger {
#[command(subcommand)]
action: ledger::LedgerCommands,
},
#[command(about = "Read and write the user config: attach the nexus")]
Config {
#[command(subcommand)]
action: config::ConfigCommands,
},
#[command(
about = "Append to, read and advance the ledger's external event queue (runs without tskd)"
)]
Events {
#[command(subcommand)]
action: events::EventsCommands,
},
#[command(about = "Attach the nexus and list the territories and repos it indexes")]
Nexus {
#[command(subcommand)]
action: nexus_cmd::NexusCommands,
},
#[command(
about = "Add the plugin marketplace, then install and update the tsk plugin through a coding agent's CLI"
)]
InstallPlugin(install_plugin::InstallPluginArgs),
#[command(about = "Print guides for agents, such as importing an existing plan")]
Guide {
#[command(subcommand)]
action: guide::GuideCommands,
},
}
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() == 1 {
let sock = tsk_core::socket_path();
let zoom = resolve_zoom_thread(&sock);
if let Err(e) = tui::run(zoom) {
eprintln!("TUI error: {}", e);
std::process::exit(1);
}
return;
}
let cli = Cli::parse();
match run_cli(cli) {
Ok(0) => {}
Ok(code) => std::process::exit(code),
Err(e) => {
eprintln!("{}", e);
std::process::exit(1);
}
}
}
fn run_cli(cli: Cli) -> Result<i32, String> {
match cli.command {
Some(Commands::Thread { action }) => thread::run(action),
Some(Commands::Ledger { action }) => ledger::run(action).map(|()| 0),
Some(Commands::Events { action }) => events::run(action),
Some(Commands::Config { action }) => config::run(action).map(|()| 0),
Some(Commands::Nexus { action }) => nexus_cmd::run(action).map(|()| 0),
Some(Commands::InstallPlugin(args)) => install_plugin::run(args).map(|()| 0),
Some(Commands::Guide { action }) => guide::run(action).map(|()| 0),
None => Ok(0),
}
}
mod tui;
mod config;
mod ledger;
mod thread;
mod events;
mod install_plugin;
mod guide;
mod nexus_cmd;
#[cfg(test)]
mod tests {
use super::*;
use tsk_core::{Priority, Thread, ThreadState};
#[test]
fn thread_subcommands_parse() {
assert!(matches!(
Cli::try_parse_from(["tsk", "thread", "start", "M-X", "missions/M-X.md"]).unwrap().command,
Some(Commands::Thread { action: thread::ThreadCommands::Start { mission_id, briefing_path } })
if mission_id == "M-X" && briefing_path == "missions/M-X.md"
));
assert!(matches!(
Cli::try_parse_from(["tsk", "thread", "pause", "1234abcd", "m.md", "", "-next: tests"]).unwrap().command,
Some(Commands::Thread { action: thread::ThreadCommands::Pause { task_id, whats_next, .. } })
if task_id.is_empty() && whats_next == "-next: tests"
));
assert!(matches!(
Cli::try_parse_from(["tsk", "thread", "stop"])
.unwrap()
.command,
Some(Commands::Thread {
action: thread::ThreadCommands::Stop { thread_id: None }
})
));
assert!(matches!(
Cli::try_parse_from(["tsk", "thread", "binding", "--no-fetch"])
.unwrap()
.command,
Some(Commands::Thread {
action: thread::ThreadCommands::Binding { no_fetch: true }
})
));
for args in [
vec!["tsk", "thread", "resume", "1234abcd"],
vec!["tsk", "thread", "detach"],
vec!["tsk", "thread", "list"],
vec!["tsk", "thread", "guard"],
vec!["tsk", "thread", "session-start"],
vec!["tsk", "thread", "session-start", "--format", "copilot"],
] {
assert!(Cli::try_parse_from(&args).is_ok(), "{:?}", args);
}
assert!(
Cli::try_parse_from(["tsk", "thread", "session-start", "--format", "gemini"]).is_err()
);
assert!(Cli::try_parse_from(["tsk", "thread", "pause", "1234abcd"]).is_err());
assert!(Cli::try_parse_from(["tsk", "thread", "resume"]).is_err());
}
#[test]
fn removed_daemon_commands_do_not_parse() {
assert!(Cli::try_parse_from(["tsk", "task", "list"]).is_err());
assert!(Cli::try_parse_from(["tsk", "context"]).is_err());
assert!(Cli::try_parse_from(["tsk", "where"]).is_err());
assert!(Cli::try_parse_from(["tsk", "thread", "create", "a", "PRIO", "d"]).is_err());
assert!(Cli::try_parse_from(["tsk", "thread", "switch-to", "1"]).is_err());
}
#[test]
fn ledger_subcommands_parse() {
assert!(matches!(
Cli::try_parse_from(["tsk", "ledger", "fetch"])
.unwrap()
.command,
Some(Commands::Ledger {
action: ledger::LedgerCommands::Fetch
})
));
assert!(matches!(
Cli::try_parse_from(["tsk", "ledger", "path"])
.unwrap()
.command,
Some(Commands::Ledger {
action: ledger::LedgerCommands::Path
})
));
assert!(matches!(
Cli::try_parse_from(["tsk", "ledger", "push", "Record a change"]).unwrap().command,
Some(Commands::Ledger { action: ledger::LedgerCommands::Push { message } }) if message == "Record a change"
));
assert!(Cli::try_parse_from(["tsk", "ledger", "push"]).is_err());
assert!(Cli::try_parse_from(["tsk", "ledger"]).is_err());
assert!(Cli::try_parse_from(["tsk", "ledger", "fetch", "extra"]).is_err());
}
#[test]
fn events_subcommands_parse() {
assert!(matches!(
Cli::try_parse_from(["tsk", "events", "append", "github", "dependabot_alert", "created", "o/r", "p.json"]).unwrap().command,
Some(Commands::Events { action: events::EventsCommands::Append { source, event_type, action, repo, payload_file } })
if source == "github" && event_type == "dependabot_alert" && action == "created" && repo == "o/r" && payload_file == std::path::Path::new("p.json")
));
assert!(matches!(
Cli::try_parse_from(["tsk", "events", "append-batch"])
.unwrap()
.command,
Some(Commands::Events {
action: events::EventsCommands::AppendBatch {
source: None,
action: None
}
})
));
assert!(matches!(
Cli::try_parse_from(["tsk", "events", "append-batch", "--source", "github", "--action", "polled"]).unwrap().command,
Some(Commands::Events { action: events::EventsCommands::AppendBatch { source: Some(source), action: Some(action) } })
if source == "github" && action == "polled"
));
assert!(Cli::try_parse_from(["tsk", "events", "append-batch", "github"]).is_err());
assert!(matches!(
Cli::try_parse_from(["tsk", "events", "read-new"])
.unwrap()
.command,
Some(Commands::Events {
action: events::EventsCommands::ReadNew
})
));
assert!(matches!(
Cli::try_parse_from(["tsk", "events", "advance-watermark", "-1"]).unwrap().command,
Some(Commands::Events { action: events::EventsCommands::AdvanceWatermark { count } }) if count == "-1"
));
assert!(
Cli::try_parse_from(["tsk", "events", "append", "github", "t", "a", "o/r"]).is_err()
);
assert!(Cli::try_parse_from(["tsk", "events", "advance-watermark"]).is_err());
assert!(Cli::try_parse_from(["tsk", "events", "read-new", "extra"]).is_err());
}
#[test]
fn install_plugin_parses_with_defaults_and_options() {
match Cli::try_parse_from(["tsk", "install-plugin", "claude-cli"])
.unwrap()
.command
{
Some(Commands::InstallPlugin(args)) => {
assert_eq!(args.target, install_plugin::Target::ClaudeCli);
assert_eq!(args.scope, None);
assert_eq!(args.plugins, vec!["tsk".to_string()]);
assert_eq!(args.marketplace_source(), "jimbarritt/claude-plugins");
assert_eq!(args.marketplace_name(), "jimbarritt-claude-plugins");
}
_ => panic!("install-plugin did not parse"),
}
match Cli::try_parse_from([
"tsk",
"install-plugin",
"claude-cli",
"--scope",
"local",
"--plugin",
"swe",
"--plugin",
"tsk",
])
.unwrap()
.command
{
Some(Commands::InstallPlugin(args)) => {
assert_eq!(args.scope, Some(install_plugin::Scope::Local));
assert_eq!(args.plugins, vec!["swe".to_string(), "tsk".to_string()]);
}
_ => panic!("install-plugin did not parse"),
}
match Cli::try_parse_from(["tsk", "install-plugin", "copilot-cli"])
.unwrap()
.command
{
Some(Commands::InstallPlugin(args)) => {
assert_eq!(args.target, install_plugin::Target::CopilotCli);
assert_eq!(args.marketplace_source(), "jimbarritt/tsk");
assert_eq!(args.marketplace_name(), "tsk");
}
_ => panic!("install-plugin did not parse"),
}
assert!(Cli::try_parse_from(["tsk", "install-plugin"]).is_err());
assert!(Cli::try_parse_from(["tsk", "install-plugin", "codex"]).is_err());
assert!(
Cli::try_parse_from(["tsk", "install-plugin", "claude-cli", "--scope", "team"])
.is_err()
);
}
#[test]
fn nexus_subcommands_parse() {
assert!(matches!(
Cli::try_parse_from(["tsk", "nexus", "add", "https://example.test/o/nexus"]).unwrap().command,
Some(Commands::Nexus { action: nexus_cmd::NexusCommands::Add { url } })
if url == "https://example.test/o/nexus"
));
assert!(matches!(
Cli::try_parse_from(["tsk", "nexus", "list"])
.unwrap()
.command,
Some(Commands::Nexus {
action: nexus_cmd::NexusCommands::List { json: false }
})
));
assert!(matches!(
Cli::try_parse_from(["tsk", "nexus", "list", "--json"])
.unwrap()
.command,
Some(Commands::Nexus {
action: nexus_cmd::NexusCommands::List { json: true }
})
));
match Cli::try_parse_from(["tsk", "nexus", "register-repo"])
.unwrap()
.command
{
Some(Commands::Nexus {
action:
nexus_cmd::NexusCommands::RegisterRepo {
id,
territory,
territory_name,
ledger,
local,
},
}) => {
assert_eq!(id, None);
assert_eq!(territory, None);
assert_eq!(territory_name, None);
assert_eq!(ledger, nexus_cmd::LedgerChoice::Nexus);
assert!(!local);
}
_ => panic!("register-repo did not parse"),
}
assert!(Cli::try_parse_from([
"tsk",
"nexus",
"register-repo",
"--id",
"x",
"--territory",
"work",
"--territory-name",
"Work",
"--ledger",
"repo",
"--local",
])
.is_ok());
assert!(
Cli::try_parse_from(["tsk", "nexus", "register-repo", "--territory-name", "Work"])
.is_err()
);
assert!(
Cli::try_parse_from(["tsk", "nexus", "register-repo", "--ledger", "cloud"]).is_err()
);
assert!(Cli::try_parse_from(["tsk", "nexus", "add"]).is_err());
assert!(Cli::try_parse_from(["tsk", "nexus"]).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(),
path: None,
};
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(),
path: None,
};
let paused = Thread {
id: 2,
slug: "b".to_string(),
state: ThreadState::Paused,
priority: Priority::Background,
description: "".to_string(),
path: None,
};
assert_eq!(tui::count_rows(&[active, paused]), 9);
}
}