Tejas GK

Really Cool terminal task manager

Tejas GK| (11d ago)

10 min read

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:

View the source code

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.

Here is the Link to replicate for yourself as well