Really Cool terminal task manager
Tejas GK| (11d ago)
I have tried enough productivity apps at this point.
I didn't want another app sitting in my dock. I didn't want another dashboard with projects, workspaces, labels, filters, notifications and a bunch of other stuff I would probably never use.
I just wanted to be able to open my terminal and type:
todo send invoice
That's it.
Then whenever I wanted to see what I had to do:
tasks
I already spend a stupid amount of time in the terminal anyway, so I started wondering: why not just keep my tasks there too?
And since I use Git for basically everything I build, I could use Git to sync and version the tasks as well.
So that's what I built.
No React. No database. No server. No API.
Just zsh + Markdown + Git.
The full source code is available on GitHub. I'll leave the link at the bottom, but I also want to explain how the interesting parts work because the whole thing is surprisingly simple.
How I Use It
Before getting into the code, here's basically the documentation.
Add something quickly
todo buy toothpaste
I use todo when something randomly enters my head and I just want to save it.
It automatically goes into my inbox.
Add a proper categorized task
task add work send invoice
Or:
task add website fix rss
I can add priorities too:
task add work --high send invoice
And due dates:
task add learning --due 2026-08-25 practice graphs
Or combine them:
task add work --high --due 2026-08-25 send invoice
See my tasks
tasks
Or just one category:
tasks work
Finish something
task done <id>
Delete something
task rm <id>
Search
task find invoice
What's due today?
task today
What have I procrastinated on?
task overdue
Just tell me what to do
task next
This one is probably my favourite.
Instead of staring at my entire list and deciding what to do, it gives me one task, prioritizing high-priority ones first.
Sync everything
task sync
That commits my task files and pushes them through Git.
And that's pretty much the application.
Where Are the Tasks Actually Stored?
This is the part I like most.
They're just files.
~/tasks/
├── inbox.md
├── work.md
├── website.md
├── learning.md
└── archive/
If I run:
task add website fix rss
it gets written to:
~/tasks/website.md
A task itself looks like this:
- [ ] [1755689240932] [high] [due:2026-08-25] Fix RSS <!-- created:2026-08-20 15:52 -->
And when I finish it:
- [x] [1755689240932] [high] [due:2026-08-25] Fix RSS <!-- created:2026-08-20 15:52 -->
I initially thought about doing something more elaborate, but then I realized Markdown already has tasks:
- [ ] Do this
- [x] Did this
Why invent another format?
I just attach some metadata to it.
Markdown Is Basically My Database
Calling Markdown a database is obviously stretching the definition a little, but for this project it does the job.
What does a task actually need?
ID
description
category
priority
due date
status
That's really not much data.
I don't need Postgres running in the background just so I can remember to buy toothpaste.
And the nice thing about plain text is that nothing is hidden from me.
I can always do:
cat ~/tasks/work.md
and there are my tasks.
If my script completely breaks tomorrow, I haven't lost anything.
I can open the files in VS Code, Vim, Obsidian or literally any text editor.
The task Command
The CLI basically starts with one big router:
task() {
task_init
command="$1"
shift
case "$command" in
add)
task_add "$@"
;;
list|ls)
task_list "$@"
;;
done)
task_done "$1"
;;
remove|rm|delete)
task_remove "$1"
;;
find|search)
task_find "$@"
;;
today)
task_today
;;
overdue)
task_overdue
;;
next)
task_next
;;
sync)
task_sync
;;
*)
task_help
;;
esac
}
Nothing fancy is happening here.
Suppose I type:
task add work send invoice
Initially:
$1
is:
add
So I store that:
command="$1"
Then:
shift
basically says:
Cool, we're done with the first argument. Move everything over.
So now the remaining arguments are:
work send invoice
The case statement sees that the command was add and sends the remaining arguments to:
task_add "$@"
That's really the core of the CLI.
Command comes in → figure out what command it is → call the correct function.
Adding a Task
This is where things get slightly more interesting.
A simplified version of my add function looks like this:
task_add() { category="$1"
shift
priority="medium"
due=""
while [[ "$1" == --* ]]; do
case "$1" in
--high)
priority="high"
shift
;;
--low)
priority="low"
shift
;;
--due)
due="$2"
shift 2
;;
esac
done
id="$(date +%s)$(printf "%03d" $((RANDOM % 1000)))"
created="$(date "+%Y-%m-%d %H:%M")"
echo "- [ ] [$id] [$priority] [due:$due] $* <!-- created:$created -->" \
>> "$TASK_DIR/$category.md"
}
Let's say I run:
task add work --high --due 2026-08-25 send invoice
First I pull out:
work
as the category.
Then the function sees:
--high
--due
2026-08-25
send
invoice
The loop keeps processing arguments as long as they start with --.
So:
--high
sets:
priority="high"
And:
--due 2026-08-25
sets:
due="2026-08-25"
After all the flags have been consumed, whatever remains is the actual task description.
Pretty primitive argument parsing.
But it works.
Every Task Gets an ID
I needed some way to tell the CLI which task I wanted to complete or delete.
So every task gets an ID:
1755689240932
I generate it using:
id="$(date +%s)$(printf "%03d" $((RANDOM % 1000)))"
date +%s gives me the Unix timestamp.
Then I throw a small random number onto the end.
Could I use UUIDs?
Of course.
But I don't particularly want to type:
6f9619ff-8b86-d011-b42d-00cf4fc964ff
every time I finish something.
This is good enough for a task list running on my laptop.
Categories Are Literally Files
I originally even considered using Git branches for categories.
Something like:
main
work
personal
learning
website
Then I realized that's a terrible abstraction.
A Git branch represents another version of the repository.
work and personal aren't different versions of my tasks. They're just categories.
Files make much more sense.
So:
task add work something
writes to:
work.md
and:
task add website something
writes to:
website.md
No category table.
No IDs linking categories.
No schema.
The filesystem already knows how to organize files. I might as well use it.
Listing Tasks Is Basically grep
To show unfinished tasks, I scan the Markdown files:
for file in "$TASK_DIR"/*.md; do
[ -e "$file" ] || continue
open_tasks="$(grep "^- \[ \]" "$file")"
if [ -n "$open_tasks" ]; then
category="$(basename "$file" .md)"
echo "[$category]"
echo "$open_tasks"
fi
done
The important part is:
grep "^- \[ \]"
I'm basically saying:
Give me every line that starts with - [ ].Because:
- [ ] unfinished
means unfinished.
While:
- [x] finished
means done.
So grep is basically my query engine.
Completing a Task Is Even Dumber
When I run:
task done 1755689240932
I first find which file contains that ID:
grep -rl "\[$id\]" "$TASK_DIR"/*.md
Then I replace:
- [ ]
with:
- [x]
using sed:
sed -i '' "/\[$id\]/s/^- \[ \]/- [x]/" "$file"
That's it.
If this were a database-backed application, I'd probably be doing something conceptually like:
UPDATE tasks
SET completed = true
WHERE id = ?
Instead I'm doing:
[ ] → [x]
Same result.
Way less machinery.
Deleting Is Just sed Too
Deleting a task is:
sed -i '' "/\[$id\]/d" "$file"
Find the line containing the ID.
Delete it.
Done.
Due Dates
Due dates are stored directly in the task:
[due:2026-08-25]
Then:
task today
gets today's date:
today="$(date "+%Y-%m-%d")"
and searches for:
grep "\[due:$today\]"
I specifically use:
YYYY-MM-DD
because ISO dates are also really convenient to compare as strings.
So checking overdue tasks doesn't need some complicated date system either.
task next
This is probably the command I like most.
Sometimes seeing 20 tasks isn't useful.
I don't want to decide what to do.
Just give me something.
So:
task next
currently follows a very sophisticated AI-powered recommendation algorithm:
high
↓
medium
↓
low
That's it 😂.
If there is a high-priority task, give me one.
Otherwise medium.
Otherwise low.
Eventually I could calculate some sort of score using:
priority
due date
task age
category
But I don't want to overengineer it yet.
Git Is Where This Gets Really Nice
The entire ~/tasks directory is also a Git repository.
cd ~/tasks
git init
So my task manager automatically gets something most tiny scripts don't have:
history.
My sync command essentially does:
git add .
git commit -m "tasks $(date '+%Y-%m-%d %H:%M')"
git pull --rebase
git push
Then I just run:
task sync
Now my tasks are backed up remotely.
But more importantly, I can see how they changed.
task history
can basically wrap:
git log --oneline
So Git becomes this accidental history of what I've been working on.
If I delete something by mistake, Git has it.
If I want to see what I was working on three months ago, Git probably has it.
If I move to another computer, clone the repo.
For something this small, that's pretty nice.
The Whole Stack Is Almost Stupid
This is basically the architecture:
Storage → Markdown
Organization → Filesystem
Search → grep
Updates → sed / awk
History → Git
Sync → Git
Interface → zsh
That's it.
And that's actually what I like about it.
I understand every part.
There isn't some library doing magic somewhere that I don't understand.
If something goes wrong, I can open the file and see what's happening.
Why Not Just Install Taskwarrior?
I could.
Taskwarrior is far more mature than anything I'm going to build in an evening.
There are also probably hundreds of todo apps better than mine.
But that wasn't really why I made this.
I wanted something that worked exactly how I wanted it to work.
More importantly, I wanted to build it.
There's something fun about realizing:
Wait, I don't need an app for this. I can make the computer do exactly what I want.
And because it's my tool, if tomorrow I decide I want:
task whatever-random-feature-I-want
I can just add it.
No feature request.
No waiting for an update.
No subscription.
I own the thing.
What I'll Probably Add Next
There are already a few ideas.
Interactive task selection with fzf:
task pick
Recurring tasks:
task repeat gym daily
Tags:
task add learning --tag dsa practice graphs
Dependencies:
task block 123 by 456
Maybe shell autocomplete.
Maybe notifications.
Maybe eventually a proper TUI.
Knowing me, this will probably get unnecessarily complicated at some point.
You start with:
todo buy toothpaste
and somehow end up building an operating system for your life.
But for now, I'm deliberately keeping it small.
Source Code
The complete source code is available here:
It's just shell code, so you can read the whole thing, modify whatever you want and throw it into your own .zshrc.
Final Thoughts
I like software like this.
Not everything I build needs users.
Not everything needs to become a startup.
And definitely not everything needs React.
Sometimes I just have a tiny annoyance and think:
I could probably automate that.
Then I spend an hour building something specifically for myself.
This task manager is objectively not some groundbreaking piece of software. It's a bunch of shell functions manipulating Markdown files.
But it's mine.
I know exactly how it works, it does exactly what I need, and I actually use it.
And honestly, those are some of my favourite things to build.