Something went wrong. Try again.
A git-based task manager
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719mod commands;pub mod errors;mod fzf;#[cfg(feature = "mcp")]mod mcp;mod merge;mod namespace;mod object;mod patch;mod properties;mod propvalue;mod queue;mod references;mod serv;mod task;mod workspace;
use clap::{Args, CommandFactory, Parser, Subcommand};use clap_complete::{Shell, generate};use errors::Result;use std::env::current_dir;use std::io::{self, IsTerminal, Write};use std::path::PathBuf;use std::str::FromStr as _;use workspace::{Id, TaskIdentifier, Workspace};
fn default_dir() -> Result<PathBuf> { Ok(current_dir()?)}
pub(crate) fn parse_id(s: &str) -> std::result::Result<Id, &'static str> { Id::from_str(s).map_err(|_| "Unable to parse tsk- ID")}
#[derive(Parser)]#[command(version, about)]struct Cli { /// Override the tsk root directory. #[arg(short = 'C', env = "TSK_ROOT", value_name = "DIR")] dir: Option<PathBuf>, /// Override the active queue for this invocation only. Affects every /// command that reads/writes the active queue (push, drop, swap, /// rot/tor, prioritize/deprioritize, list, inbox, assign, accept, /// reject, export, ...). #[arg(short = 'q', long = "queue", value_name = "QUEUE", global = true)] queue: Option<String>, /// Print header rows for commands that emit tabular output. #[arg(long, global = true)] headers: bool, #[command(subcommand)] command: Commands,}
#[derive(Subcommand)]enum Commands { /// Bootstrap user-local state in `<git-dir>/tsk/`. (Auto-created on first use.) Init, /// Create a new task and push it onto the active queue. Push { /// Open $EDITOR before saving the new task. #[arg( short = 'e', long = "edit", default_value_t = false, help = "Open $EDITOR before saving the new task" )] edit: bool, /// Set the task body. Use `-b -` to read the body from stdin. #[arg( short = 'b', value_name = "BODY", help = "Set the task body. Use `-b -` to read stdin" )] body: Option<String>, #[command(flatten)] title: Title, }, /// Create a new task and append it to the bottom of the active queue. Append { /// Open $EDITOR before saving the new task. #[arg( short = 'e', long = "edit", default_value_t = false, help = "Open $EDITOR before saving the new task" )] edit: bool, /// Set the task body. Use `-b -` to read the body from stdin. #[arg( short = 'b', value_name = "BODY", help = "Set the task body. Use `-b -` to read stdin" )] body: Option<String>, #[command(flatten)] title: Title, }, /// Print the active queue's stack (top-of-stack first). List { /// Show all queued tasks instead of truncating at the default count. #[arg(short = 'a', default_value_t = false)] all: bool, /// Maximum number of queued tasks to print unless `--all` is set. #[arg(short = 'c', default_value_t = 10)] count: usize, /// Print only task ids. #[arg(short = 'i', default_value_t = false)] ids_only: bool, }, /// Print open tasks in the active namespace, with queue membership. /// /// Queue membership is printed as a comma-separated list, or `none` /// when the open task is not currently in any queue index. Open, /// Fuzzy-find tasks and print the selected task id(s). Find { /// Allow selecting multiple tasks. #[arg(short, long, default_value_t = false)] multi: bool, /// Search every task in the active namespace instead of only the active queue. #[arg(short, long, default_value_t = false)] all: bool, /// Include task bodies in the search text. #[arg(short, long, default_value_t = false)] body: bool, }, /// Show a task by id. Show { /// Print xattr-style YAML front-matter for the task's properties. #[arg(short = 'x', default_value_t = false)] show_attrs: bool, /// Print the task's stable id instead of its body. #[arg(short = 'i', long = "stable-id", default_value_t = false)] stable_id: bool, /// Print the task's latest commit instead of its body. #[arg(short = 'c', long = "latest-commit", default_value_t = false)] latest_commit: bool, /// Skip the rich-text parser and print the raw bytes verbatim. #[arg(short = 'R', default_value_t = false)] raw: bool, #[command(flatten)] task_id: TaskId, }, /// List or follow a link parsed from a task's body. Follow { /// The task whose body will be searched for links. #[command(flatten)] task_id: TaskId, /// The index of the link to open. Omit with no -s to just list links. #[arg(short = 'l')] link_index: Option<usize>, /// fzf-pick a link to open instead of supplying -l. #[arg(short = 's', default_value_t = false)] select: bool, /// When opening an internal link, edit the addressed task instead of showing. #[arg(short = 'e', default_value_t = false)] edit: bool, }, /// Open `$EDITOR` to modify a task. Edit { /// Replace the task body non-interactively. Use `-b -` to read stdin. #[arg(short = 'b')] body: Option<String>, #[command(flatten)] task_id: TaskId, }, /// Drop a task (remove from queue + mark done, history retained). Drop { /// Record the enclosing git repo's current HEAD commit in `closed-on`. #[arg( short = 'x', long = "closed-on-commit", alias = "closed-on-head", default_value_t = false )] closed_on_commit: bool, #[command(flatten)] task_id: TaskId, }, /// Remove a task from the active queue without changing its status. Abandon { #[command(flatten)] task_id: TaskId, }, /// Flip a `done` task back to `open` and push it onto the active queue. Reopen { /// Include task bodies in the interactive search text. #[arg(short, long, default_value_t = false)] body: bool, /// Mark the task open without adding it to the active queue. #[arg(long, default_value_t = false)] no_queue: bool, #[command(flatten)] task_id: TaskId, }, /// Swap the top two tasks. Swap, /// Rotate top 3: third → top. Rot, /// Reverse-rotate top 3: top → third. Tor, /// Move a task to the top of the stack. Prioritize { #[command(flatten)] task_id: TaskId, }, /// Move a task to the bottom of the stack. Deprioritize { #[command(flatten)] task_id: TaskId, }, /// Repair calculated properties and prune stale tsk refs. Clean, /// Export one or more tasks as a concatenated mbox-format patch series. /// With no -T / --where / --all, drops into fzf for a single-task pick. /// Pipe to a file for offline transfer; recipient runs `tsk import`. Export { /// Specific task by tsk-id. Repeatable for multi-task export. #[arg(short = 'T', value_parser = parse_id)] ids: Vec<Id>, /// Property filter: `--where status=open`. Combines with -T flags. #[arg(long, value_name = "KEY=VALUE")] r#where: Option<String>, /// Export every task bound in the active namespace. #[arg(long)] all: bool, /// Embed each task's namespace+human-id in its root entry so the /// recipient can opt in to mirroring the bindings on import. #[arg(long)] bind: bool, }, /// Import a task from an mbox-format patch series (read from stdin). /// Verifies stable id; rejects tampered patches. Import { /// Bind the imported task into the active namespace, allocating a /// fresh human id (or reusing an existing binding to the same stable id). #[arg(long)] bind: bool, }, /// Print the commit history of a tsk ref. Newest commit first. Log { #[command(subcommand)] target: LogTarget, }, /// Print refspec/setup hints for `git push`/`git fetch` to include `refs/tsk/*`. GitSetup { /// Configure push/fetch refspecs on the named remote (default: origin). #[arg(short = 'r')] remote: Option<String>, }, /// Push tsk refs to a git remote (default: origin). GitPush { remote: Option<String> }, /// Fetch tsk refs from a git remote (default: origin) and reconcile /// divergent task histories. Default strategy is merge; pass --rebase /// to replay local-only commits onto the remote tip instead. GitPull { remote: Option<String>, #[arg(long)] rebase: bool, }, /// Share a task into another namespace (binds same stable id under that namespace's next human id). Share { target: String, #[command(flatten)] task_id: TaskId, }, /// Move a task from the active queue's index into another queue's inbox. Assign { /// Target queue. Omit to fzf-pick from existing queues. target: Option<String>, #[command(flatten)] task_id: TaskId, /// Auto-push refs to this remote after assigning. Empty string skips. Default: origin. #[arg(short = 'R')] remote: Option<String>, }, /// Pull a task from another queue's index (only allowed if its can-pull is true). Pull { source: String, #[command(flatten)] task_id: TaskId, }, /// List inbox items pending in the active queue. Inbox { /// Auto-pull from this remote first. Empty string skips. Default: origin. #[arg(short = 'R')] remote: Option<String>, }, /// Accept an inbox item by key (no key = top item), or accept an unqueued task by id. Accept { key: Option<String>, #[command(flatten)] task_id: TaskId, /// Auto-push refs to this remote after accepting. Empty string skips. Default: origin. #[arg(short = 'R')] remote: Option<String>, }, /// Reject an inbox item by key (no key = first item). Reject { key: Option<String>, /// Auto-push refs to this remote after rejecting. Empty string skips. Default: origin. #[arg(short = 'R')] remote: Option<String>, }, /// Get/set/find tasks by property. Properties are zero-or-more text values /// stored as files in the task's tree object; each value is one line. Prop { #[command(subcommand)] action: PropAction, }, /// Manage namespaces. Namespace { #[command(subcommand)] action: NamespaceAction, }, /// Manage queues. Queue { #[command(subcommand)] action: QueueAction, }, /// Manage git remotes that carry tsk refs. Remote { #[command(subcommand)] action: RemoteAction, }, /// Switch active namespace (shorthand). With no name, fzf-picks from /// existing namespaces (plus a `<new>` sentinel for creating one on /// the fly). Switch { name: Option<String> }, /// Generate shell completion. Completion { #[arg(short = 's')] shell: Shell, }, /// Print coding-agent usage guidance for tsk. Skill, /// Host a read-only HTTP browser for queues, namespaces, and tasks. Serv { #[command(flatten)] args: serv::ServeArgs, }, /// Host an MCP server over standard input and output. #[cfg(feature = "mcp")] Mcp,}
#[derive(Subcommand)]pub(crate) enum LogTarget { /// Edit history of a single task. Task { #[command(flatten)] task_id: TaskId, }, /// Edit history of a namespace tree (id assignments, drops, shares). /// Defaults to the active namespace. Namespace { name: Option<String> }, /// Edit history of a queue tree (pushes, drops, inbox moves). /// Defaults to the active queue. Queue { name: Option<String> },}
#[derive(Subcommand)]pub(crate) enum PropAction { /// List properties set on a task. List { #[command(flatten)] task_id: TaskId, }, /// Print newline-delimited values for one property on a task. Get { #[command(flatten)] task_id: TaskId, key: String, }, /// Append a value to a property on a task. Creates the property if absent. Add { #[command(flatten)] task_id: TaskId, key: String, value: String, }, /// Replace the entire value list for a property. With no values, removes the property. Set { #[command(flatten)] task_id: TaskId, key: String, values: Vec<String>, }, /// Remove a single value (or, with no value, the entire property). Unset { #[command(flatten)] task_id: TaskId, key: String, value: Option<String>, }, /// List property keys set on a task. Keys { #[command(flatten)] task_id: TaskId, }, /// List distinct values seen for a property key. Values { key: String }, /// Find every task in the active namespace whose `key` is set (and equals /// `value`, if supplied). With both omitted, fzf-picks the key, then value. Find { key: Option<String>, value: Option<String>, },}
#[derive(Subcommand)]pub(crate) enum NamespaceAction { List { /// Include each namespace ref's HEAD commit. #[arg(long)] head: bool, }, Current { /// Print the active namespace ref's HEAD commit instead of its name. #[arg(long)] head: bool, }, /// Switch active namespace. With no name, fzf-picks from existing /// namespaces (plus a `<new>` sentinel for creating one on the fly). Switch { name: Option<String> }, /// List every task bound in a namespace (defaults to active), /// regardless of which queue (if any) it's on. One row per id. Tasks { /// Include each task's body as a third column. #[arg(short = 'b', long)] body: bool, name: Option<String>, }, /// List unique property keys on tasks in a namespace (defaults to active). Props { name: Option<String> },}
#[derive(Subcommand)]pub(crate) enum QueueAction { List, Current, /// Create a new queue. By default `can-pull=false`; use `-p` to make it true. Create { name: String, #[arg(short = 'p', default_value_t = false)] can_pull: bool, }, /// Change whether tasks can be pulled out of this queue. CanPull { name: String, can_pull: String, }, /// Delete a queue. Refuses the default queue. Use -R to also delete it remotely. Delete { name: String, /// Also push the delete to this remote. Empty string skips. #[arg(short = 'R')] remote: Option<String>, }, /// Switch active queue. With no name, fzf-picks from existing queues /// (plus a `<new>` sentinel for creating one on the fly). Switch { name: Option<String>, },}
#[derive(Subcommand)]pub(crate) enum RemoteAction { /// Print the active default remote (the one used when no `-R` is given). Default, /// Persist the active default remote for this clone. Must already be /// a git remote — use `git remote add ...` (and `tsk git-setup -r /// <name>` to configure refspecs) first. SetDefault { name: String },}
#[derive(Args)]#[group(required = true, multiple = false)]pub(crate) struct Title { #[arg( short, value_name = "TITLE", help = "Task title. A multiline value is split at the first newline into title + body. Use `-` to read the full title + body from stdin" )] pub(crate) title: Option<String>, #[arg( value_name = "TITLE", help = "Task title. A multiline value is split at the first newline into title + body. Use `-` to read the full title + body from stdin" )] pub(crate) title_simple: Option<Vec<String>>,}
#[derive(Args, Default)]#[group(required = false, multiple = false)]pub(crate) struct TaskId { #[arg(short = 't', value_name = "ID")] pub(crate) id: Option<u32>, #[arg(short = 'T', value_name = "TSK-ID", value_parser = parse_id)] pub(crate) tsk_id: Option<Id>, #[arg(short = 'r', value_name = "RELATIVE")] pub(crate) relative_id: Option<u32>, /// Fuzzy-find a task by title. #[arg(short = 'f', default_value_t = false)] pub(crate) fuzzy: bool, /// Fuzzy-find a task by title and body. #[arg(short = 'F', default_value_t = false)] pub(crate) fuzzy_body: bool,}
pub(crate) enum TaskPickScope { Namespace, ActiveQueue, Queue(String),}
impl TaskId { /// True when the user passed none of `-t`, `-T`, `-r`, `-f`, or `-F`. /// Commands with fuzzy default behavior use this to decide whether to /// prompt; commands that prefer "top of stack" treat this as `Relative(0)`. pub(crate) fn is_empty(&self) -> bool { !self.has_id_selector() && !self.is_fuzzy() }
pub(crate) fn resolve( self, dir: &std::path::Path, ws: &Workspace, scope: TaskPickScope, ) -> Result<TaskIdentifier> { self.resolve_inner(dir, ws, scope, false, false) }
pub(crate) fn resolve_or_pick( self, dir: &std::path::Path, ws: &Workspace, scope: TaskPickScope, body: bool, ) -> Result<TaskIdentifier> { self.resolve_inner(dir, ws, scope, body, true) }
fn resolve_inner( self, dir: &std::path::Path, ws: &Workspace, scope: TaskPickScope, default_body: bool, pick_when_empty: bool, ) -> Result<TaskIdentifier> { if self.has_id_selector() { return Ok(self.into()); } if !self.is_fuzzy() && !pick_when_empty { return Ok(TaskIdentifier::Relative(0)); } if !task_picker_allowed() { return Err(errors::Error::Parse( "task selection requires an interactive terminal".into(), )); } let entries = match scope { TaskPickScope::Namespace => ws.list_namespace_tasks(&ws.namespace()?)?, TaskPickScope::ActiveQueue => ws.read_stack()?, TaskPickScope::Queue(queue) => ws.read_queue_stack(&queue)?, }; if entries.is_empty() { return Err(errors::Error::NoTasks); } let include_body = self.fuzzy_body || (pick_when_empty && default_body); let id = commands::select_task_ids(dir, ws, entries, include_body, false)? .into_iter() .next() .ok_or_else(|| errors::Error::Parse("No task selected".into()))?; Ok(TaskIdentifier::Id(id)) }
fn has_id_selector(&self) -> bool { self.id.is_some() || self.tsk_id.is_some() || self.relative_id.is_some() }
fn is_fuzzy(&self) -> bool { self.fuzzy || self.fuzzy_body }}
fn task_picker_allowed() -> bool { io::stdin().is_terminal() || std::env::var_os("TSK_TEST_ALLOW_FZF").is_some()}
impl From<TaskId> for TaskIdentifier { fn from(v: TaskId) -> Self { if let Some(id) = v.id.map(Id::from).or(v.tsk_id) { TaskIdentifier::Id(id) } else { TaskIdentifier::Relative(v.relative_id.unwrap_or(0)) } }}
fn dispatch(cli: Cli) -> Result<()> { workspace::set_queue_override(cli.queue.clone()); let dir = match cli.dir { Some(d) => d, None => default_dir()?, }; match cli.command { Commands::Init => Workspace::init(dir), Commands::Push { edit, body, title } => { commands::command_push(dir, edit, body, title, true) } Commands::Append { edit, body, title } => { commands::command_push(dir, edit, body, title, false) } Commands::List { all, count, ids_only, } => commands::command_list(dir, all, count, ids_only, cli.headers), Commands::Open => commands::command_open(dir, cli.headers), Commands::Find { multi, all, body } => commands::command_find(dir, multi, all, body), Commands::Show { task_id, show_attrs, stable_id, latest_commit, raw, } => commands::command_show(dir, task_id, show_attrs, stable_id, latest_commit, raw), Commands::Follow { task_id, link_index, select, edit, } => commands::command_follow(dir, task_id, link_index, select, edit), Commands::Edit { body, task_id } => commands::command_edit(dir, task_id, body), Commands::Drop { closed_on_commit, task_id, } => commands::command_drop(dir, task_id, closed_on_commit), Commands::Abandon { task_id } => commands::command_abandon(dir, task_id), Commands::Reopen { body, no_queue, task_id, } => commands::command_reopen(dir, task_id, body, no_queue), Commands::Swap => Workspace::from_path(dir)?.swap_top(), Commands::Rot => Workspace::from_path(dir)?.rot(), Commands::Tor => Workspace::from_path(dir)?.tor(), Commands::Prioritize { task_id } => commands::command_prioritize(dir, task_id), Commands::Deprioritize { task_id } => commands::command_deprioritize(dir, task_id), Commands::Clean => commands::clean(dir), Commands::Export { ids, r#where, all, bind, } => commands::command_export(dir, ids, r#where, all, bind), Commands::Import { bind } => commands::command_import(dir, bind), Commands::Log { target } => commands::command_log(dir, target), Commands::GitSetup { remote } => commands::git_setup(dir, remote), Commands::GitPush { remote } => commands::git_push(dir, remote), Commands::GitPull { remote, rebase } => commands::git_pull(dir, remote, rebase), Commands::Share { target, task_id } => commands::command_share(dir, target, task_id), Commands::Assign { target, task_id, remote, } => commands::command_assign(dir, target, task_id, remote), Commands::Pull { source, task_id } => commands::command_pull(dir, source, task_id), Commands::Inbox { remote } => commands::command_inbox(dir, remote, cli.headers), Commands::Accept { key, task_id, remote, } => commands::command_accept(dir, key, task_id, remote), Commands::Reject { key, remote } => commands::command_reject(dir, key, remote), Commands::Prop { action } => commands::command_prop(dir, action, cli.headers), Commands::Namespace { action } => commands::command_namespace(dir, action, cli.headers), Commands::Queue { action } => commands::command_queue(dir, action, cli.headers), Commands::Remote { action } => commands::command_remote(dir, action), Commands::Switch { name } => { commands::resolve_and_switch_namespace(&Workspace::from_path(dir)?, name) } Commands::Completion { shell } => { generate(shell, &mut Cli::command(), "tsk", &mut io::stdout()); Ok(()) } Commands::Skill => { io::stdout().write_all(include_bytes!("../SKILL.md"))?; Ok(()) } Commands::Serv { args } => serv::serve(dir, args), #[cfg(feature = "mcp")] Commands::Mcp => mcp::serve(dir, cli.queue), }}
/// Parse the CLI from `std::env::args()` and execute. Returns the process/// exit code so callers (the `tsk` and `git-tsk` bins) can hand it to/// `std::process::exit`.pub fn run() -> i32 { result_exit_code(dispatch(Cli::parse()))}
fn result_exit_code(result: Result<()>) -> i32 { match result { Ok(()) => 0, Err(e) => { eprintln!("{e}"); 2 } }}
/// Dedicated entry point for the `tsk-serv` binary.pub fn run_serv() -> i32 { result_exit_code(serv::run())}