↓ Skip to main content

Projects

·2614 words·13 mins·
Table of Contents

Most of what I have built belongs to somebody else. These are the ones that do not — small tools written to scratch my own itch, kept public in case the itch was not only mine.

They are all works in progress, and none of them are asking to be depended on. Have a look around anyway; issues and pull requests are welcome.

Repositories
#

org-roam-to-obsidian

Points at an org-roam vault and hands back notes Obsidian can open. ID links become wikilinks, org properties become YAML front matter, source blocks become fenced code, and the org-mode scaffolding is dropped on the way through. Standard library only, so there is nothing to install before you run it.
python3 convert.py --help
usage: convert.py [-h] [-i INPUT] [-o OUTPUT] [-p PROPERTIES [PROPERTIES ...]]
                  [--no-created] [--title-heading] [--keep-dead-links]
                  [--publish-when PROPERTY=VALUE] [--publish-key KEY]
                  [--created-key KEY [KEY ...]] [--modified-key KEY [KEY ...]]
                  [--no-modified] [--created-property CREATED_PROPERTY]

Convert org-roam files to Obsidian markdown format

options:
  -h, --help            show this help message and exit
  -i, --input INPUT     Input directory containing org-roam files (default:
                        ~/Documents/slip-box)
  -o, --output OUTPUT   Output directory for markdown files (default:
                        ./output)
  -p, --properties PROPERTIES [PROPERTIES ...]
                        Org-mode properties to extract (e.g., filetags
                        roam_refs)
  --no-created          Disable adding created timestamp from filename
  --title-heading       Repeat the title as an H1 at the top of the body. Off
                        by default, because Obsidian and Quartz already show
                        it
  --keep-dead-links     Keep wikilinks whose target does not exist, instead of
                        unwrapping them
  --publish-when PROPERTY=VALUE
                        Mark a note for publishing when it carries this org
                        property value, for example category=public
  --publish-key KEY     Frontmatter key for the publish flag (default:
                        publish)
  --created-key KEY [KEY ...]
                        One or more frontmatter keys for the created timestamp
                        (default: created). Quartz also reads created_at and
                        date
  --modified-key KEY [KEY ...]
                        One or more frontmatter keys for the modified
                        timestamp (default: modified). Quartz also reads
                        lastmod, updated and last-modified
  --no-modified         Disable adding the modified timestamp from the org
                        file's mtime
  --created-property CREATED_PROPERTY
                        Use a specific org-mode property for created timestamp
                        instead of filename

PythonGPL-3.0

gwttr

A small command-line weather client for wttr.in. The client is a proof of concept and stays that way on purpose — the point was somewhere to hang a real Go toolchain, so it comes with tests, coverage reporting and a published module.
./gwttr
Weather report: honolulu

      \   /     Clear
       .-.      26 °C
    ― (   ) ―   ↙ 23 km/h
       `-’      10 km
      /   \     0.0 mm

GoGPL-3.0

Washy Washy

washy-washy-cli

Turns a JSON chart of laundry piles into PDFs — a tall page for your phone at the machine and an A4 sheet to pin beside it, each also cut into washing-only and ironing-only halves. Every card is drawn for your appliances: you describe the washing machine and iron once, and each pile then shows the programme dial with the pointer where you turn it, the temperature and spin, which buttons to press, and the iron’s thermostat ring. Fascia labels are never translated — if the dial says Fijn/Zijde, so does the card.
Two washy-washy instruction cards side by side, each with a drawn programme dial, temperature and spin chips, and iron guidance
bun run generate --help
Usage: bun run generate [config] [--out <dir>]

  config            machine+chart config to read (default: data/washy-washy.json,
                    falling back to the committed .dist)
  --out <dir>       where the six PDFs go (default: out)

TypeScriptGPL-3.0

washy-washy-web

Live demo
The washy-washy laundry chart as a web page instead of a PDF — filter it by pile or by what’s due for a wash, download it, or share the filtered view as a link. Add it to your phone’s home screen so it opens by the machine with no address bar. Upload your own chart and machine file, or edit every field — piles, programmes, temperatures — right in the browser, all kept on your device rather than a server.
The washy-washy web app showing the bundled example laundry chart, with a filter panel for cutting it down by pile or by wash, and buttons to download the sheet as a PDF or share the current view The washing loads editor: upload or download a config, a summary of the current machine, and editable cards for every pile in the chart The machine editor: name, capacity, dial programmes in order and temperatures, all editable fields for describing your own washer and iron

AstroGPL-3.0

form-handler

Turns a contact form submission into an email, which is the whole job. It exists because a static site cannot open an SMTP connection and the mail for these domains is self-hosted, so routing the form through a third party was the alternative. One instance serves any number of forms across any number of domains, each with its own allowed origins, so one site cannot post to another’s form.
form-handler --help
Accepts contact form submissions over HTTP and emails them on.

Configured from the environment, or from a forms file when FORMS_FILE
is set. See the README for the variables it reads.

Usage:
  form-handler [flags]

Flags:
      --healthcheck   probe the local /healthz and exit
  -h, --help          help for form-handler
  -v, --version       version for form-handler

GoGPL-3.0

backup-git-repos

Mirrors every repository on a self-hosted GitLab or Forgejo instance, or a GitHub.com account, to a local, restorable copy — a bare clone of every branch and tag, kept under the forge’s own namespace structure. It tells archived repositories from active ones, refreshes existing mirrors incrementally instead of re-cloning, and can write either set out as a .tar.gz alongside the mirror.
backup-git-repos --help
Back up git repositories from GitLab, Forgejo, and GitHub

Usage:
  backup-git-repos [command]

Available Commands:
  completion  Generate the autocompletion script for the specified shell
  config      Manage the backup-git-repos config file
  help        Help about any command
  list        Print what would be backed up, clone nothing
  run         Mirror repositories from the configured forges
  version     Print the version

Flags:
  -c, --config string   config file (default: $XDG_CONFIG_HOME/backup-git-repos/config.yaml)
  -h, --help            help for backup-git-repos

Use "backup-git-repos [command] --help" for more information about a command.

GoGPL-3.0

linkwarden-obsidian-sync

Keeps a directory of Obsidian notes in sync with your saved Linkwarden links: one note per link, added, updated or removed to match Linkwarden’s current state exactly. No LLM in the loop — everything is a deterministic API call and a file write, meant to run unattended on a schedule such as a systemd --user timer. It doesn’t touch git; reviewing and committing whatever changed is your own workflow from there.
linkwarden-obsidian-sync
2026/10/19 linkwarden-obsidian-sync 2.6.5
2026/10/19 synced 2 link(s): 1 added, 1 updated, 1 removed

GoGPL-3.0

Hush-Hush

Hush-Hush

A lightweight, standalone secrets object store: injecting a value seals it with public-key encryption (age) to whichever recipient(s) the writer chooses, and the server only ever stores and serves that ciphertext — it never touches a private key or a plaintext value. Ships container-only; every fetch is audit logged. Official SDKs for PHP, Python, Node.js and Go are available.
The Hush-Hush web interface showing a list of three secrets with their ids, descriptions and timestamps, and inspect, view, edit and delete buttons for each

GoGPL-3.0

hush-hush-cli

The client for Hush-Hush: inject a secret, fetch and decrypt one, rotate a value, delete an object. age keeps the private keys and the plaintext local — this CLI only ever handles already-sealed ciphertext outside a single inject/get call.
hush-hush-cli --help
Client for the hush-hush secrets object store

Usage:
  hush-hush-cli [command]

Available Commands:
  audit-log   Query the audit trail of create/read/update/delete calls
  completion  Generate the autocompletion script for the specified shell
  consumer    Manage the consumer directory
  delete      Permanently remove an object
  edit        Change an object's description, tags or name without touching its value
  get         Fetch and decrypt one value to stdout
  health      Report whether the target server is up
  help        Help about any command
  init        Write a starter config file
  inject      Seal a value (read from stdin) and create a new object
  list        List stored objects' metadata
  status      Report whether the target server has an admin account bootstrapped yet
  token       Mint and manage consumer read tokens
  update      Seal a new value (read from stdin) and replace an object's stored value
  used-by     Print an object's recorded consumers

Flags:
      --caller string                   self-presented identity recorded in the audit log
      --consumer-token string           read-only, consumer-scoped bearer token - used by get only, as a fallback when --token isn't set
      --consumer-token-command string   command whose trimmed stdout is the consumer token instead (wins over --consumer-token if both are set)
  -h, --help                            help for hush-hush-cli
      --server string                   hush-hush server URL (default "http://localhost:8080")
      --token string                    write-path bearer token
      --token-command string            command whose trimmed stdout is the write-path bearer token (wins over --token if both are set)
  -v, --version                         version for hush-hush-cli
  -y, --yes                             write a starter config with no prompt, if none exists

Use "hush-hush-cli [command] --help" for more information about a command.

GoGPL-3.0

Movie Planner

movie-planner

Logs the movies you’ve watched and syncs each viewing to a Baikal (CalDAV) calendar. A guided prompt replaces a hand-maintained log, with ratings from OMDb and optional TMDb extras, Letterboxd and IMDb imports, JSON export, fuzzy duplicate detection and logging straight from a Pathé booking email. It is the command-line companion to movie-planner-web.
movie-planner --help
 Usage: movie-planner [OPTIONS] COMMAND [ARGS]...                               
                                                                                
 movie-planner: log watched movies and sync them to a calendar.                 
                                                                                
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --config                      Path to config.toml. Defaults to the XDG │
│                                     config location                          │
│                                     ($XDG_CONFIG_HOME/movie-planner/config.… │
│                                     or ~/.config/movie-planner/config.toml   │
│                                     if that's unset). Point this elsewhere   │
│                                     to run against a second account or a     │
│                                     test database without touching the       │
│                                     default file.                            │
│                                     [env var: MOVIE_PLANNER_CONFIG]          │
│ --caldav-url                   Override caldav.url from the config file │
│                                     for this one invocation - the config     │
│                                     file stays the persisted default; this   │
│                                     is for a throwaway run against a         │
│                                     different calendar. Also settable as     │
│                                     $MOVIE_PLANNER_CALDAV_URL, for scripting │
│                                     without a second config file.            │
│                                     [env var: MOVIE_PLANNER_CALDAV_URL]      │
│ --caldav-username              Override caldav.username from the config │
│                                     file for this one invocation. Also       │
│                                     settable as                              │
│                                     $MOVIE_PLANNER_CALDAV_USERNAME.          │
│                                     [env var: MOVIE_PLANNER_CALDAV_USERNAME] │
│ --omdb-api-key                 Override omdb.api_key from the config    │
│                                     file for this one invocation - useful    │
│                                     for trying a different key (a            │
│                                     higher-tier plan, a per-CI key) without  │
│                                     editing the config file. Also settable   │
│                                     as $MOVIE_PLANNER_OMDB_API_KEY.          │
│                                     [env var: MOVIE_PLANNER_OMDB_API_KEY]    │
│ --tmdb-api-key                 Override tmdb.api_key from the config    │
│                                     file for this one invocation. Optional - │
│                                     with no key set (here, in the config     │
│                                     file, or as                              │
│                                     $MOVIE_PLANNER_TMDB_API_KEY), trailer    │
│                                     lookups are simply skipped rather than   │
│                                     treated as an error.                     │
│                                     [env var: MOVIE_PLANNER_TMDB_API_KEY]    │
│ --db-path                     Override storage.db_path from the config │
│                                     file for this one invocation - point at  │
│                                     a scratch database for testing, or a     │
│                                     second one for a different movie log,    │
│                                     without editing the config file. Also    │
│                                     settable as $MOVIE_PLANNER_DB_PATH.      │
│                                     [env var: MOVIE_PLANNER_DB_PATH]         │
│ --verbose                           Print diagnostic detail (OMDb/TMDb       │
│                                     requests, the exact calendar payload     │
│                                     pushed, why a duplicate was or wasn't    │
│                                     detected) to stderr, for every command.  │
│                                     Normal output is unchanged - this only   │
│                                     adds detail, on top of it. Also settable │
│                                     as $MOVIE_PLANNER_VERBOSE.               │
│                                     [env var: MOVIE_PLANNER_VERBOSE]         │
│ --install-completion                Install completion for the current       │
│                                     shell.                                   │
│                                     [env var:                                │
│                                     MOVIE_PLANNER_INSTALL_COMPLETION]        │
│ --show-completion                   Show completion for the current shell,   │
│                                     to copy it or customize the              │
│                                     installation.                            │
│                                     [env var: MOVIE_PLANNER_SHOW_COMPLETION] │
│ --help                              Show this message and exit.              │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Commands ───────────────────────────────────────────────────────────────────╮
│ init              Write a starter config.toml, ready to edit. Prompts for    │
│                   the                                                        │
│                   CalDAV URL, CalDAV username, and OMDb API key - unless     │
│                   already                                                    │
│                   given as a flag or environment variable - or fails clearly │
│                   instead                                                    │
│                   of hanging when not running in a terminal (issue #144).    │
│                   The CalDAV                                                 │
│                   password stays out of it, same as everywhere else: edit it │
│                   in by                                                      │
│                   hand afterwards, as `password` or `password_command`.      │
│ bug-report        Generates a diagnostic report safe to share - with an AI   │
│                   session, in                                                │
│                   a GitHub issue, with anyone helping debug (issue #256).    │
│                   Never                                                      │
│                   includes CalDAV credentials, API keys, entry titles/notes, │
│                   or venue                                                   │
│                   names - only tool/Python version, config shape, and store  │
│                   counts.                                                    │
│ log               Interactively log a watched movie.                         │
│ list              List logged entries.                                       │
│ export            Write the entries as JSON that `import` and                │
│                   movie-planner-web both read.                               │
│ show              Show one logged entry's full metadata, with the poster     │
│                   rendered                                                   │
│                   inline where the terminal supports it (iTerm2/WezTerm or   │
│                   Kitty/Ghostty                                              │
│                   - see display.py for why Sixel and JPEG-on-Kitty aren't    │
│                   covered).                                                  │
│ update            Update an existing logged entry. Every option is optional  │
│                   and                                                        │
│                   independent - give only the fields that are actually       │
│                   changing;                                                  │
│                   anything omitted keeps its current value.                  │
│ delete            Delete a logged entry.                                     │
│ purge             Delete every event from the calendar. Local entries are    │
│                   kept.                                                      │
│ import            Bulk import viewing entries from a CSV or JSON file, or    │
│                   pipe JSON                                                  │
│                   (an array, or a single row object) in on stdin when no     │
│                   path is                                                    │
│                   given. A row that already supplies every OMDb-derived      │
│                   field itself                                               │
│                   (ratings, poster, director, actors, genre, release year)   │
│                   skips the                                                  │
│                   OMDb lookup for that row alone, regardless of              │
│                   --no-metadata.                                             │
│ activity          Show the CLI's own local activity log - every              │
│                   create/update/delete                                       │
│                   it's made, most recent first, with which fields changed    │
│                   and their                                                  │
│                   before/after values for an update (issue #276). Local to   │
│                   this app                                                   │
│                   only, mirroring movie-planner-web's own log rather than    │
│                   sharing one                                                │
│                   with it - a separate, bigger cross-repo design question    │
│                   that's been                                                │
│                   deliberately punted on.                                    │
│ from-pathe-email  Parse a Pathé booking confirmation email and log or update │
│                   the                                                        │
│                   matching entry. Reads from the given file, or from stdin   │
│                   when no path                                               │
│                   is given - e.g. `cat ticket.eml | movie-planner            │
│                   from-pathe-email`. A                                       │
│                   re-sent confirmation for a booking already logged (matched │
│                   by its                                                     │
│                   booking number) updates that entry instead of creating a   │
│                   second one.                                                │
│ locations         Manage the medium and venue lists.                         │
│ sync              Manage calendar sync.                                      │
│ import-failures   Inspect and clear past import failures.                    │
╰──────────────────────────────────────────────────────────────────────────────╯

PythonGPL-3.0

service-not-ready

One Caddy container that answers with a self-refreshing 503 page while the service behind your reverse proxy starts, plus matching error pages for the other statuses a proxy can produce. Every page is a single file with nothing loaded from elsewhere.
The service not ready page: a neon cityscape behind a card reading Service not ready, a checking-again countdown and a Retry now button

HTMLGPL-3.0

forge-dashboard

A self-hosted dashboard of open pull requests, issues and CI status across every repository you can write to on GitHub and a Forgejo instance, so there is one page instead of two forge UIs. It has passwordless login with passkeys, per-user tokens, sharing and an admin role.
The forge-dashboard pull requests page listing open pull requests from GitHub and Forgejo repositories, each with a CI status of passed, failed or running, below a row of filters

GoAGPL-3.0