Plain-text, pass-style bookmarks
Project description
bm — plain‑text bookmarks
A tiny, stdlib‑only bookmark manager inspired by the Unix philosophy and pass:
- One text file per bookmark (
.bm) with front matter + freeform notes - Human‑readable paths with a short hash to avoid collisions
- Stable IDs derived from the URL (rename‑safe)
- Greppable store; composable CLI
- Atomic writes & path‑safety checks
- JSON / JSONL output for pipelines
- Netscape HTML import/export for browser interoperability
- Optional Git sync for history and cross‑device
Works on macOS, Linux, WSL, and Windows (PowerShell). No third‑party dependencies.
Table of contents
Why bm?
Most bookmark tools are databases or browser‑locked. bm chooses text first: plain UTF‑8 files that last decades, are easy to diff, and play well with your editor, shell, and Git. It embraces "do one thing well" and stays small so you can integrate it anywhere.
Install
Requires Python >=3.8. Install from PyPI:
pip install bkmrk
Alternatively, using uv to install system-wide as a tool:
uv tool install bkmrk
For the latest development version, clone and install locally:
git clone https://github.com/jtabke/bkmrk
cd bkmrk
python -m pip install .
Or with uv:
git clone https://github.com/jtabke/bkmrk
cd bkmrk
uv pip install .
Development workflows can pull in the optional extras declared in pyproject.toml:
python -m pip install -e '.[dev]'
Or with uv:
uv pip install -e '.[dev]'
Prefer running straight from the repository? The module entry point works without installation:
python -m bm --help
On Windows (PowerShell):
python -m bm --help
Quickstart
# initialize a new store (optionally a git repo)
bm init --git
# import bookmarks from a browser export (Netscape HTML)
bm import ~/Downloads/bookmarks.html
# add a bookmark
bm add https://example.com -n "Example" -t ref,demo -d "Short note"
# list newest bookmarks (ID, path, title, URL)
bm list
# search across title/url/tags/body
bm search kernel
# search within a specific path
bm search kernel --path dev/linux
# list directory prefixes
bm dirs
# open the first result
ID=$(bm search kernel --jsonl | head -1 | jq -r '.id')
bm open "$ID"
# export for browsers (Netscape HTML)
bm export netscape > bookmarks.html
Concepts
Store layout
Default store directory is ~/.bookmarks.d (override via $BOOKMARKS_DIR). Each bookmark is a single .bm file; directories serve as namespaces.
~/.bookmarks.d/
dev/python/fastapi-3a1b2c4.bm
news-ycombinator-com-1234567.bm
README.txt
File names are human readable and end with a short hash of the URL to avoid collisions.
Bookmark file format
Each .bm file contains front matter and an optional body:
---
url: https://example.com/blog/post
title: Great post
tags: [read, blog, "needs,comma"]
created: 2025-09-16T08:42:00-07:00
modified: 2025-09-17T09:10:00-07:00
---
Longer notes, checklists, code blocks…
tagsis a list and supports quoting for commas/spaces- Any extra keys are preserved on round‑trip
IDs
Each bookmark has a stable ID derived from its URL (BLAKE2b short hash). The ID does not change if you rename/move the file. You can use either the ID or a path‑like slug with commands.
CLI usage
Run bm --help or bm <command> --help for command details.
init
Create a store; optional --git initializes a Git repo.
bm init --git
add
Add a bookmark. --edit opens your $EDITOR with a pre‑filled template.
bm add <url> [-n TITLE] [-t tag1,tag2] [-d NOTES] [-p dir1/dir2] [--id SLUG] [--edit] [-f]
Prints the stable ID on success.
list
List bookmarks (newest first).
bm list [--host HOST] [--since ISO|YYYY-MM-DD] [-t TAG] [--path PREFIX] [--json|--jsonl]
search
Default semantics: case‑insensitive substring AND across title, url, tags, and body. Each whitespace‑separated word in the query must appear somewhere; quote phrases at the shell level if you need a single token.
bm search <query> [-t TAG] [--host HOST] [--since ISO|YYYY-MM-DD] [--path PREFIX] [--regex] [--field FIELD] [--json|--jsonl]
--regextreats the query as a Python regex (case‑insensitive).--field {title,url,tags,body}restricts the scope; repeat to search multiple fields.- Exits 0 when at least one match is printed, 1 when there are no matches (so
bm search foo && open …works as expected).
show and open
Display metadata/notes or open the URL in your default browser:
bm show <ID|path>
bm open <ID|path>
edit, rm, mv
bm edit <ID|path> # bumps modified timestamp
bm rm <ID|path>
bm mv <SRC> <DST> [-f]
tags and tag add|rm
List discovered tags (from folder segments and header tags), or mutate tags without opening an editor.
bm tags
bm tag add <ID|path> tag1 tag2
bm tag rm <ID|path> tag1
dirs
List all known directory prefixes in the bookmark store.
bm dirs [--json]
dedupe
Merge duplicate bookmarks that resolve to the same normalized URL. The command unions tags (including folder segments), keeps the most informative entry, and appends any extra notes with provenance markers.
bm dedupe [--dry-run] [--json]
--dry-runprints the planned merges without modifying files--jsonemits a machine-readable summary of the merge actions
URL normalization for duplicate detection:
- Lowercases the scheme/host and strips only a leading
www.host label. - Treats
http://andhttps://versions of the same URL as equivalent. - Removes default ports (
:80for HTTP/schemeless URLs,:443for HTTPS), but preserves non-default ports. - Collapses duplicate path slashes, resolves
./..path segments, and ignores trailing/. - Drops fragments (
#section). - Preserves path params (
;param) and userinfo (user:pass@host) in the dedupe key. - Sorts query parameters while preserving duplicate keys and blank values.
- Treats host-like schemeless inputs such as
example.com/pathandlocalhost:8000/pathas web URLs.
Auto-generated bookmark slugs use similar host/path parsing, but exclude userinfo so credentials are not written into filenames.
export and import
Netscape HTML (for browsers) and JSON exports; Netscape import with folder hierarchies preserved.
bm export netscape [-t TAG] [--host HOST] [--since ISO|YYYY-MM-DD] [--path PREFIX] > bookmarks.html
bm export json [-t TAG] [--host HOST] [--since ISO|YYYY-MM-DD] [--path PREFIX] [--jsonl] > dump.json
bm import bookmarks.html [-f]
sync
If the store is a Git repo, stage/commit and (if upstream exists) push.
bm sync
Filtering & output formats
--hostmatches the URL host (case‑insensitive, ignores leadingwww.)--pathfilters by path prefix (e.g.,--path dev/pythonshows only entries under that directory tree)--sinceacceptsYYYY-MM-DDor full ISO timestamps; comparisons are proper datetimes--jsonemits a single JSON array;--jsonloutputs one JSON object per line (NDJSON)
Common JSON schema fields: id, path, title, url, tags, created, modified.
Integration recipes
fzf launcher
bm list --jsonl | fzf --with-nth=2.. | awk '{print $1}' | xargs -r bm open
Open the latest saved from a host
bm list --host example.com --jsonl | head -1 | jq -r '.id' | xargs -r bm open
Rofi launcher
#!/bin/sh
choice="$(
bm list --jsonl |
jq -r '.id + "\t" + .title + " — " + .url' |
rofi -dmenu -i -p "bm"
)"
if [ -n "$choice" ]; then
bm open "$(printf "%s" "$choice" | cut -f1)"
fi
Save as bm-rofi.sh, make it executable (chmod +x bm-rofi.sh), and bind it to a hotkey. The
tab delimiter keeps IDs intact even when titles contain spaces; rofi shows the full title and URL
while bm open receives only the bookmark ID.
dmenu launcher
#!/bin/sh
choice="$(
bm list --jsonl |
jq -r '.id + "\t" + .title + " — " + .url' |
dmenu -l 15 -i -p "bm"
)"
if [ -n "$choice" ]; then
bm open "$(printf "%s" "$choice" | cut -f1)"
fi
You can adjust -l 15 to change the number of visible rows. Because the script uses tabs between
the ID and the description, cut -f1 reliably extracts the ID even when titles or URLs contain
spaces.
Bulk tag HN links
bm list --host news.ycombinator.com --jsonl | jq -r '.id' | xargs -n1 bm tag add hn
List bookmarks in a specific category
bm list --path dev/python
bm search "framework" --path dev
Explore directory structure
bm dirs
bm dirs --json | jq
Export → browser import
bm export netscape > ~/Desktop/bookmarks.html
# Import that file in your browser’s bookmarks manager
Sync with Syncthing
For cross-device synchronization without Git, use Syncthing to sync your bookmark store:
- Install Syncthing on all devices.
- Add your bookmark store directory (
~/.bookmarks.dor$BOOKMARKS_DIR) as a synced folder in Syncthing. - Configure devices to share the folder bidirectionally.
- Syncthing will keep your bookmarks in sync across devices automatically.
Auto-export for browser import
To automatically export bookmarks to Netscape HTML for browser import:
#!/bin/bash
# auto_export.sh
bm export netscape > ~/bookmarks_auto.html
echo "Bookmarks exported to ~/bookmarks_auto.html. Import this file in your browser."
Run this script periodically or on demand to generate an up-to-date bookmark file for browser import.
Configuration
- Store directory: set
BOOKMARKS_DIRor pass--storeto any command - Editor:
VISUALorEDITOR(supports commands likecode --wait) - Debug: set
BM_DEBUG=1to re-raise unexpected exceptions with a full traceback (otherwise printed as a one-linebm: <Type>: <msg>to stderr with exit code 2) - Shell completion: install the
completionextra (pip install 'bkmrk[completion]') for argcomplete. Then enable global completion (activate-global-python-argcomplete) or wire it per‑shell witheval "$(register-python-argcomplete bm)".
Windows notes:
- Paths avoid reserved names and use atomic replaces; long paths depend on OS settings
Security & robustness
- Atomic writes: all modifications write to a temp file then
os.replaceit - Path safety:
..and absolute paths are rejected; files cannot escape the store - No network by default:
bmnever fetches content (future hooks can) - Git: pushes only if an upstream is configured
Development
# lint (optional) — stdlib only, so just run the script
python3 -m compileall src
# run tests (if added)
pytest -q
Roadmap / ideas
bm reindex+ optional on‑disk index for very large stores- Markdown/CSV exports
- Simple HTTP UI (
bm serve) and browser extension hooks - Optional encryption (GPG or git‑crypt) for private notes
License
See LICENSE.
MIT. Do what you want; a credit is appreciated.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file bkmrk-0.3.0.tar.gz.
File metadata
- Download URL: bkmrk-0.3.0.tar.gz
- Upload date:
- Size: 46.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1700c2558c57a66efbf541643407331dd279177791c637881b552316f1e206e8
|
|
| MD5 |
1503d9c372233ed2a9f4b0ac37b10ffd
|
|
| BLAKE2b-256 |
6a0644e49348ca9dbba54df192904f24278a95ed533d7138d1fa594b778c7887
|
Provenance
The following attestation bundles were made for bkmrk-0.3.0.tar.gz:
Publisher:
publish.yml on jtabke/bkmrk
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
bkmrk-0.3.0.tar.gz -
Subject digest:
1700c2558c57a66efbf541643407331dd279177791c637881b552316f1e206e8 - Sigstore transparency entry: 1436966500
- Sigstore integration time:
-
Permalink:
jtabke/bkmrk@c26bb5c2de05f7fd90ff3d3d6f8bb0a6794b73f7 -
Branch / Tag:
refs/tags/0.3.0 - Owner: https://github.com/jtabke
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@c26bb5c2de05f7fd90ff3d3d6f8bb0a6794b73f7 -
Trigger Event:
push
-
Statement type:
File details
Details for the file bkmrk-0.3.0-py3-none-any.whl.
File metadata
- Download URL: bkmrk-0.3.0-py3-none-any.whl
- Upload date:
- Size: 27.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
447f9cf900aaa9c10a540936d9092153d752748e91247b4b1b9e5cd8dcf4aae7
|
|
| MD5 |
1994d63cb0ac6db4bc7ae4f61b959cde
|
|
| BLAKE2b-256 |
71d0a27b60866a98aa17ccc1ccaaf6e58f102bdc4e0751b33b8f82c3ad6a93b0
|
Provenance
The following attestation bundles were made for bkmrk-0.3.0-py3-none-any.whl:
Publisher:
publish.yml on jtabke/bkmrk
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
bkmrk-0.3.0-py3-none-any.whl -
Subject digest:
447f9cf900aaa9c10a540936d9092153d752748e91247b4b1b9e5cd8dcf4aae7 - Sigstore transparency entry: 1436966505
- Sigstore integration time:
-
Permalink:
jtabke/bkmrk@c26bb5c2de05f7fd90ff3d3d6f8bb0a6794b73f7 -
Branch / Tag:
refs/tags/0.3.0 - Owner: https://github.com/jtabke
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@c26bb5c2de05f7fd90ff3d3d6f8bb0a6794b73f7 -
Trigger Event:
push
-
Statement type: