mod 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 { Ok(current_dir()?) } pub(crate) fn parse_id(s: &str) -> std::result::Result { 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, /// 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, /// 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 `/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, #[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, #[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, /// 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, #[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, /// Property filter: `--where status=open`. Combines with -T flags. #[arg(long, value_name = "KEY=VALUE")] r#where: Option, /// 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, }, /// Push tsk refs to a git remote (default: origin). GitPush { remote: Option }, /// 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, #[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, #[command(flatten)] task_id: TaskId, /// Auto-push refs to this remote after assigning. Empty string skips. Default: origin. #[arg(short = 'R')] remote: Option, }, /// 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, }, /// Accept an inbox item by key (no key = top item), or accept an unqueued task by id. Accept { key: Option, #[command(flatten)] task_id: TaskId, /// Auto-push refs to this remote after accepting. Empty string skips. Default: origin. #[arg(short = 'R')] remote: Option, }, /// Reject an inbox item by key (no key = first item). Reject { key: Option, /// Auto-push refs to this remote after rejecting. Empty string skips. Default: origin. #[arg(short = 'R')] remote: Option, }, /// 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 `` sentinel for creating one on /// the fly). Switch { name: Option }, /// 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 }, /// Edit history of a queue tree (pushes, drops, inbox moves). /// Defaults to the active queue. Queue { name: Option }, } #[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, }, /// Remove a single value (or, with no value, the entire property). Unset { #[command(flatten)] task_id: TaskId, key: String, value: Option, }, /// 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, value: Option, }, } #[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 `` sentinel for creating one on the fly). Switch { name: Option }, /// 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, }, /// List unique property keys on tasks in a namespace (defaults to active). Props { name: Option }, } #[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, }, /// Switch active queue. With no name, fzf-picks from existing queues /// (plus a `` sentinel for creating one on the fly). Switch { name: Option, }, } #[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 /// ` 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, #[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>, } #[derive(Args, Default)] #[group(required = false, multiple = false)] pub(crate) struct TaskId { #[arg(short = 't', value_name = "ID")] pub(crate) id: Option, #[arg(short = 'T', value_name = "TSK-ID", value_parser = parse_id)] pub(crate) tsk_id: Option, #[arg(short = 'r', value_name = "RELATIVE")] pub(crate) relative_id: Option, /// 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 { 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 { 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 { 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 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()) }