Omahub
← All plugins
B

HabitGrid

by Blizl Labs LLC

Habit tracking from the bar: a GitHub-style contribution graph per habit, one click to log today.

Security review

Review recommended · 2 findings

Deterministic scan — not a security guarantee

Low
Risk level
Low
Analyzed commit
79d2f77
Scanned
1 month ago

Flagged patterns appear only in documentation files (README / docs) — descriptive examples, not executable code.

  • Docs external_hosts README.md:72

    Downloads or connects to an external HTTP(S) host.

    git clone https://github.com/Blizl/Omarchy-HabitGrid ~/src/habitgrid
  • Docs package_manager README.md:85

    System-wide Python package installation (not --user).

    pip

Automated analysis only — not a security guarantee.

AI advisory review

No obvious issues detected

Language-model assessment · ~deepseek/deepseek-v4-flash-latest — advisory only

Low
AI risk level
Low
Recommendation
install
Model
~deepseek/deepseek-v4-flash-latest
Analyzed commit
79d2f77
Reviewed
1 month ago

Independent review agrees with the deterministic scan's low risk: the flagged items are README-only documentation snippets (a git clone command and the word "pip" in "no pip install"), not part of the executable install path. The plugin contains no obfuscated code, no credential exfiltration, no destructive commands, and no hidden persistence. Its only out-of-directory side effect is a clearly documented, idempotent, and reversible Hyprland keybinding/config write performed by the user-invoked setup script.

  • The setup/install path modifies ~/.config/hypr/bindings.lua to bind Super+Shift+H; this is disclosed in the README and reversible via uninstall.sh, but it is a system-config write outside the plugin directory and worth a human glance at bin/setup before publishing.
  • The optional github-contributions backend can send a GitHub personal access token to api.github.com if the user configures one; the token is not exfiltrated elsewhere, but it is stored in the user's local config.
  • The deterministic scan's medium findings are documentation-only and should not block publication.
How this check works

This review combines the deterministic scan (the rule-based results above) with an independent look at the plugin's code by a language model. The model reads a trimmed sample of the repository's files, the manifest, and the README, then gives a plain-language risk level and a recommendation: install (no notable danger), review (look closer first), or avoid (clearly dangerous).

It runs on the same analyzed commit as the deterministic scan and is strictly advisory — it is not a security guarantee and never blocks a plugin by itself. A human moderator still reviews plugins before they are listed.

AI advisory only — automated analysis, not a security guarantee.

Install
$ omarchy plugin add https://github.com/Blizl/Omarchy-HabitGrid --enable
Productivity #bar #quickshell

HabitGrid

A contribution graph for your life.

The graph that got you to code every day — now for everything else.

The HabitGrid panel: a year of habit history as a contribution graph

HabitGrid is an Omarchy shell plugin. Press Super+Shift+H, press Enter, and today's square turns green. That is the whole interaction.

  • One keystroke to log. No app to open, no form, no sync spinner.
  • A year at a glance. 53 weeks of every habit, GitHub-style, with hover values and streaks.
  • Your data is a markdown file in your own vault. No account, no cloud, no database. Open it in Obsidian and it reads like notes, because it is notes.

Backends: the same panel, other data

The panel does not know what a habit is. It asks a backend for counts per day, and draws them. Point it at GitHub and the same chip row carries your commit graph next to your water intake.

{
  "sources": [
    { "backend": "obsidian-markdown", "file": "~/Obsidian/MyVault/Habits.md" },
    { "backend": "github-contributions", "username": "your-handle" }
  ]
}

One panel, two sources: habit chips and a GitHub chip sharing a chip row

Two backends ship with the plugin: obsidian-markdown (the default, and the one this is really for) and github-contributions (read-only). Up to four can be active at once, and writing a third of your own is about sixty lines.


Install

omarchy plugin add https://github.com/Blizl/Omarchy-HabitGrid --enable && ~/.config/omarchy/plugins/vliang.habitgrid/bin/setup

omarchy plugin add fetches the plugin and --enable puts it on the bar. bin/setup then does the two things a plugin is not allowed to do for itself: write a first-run config and bind Super+Shift+H. Both halves are safe to re-run — an existing config is kept, and the keybinding block is rewritten in place rather than appended a second time.

Flag Effect
bin/setup --skip-keybindings set up without touching your Hyprland config
bin/setup --override-keybindings take Super+Shift+H even if something else owns it

What it touches. No files outside ~/.config/omarchy/plugins/vliang.habitgrid, ~/.config/habitgrid, ~/.cache/habitgrid, the habit file you point it at, and — only with your consent — ~/.config/hypr/bindings.lua are modified. Nothing runs in the background and nothing phones home.

<details> <summary>From a clone, if you would rather hack on it</summary>
git clone https://github.com/Blizl/Omarchy-HabitGrid ~/src/habitgrid
~/src/habitgrid/install.sh
omarchy plugin enable vliang.habitgrid center

install.sh copies the checkout into the plugins directory, deletes files an older version left behind, and then runs bin/setup for you — same flags, same result.

</details>

./uninstall.sh removes the plugin and the binding; ./uninstall.sh --keybinding-only removes just the binding. Neither touches your habit file.

Requirements: Omarchy 4.x (Quattro) and Python 3.11+. Nothing else — no pip install, no node, no daemon.

Try it before you track anything

The repo ships a year of sample history. Point the config at it and the panel is full immediately:

{
  "sources": [
    { "backend": "obsidian-markdown",
      "file": "~/.config/omarchy/plugins/vliang.habitgrid/demo/Habits-demo.md" }
  ]
}

Switch the path back to your own file when you have seen enough.


Using it

Super+Shift+H open / close the panel
h l or ← → previous / next habit
j k or ↑ ↓ move the cell cursor a day, showing that day's value
w b move the cell cursor a week
Enter / Space / + +1 today for the selected habit
x −1 today — the undo for a mis-tapped +1. At zero the count visibly refuses rather than doing nothing
1–9 jump to the nth habit
[ ] previous / next habit, cycling
H L previous / next year
0 back to the rolling past year
n new habit — type a name, Enter creates it, Esc backs out
e edit the selected habit — the goal first; ↑ ↓ (or Tab) moves between goal and name. Enter saves both, Esc cancels, empty or 0 clears the goal
d delete the selected habit — asks first; Enter confirms, Esc keeps
Esc close

With the mouse: click a chip to switch habits, click +1 or today's square to log, hover any square for its exact value. Right-clicking today is the same undo as x. Middle-clicking the bar icon re-reads the file without opening anything, and the icon lights up once every habit has been logged today.

Every change answers in the same frame: today's square swells on a +1 and dips on an x, and the header's count rolls the same way — bigger means more, smaller means less. The number moves on the keystroke rather than after the file has been written, so holding + climbs smoothly and a press that lands while a write is still going is queued rather than dropped. The grid never guesses: its colours change only once the file has been re-read, so if a square moved, something was written.

Adding a habit without leaving the panel

The + at the end of the graph — or the n key — turns the end of the chip row into a field you type a name into. Enter writes - <name> to your habit file, the chip appears and is selected; Esc leaves nothing behind. A name may not contain : or |, and a habit you already have is refused rather than written twice — including one that differs only in case, so typing Water at a file that has water answers "habit 'water' is already defined" instead of leaving you with two chips a letter apart. Either way the reason appears next to the field.

A goal goes in the same field. Type water | goal: 8 — or just water | 8 — and the habit is created with a target of eight times a day, written to the file as SPEC's - water | goal: 8. Without one the habit is still tracked; it just scales its colours against your own typical day instead of a target. A goal has to be a positive whole number, and the reason a particular one was refused appears next to the field like any other.

The + is only there when a source can hold a new habit, so a panel showing nothing but a GitHub calendar does not offer it.

Editing a habit without leaving the panel

e edits both things a habit is, each where you already read it. The header's 6 / 8 becomes 6 / [8] with the number selected, and the habit's name above it becomes a field too. Type 12, press Enter, and the line in your file becomes - water | goal: 12. Esc leaves both alone.

The goal has the keyboard first, because it is the edit you usually came for. ↑, ↓, Tab and Shift+Tab all move to the other field — any of them, either way round, so no arrow is ever dead — and clicking the other field does the same thing. Nothing is written by moving between them; what you have typed into one stays in it. Enter saves the edit, not just the field you pressed it in.

The field that does not have the keyboard is still obviously editable: both are chips, and the one holding the keyboard wears the accent border the selected chip wears. That is the whole difference, and the ring fades across when you switch.

Clear the goal field — or type 0 — and the goal comes off. The habit stays exactly where it is, with all of its history, and goes back to scaling its colours against your own typical day. A habit that never had a goal can be given one the same way, without deleting and re-creating it.

Renaming takes the history with it. The - water in your ## Definitions becomes - hydrate, and so does every - water: 6 in the log — that is the whole point of the operation, and it is why the graph does not go dark when you fix a name you regret. Everything else in the file is byte-for-byte as it was: if your definition line is - Morning Café | goal: 3, only the name's own bytes change, and - Morning Café : 2 in the log keeps its own spacing too. The habit also keeps its place in the chip row — renaming is not deleting and re-adding.

A name another habit already has is refused, including one that differs only in case, and the message names the spelling that exists so the fix is obvious. Changing only the capitalisation of a habit's own name is allowed, though — Water to water is a real rename, and the log follows it like any other.

When you change the name and the goal at once, the name is written first and the goal against the new name, so there is only ever one name in flight. If the rename is refused nothing at all is written; if the rename lands and the goal is turned down, the new name is already real and only the goal is left open beside its reason.

e is offered for a habit whose source can change a goal or a name — either is enough, and the panel opens on whichever it has — and not while you are looking at a past year.

Deleting a habit without leaving the panel

d asks — the header turns into Delete water? · its history stays in the file, the chip goes red, and the footer offers ⏎ delete · esc keep. Enter deletes; Esc, or any other key, keeps. One keystroke never deletes anything, which is why there is no button for it — the same reason x has none.

What it deletes is the definition, not the history. The - water line leaves your ## Definitions list and every - water: 6 in the log stays exactly where it is, so adding the habit back later brings the whole graph back with it. The panel then selects the next chip along, or falls into the empty state if that was the last habit.

d is offered only for a habit whose source can undefine one, and not while you are looking at a past year.

Looking at a past year

The same panel showing 2025

The stepper in the header walks back through the years you have data for. Every view is the same shape — 53 weeks — so nothing moves when you step; only the anchor changes. A past year is read-only: the +1 becomes a Today button that takes you back, the header counts the year instead of the day, and the stats drop the current streak because a closed year does not have one.

Years appear in the stepper once they are over and have something in them, so a new user never sees the control at all.


Track your habits in Obsidian

The default backend keeps everything in one markdown file in your vault.

1. Point it at your vault

~/.config/habitgrid/config.json:

{
  "sources": [
    { "backend": "obsidian-markdown", "file": "~/Obsidian/MyVault/Habits.md" }
  ]
}

The default is ~/Obsidian/2026_Notes_Sync/Habits.md. If the file does not exist, HabitGrid creates it from the starter below, so you can skip this step entirely and move the file later.

2. The file

# Habits

## Definitions

- water | goal: 8
- exercise | goal: 1
- read

## Log

### 2026-08-21

- water: 6
- exercise: 1

That is the whole format.

  • ## Definitions is your habit list, in the order the chips appear. | goal: N is optional — a habit without one is still tracked, it just scales its colours against your own typical day instead of a target.
  • ## Log holds one ### YYYY-MM-DD section per day, newest first, with one - habit: count line per habit. Days you skipped simply are not there.

Three promises about that file:

  1. It is a normal note. Open it in Obsidian, put it in a folder, link to it, add frontmatter.
  2. Anything else you write in it is preserved untouched. Prose, tags, headings, HTML comments — HabitGrid rewrites the one count line it is changing and leaves every other byte exactly as it found it.
  3. Edits in Obsidian show up in the panel within the pull interval (30s by default), or instantly if you middle-click the bar icon.

3. Adding, renaming, removing habits

Edit the ## Definitions list in Obsidian. Add a line, and the chip appears. Delete a line, and the chip goes away — the history stays in the log, so putting the habit back brings its graph back with it.

Adding, editing and deleting are also keys. The + at the end of the graph (or n) appends a - <name> line — - <name> | goal: N if you typed a goal — at the end of the list, and touches nothing else in the file. e rewrites the | goal: N on one line, or the name on that line and on every log line that carried it, and leaves every other byte where it was. d asks first and then takes that one definition line back out, leaving the log intact. bin/habitgrid add <name> [--goal N], bin/habitgrid goal <name> <N>, bin/habitgrid rename <name> <new-name> and bin/habitgrid remove <name> do the same from a terminal.

Renaming by hand in Obsidian still works, and it is still two edits: the definition line and every log line under it. e is the one that does both.

4. Syncing

HabitGrid reads and writes one markdown file, so any vault sync works: Obsidian Sync, Syncthing, or a vault inside a Drive/iCloud folder. There is no account or API in the plugin at all.

Writes are safe across devices: every +1 re-reads the file immediately before writing and changes only the line it owns, then swaps the new file into place atomically. Two devices incrementing the same day cannot truncate each other's work, and a reader never sees a half-written file.

5. When something looks wrong

Symptom Cause
Panel says "No habits defined yet" The file has no ## Definitions list, or the path points somewhere else. Run bin/habitgrid config to see the path in effect.
A habit you added is missing Its line does not match `- name
Counts look stale The vault has not synced yet, or the pull interval has not elapsed. Middle-click the bar icon.
Nothing at all bin/habitgrid list prints the same data as the panel and the same errors, straight to your terminal.

Errors from the backend appear in the panel itself rather than being swallowed.


Link your GitHub

The github-contributions backend renders your real contribution graph in the same panel. It is read-only: no +1 button, because nobody commits by clicking a square.

{
  "sources": [
    { "backend": "github-contributions", "username": "your-handle" }
  ]
}

That is the whole setup for a public profile — no token, no OAuth, no account linking. It reads the same public contributions page anyone can see at github.com/your-handle.

Add it alongside your habits rather than instead of them, and both live in one chip row:

{
  "sources": [
    { "backend": "obsidian-markdown", "file": "~/Obsidian/MyVault/Habits.md" },
    { "backend": "github-contributions", "username": "your-handle" }
  ]
}

Private contributions (optional)

To count contributions to private repositories, add a personal access token:

{
  "sources": [
    { "backend": "github-contributions", "username": "your-handle", "token": "ghp_..." }
  ]
}

A classic token needs the read:user scope; a fine-grained token needs no extra permissions beyond the default read access to your own profile. With a token the backend uses GitHub's GraphQL API instead of the public page.

Switching back

Delete the GitHub entry from sources, or move it after your habits so the default selection lands on a habit again. Nothing else changes, and no data is touched either way: the GitHub backend only ever reads.

When something looks wrong

Symptom Cause
"no such GitHub user: x" Check the handle. It is the name in your profile URL, not your display name.
"GitHub refused the request (HTTP 403)" Rate limited, or a bad token. The public page allows plenty of requests for a 5-minute refresh; wait a few minutes.
Squares look right but are hours old Expected. The backend caches and never asks GitHub more than once every 5 minutes, whatever the pull interval says.
Offline The last successful calendar is served from cache, so the panel keeps working. It only errors if there is no cache at all.

Follows your theme

Run omarchy theme set and the panel follows: background, border, chips, the +1 button, focus rings, tooltip. The contribution squares stay green, because green means done — a red or pink ramp reads as a warning, and the accent is already doing work as the button and the selection.

▶ design/theme-switching.mp4 — 20 seconds of the same panel through Catppuccin, Nord, Catppuccin Latte and Rose Pine. Only the light and dark variants of the green ramp swap, following the theme's own lightness.

With the panel open, a theme change crossfades rather than jumps: the whole palette eases across together over about 400ms. Set HABITGRID_THEME_TRANSITION=none in the shell's environment if you would rather it switched instantly.

If you would rather the squares matched your accent, set "palette": "theme":

{ "palette": "theme" }

It builds a four-step ramp from your accent with an explicit lightness spine, and moves the focus ring and the +1 button to neutral so they stay legible against it. "github" (the default) and "theme" are the only two values, on purpose — see design/DESIGN.md §14.


Configuration

~/.config/habitgrid/config.json. Every field is optional; delete the file and everything falls back to the defaults below.

{
  "sources": [
    { "backend": "obsidian-markdown", "file": "~/Obsidian/MyVault/Habits.md" },
    { "backend": "github-contributions", "username": "your-handle" }
  ],
  "palette": "github",
  "sync": { "pullIntervalSeconds": 30, "pushMode": "immediate" }
}
Field Default Meaning
sources one markdown source An ordered list of active backends. Each entry is a backend plus that backend's options. Max 4.
sources[].backend — A bundled backend's name, or an absolute path (~ allowed) to your own executable.
palette "github" "github" for the fixed contribution greens, "theme" to derive the ramp from your accent.
sync.pullIntervalSeconds 30 How often to re-read from the backends.
sync.pushMode "immediate" Only immediate is implemented: a +1 is written the moment you press it.

Chips appear with the loggable habits first, then the other sources in the order you listed them, with a divider between groups.

<details> <summary>The older single-backend config still works</summary>
{
  "backend": "obsidian-markdown",
  "backends": {
    "obsidian-markdown": { "file": "~/Obsidian/MyVault/Habits.md" }
  }
}

This is read as a one-element sources list. Nothing needs changing unless you want a second source.

</details>

Bundled backend options:

Backend Option Default Meaning
obsidian-markdown file ~/Obsidian/2026_Notes_Sync/Habits.md The habit file. Created if missing.
github-contributions username — Required. The GitHub handle to read.
github-contributions token — Optional PAT for private contributions.
github-contributions minRefreshSeconds 300 Floor on how often GitHub is asked.

A malformed config never stops the plugin: it falls back to defaults and says why. bin/habitgrid config prints exactly what took effect.


Command line

The panel runs bin/habitgrid and nothing else, so anything it can do, you can do from a terminal or a script:

bin/habitgrid list                 # every habit: today, streak, best, total
bin/habitgrid list --year 2025     # the same, for one year
bin/habitgrid increment water      # +1 for today, prints the new count
bin/habitgrid decrement water      # -1, floored at zero
bin/habitgrid add stretch          # define a new habit, prints its name
bin/habitgrid add water --goal 8   # ...with a target of 8 times a day
bin/habitgrid goal water 12        # retarget an existing habit
bin/habitgrid goal water 0         # ...or take its goal off, keeping the habit
bin/habitgrid rename water hydrate # rename it, log lines and all
bin/habitgrid remove stretch       # undefine it again, keeping its history
bin/habitgrid view --habit water   # the exact JSON the panel renders
bin/habitgrid config               # the effective configuration
bin/habitgrid backend              # which executable would run

--date YYYY-MM-DD pretends it is another day, --config PATH uses another config file, and --source N picks which source a habit belongs to when two of them use the same name. add takes --source N too, for a config with more than one writable source; without it the habit goes to the first source that says it can hold one. remove, goal and rename are the other way round: they go to the source that has the habit, and say so when no source does.


Writing your own backend

A backend is an executable implementing two verbs — list and increment — plus any of five optional ones it has an answer for. Point backend at its absolute path and it is in charge of everything the panel shows.

<backend> list

Print one JSON object on stdout and exit 0:

{
  "canIncrement": true,
  "canAdd": true,
  "canRemove": true,
  "canSetGoal": true,
  "canRename": true,
  "years": [2026, 2025, 2024],
  "habits": [
    {
      "name": "water",
      "label": "water",
      "goal": 8,
      "today": 5,
      "history": { "2026-08-21": 5, "2026-08-20": 8, "2026-08-18": 3 }
    }
  ]
}
Field Type Required Notes
habits[].name string yes Non-empty. Habits without one are skipped.
habits[].label string no What the chip shows, when that differs from the name the tooltip reads out. Defaults to name.
habits[].goal positive integer or null no null, 0 or absent means "no goal": the ramp then scales against the habit's own typical day.
habits[].today non-negative integer no Today's count. Absent falls back to history[today], then 0.
habits[].history object yes {"YYYY-MM-DD": count} covering the display window. Only days with data need to appear.
canIncrement boolean no false hides the +1 button and makes the increment keys inert for this source's chips. Anything else, including absent, means writes are accepted.
canAdd boolean no true — and only a literal true — puts the panel's + on screen and lets habitgrid add route new habits here. The opposite default to canIncrement: increment has always been part of the contract, add has not, so silence means "does not implement it".
canRemove boolean no true — and only a literal true — offers the panel's d for this source's chips. Its own flag rather than a second reading of canAdd, because they are two optional verbs: a backend that implements add and not remove says canAdd: true truthfully, and must not be asked to delete.
canSetGoal boolean no true — and only a literal true — offers the goal field of the panel's e for this source's chips. One flag per optional verb, for the same reason: implementing add and remove says nothing whatever about set-goal.
canRename boolean no true — and only a literal true — offers the name field of the panel's e. Its own flag again, and this one asks for more than the others: the habit's whole history has to follow the name, so a backend that can only rewrite a definition must leave it out.
years array of integers no Years you hold data for beyond the window you just sent, for the year stepper. Without it the stepper only offers years the visible history happens to touch.

<backend> list --year YYYY

The same output, restricted to that calendar year. A backend that ignores the flag still works — the panel windows whatever it gets — but honouring it keeps the payload small.

history is what the graph, the hover values and the streaks are made of, so report at least HABITGRID_HISTORY_DAYS days back from today. A backend that returns less still works; the graph is just emptier.

Chips appear in the order you print them, and the first habit is selected when the panel opens.

<backend> increment <name>

Add 1 to that habit for today, print the single updated habit object — the same shape as one element of habits — and exit 0:

{ "name": "water", "goal": 8, "today": 6, "history": { "2026-08-21": 6 } }

<backend> decrement <name> (optional)

The same, one lower, floored at zero. Backends that do not implement it exit non-zero and the panel reports that; nothing else breaks.

<backend> add <name> [--goal N] (optional)

Define a habit that does not exist yet — no counts — and print the new habit object, the same shape as one element of habits:

{ "name": "stretch", "goal": null, "today": 0, "history": {} }

--goal N is passed only when the user asked for one, so a backend that predates goals still handles a plain add. N arrives as text, unvalidated: you are the one storing it, so you are the one who decides what a goal may be.

Only offered to backends whose list says "canAdd": true. Refuse anything you cannot store, non-zero and on stderr, rather than storing an approximation of it: the reason is shown beside the field the user is still typing in, so "habit 'water' is already defined" is a useful answer and "invalid" is not. The bundled markdown backend refuses an empty name, a name containing : or | (SPEC forbids both), a habit it already has under any capitalisation, and a goal that is not a positive whole number. Note that the case-folding is a rule about creating a habit, not about reading one: everything that parses the file matches names exactly, because every log line ever written did too.

<backend> set-goal <name> <goal> (optional)

Change the daily goal of a habit that exists, and print the updated habit object:

{ "name": "water", "goal": 12, "today": 5, "history": { "2026-08-21": 5 } }

<goal> arrives as text, unvalidated, exactly like add --goal. Two spellings mean the same thing and both are yours to honour: 0 and an empty string mean "take the goal away" — the habit stays, with its history, and goes back to being levelled against its own typical day. This is the one place 0 is a request rather than a mistake, which is why add --goal 0 is refused with "a goal of 0 is the same as no goal — leave it out" and set-goal water 0 simply does it.

Only offered to backends whose list says "canSetGoal": true. Refuse a habit you do not have, and a goal you cannot store, non-zero and on stderr; the reason appears beside the field the user is still typing in.

The bundled markdown backend rewrites only the | goal: N on the habit's definition line, so the name keeps the exact spelling, casing and spacing the user gave it, and every log line written against that spelling still matches. A habit's position in the list never changes either: retargeting is not deleting and re-adding.

<backend> rename <name> <new-name> (optional)

Give a habit that exists another name, and print the updated habit object, under the name it now has:

{ "name": "hydrate", "goal": 8, "today": 5, "history": { "2026-08-21": 5 } }

The history must follow the name. That is the whole operation, and it is what tells it apart from remove-and-recreate, which deliberately leaves the old counts where they are. A backend that can rewrite a habit's name but cannot carry its past along must not advertise canRename: half a rename splits one habit into two that no client can rejoin, and the panel would have no way to know it had happened.

Only offered to backends whose list says "canRename": true. Refuse a habit you do not have, a name you cannot store, and a name another habit already owns — naming the spelling that exists, as add does — non-zero and on stderr; the reason appears beside the field the user is still typing in. A rename to the name the habit already has is a no-op rather than an error: an editor prefilled with the current name and submitted unchanged is how people look at things.

The bundled markdown backend rewrites the name's bytes on the definition line and on every log line that carried it, in every date section, later duplicates included — and nothing else, so the goal's own spacing, the counts and the bullets all survive. It refuses a rename when the habit is defined twice over (a sync conflict): renaming around the duplicate would leave one copy holding the entire history and promote the other to a habit with none. A rename that only changes capitalisation is allowed, since a habit cannot collide with itself.

<backend> remove <name> (optional)

Undefine a habit that exists, and print the name that is now gone:

{ "name": "stretch", "removed": true }

Only offered to backends whose list says "canRemove": true. What happens to the history is yours to decide, and to say in your docs. The bundled markdown backend keeps every logged count and removes only the ## Definitions line, so re-adding the name restores the whole grid — which is what makes the panel's one-key delete a small enough decision to take without a dialog. A backend that genuinely destroys data should say so loudly somewhere the user will read it before pressing d.

Refuse a habit you do not have, non-zero and on stderr; the panel shows the reason rather than pretending something was deleted.

Errors

Exit non-zero with one human-readable line on stderr. The panel shows that line instead of the graph; it does not crash or blank. Unparseable stdout is reported the same way.

What the backend is told

Configuration arrives in the environment, so a backend in any language can read it without an argument parser:

Variable Example Meaning
HABITGRID_BACKEND obsidian-markdown The configured backend string.
HABITGRID_TODAY 2026-08-21 The date the plugin considers today. Honour it.
HABITGRID_HISTORY_DAYS 371 How many days of history to report.
HABITGRID_OPT_* HABITGRID_OPT_FILE One per key under backends.<backend>.

Option keys become variable names by upper-casing and splitting camelCase: file → HABITGRID_OPT_FILE, apiToken → HABITGRID_OPT_API_TOKEN, base-url → HABITGRID_OPT_BASE_URL. Strings and numbers arrive as text, booleans as true/false, arrays and objects as compact JSON.

The backend inherits the shell's environment, must not need a terminal, and has 15 seconds to answer.

A complete backend

tests/fixtures/fake-backend is a conforming backend in bash that has never heard of markdown — the test suite uses it to prove the panel works against any implementation. Copy it as a starting point.


How the colours are decided

The colour of a square is the only thing the graph encodes, so it is worth knowing the rule:

level 0        count is zero
level 1..4     clamp(ceil(4 × count / reference), 1, 4)

reference =    the habit's goal, when it has one
               otherwise the 75th percentile of its active days

So with a goal, level 4 means you met it. Without one, a typical day for that habit is level 4 — which is why a GitHub calendar with one 300-commit afternoon still shows an ordinary week as green rather than as a faint smear.

Streaks count consecutive days with any activity, ending today, or ending yesterday when today is still empty — an unlogged morning is not a broken streak.


Layout

Path What
BarWidget.qml The bar slot: an icon, a tooltip, and the open/close contract the bar routes through.
Panel.qml The panel: chips, header, +1, stats, tooltip, keyboard.
ContributionGraph.qml The graph. Draws a finished model — no dates, no goals, no habit-file knowledge.
HabitNameField.qml The inline chip you type into: a new habit's name — and goal — for n, an existing habit's goal for e.
DesignTokens.qml Every colour and measurement, themed or fixed, in one place.
bin/habitgrid Front-end: config, backend resolution, streaks and levels. All the QML ever runs.
bin/setup Post-install: a first-run config and the keybinding. What omarchy plugin add cannot do for us.
bin/habitgrid-obsidian-markdown Default backend: one markdown file in a vault.
bin/habitgrid-github-contributions Read-only backend: a GitHub contribution calendar.
lib/ The logic behind those: config, display maths, markdown, GitHub, keybindings.
design/DESIGN.md The implementation spec this UI is built from.
demo/Habits-demo.md A year of sample history.

The QML is deliberately thin: it renders JSON and shells out. Almost every rule worth testing lives in Python. The one thing the panel decides for itself — how a keypress answers before the file has caught up with it — is tested in QML, headlessly, in tests/qml/.

Tests

./tests/run              # everything
./tests/run markdown     # only suites whose name matches

No display, no network, no real habit file: the shell suites run against a throwaway $HOME under /tmp, the GitHub suite parses a fixture, and the QML is type-checked against the real shell modules when Qt's qmllint is present.

motion_test.sh goes further and runs the panel: Qt's own test runner drives the real Panel.qml and ContributionGraph.qml offscreen, with Quickshell's Process replaced by a habitgrid that keeps the file in memory, so the write queue, the burst gate and both halves of the change gesture are exercised at real animation speeds. It skips itself where Qt 6's qmltestrunner is missing.

License

MIT. See LICENSE.