Recently Written · git

sbm

dmenu bookmarks. Instantly fuzzy search thousands of bookmarks and plumb them. LOOKING FOR SUCKLESS SOFTWARE EDITION? GO TO sbm-suckless INSTEAD

git clone https://github.com/equwal/sbm

Log | Files | Refs


commit 55cccc49b46fdae17ffb4d6d35b6662a1547e112
Spenser Truex <truex@equwal.com>
2026-09-20 21:58:47 -0700

packaging: Homebrew, Gentoo, Arch, Debian, Chocolatey, Scoop, Nix, RPM, Alpine, Void, FreeBSD, MacPorts, Guix

Templates. SBM_LICENSE_TBD and the SBM_*_TBD checksums wait for a LICENSE and a v0.3 tag; packaging/README.md lists every line.

 packaging/README.md                                | 244 +++++++++++++++++++++
 packaging/alpine/APKBUILD                          |  55 +++++
 packaging/arch/sbm-git/PKGBUILD                    |  67 ++++++
 packaging/arch/sbm/PKGBUILD                        |  59 +++++
 packaging/chocolatey/sbm.nuspec                    |  76 +++++++
 packaging/chocolatey/tools/chocolateyInstall.ps1   | 166 ++++++++++++++
 packaging/chocolatey/tools/chocolateyUninstall.ps1 |  22 ++
 packaging/debian/apt/README.md                     | 129 +++++++++++
 packaging/debian/debian/changelog                  |   8 +
 packaging/debian/debian/control                    |  45 ++++
 packaging/debian/debian/copyright                  |  23 ++
 packaging/debian/debian/docs                       |   2 +
 packaging/debian/debian/examples                   |   2 +
 packaging/debian/debian/rules                      |  19 ++
 packaging/debian/debian/source/format              |   1 +
 packaging/debian/debian/tests/control              |   9 +
 packaging/debian/debian/tests/smoke                |  28 +++
 packaging/debian/debian/tests/upstream-suite       |   8 +
 packaging/debian/debian/upstream/metadata          |   5 +
 packaging/debian/debian/watch                      |   4 +
 packaging/freebsd/deskutils/sbm/Makefile           |  65 ++++++
 packaging/freebsd/deskutils/sbm/distinfo           |   3 +
 packaging/freebsd/deskutils/sbm/pkg-descr          |  15 ++
 packaging/freebsd/deskutils/sbm/pkg-plist          |  11 +
 packaging/gentoo/app-misc/sbm/metadata.xml         |  39 ++++
 packaging/gentoo/app-misc/sbm/sbm-0.3.ebuild       |  72 ++++++
 packaging/gentoo/app-misc/sbm/sbm-9999.ebuild      |  56 +++++
 packaging/guix/sbm.scm                             | 133 +++++++++++
 packaging/homebrew/sbm.rb                          |  76 +++++++
 packaging/macports/Portfile                        |  85 +++++++
 packaging/nix/default.nix                          | 106 +++++++++
 packaging/nix/flake.nix                            |  34 +++
 packaging/rpm/sbm.spec                             |  87 ++++++++
 packaging/scoop/sbm.json                           |  68 ++++++
 packaging/void/srcpkgs/sbm/template                |  44 ++++
 35 files changed, 1866 insertions(+)
diff --git a/packaging/README.md b/packaging/README.md
new file mode 100644
index 0000000..9ea3518
--- /dev/null
+++ b/packaging/README.md
@@ -0,0 +1,244 @@
+# Packaging sbm
+
+Package definitions for sbm 0.3, one directory per format. Nothing here is
+submitted anywhere yet, and nothing here changes the rest of the repository.
+
+Two facts shape every file below.
+
+1. **There is no licence.** The repository has no `LICENSE` file and no
+   licence has been chosen. Every format needs a licence field, so every
+   licence field holds the token `SBM_LICENSE_TBD`. It is not a guess and it
+   is not a default. Every occurrence is listed under
+   [Placeholders](#placeholders).
+2. **There is no release tag and no tarball.** The source URL pattern is
+   `https://github.com/equwal/sbm/archive/refs/tags/v0.3.tar.gz`, which
+   returns 404 today. Checksums hold placeholder tokens. Where a format has a
+   live or VCS variant, it is provided as well, because those work now:
+   `arch/sbm-git`, `gentoo/.../sbm-9999.ebuild`, and `head` in the Homebrew
+   formula.
+
+This is the edition without cloud sync. Nothing here packages, requires or
+mentions a sync client.
+
+## What gets installed
+
+`make install` copies the programs in `TOOLS` to `${DESTDIR}${PREFIX}/bin`:
+
+    bm  bm-migrate  bm-import  bm-check  bm-html  bm-title  bm-commit
+
+Nothing is compiled. There are no man pages. Every package calls
+`make DESTDIR=... PREFIX=... install` rather than copying the scripts itself,
+except the Windows packages, which unpack the release archive and generate
+`.cmd` shims instead.
+
+`README` is installed as documentation. `usertags` and `engines` are
+examples, not configuration: they go to the examples directory of whatever
+format is in use, and the user copies them to `~/.local/share/sbm/`.
+
+## Dependencies, as the packages express them
+
+| | Programs | Why |
+|---|---|---|
+| Required | POSIX sh, awk | everything |
+| Required | dmenu **or** fzf | the menu |
+| Required | xclip **or** xsel **or** wl-clipboard | `bm --copy` |
+| Recommended | xdg-utils | opening URLs (`xdg-open`) |
+| Optional | curl | `bm-title`, `bm-check` |
+| Optional | jq | `bm-import` of Chromium JSON files |
+| Optional | git | `bm-commit` |
+
+Only Debian, Gentoo and RPM can say "a or b". The others pick one program as
+the hard dependency and name the alternatives in the optional list, with a
+comment saying so:
+
+* **Arch, FreeBSD, Gentoo, Debian, RPM**: dmenu is the first choice, because
+  `bm` prefers it whenever there is a display.
+* **Alpine, Void, Homebrew, MacPorts, Chocolatey, Scoop, Nix**: fzf, because
+  it needs no display, and on macOS and Windows dmenu is not an option at
+  all.
+
+`bm` picks dmenu when `$DISPLAY` or `$WAYLAND_DISPLAY` is set and fzf
+otherwise, so installing both is fine and needs no configuration.
+
+## The formats
+
+| Format | Path | Build and test locally | Where it goes | Status |
+|---|---|---|---|---|
+| Homebrew | `homebrew/sbm.rb` | `brew install --build-from-source ./sbm.rb`, `brew test sbm`, `brew audit --strict --new sbm` | Your own tap (`brew tap equwal/sbm`) first. homebrew-core has a notability bar: roughly 30 forks, 30 watchers, 75 stars, and a maintained, versioned release. sbm meets none of it today. | Written, not built. `license` and `sha256` are placeholders, so `brew audit` fails until they are real. `head` works now. |
+| Gentoo | `gentoo/app-misc/sbm/` | Put it in a local overlay, then `ebuild sbm-0.3.ebuild manifest`, `emerge -av app-misc/sbm`, `FEATURES=test emerge app-misc/sbm`, `pkgcheck scan` | GURU (the user overlay) first: it takes anyone. `::gentoo` needs a developer or the proxy-maintainers project; `metadata.xml` already names proxy-maint. | Written, not built. `pkgcheck` will flag `SBM_LICENSE_TBD` as a nonexistent licence. `sbm-9999.ebuild` works now. |
+| Arch | `arch/sbm/PKGBUILD`, `arch/sbm-git/PKGBUILD` | `makepkg -si`, `makepkg --printsrcinfo > .SRCINFO`, `namcap PKGBUILD` and `namcap *.pkg.tar.zst` | AUR: `git clone ssh://aur@aur.archlinux.org/sbm.git`, commit `PKGBUILD` and `.SRCINFO`, push. No review queue. `[extra]` needs a Trusted User. | Written, not built. `sbm-git` works now; `sbm` needs the tag. `.SRCINFO` is not committed here: it is generated, and it would go stale. |
+| Debian | `debian/debian/`, notes in `debian/apt/README.md` | `dpkg-buildpackage -us -uc -b`, `lintian -i -I --pedantic`, `autopkgtest`. See `debian/apt/README.md`. | Debian proper needs an ITP bug and a sponsor through mentors.debian.net, which takes weeks. Your own signed apt repository (reprepro or aptly) works the same day; `debian/apt/README.md` covers both. | Written, not built. `debian/rules` and `debian/tests/*` need the execute bit set after copying. |
+| Chocolatey | `chocolatey/sbm.nuspec`, `chocolatey/tools/` | `choco pack` (done, see below), then `choco install sbm -s .` on a throwaway machine | community.chocolatey.org, with automated checks plus human moderation. Moderators dislike embedded binaries and like package scripts that download from the official source, which is what this does. | `choco pack` succeeds. Not installed or pushed from here. |
+| Scoop | `scoop/sbm.json` | `scoop install .\sbm.json`, `scoop checkver sbm <bucket>`, `scoop bucket add` your own bucket | Your own bucket repository. `ScoopInstaller/Extras` takes pull requests; `Main` is for widely used, well known programs. | Written, not installed. JSON parses and the shim generator was run for real. |
+| Nix | `nix/default.nix`, `nix/flake.nix` | `nix build -f default.nix`, or `nix build .#sbm` in this directory, then `nixpkgs-review` | A pull request against NixOS/nixpkgs, package file under `pkgs/by-name/sb/sbm/package.nix`. | Written, not evaluated. `meta.license` must become `lib.licenses.<id>`, an attribute and not a string, before nixpkgs will take it. |
+| RPM | `rpm/sbm.spec` | `rpmbuild -ba sbm.spec`, `mock -r fedora-rawhide-x86_64 --rebuild *.src.rpm`, `rpmlint sbm.spec` | Fedora: a package review bug in Bugzilla and a sponsor for the first package. openSUSE: a submit request to Factory through the Open Build Service, which is faster. | Written, not built. Rich dependencies `(dmenu or fzf)` need rpm 4.13 or newer: Fedora, and openSUSE Leap 15 and later. |
+| Alpine | `alpine/APKBUILD` | `abuild -r`, `abuild checksum`, `apkbuild-lint APKBUILD` | A merge request against `alpinelinux/aports`, in `community/`. | Written, not built. Alpine hashes with sha512, so the token there is `SBM_SHA512_TBD`. |
+| Void | `void/srcpkgs/sbm/template` | Inside a void-packages checkout: `./xbps-src pkg sbm`, `xlint srcpkgs/sbm/template` | A pull request against `void-linux/void-packages`. | Written, not built. |
+| FreeBSD | `freebsd/deskutils/sbm/` | In a ports tree: `make makesum`, `make stage`, `make check-plist`, `make test`, `portlint -A` | A `ports` bug in Bugzilla with the shar or a patch, or a pull request against `freebsd/freebsd-ports`. | Written, not built. `USES=gmake` is needed; see [Upstream notes](#upstream-notes). |
+| MacPorts | `macports/Portfile` | `port lint --nitpick`, `sudo port -v install` from a local ports tree | A pull request against `macports/macports-ports`. | Written, not built. `checksums` needs three tokens replaced, not one. |
+| Guix | `guix/sbm.scm` | `guix build -L packaging/guix sbm`, `guix shell -L packaging/guix sbm -- bm --list`, `guix lint -L packaging/guix sbm` | A patch to `guix-patches@gnu.org`, or a channel of your own, which works immediately. | Written, not evaluated. The `#:use-module` lines are a best guess at where each program lives in `gnu/packages/`; `guix build` will say if one is wrong. |
+
+## Placeholders
+
+One search and replace per token finishes each job. Nothing else in these
+files is a placeholder.
+
+### `SBM_LICENSE_TBD` — 31 lines in 16 files
+
+Replace with the licence, in whatever spelling the format wants: an SPDX
+identifier for most, a name from `licenses/` for Gentoo, an abbreviation from
+`Mk/bsd.licenses.db.mk` for FreeBSD, a `(guix licenses)` variable for Guix, a
+`lib.licenses` attribute for Nix, and the full text plus a short name for
+Debian.
+
+| File | Lines |
+|---|---|
+| `alpine/APKBUILD` | 10 (comment), 13 |
+| `arch/sbm/PKGBUILD` | 10 (comment), 14 |
+| `arch/sbm-git/PKGBUILD` | 14 |
+| `chocolatey/sbm.nuspec` | 16 (comment), 19 |
+| `debian/apt/README.md` | 13 (prose) |
+| `debian/debian/copyright` | 8, 12, 14, 18 (prose) |
+| `freebsd/deskutils/sbm/Makefile` | 11 (comment), 14 |
+| `gentoo/app-misc/sbm/sbm-0.3.ebuild` | 14 (comment), 16 |
+| `gentoo/app-misc/sbm/sbm-9999.ebuild` | 14 (comment), 15 |
+| `guix/sbm.scm` | 128 (comment), 131 |
+| `homebrew/sbm.rb` | 7 (comment), 9 |
+| `macports/Portfile` | 26 (comment), 29 |
+| `nix/default.nix` | 101 |
+| `rpm/sbm.spec` | 7 (comment), 10 |
+| `scoop/sbm.json` | 10 (comment), 17 |
+| `void/srcpkgs/sbm/template` | 15 (comment), 17 |
+
+One of these is not a bare token. `choco pack` refuses a `licenseUrl` that
+does not parse as a URL (error CHCU0001), so
+`chocolatey/sbm.nuspec` line 19 carries the token inside a URL path:
+`https://github.com/equwal/sbm/blob/master/SBM_LICENSE_TBD`. That URL does
+not resolve. The same search and replace still finds it.
+
+### Checksum placeholders
+
+No tag means no tarball means no checksum. Four tokens, because the formats
+do not agree on a hash.
+
+`SBM_SHA256_TBD` — 15 lines in 9 files:
+
+| File | Lines | Replace by running |
+|---|---|---|
+| `arch/sbm/PKGBUILD` | 32 (comment), 33 | `makepkg -g` |
+| `chocolatey/tools/chocolateyInstall.ps1` | 24 (comment), 26 | `Get-FileHash .\v0.3.tar.gz -Algorithm SHA256` |
+| `freebsd/deskutils/sbm/distinfo` | 2 | `make makesum` |
+| `guix/sbm.scm` | 40 (comment), 42 | `guix download <url>` (base32, not hex) |
+| `homebrew/sbm.rb` | 5 | `brew fetch --build-from-source sbm` |
+| `macports/Portfile` | 34 | `port -v checksum sbm` |
+| `nix/default.nix` | 34 (comment), 36 | `nix-prefetch-url --unpack <url>` (SRI, not hex) |
+| `scoop/sbm.json` | 11 (comment), 26 | `scoop checkver sbm <bucket> -u` |
+| `void/srcpkgs/sbm/template` | 21 (comment), 22 | `xgensum -i srcpkgs/sbm/template` |
+
+`SBM_SHA512_TBD` — `alpine/APKBUILD` lines 53 (comment) and 55. Alpine hashes
+with sha512. Replace by running `abuild checksum`.
+
+`SBM_RMD160_TBD` and `SBM_SIZE_TBD` — `macports/Portfile` lines 33 and 35.
+MacPorts wants rmd160, sha256 and the byte size. `port -v checksum sbm`
+prints all three.
+
+`freebsd/deskutils/sbm/distinfo` also has `TIMESTAMP = 0` and `SIZE ... = 0`.
+`make makesum` rewrites the whole file, so those need no hand editing.
+
+### Native "skip" values, used on purpose
+
+`arch/sbm-git/PKGBUILD` line 29 uses `sha256sums=('SKIP')`. That is correct,
+not a placeholder: a git source has no fixed archive to hash.
+
+## Upstream notes
+
+Things a reviewer will raise, none of which are fixed in this directory,
+because nothing outside `packaging/` was touched.
+
+* **No `LICENSE` file, no licence chosen.** Every repository above will
+  refuse the package, and an unlicensed work is not distributable at all.
+  This is the one blocker.
+* **No tags.** Nine of the twelve formats need a versioned archive.
+  `debian/watch`, `checkver` and `autoupdate` all look for `v*` tags and find
+  nothing.
+* **No man pages.** `TODO` lists them as unfinished. Debian, Fedora and
+  FreeBSD all expect a man page for a program in `bin`, and lintian will say
+  so. `config.mk` defines `MANPREFIX` and the `Makefile` never uses it.
+* **`Makefile` needs GNU make, not POSIX make.** The first line is
+  `include config.mk`. BSD make wants `.include "config.mk"` and cannot parse
+  the file at all. That is why the FreeBSD port sets `USES=gmake`, which is
+  an odd thing to need in a project whose README says it keeps to POSIX. POSIX
+  make only gained `include` in the 2024 edition. Changing the line to
+  `.include` would break GNU make instead; supporting both means a small
+  shim, or moving the variables into the `Makefile`.
+* **`install` and `uninstall` disagree.** `install` copies `${TOOLS}`;
+  `uninstall` removes `${SCRIPTS}`, the full list. Harmless for packages,
+  which never call `uninstall`, but surprising.
+* **`.gitignore` contains `bm`.** The main program is already tracked, so it
+  is unaffected today, but deleting and re-adding `bm` would silently fail.
+  `bmks` and `tags.*` in the same file are fine.
+* **`bm` has no `--version`.** Several ecosystems' smoke tests reach for it
+  first. The test blocks here use `bm --list` and `bm --merge` instead, which
+  are the two actions that never open a menu.
+* **`make check` is not hermetic.** It runs shellcheck only when shellcheck
+  happens to be installed, so the same command lints or does not lint
+  depending on the machine. The Gentoo, Nix, Guix and MacPorts definitions
+  call `sh test/run.sh` directly for that reason, and declare shellcheck as a
+  build-time dependency where the format has one.
+* **The test suite is not declared anywhere.** It uses `jq`, `git`, `node`
+  and `make` when they are present and skips those groups otherwise, so it
+  passes either way, but a build that has none of them tests less than a
+  build that has all of them.
+* `DESTDIR` itself is well behaved: `make install DESTDIR=... PREFIX=...`
+  creates the directory and copies the scripts with mode 755, and the test
+  suite has its own check that `TOOLS` is honoured.
+
+## Release checklist
+
+1. **Choose a licence.** Add `LICENSE` at the top of the repository and a
+   line in `README`. Nothing else on this list matters until this is done.
+2. Replace `SBM_LICENSE_TBD` everywhere listed above. Then add the licence
+   file to the packages that install one:
+   * Arch: `install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"`
+   * Alpine: `install -Dm644 LICENSE "$pkgdir"/usr/share/licenses/$pkgname/LICENSE`
+   * Void: `vlicense LICENSE`
+   * RPM: `%license LICENSE` in `%files`
+   * FreeBSD: `LICENSE_FILE=${WRKSRC}/LICENSE`
+   * Debian: the full text goes into `debian/copyright`
+3. Tag the release: `git tag -a v0.3 -m 'sbm 0.3' && git push --tags`, and
+   check that
+   `https://github.com/equwal/sbm/archive/refs/tags/v0.3.tar.gz` downloads
+   and unpacks to `sbm-0.3/`.
+4. Compute the checksums and replace the four checksum tokens, using the
+   commands in the table above.
+5. Optional, and worth doing first: write the man pages that `TODO` asks for,
+   and decide what to do about `include config.mk` versus BSD make.
+6. Submit, easiest first:
+   * **AUR** (`sbm`, `sbm-git`): push. No review.
+   * **Your own apt repository**: `debian/apt/README.md`.
+   * **Your own Homebrew tap and Scoop bucket**: push.
+   * **A Guix channel**: push.
+   * **GURU** (Gentoo user overlay): pull request.
+   * **Void, Alpine, nixpkgs, macports-ports, freebsd-ports**: pull request
+     or bug, reviewed but not sponsored.
+   * **Chocolatey community**: push, then wait for moderation.
+   * **Fedora, openSUSE Factory**: review bug or submit request.
+   * **Debian proper**: ITP bug, then a sponsor. Slowest.
+   * **homebrew-core**: only once the project clears the notability bar.
+
+## What was checked on this machine, and what was not
+
+Checked: `bash -n` on both PKGBUILDs, the APKBUILD, the Void template and
+both ebuilds; `sh -n` on the autopkgtest scripts; Python XML parsing of
+`metadata.xml` and `sbm.nuspec`; Python JSON parsing of `sbm.json`; the
+PowerShell language parser on both `.ps1` files; `choco pack`, which built a
+`.nupkg` that was then deleted; bracket balance and field syntax for the Nix,
+Guix, Tcl, Ruby and Debian files; and the generated Windows `.cmd` shim, run
+for real against the `bm` and `bm-html` in this repository.
+
+Not checked: nothing was installed or built as a package, because that needs
+each distribution. There is no Ruby here, so the formula was not run through
+`ruby -c`. There is no `make` and no shellcheck here, and `test/run.sh` does
+not run under Git Bash: `mkdir -m 700` inside the Windows temporary directory
+fails with "cannot change permissions". That is an MSYS and Windows problem,
+not a fault in the suite.
diff --git a/packaging/alpine/APKBUILD b/packaging/alpine/APKBUILD
new file mode 100644
index 0000000..86b0e42
--- /dev/null
+++ b/packaging/alpine/APKBUILD
@@ -0,0 +1,55 @@
+# Contributor: Spenser Truex <truex@equwal.com>
+# Maintainer: Spenser Truex <truex@equwal.com>
+pkgname=sbm
+pkgver=0.3
+pkgrel=0
+pkgdesc="Bookmark manager made of POSIX sh scripts, driven by fzf"
+url="https://github.com/equwal/sbm"
+arch="noarch"
+# Upstream has not chosen a licence yet and ships no LICENSE file. Replace
+# SBM_LICENSE_TBD with the SPDX identifier, and add
+#   install -Dm644 LICENSE "$pkgdir"/usr/share/licenses/$pkgname/LICENSE
+# to package(). abuild's licence check rejects the token, which is intended.
+license="SBM_LICENSE_TBD"
+# abuild has no "a or b" operator. A menu is required; fzf is in community and
+# works without a display, so it is the hard dependency. dmenu is in the
+# aports tree too: install it instead if you run X11.
+# sh and awk come from busybox, which is always installed.
+depends="fzf"
+makedepends="make"
+checkdepends="shellcheck"
+subpackages="$pkgname-doc"
+source="$pkgname-$pkgver.tar.gz::https://github.com/equwal/sbm/archive/refs/tags/v$pkgver.tar.gz"
+
+build() {
+	# Nothing is compiled; the package is a set of sh scripts.
+	:
+}
+
+check() {
+	# make check runs shellcheck -s sh over every script, then the test
+	# suite. The suite scripts the menu, the clipboard and curl: no display,
+	# no network.
+	make check
+}
+
+package() {
+	make DESTDIR="$pkgdir" PREFIX=/usr install
+
+	install -Dm644 README -t "$pkgdir"/usr/share/doc/$pkgname/
+	install -Dm644 TODO -t "$pkgdir"/usr/share/doc/$pkgname/
+	install -Dm644 usertags engines \
+		-t "$pkgdir"/usr/share/doc/$pkgname/examples/
+}
+
+# Optional at runtime, none of them pulled in:
+#   curl       bm-title, bm-check
+#   jq         bm-import of Chromium JSON bookmark files
+#   git        bm-commit
+#   xclip      clipboard under X11 (or xsel, or wl-clipboard under Wayland)
+#   xdg-utils  opening URLs with xdg-open
+
+# Alpine hashes sources with sha512, not sha256. No release tag exists yet,
+# so there is nothing to hash: replace SBM_SHA512_TBD by running
+#   abuild checksum
+sha512sums="SBM_SHA512_TBD  sbm-0.3.tar.gz"
diff --git a/packaging/arch/sbm-git/PKGBUILD b/packaging/arch/sbm-git/PKGBUILD
new file mode 100644
index 0000000..85a40e6
--- /dev/null
+++ b/packaging/arch/sbm-git/PKGBUILD
@@ -0,0 +1,67 @@
+# Maintainer: Spenser Truex <truex@equwal.com>
+
+# This is the package that works today: upstream has no release tag yet, so
+# sbm (the versioned package) has nothing to download.
+
+pkgname=sbm-git
+_pkgname=sbm
+pkgver=0.3.r0.gUNKNOWN
+pkgrel=1
+pkgdesc='Bookmark manager made of POSIX sh scripts, driven by dmenu or fzf (git)'
+arch=('any')
+url='https://github.com/equwal/sbm'
+# See the comment in ../sbm/PKGBUILD: no licence has been chosen upstream yet.
+license=('SBM_LICENSE_TBD')
+# makepkg has no "a or b" operator; see ../sbm/PKGBUILD.
+depends=('dmenu' 'xclip')
+makedepends=('git' 'make')
+checkdepends=('shellcheck')
+optdepends=('fzf: menu on a bare terminal, and an alternative to dmenu'
+            'xsel: clipboard, instead of xclip'
+            'wl-clipboard: clipboard under Wayland, instead of xclip'
+            'curl: page titles for bm --add (bm-title) and dead-link checks (bm-check)'
+            'jq: importing Chromium bookmark files with bm-import'
+            'git: bookmark file history with bm-commit'
+            'xdg-utils: opening URLs with xdg-open')
+provides=("$_pkgname=$pkgver")
+conflicts=("$_pkgname")
+source=("$_pkgname::git+$url.git")
+sha256sums=('SKIP')
+
+pkgver() {
+  cd "$_pkgname"
+  # There are no tags yet, so git describe has nothing to work from. Fall
+  # back to the version in config.mk plus the commit count. Once v0.3 is
+  # tagged the first branch takes over on its own.
+  # The exit status of a pipeline is sed's, which always succeeds: ask git
+  # first, and only then format.
+  if git describe --long --tags >/dev/null 2>&1; then
+    git describe --long --tags | sed 's/^v//; s/\([^-]*-g\)/r/; s/-/./g'
+  else
+    printf '%s.r%s.g%s'       "$(sed -n 's/^VERSION=//p' config.mk)"       "$(git rev-list --count HEAD)"       "$(git rev-parse --short HEAD)"
+  fi
+}
+
+build() {
+  # Nothing is compiled; the package is a set of sh scripts.
+  :
+}
+
+check() {
+  cd "$_pkgname"
+  make check
+}
+
+package() {
+  cd "$_pkgname"
+  make DESTDIR="$pkgdir" PREFIX=/usr install
+
+  install -Dm644 README -t "$pkgdir/usr/share/doc/$_pkgname"
+  install -Dm644 TODO -t "$pkgdir/usr/share/doc/$_pkgname"
+  install -Dm644 usertags engines -t "$pkgdir/usr/share/doc/$_pkgname/examples"
+}
+
+# Before uploading to the AUR:
+#   makepkg --printsrcinfo > .SRCINFO
+# A -git package's .SRCINFO records the pkgver produced by the last local
+# build, so regenerate it after building.
diff --git a/packaging/arch/sbm/PKGBUILD b/packaging/arch/sbm/PKGBUILD
new file mode 100644
index 0000000..3622290
--- /dev/null
+++ b/packaging/arch/sbm/PKGBUILD
@@ -0,0 +1,59 @@
+# Maintainer: Spenser Truex <truex@equwal.com>
+
+pkgname=sbm
+pkgver=0.3
+pkgrel=1
+pkgdesc='Bookmark manager made of POSIX sh scripts, driven by dmenu or fzf'
+arch=('any')
+url='https://github.com/equwal/sbm'
+# Upstream has not chosen a licence yet and ships no LICENSE file. Replace
+# SBM_LICENSE_TBD with the SPDX identifier, and add the usual
+#   install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
+# to package() if the licence is not one of /usr/share/licenses/common.
+# namcap reports the token as an unknown licence, which is intended.
+license=('SBM_LICENSE_TBD')
+# makepkg has no "a or b" operator, so the hard dependencies below name the
+# usual choice and the alternatives are listed in optdepends:
+#   menu       dmenu  or fzf
+#   clipboard  xclip  or xsel or wl-clipboard
+# sh (bash) and awk (gawk) are members of the base group and are not listed.
+depends=('dmenu' 'xclip')
+makedepends=('make')
+checkdepends=('shellcheck')
+optdepends=('fzf: menu on a bare terminal, and an alternative to dmenu'
+            'xsel: clipboard, instead of xclip'
+            'wl-clipboard: clipboard under Wayland, instead of xclip'
+            'curl: page titles for bm --add (bm-title) and dead-link checks (bm-check)'
+            'jq: importing Chromium bookmark files with bm-import'
+            'git: bookmark file history with bm-commit'
+            'xdg-utils: opening URLs with xdg-open')
+source=("$pkgname-$pkgver.tar.gz::$url/archive/refs/tags/v$pkgver.tar.gz")
+# No release tag exists yet, so there is nothing to hash. Replace
+# SBM_SHA256_TBD with the output of: makepkg -g
+sha256sums=('SBM_SHA256_TBD')
+
+build() {
+  # Nothing is compiled; the package is a set of sh scripts.
+  :
+}
+
+check() {
+  cd "$pkgname-$pkgver"
+  # make check runs shellcheck -s sh over every script, then the test suite.
+  # The suite scripts the menu, the clipboard and curl: no display, no network.
+  make check
+}
+
+package() {
+  cd "$pkgname-$pkgver"
+  make DESTDIR="$pkgdir" PREFIX=/usr install
+
+  install -Dm644 README -t "$pkgdir/usr/share/doc/$pkgname"
+  install -Dm644 TODO -t "$pkgdir/usr/share/doc/$pkgname"
+  install -Dm644 usertags engines -t "$pkgdir/usr/share/doc/$pkgname/examples"
+}
+
+# Before uploading to the AUR, regenerate the metadata:
+#   makepkg --printsrcinfo > .SRCINFO
+# and commit PKGBUILD and .SRCINFO together. Check the result with
+#   namcap PKGBUILD && namcap sbm-0.3-1-any.pkg.tar.zst
diff --git a/packaging/chocolatey/sbm.nuspec b/packaging/chocolatey/sbm.nuspec
new file mode 100644
index 0000000..ca7d237
--- /dev/null
+++ b/packaging/chocolatey/sbm.nuspec
@@ -0,0 +1,76 @@
+<?xml version="1.0" encoding="utf-8"?>
+<package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">
+  <metadata>
+    <id>sbm</id>
+    <version>0.3</version>
+    <packageSourceUrl>https://github.com/equwal/sbm/tree/master/packaging/chocolatey</packageSourceUrl>
+    <owners>equwal</owners>
+    <title>sbm (bookmark manager)</title>
+    <authors>Spenser Truex</authors>
+    <projectUrl>https://github.com/equwal/sbm</projectUrl>
+    <!--
+      Upstream has not chosen a licence yet and the repository has no LICENSE
+      file. licenseUrl must parse as a URL or "choco pack" refuses the
+      package outright (CHCU0001), so the placeholder token is carried in the
+      path instead of standing alone. The URL below does not resolve.
+      Replace SBM_LICENSE_TBD with the real file name, for example
+      https://github.com/equwal/sbm/blob/master/LICENSE
+    -->
+    <licenseUrl>https://github.com/equwal/sbm/blob/master/SBM_LICENSE_TBD</licenseUrl>
+    <requireLicenseAcceptance>false</requireLicenseAcceptance>
+    <projectSourceUrl>https://github.com/equwal/sbm</projectSourceUrl>
+    <docsUrl>https://github.com/equwal/sbm/blob/master/README</docsUrl>
+    <bugTrackerUrl>https://github.com/equwal/sbm/issues</bugTrackerUrl>
+    <tags>sbm bookmarks bookmark-manager fzf posix shell cli suckless</tags>
+    <summary>Bookmark manager made of POSIX sh scripts, driven by fzf</summary>
+    <description><![CDATA[
+sbm keeps bookmarks in one tab separated file: URL, description, tags.
+`bm` picks one from a menu and opens it, copies it, edits it or deletes it.
+Text that is no bookmark is opened as an address or searched for on the web.
+
+The other programs are small filters over bookmark lines: `bm-import`,
+`bm-check`, `bm-html`, `bm-migrate`, `bm-title` and `bm-commit`.
+
+### What this package does
+
+sbm is POSIX shell. This package does not port it to PowerShell. It installs
+the scripts and generates `.cmd` shims that run them through the `sh.exe` that
+comes with Git for Windows, which is why `git` is a dependency.
+
+### What works on Windows
+
+* The menu is **fzf**. `bm` picks fzf when there is no X11 or Wayland
+  display, which is always the case here.
+* Clipboard: the shims default `SBM_COPY` to `clip` and `SBM_PASTE` to
+  `powershell -NoProfile -Command Get-Clipboard` when you have not set them.
+* Opening URLs: set `SBM_OPEN` yourself, for example
+
+        setx SBM_OPEN "powershell -NoProfile -Command Start-Process"
+
+* `bm-title`, `bm-check`: need `curl`, which ships with Windows 10 1803 and
+  later, and with Git for Windows.
+* `bm-import` of a Chromium JSON bookmark file needs `jq`
+  (`choco install jq`).
+* `bm-commit` needs `git`, which is already a dependency.
+
+### What does not work on Windows
+
+* **dmenu.** It is an X11 program. There is no dmenu here and none is
+  installed. Everything menu driven goes through fzf.
+* `xclip`, `xsel`, `wl-clipboard`, `xdg-open`: X11 and Wayland programs,
+  replaced by the `SBM_COPY`, `SBM_PASTE` and `SBM_OPEN` settings above.
+
+Bookmarks live in `%LOCALAPPDATA%` terms under the Git Bash HOME:
+`~/.local/share/sbm/bookmarks`. Set `BOOKMARKS` to move it.
+]]></description>
+    <releaseNotes>https://github.com/equwal/sbm/commits/master</releaseNotes>
+    <dependencies>
+      <!-- git supplies sh.exe; fzf is the menu. -->
+      <dependency id="git" version="2.30.0" />
+      <dependency id="fzf" version="0.30.0" />
+    </dependencies>
+  </metadata>
+  <files>
+    <file src="tools\**" target="tools" />
+  </files>
+</package>
diff --git a/packaging/chocolatey/tools/chocolateyInstall.ps1 b/packaging/chocolatey/tools/chocolateyInstall.ps1
new file mode 100644
index 0000000..26d3b08
--- /dev/null
+++ b/packaging/chocolatey/tools/chocolateyInstall.ps1
@@ -0,0 +1,166 @@
+$ErrorActionPreference = 'Stop'
+
+$packageName = 'sbm'
+$version     = '0.3'
+$toolsDir    = Split-Path -Parent $MyInvocation.MyCommand.Definition
+$stage       = Join-Path $toolsDir 'staging'
+
+# What Makefile's TOOLS installs.
+$scripts = @('bm', 'bm-migrate', 'bm-import', 'bm-check', 'bm-html', 'bm-title', 'bm-commit')
+
+# ---------------------------------------------------------------------------
+# Fetch and unpack.
+#
+# GitHub serves a tag as .tar.gz. 7zip needs two passes for that: .tar.gz to
+# .tar, then .tar to the tree. Nothing is embedded in this package, because no
+# licence has been chosen upstream yet.
+# ---------------------------------------------------------------------------
+
+$packageArgs = @{
+  packageName   = $packageName
+  unzipLocation = $stage
+  url           = "https://github.com/equwal/sbm/archive/refs/tags/v$version.tar.gz"
+  # No release tag exists yet, so there is nothing to hash. Replace
+  # SBM_SHA256_TBD with the sha256 of the tarball once v0.3 is tagged:
+  #   Get-FileHash .\v0.3.tar.gz -Algorithm SHA256
+  checksum      = 'SBM_SHA256_TBD'
+  checksumType  = 'sha256'
+}
+
+Install-ChocolateyZipPackage @packageArgs
+
+$tar = Get-ChildItem -Path $stage -Filter '*.tar' -File | Select-Object -First 1
+if (-not $tar) {
+  throw "sbm: no .tar found in $stage after unpacking the release archive."
+}
+Get-ChocolateyUnzip -FileFullPath $tar.FullName -Destination $stage -PackageName $packageName
+Remove-Item -LiteralPath $tar.FullName -Force
+
+$src = Join-Path $stage "$packageName-$version"
+if (-not (Test-Path -LiteralPath $src)) {
+  # A -git style archive, or a renamed top directory: take the only one there.
+  $only = Get-ChildItem -Path $stage -Directory | Select-Object -First 1
+  if (-not $only) { throw "sbm: the release archive did not unpack into a directory." }
+  $src = $only.FullName
+}
+
+foreach ($s in $scripts) {
+  $from = Join-Path $src $s
+  if (-not (Test-Path -LiteralPath $from)) { throw "sbm: $s is missing from the release archive." }
+  Copy-Item -LiteralPath $from -Destination (Join-Path $toolsDir $s) -Force
+}
+foreach ($extra in @('README', 'usertags', 'engines')) {
+  $from = Join-Path $src $extra
+  if (Test-Path -LiteralPath $from) {
+    Copy-Item -LiteralPath $from -Destination (Join-Path $toolsDir $extra) -Force
+  }
+}
+Remove-Item -LiteralPath $stage -Recurse -Force -ErrorAction SilentlyContinue
+
+# ---------------------------------------------------------------------------
+# Find a POSIX sh. Git for Windows ships one; the package depends on git for
+# exactly that reason. Look in several places rather than one hardcoded path,
+# because Git installs per machine, per user, 32 bit, and through Chocolatey.
+# ---------------------------------------------------------------------------
+
+function Get-PosixSh {
+  $candidates = New-Object 'System.Collections.Generic.List[string]'
+
+  $gitCmd = Get-Command 'git.exe' -ErrorAction SilentlyContinue
+  if ($gitCmd) {
+    # ...\Git\cmd\git.exe and ...\Git\bin\git.exe both give ...\Git here.
+    $gitRoot = Split-Path -Parent (Split-Path -Parent $gitCmd.Source)
+    $candidates.Add((Join-Path $gitRoot 'bin\sh.exe'))
+    $candidates.Add((Join-Path $gitRoot 'usr\bin\sh.exe'))
+  }
+
+  $keys = @(
+    'HKLM:\SOFTWARE\GitForWindows',
+    'HKLM:\SOFTWARE\WOW6432Node\GitForWindows',
+    'HKCU:\SOFTWARE\GitForWindows'
+  )
+  foreach ($key in $keys) {
+    try {
+      $installPath = (Get-ItemProperty -Path $key -Name 'InstallPath' -ErrorAction Stop).InstallPath
+      if ($installPath) {
+        $candidates.Add((Join-Path $installPath 'bin\sh.exe'))
+        $candidates.Add((Join-Path $installPath 'usr\bin\sh.exe'))
+      }
+    } catch {
+      # No such key on this machine.
+    }
+  }
+
+  $roots = @(
+    $env:ProgramFiles,
+    ${env:ProgramFiles(x86)},
+    $env:ProgramW6432,
+    $(if ($env:LOCALAPPDATA) { Join-Path $env:LOCALAPPDATA 'Programs' }),
+    $(if ($env:ChocolateyInstall) { Join-Path $env:ChocolateyInstall 'lib\git.install\tools' })
+  )
+  foreach ($root in $roots) {
+    if ($root) {
+      $candidates.Add((Join-Path $root 'Git\bin\sh.exe'))
+      $candidates.Add((Join-Path $root 'Git\usr\bin\sh.exe'))
+    }
+  }
+
+  $shCmd = Get-Command 'sh.exe' -ErrorAction SilentlyContinue
+  if ($shCmd) { $candidates.Add($shCmd.Source) }
+
+  foreach ($candidate in $candidates) {
+    if ($candidate -and (Test-Path -LiteralPath $candidate -PathType Leaf)) { return $candidate }
+  }
+  return $null
+}
+
+$sh = Get-PosixSh
+if (-not $sh) {
+  throw @'
+sbm: no POSIX sh found.
+
+sbm is POSIX shell and needs one to run. Git for Windows ships sh.exe and is
+a dependency of this package:
+
+    choco install git
+
+If Git is installed somewhere unusual, sbm still works: put that sh.exe on
+PATH and reinstall this package.
+'@
+}
+Write-Host "sbm: using $sh"
+
+# ---------------------------------------------------------------------------
+# Generate a .cmd per script and register it on PATH.
+#
+# The sh path found above is baked in, with a fall back to whatever sh.exe is
+# on PATH, so moving or upgrading Git does not break the shims outright.
+# ---------------------------------------------------------------------------
+
+foreach ($s in $scripts) {
+  $cmdPath = Join-Path $toolsDir "$s.cmd"
+  $body = @"
+@echo off
+rem Generated by the sbm Chocolatey package. Do not edit: reinstalling
+rem the package rewrites this file.
+setlocal
+if not defined SBM_COPY set "SBM_COPY=clip"
+if not defined SBM_PASTE set "SBM_PASTE=powershell -NoProfile -Command Get-Clipboard"
+set "SBM_SH=$sh"
+if not exist "%SBM_SH%" set "SBM_SH=sh.exe"
+"%SBM_SH%" "%~dp0$s" %*
+"@
+  Set-Content -LiteralPath $cmdPath -Value $body -Encoding ASCII
+  Install-BinFile -Name $s -Path $cmdPath
+}
+
+Write-Host @'
+sbm is installed. On Windows:
+
+  * The menu is fzf. dmenu is an X11 program and is not available here.
+  * SBM_COPY and SBM_PASTE default to clip and Get-Clipboard.
+  * Set an opener yourself, for example:
+      setx SBM_OPEN "powershell -NoProfile -Command Start-Process"
+  * Example tag and engine files are in this package's tools directory.
+    Copy them to ~/.local/share/sbm/ (the HOME that Git Bash uses).
+'@
diff --git a/packaging/chocolatey/tools/chocolateyUninstall.ps1 b/packaging/chocolatey/tools/chocolateyUninstall.ps1
new file mode 100644
index 0000000..2f40f0c
--- /dev/null
+++ b/packaging/chocolatey/tools/chocolateyUninstall.ps1
@@ -0,0 +1,22 @@
+$ErrorActionPreference = 'Stop'
+
+$toolsDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
+
+$scripts = @('bm', 'bm-migrate', 'bm-import', 'bm-check', 'bm-html', 'bm-title', 'bm-commit')
+
+foreach ($s in $scripts) {
+  $cmdPath = Join-Path $toolsDir "$s.cmd"
+  Uninstall-BinFile -Name $s -Path $cmdPath
+  Remove-Item -LiteralPath $cmdPath -Force -ErrorAction SilentlyContinue
+  Remove-Item -LiteralPath (Join-Path $toolsDir $s) -Force -ErrorAction SilentlyContinue
+}
+
+foreach ($extra in @('README', 'usertags', 'engines')) {
+  Remove-Item -LiteralPath (Join-Path $toolsDir $extra) -Force -ErrorAction SilentlyContinue
+}
+
+Remove-Item -LiteralPath (Join-Path $toolsDir 'staging') -Recurse -Force -ErrorAction SilentlyContinue
+
+# The bookmark file is the user's data and is left alone. It is under
+# ~/.local/share/sbm/ unless BOOKMARKS says otherwise.
+Write-Host 'sbm: your bookmark file was not touched.'
diff --git a/packaging/debian/apt/README.md b/packaging/debian/apt/README.md
new file mode 100644
index 0000000..1b95c47
--- /dev/null
+++ b/packaging/debian/apt/README.md
@@ -0,0 +1,129 @@
+# Building the .deb, and serving it from your own apt repository
+
+`../debian/` is a complete `debian/` directory. It is kept one level down so
+that `packaging/debian/` can hold this note beside it. To use it, copy it to
+the top of a source tree:
+
+    cp -r packaging/debian/debian /path/to/sbm-0.3/debian
+
+## Before the first build
+
+Two things in `debian/` are placeholders.
+
+* `debian/copyright` says `SBM_LICENSE_TBD`. Nothing can be uploaded
+  anywhere until a real licence is chosen: an unlicensed work is not
+  distributable.
+* `debian/changelog` says `UNRELEASED` and has no `Closes:` line, because no
+  ITP bug has been filed.
+
+Git does not carry the execute bit through every path this directory may
+travel, so check it:
+
+    chmod 755 debian/rules debian/tests/smoke debian/tests/upstream-suite
+
+## Building
+
+`3.0 (quilt)` needs an orig tarball beside the source directory:
+
+    sbm-0.3.orig.tar.gz
+    sbm-0.3/debian/...
+
+Once v0.3 is tagged, the tarball is the release archive renamed:
+
+    wget -O sbm-0.3.orig.tar.gz \
+      https://github.com/equwal/sbm/archive/refs/tags/v0.3.tar.gz
+
+Until then, `make dist` in the source tree produces `sbm-0.3.tar.gz` with the
+same top level directory name, which works for local builds.
+
+Build a binary package:
+
+    cd sbm-0.3
+    dpkg-buildpackage -us -uc -b
+
+Build source and binary, in a clean chroot, which is what an upload needs:
+
+    sbuild -d unstable
+    # or
+    pbuilder build ../sbm_0.3-1.dsc
+
+Check it:
+
+    lintian -i -I --pedantic ../sbm_0.3-1_all.deb ../sbm_0.3-1.dsc
+    autopkgtest ../sbm_0.3-1_all.deb -- null
+
+Install it locally:
+
+    sudo apt install ./sbm_0.3-1_all.deb
+
+## Getting into Debian proper
+
+Debian needs a Debian Developer to sponsor the first upload.
+
+1. Choose a licence and add a `LICENSE` file upstream.
+2. Tag `v0.3` upstream so `debian/watch` has something to find.
+3. File an ITP bug: `reportbug wnpp`, type `ITP`. Put the bug number in
+   `debian/changelog` as `Closes: #NNNNNN`.
+4. Build in a clean chroot and get `lintian --pedantic` quiet.
+5. Upload the source package to <https://mentors.debian.net/>.
+6. Ask for a sponsor on debian-mentors@lists.debian.org, or in the RFS bug.
+
+This takes weeks to months. The apt repository below works the same day.
+
+## Your own signed apt repository
+
+Both tools below produce a repository that `apt` trusts once the user adds
+your key. Pick one.
+
+### reprepro
+
+`conf/distributions`:
+
+    Origin: equwal.com
+    Label: sbm
+    Codename: stable
+    Architectures: amd64 arm64 source
+    Components: main
+    Description: sbm packages
+    SignWith: YOURKEYID
+
+Then:
+
+    mkdir -p ~/apt/conf                  # put the file above in there
+    cd ~/apt
+    reprepro includedeb stable /path/to/sbm_0.3-1_all.deb
+    reprepro include stable /path/to/sbm_0.3-1_amd64.changes   # source too
+
+`reprepro` signs `Release` with the key named in `SignWith`. Copy `~/apt`
+to any static web server; nothing server side is needed.
+
+### aptly
+
+    aptly repo create -distribution=stable -component=main sbm
+    aptly repo add sbm /path/to/sbm_0.3-1_all.deb
+    aptly publish repo -gpg-key=YOURKEYID sbm
+    # publishes under ~/.aptly/public; copy that to the web server
+
+`aptly` can also mirror and snapshot, which matters once there is more than
+one package.
+
+### The key
+
+Export the public half in binary form, which is what modern apt wants:
+
+    gpg --export YOURKEYID > equwal-archive-keyring.gpg
+
+Serve it next to the repository. Users then write
+`/etc/apt/sources.list.d/sbm.sources`:
+
+    Types: deb
+    URIs: https://example.com/apt
+    Suites: stable
+    Components: main
+    Signed-By: /usr/share/keyrings/equwal-archive-keyring.gpg
+
+and put the exported key at that path. Do not tell users to pipe a key into
+`apt-key`: it has been removed.
+
+`Architectures: amd64 arm64` is only about the index. The package itself is
+`Architecture: all`, so one build serves every architecture.
diff --git a/packaging/debian/debian/changelog b/packaging/debian/debian/changelog
new file mode 100644
index 0000000..aa656a4
--- /dev/null
+++ b/packaging/debian/debian/changelog
@@ -0,0 +1,8 @@
+sbm (0.3-1) UNRELEASED; urgency=medium
+
+  * Initial packaging.
+  * No ITP bug has been filed yet, so there is no Closes entry.  File one
+    against wnpp and add "Closes: #NNNNNN" here before uploading, and change
+    UNRELEASED to unstable.
+
+ -- Spenser Truex <truex@equwal.com>  Sun, 20 Sep 2026 21:36:41 -0700
diff --git a/packaging/debian/debian/control b/packaging/debian/debian/control
new file mode 100644
index 0000000..e1d4892
--- /dev/null
+++ b/packaging/debian/debian/control
@@ -0,0 +1,45 @@
+Source: sbm
+Section: web
+Priority: optional
+Maintainer: Spenser Truex <truex@equwal.com>
+Build-Depends:
+ debhelper-compat (= 13),
+ shellcheck <!nocheck>,
+Standards-Version: 4.7.0
+Rules-Requires-Root: no
+Homepage: https://github.com/equwal/sbm
+Vcs-Browser: https://github.com/equwal/sbm
+Vcs-Git: https://github.com/equwal/sbm.git
+
+Package: sbm
+Architecture: all
+# Depends can say "a or b", so the menu and the clipboard are honest here.
+# sh and awk are not listed: dash and mawk are Priority: required.
+Depends:
+ ${misc:Depends},
+ dmenu | fzf,
+Recommends:
+ xclip | xsel | wl-clipboard,
+ xdg-utils,
+Suggests:
+ curl,
+ jq,
+ git,
+Description: bookmark manager made of POSIX sh scripts
+ sbm keeps bookmarks in one tab separated file: URL, description, tags.
+ Lines starting with # are ignored, so cut, grep, awk and an editor work on
+ it as well as the tools do.
+ .
+ bm picks a bookmark with dmenu, or with fzf on a bare terminal, and then
+ opens it, copies it, edits it or deletes it. Text that is no bookmark is
+ opened as an address or searched for on the web.
+ .
+ The rest are small filters that read and write bookmark lines:
+  * bm-import turns a browser's bookmarks into bookmark lines;
+  * bm-check reports dead and permanently moved links;
+  * bm-html writes the bookmarks as one self-contained web page;
+  * bm-migrate converts a file from the old sbm format;
+  * bm-title prints a page's title, which bm --add offers as a description;
+  * bm-commit keeps the bookmark file's history in git.
+ .
+ Everything is POSIX sh and POSIX utilities. Nothing is compiled.
diff --git a/packaging/debian/debian/copyright b/packaging/debian/debian/copyright
new file mode 100644
index 0000000..b46fdec
--- /dev/null
+++ b/packaging/debian/debian/copyright
@@ -0,0 +1,23 @@
+Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
+Upstream-Name: sbm
+Upstream-Contact: Spenser Truex <truex@equwal.com>
+Source: https://github.com/equwal/sbm
+
+Files: *
+Copyright: 2024-2026 Spenser Truex <truex@equwal.com>
+License: SBM_LICENSE_TBD
+
+Files: debian/*
+Copyright: 2026 Spenser Truex <truex@equwal.com>
+License: SBM_LICENSE_TBD
+
+License: SBM_LICENSE_TBD
+ The sbm repository carries no LICENSE file and upstream has not chosen a
+ licence yet. This paragraph is a placeholder, not a licence.
+ .
+ Replace every SBM_LICENSE_TBD in this file with the licence's short name,
+ and replace this text with the licence's full text, or with a pointer into
+ /usr/share/common-licenses when the licence is one of the common ones.
+ .
+ The package cannot be uploaded to Debian until that is done: an
+ unlicensed work is not distributable.
diff --git a/packaging/debian/debian/docs b/packaging/debian/debian/docs
new file mode 100644
index 0000000..724e084
--- /dev/null
+++ b/packaging/debian/debian/docs
@@ -0,0 +1,2 @@
+README
+TODO
diff --git a/packaging/debian/debian/examples b/packaging/debian/debian/examples
new file mode 100644
index 0000000..456f1c1
--- /dev/null
+++ b/packaging/debian/debian/examples
@@ -0,0 +1,2 @@
+usertags
+engines
diff --git a/packaging/debian/debian/rules b/packaging/debian/debian/rules
new file mode 100755
index 0000000..8243436
--- /dev/null
+++ b/packaging/debian/debian/rules
@@ -0,0 +1,19 @@
+#!/usr/bin/make -f
+# Nothing here is compiled, so there is no build step and no hardening to do.
+
+%:
+	dh $@
+
+override_dh_auto_build:
+	# The upstream "all" target only prints a reminder; there is nothing
+	# to build.
+
+# dh_auto_test finds the "check" target on its own, since there is no
+# "test" target. It runs shellcheck -s sh over every script and then the
+# test suite, which scripts the menu, the clipboard and curl: no display,
+# no network. The override is here only to carry this comment.
+override_dh_auto_test:
+	dh_auto_test
+
+override_dh_auto_install:
+	dh_auto_install -- PREFIX=/usr
diff --git a/packaging/debian/debian/source/format b/packaging/debian/debian/source/format
new file mode 100644
index 0000000..163aaf8
--- /dev/null
+++ b/packaging/debian/debian/source/format
@@ -0,0 +1 @@
+3.0 (quilt)
diff --git a/packaging/debian/debian/tests/control b/packaging/debian/debian/tests/control
new file mode 100644
index 0000000..a1919c4
--- /dev/null
+++ b/packaging/debian/debian/tests/control
@@ -0,0 +1,9 @@
+# smoke uses the installed programs; upstream-suite runs the source tree's
+# own tests, which exercise the same scripts before they are installed.
+Tests: smoke
+Depends: @
+Restrictions: allow-stderr
+
+Tests: upstream-suite
+Depends: @, make
+Restrictions: allow-stderr
diff --git a/packaging/debian/debian/tests/smoke b/packaging/debian/debian/tests/smoke
new file mode 100755
index 0000000..5f43d15
--- /dev/null
+++ b/packaging/debian/debian/tests/smoke
@@ -0,0 +1,28 @@
+#!/bin/sh
+# Exercise the installed programs. --list and --merge are the two actions
+# that never open a menu, so this needs no display and no menu program.
+set -e
+
+work=${AUTOPKGTEST_TMP:?AUTOPKGTEST_TMP is not set}
+
+BOOKMARKS="$work/bookmarks"
+USERTAGS="$work/usertags"
+export BOOKMARKS USERTAGS
+SBM_GIT=0
+SBM_FETCH=0
+export SBM_GIT SBM_FETCH
+
+printf '%s\t%s\t%s\n' 'https://equwal.com' "Spenser Truex's website" 'users' \
+	| bm --merge
+
+bm --list | grep -q 'https://equwal.com'
+
+# The same URL again, with a trailing slash, must be refused as a duplicate.
+printf '%s\t%s\t%s\n' 'https://equwal.com/' 'duplicate' 'users' \
+	| bm --merge | grep -q 'skipped 1'
+
+test "$(bm --list | wc -l)" = 1
+
+bm-html | grep -q '<li>'
+
+echo 'smoke: ok'
diff --git a/packaging/debian/debian/tests/upstream-suite b/packaging/debian/debian/tests/upstream-suite
new file mode 100755
index 0000000..1b2c7dc
--- /dev/null
+++ b/packaging/debian/debian/tests/upstream-suite
@@ -0,0 +1,8 @@
+#!/bin/sh
+# Run upstream's own test suite against the source tree autopkgtest copied
+# into the testbed. It scripts the menu, the clipboard and curl, so it needs
+# no display and no network.
+set -e
+
+cd "$(dirname "$0")/../.."
+sh test/run.sh
diff --git a/packaging/debian/debian/upstream/metadata b/packaging/debian/debian/upstream/metadata
new file mode 100644
index 0000000..2f6b143
--- /dev/null
+++ b/packaging/debian/debian/upstream/metadata
@@ -0,0 +1,5 @@
+---
+Bug-Database: https://github.com/equwal/sbm/issues
+Bug-Submit: https://github.com/equwal/sbm/issues/new
+Repository: https://github.com/equwal/sbm.git
+Repository-Browse: https://github.com/equwal/sbm
diff --git a/packaging/debian/debian/watch b/packaging/debian/debian/watch
new file mode 100644
index 0000000..50d18fe
--- /dev/null
+++ b/packaging/debian/debian/watch
@@ -0,0 +1,4 @@
+version=4
+opts=filenamemangle=s%(?:.*?)?v?@ANY_VERSION@@ARCHIVE_EXT@%@PACKAGE@-$1.tar.gz% \
+ https://github.com/equwal/sbm/tags \
+ (?:.*?/)?v?@ANY_VERSION@@ARCHIVE_EXT@ debian uupdate
diff --git a/packaging/freebsd/deskutils/sbm/Makefile b/packaging/freebsd/deskutils/sbm/Makefile
new file mode 100644
index 0000000..d91d7fa
--- /dev/null
+++ b/packaging/freebsd/deskutils/sbm/Makefile
@@ -0,0 +1,65 @@
+PORTNAME=	sbm
+DISTVERSIONPREFIX=	v
+DISTVERSION=	0.3
+CATEGORIES=	deskutils
+
+MAINTAINER=	truex@equwal.com
+COMMENT=	Bookmark manager made of POSIX sh scripts, driven by dmenu or fzf
+WWW=		https://github.com/equwal/sbm
+
+# Upstream has not chosen a licence yet and ships no LICENSE file. Replace
+# SBM_LICENSE_TBD with an abbreviation from Mk/bsd.licenses.db.mk and add
+#   LICENSE_FILE=	${WRKSRC}/LICENSE
+# The ports framework refuses an unknown licence, which is intended.
+LICENSE=	SBM_LICENSE_TBD
+
+# The ports framework has no "a or b" operator. A menu is required; fzf needs
+# no display, so it is the hard dependency. Install x11/dmenu as well if you
+# run X11: bm prefers it whenever $DISPLAY is set.
+RUN_DEPENDS=	fzf:textproc/fzf
+
+# FreeBSD's /bin/sh is POSIX and runs everything here as is. gmake is not
+# about building, since nothing is compiled: upstream's Makefile starts with
+#   include config.mk
+# which is GNU make syntax. bmake wants ".include" and cannot parse the file.
+USES=		gmake
+USE_GITHUB=	yes
+GH_ACCOUNT=	equwal
+
+NO_ARCH=	yes
+NO_BUILD=	yes
+
+OPTIONS_DEFINE=		DOCS EXAMPLES
+
+TEST_DEPENDS=	shellcheck:devel/hs-ShellCheck
+
+# PREFIX is /usr/local here, not /usr, and the upstream config.mk already
+# defaults to /usr/local. It is passed on the command line anyway, because a
+# plain assignment in config.mk wins over the environment under GNU make.
+do-install:
+	@${MKDIR} ${STAGEDIR}${PREFIX}/bin
+	cd ${WRKSRC} && ${SETENV} ${MAKE_ENV} ${MAKE_CMD} install \
+		DESTDIR=${STAGEDIR} PREFIX=${PREFIX}
+
+post-install-DOCS-on:
+	@${MKDIR} ${STAGEDIR}${DOCSDIR}
+	${INSTALL_DATA} ${WRKSRC}/README ${WRKSRC}/TODO ${STAGEDIR}${DOCSDIR}
+
+post-install-EXAMPLES-on:
+	@${MKDIR} ${STAGEDIR}${EXAMPLESDIR}
+	${INSTALL_DATA} ${WRKSRC}/usertags ${WRKSRC}/engines \
+		${STAGEDIR}${EXAMPLESDIR}
+
+# make check runs shellcheck -s sh over every script, then the test suite.
+# The suite scripts the menu, the clipboard and curl: no display, no network.
+do-test:
+	cd ${WRKSRC} && ${SETENV} ${MAKE_ENV} ${MAKE_CMD} check
+
+# Optional at runtime, none of them pulled in:
+#   ftp/curl       bm-title, bm-check
+#   textproc/jq    bm-import of Chromium JSON bookmark files
+#   devel/git      bm-commit
+#   x11/xclip      clipboard under X11 (or x11/xsel, or x11/wl-clipboard)
+#   devel/xdg-utils  opening URLs with xdg-open
+
+.include <bsd.port.mk>
diff --git a/packaging/freebsd/deskutils/sbm/distinfo b/packaging/freebsd/deskutils/sbm/distinfo
new file mode 100644
index 0000000..d5db7b4
--- /dev/null
+++ b/packaging/freebsd/deskutils/sbm/distinfo
@@ -0,0 +1,3 @@
+TIMESTAMP = 0
+SHA256 (equwal-sbm-v0.3_GH0.tar.gz) = SBM_SHA256_TBD
+SIZE (equwal-sbm-v0.3_GH0.tar.gz) = 0
diff --git a/packaging/freebsd/deskutils/sbm/pkg-descr b/packaging/freebsd/deskutils/sbm/pkg-descr
new file mode 100644
index 0000000..a39d5c2
--- /dev/null
+++ b/packaging/freebsd/deskutils/sbm/pkg-descr
@@ -0,0 +1,15 @@
+sbm keeps bookmarks in one tab separated file: URL, description, tags. Lines
+starting with # are ignored, so cut, grep, awk and an editor work on it as
+well as the tools do.
+
+bm picks a bookmark with dmenu, or with fzf on a bare terminal, and then
+opens it, copies it, edits it or deletes it. Text that is no bookmark is
+opened as an address or searched for on the web.
+
+The rest are small filters that read and write bookmark lines: bm-import
+turns a browser's bookmarks into bookmark lines, bm-check reports dead links,
+bm-html writes a searchable web page, bm-migrate converts the old file
+format, bm-title prints a page's title and bm-commit keeps the bookmark
+file's history in git.
+
+Everything is POSIX sh and POSIX utilities. Nothing is compiled.
diff --git a/packaging/freebsd/deskutils/sbm/pkg-plist b/packaging/freebsd/deskutils/sbm/pkg-plist
new file mode 100644
index 0000000..6edba24
--- /dev/null
+++ b/packaging/freebsd/deskutils/sbm/pkg-plist
@@ -0,0 +1,11 @@
+bin/bm
+bin/bm-check
+bin/bm-commit
+bin/bm-html
+bin/bm-import
+bin/bm-migrate
+bin/bm-title
+%%PORTDOCS%%%%DOCSDIR%%/README
+%%PORTDOCS%%%%DOCSDIR%%/TODO
+%%PORTEXAMPLES%%%%EXAMPLESDIR%%/engines
+%%PORTEXAMPLES%%%%EXAMPLESDIR%%/usertags
diff --git a/packaging/gentoo/app-misc/sbm/metadata.xml b/packaging/gentoo/app-misc/sbm/metadata.xml
new file mode 100644
index 0000000..b348689
--- /dev/null
+++ b/packaging/gentoo/app-misc/sbm/metadata.xml
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE pkgmetadata SYSTEM "https://www.gentoo.org/dtd/metadata.dtd">
+<pkgmetadata>
+	<maintainer type="person" proxied="yes">
+		<email>truex@equwal.com</email>
+		<name>Spenser Truex</name>
+	</maintainer>
+	<maintainer type="project" proxied="proxy">
+		<email>proxy-maint@gentoo.org</email>
+		<name>Proxy Maintainers</name>
+	</maintainer>
+	<longdescription lang="en">
+		sbm keeps bookmarks in one tab separated file: URL, description,
+		tags. bm picks a bookmark with dmenu, or with fzf on a bare
+		terminal, and opens it, copies it, edits it or deletes it. Text
+		that is no bookmark is opened as an address or searched for on
+		the web.
+
+		The rest are small filters that read and write bookmark lines:
+		bm-import reads a browser's bookmarks, bm-check reports dead
+		links, bm-html writes a searchable web page, bm-migrate converts
+		the old file format, bm-title fetches a page title and bm-commit
+		keeps the file's history in git.
+
+		Everything is POSIX sh and POSIX utilities. Nothing is compiled.
+	</longdescription>
+	<!--
+		No <use> block: X and wayland are global USE flags, and pkgcheck
+		reports a local description that duplicates a global one.
+	-->
+	<upstream>
+		<maintainer status="active">
+			<email>truex@equwal.com</email>
+			<name>Spenser Truex</name>
+		</maintainer>
+		<bugs-to>https://github.com/equwal/sbm/issues</bugs-to>
+		<remote-id type="github">equwal/sbm</remote-id>
+	</upstream>
+</pkgmetadata>
diff --git a/packaging/gentoo/app-misc/sbm/sbm-0.3.ebuild b/packaging/gentoo/app-misc/sbm/sbm-0.3.ebuild
new file mode 100644
index 0000000..7ae3492
--- /dev/null
+++ b/packaging/gentoo/app-misc/sbm/sbm-0.3.ebuild
@@ -0,0 +1,72 @@
+# Copyright 1999-2026 Gentoo Authors
+# Distributed under the terms of the GNU General Public License v2
+
+EAPI=8
+
+inherit optfeature
+
+DESCRIPTION="Bookmark manager made of POSIX sh scripts, driven by dmenu or fzf"
+HOMEPAGE="https://github.com/equwal/sbm"
+SRC_URI="https://github.com/equwal/sbm/archive/refs/tags/v${PV}.tar.gz
+	-> ${P}.tar.gz"
+
+# Upstream has not chosen a licence yet and ships no LICENSE file.
+# Replace SBM_LICENSE_TBD with a name from licenses/ in the tree.
+# pkgcheck reports this token as a nonexistent licence, which is intended.
+LICENSE="SBM_LICENSE_TBD"
+SLOT="0"
+KEYWORDS="~amd64 ~arm64 ~x86"
+IUSE="+X wayland"
+
+# A menu is required and either program will do; || () says exactly that.
+# The clipboard programs are per-display-server, so they hang off USE.
+# Without X and without wayland nothing is pulled in and you set $SBM_COPY
+# and $SBM_PASTE yourself.
+RDEPEND="
+	|| (
+		x11-misc/dmenu
+		app-shells/fzf
+	)
+	X? (
+		|| (
+			x11-misc/xclip
+			x11-misc/xsel
+		)
+	)
+	wayland? ( gui-apps/wl-clipboard )
+"
+
+src_compile() {
+	# Nothing is compiled; the package is a set of sh scripts.
+	:
+}
+
+src_test() {
+	# "make check" also runs shellcheck when it happens to be installed,
+	# which would make the result depend on the build host. The suite itself
+	# is deterministic: it scripts the menu, the clipboard and curl, so it
+	# needs no display and no network.
+	sh test/run.sh || die "test suite failed"
+}
+
+src_install() {
+	emake DESTDIR="${D}" PREFIX="${EPREFIX}/usr" install
+
+	dodoc README TODO
+	docinto examples
+	dodoc usertags engines
+
+	# Once upstream adds a LICENSE file, nothing extra is needed here:
+	# dodoc handles it through the LICENSE variable above.
+}
+
+pkg_postinst() {
+	optfeature "page titles for 'bm --add' and dead-link checks" net-misc/curl
+	optfeature "importing Chromium bookmark files" app-misc/jq
+	optfeature "keeping the bookmark file's history in git" dev-vcs/git
+	optfeature "opening URLs with the desktop's handler" x11-misc/xdg-utils
+
+	elog "Example tag and search-engine files are in"
+	elog "  /usr/share/doc/${PF}/examples"
+	elog "Copy them to ~/.local/share/sbm/ if you want them."
+}
diff --git a/packaging/gentoo/app-misc/sbm/sbm-9999.ebuild b/packaging/gentoo/app-misc/sbm/sbm-9999.ebuild
new file mode 100644
index 0000000..f96f947
--- /dev/null
+++ b/packaging/gentoo/app-misc/sbm/sbm-9999.ebuild
@@ -0,0 +1,56 @@
+# Copyright 1999-2026 Gentoo Authors
+# Distributed under the terms of the GNU General Public License v2
+
+EAPI=8
+
+inherit git-r3 optfeature
+
+DESCRIPTION="Bookmark manager made of POSIX sh scripts, driven by dmenu or fzf"
+HOMEPAGE="https://github.com/equwal/sbm"
+EGIT_REPO_URI="https://github.com/equwal/sbm.git"
+EGIT_BRANCH="master"
+
+# Upstream has not chosen a licence yet and ships no LICENSE file.
+# Replace SBM_LICENSE_TBD with a name from licenses/ in the tree.
+LICENSE="SBM_LICENSE_TBD"
+SLOT="0"
+# A live ebuild carries no KEYWORDS on purpose.
+IUSE="+X wayland"
+
+RDEPEND="
+	|| (
+		x11-misc/dmenu
+		app-shells/fzf
+	)
+	X? (
+		|| (
+			x11-misc/xclip
+			x11-misc/xsel
+		)
+	)
+	wayland? ( gui-apps/wl-clipboard )
+"
+
+src_compile() {
+	# Nothing is compiled; the package is a set of sh scripts.
+	:
+}
+
+src_test() {
+	sh test/run.sh || die "test suite failed"
+}
+
+src_install() {
+	emake DESTDIR="${D}" PREFIX="${EPREFIX}/usr" install
+
+	dodoc README TODO
+	docinto examples
+	dodoc usertags engines
+}
+
+pkg_postinst() {
+	optfeature "page titles for 'bm --add' and dead-link checks" net-misc/curl
+	optfeature "importing Chromium bookmark files" app-misc/jq
+	optfeature "keeping the bookmark file's history in git" dev-vcs/git
+	optfeature "opening URLs with the desktop's handler" x11-misc/xdg-utils
+}
diff --git a/packaging/guix/sbm.scm b/packaging/guix/sbm.scm
new file mode 100644
index 0000000..c7a7053
--- /dev/null
+++ b/packaging/guix/sbm.scm
@@ -0,0 +1,133 @@
+;;; sbm: bookmarks in a plain file, picked with dmenu or fzf.
+;;;
+;;; Build it out of this file:
+;;;
+;;;   guix build -L packaging/guix sbm
+;;;   guix shell -L packaging/guix sbm -- bm --list
+;;;
+;;; To send it upstream, move the package form into gnu/packages/, most
+;;; likely web.scm or suckless.scm, and drop the define-module header.
+
+(define-module (sbm)
+  #:use-module (guix packages)
+  #:use-module (guix download)
+  #:use-module (guix gexp)
+  #:use-module (guix utils)
+  #:use-module (guix build-system gnu)
+  #:use-module ((guix licenses) #:prefix license:)
+  #:use-module (gnu packages base)
+  #:use-module (gnu packages curl)
+  #:use-module (gnu packages freedesktop)
+  #:use-module (gnu packages gawk)
+  #:use-module (gnu packages haskell-apps)
+  #:use-module (gnu packages suckless)
+  #:use-module (gnu packages terminals)
+  #:use-module (gnu packages version-control)
+  #:use-module (gnu packages web)
+  #:use-module (gnu packages xdisorg))
+
+(define-public sbm
+  (package
+    (name "sbm")
+    (version "0.3")
+    (source
+     (origin
+       (method url-fetch)
+       (uri (string-append "https://github.com/equwal/sbm"
+                           "/archive/refs/tags/v" version ".tar.gz"))
+       (file-name (string-append name "-" version ".tar.gz"))
+       ;; No release tag exists yet, so there is nothing to hash. Replace
+       ;; SBM_SHA256_TBD with the base32 hash that this prints:
+       ;;   guix download <the URL above>
+       (sha256 (base32 "SBM_SHA256_TBD"))))
+    (build-system gnu-build-system)
+    (arguments
+     (list
+      #:make-flags
+      #~(list (string-append "PREFIX=" #$output))
+      #:phases
+      #~(modify-phases %standard-phases
+          ;; There is no configure script, and nothing is compiled: the
+          ;; upstream "all" target only prints a reminder.
+          (delete 'configure)
+          (delete 'build)
+          (replace 'check
+            (lambda* (#:key tests? #:allow-other-keys)
+              (when tests?
+                ;; "make check" also runs shellcheck when it is installed,
+                ;; which would make the result depend on the build
+                ;; environment. The suite scripts the menu, the clipboard
+                ;; and curl: no display, no network.
+                (invoke "sh" "test/run.sh"))))
+          (add-after 'install 'install-doc
+            (lambda _
+              (let* ((doc (string-append #$output "/share/doc/" #$name "-"
+                                         #$version))
+                     (examples (string-append doc "/examples")))
+                (install-file "README" doc)
+                (install-file "TODO" doc)
+                (install-file "usertags" examples)
+                (install-file "engines" examples))))
+          (add-after 'install-doc 'wrap-programs
+            (lambda* (#:key inputs #:allow-other-keys)
+              ;; bm calls bm-title and bm-commit by name, so $output/bin
+              ;; goes on the PATH as well as the runtime programs.
+              (let ((path
+                     (cons (string-append #$output "/bin")
+                           (map (lambda (file)
+                                  (dirname (search-input-file inputs file)))
+                                '("bin/awk"
+                                  "bin/fzf"
+                                  "bin/dmenu"
+                                  "bin/xclip"
+                                  "bin/wl-copy"
+                                  "bin/xdg-open"
+                                  "bin/curl"
+                                  "bin/jq"
+                                  "bin/git")))))
+                (for-each (lambda (program)
+                            (wrap-program program
+                              `("PATH" ":" prefix ,path)))
+                          (find-files (string-append #$output "/bin")))))))))
+    (inputs
+     (list coreutils
+           gawk
+           ;; A menu is required and either will do; both are wrapped in, so
+           ;; bm picks dmenu when there is a display and fzf otherwise.
+           dmenu
+           fzf
+           ;; Clipboard.
+           xclip
+           wl-clipboard
+           ;; Optional, but cheap and wrapped in so the tools just work:
+           ;; curl (bm-title, bm-check), jq (bm-import of Chromium JSON
+           ;; files), git (bm-commit), xdg-utils (opening URLs).
+           curl
+           jq
+           git
+           xdg-utils))
+    (native-inputs
+     (list shellcheck))
+    (home-page "https://github.com/equwal/sbm")
+    (synopsis "Bookmark manager made of POSIX sh scripts, driven by dmenu or fzf")
+    (description
+     "sbm keeps bookmarks in one tab separated file: URL, description, tags.
+@command{bm} picks one with dmenu, or with fzf on a bare terminal, and then
+opens it, copies it, edits it or deletes it.  Text that is no bookmark is
+opened as an address or searched for on the web.
+
+The rest are small filters that read and write bookmark lines:
+@command{bm-import} turns a browser's bookmarks into bookmark lines,
+@command{bm-check} reports dead links, @command{bm-html} writes a searchable
+web page, @command{bm-migrate} converts the old file format,
+@command{bm-title} prints a page's title and @command{bm-commit} keeps the
+bookmark file's history in git.
+
+Everything is POSIX sh and POSIX utilities.  Nothing is compiled.")
+    ;; Upstream has not chosen a licence yet and ships no LICENSE file.
+    ;; Replace SBM_LICENSE_TBD with a variable from (guix licenses), for
+    ;; example license:gpl3+. As written this is an unbound variable and
+    ;; evaluation fails, which is what a placeholder should do.
+    (license license:SBM_LICENSE_TBD)))
+
+sbm
diff --git a/packaging/homebrew/sbm.rb b/packaging/homebrew/sbm.rb
new file mode 100644
index 0000000..f413c95
--- /dev/null
+++ b/packaging/homebrew/sbm.rb
@@ -0,0 +1,76 @@
+class Sbm < Formula
+  desc "Bookmark manager made of POSIX sh scripts, driven by dmenu or fzf"
+  homepage "https://github.com/equwal/sbm"
+  url "https://github.com/equwal/sbm/archive/refs/tags/v0.3.tar.gz"
+  sha256 "SBM_SHA256_TBD"
+  # No licence has been chosen upstream yet and the repository carries no
+  # LICENSE file. Replace SBM_LICENSE_TBD with the SPDX identifier.
+  # `brew audit --strict` rejects this token, which is intended.
+  license "SBM_LICENSE_TBD"
+  head "https://github.com/equwal/sbm.git", branch: "master"
+
+  # A menu is required: dmenu or fzf. Homebrew has no dmenu formula, and dmenu
+  # needs X11 on macOS, so fzf is the only practical menu here and is a hard
+  # dependency. See caveats.
+  depends_on "fzf"
+
+  # Optional runtime helpers are deliberately not dependencies:
+  #   curl  ships with macOS      (bm-title, bm-check)
+  #   git   ships with the CLT    (bm-commit)
+  #   jq    only for Chromium JSON bookmark files (bm-import)
+  # Homebrew dropped :optional and :recommended, so these are named in caveats.
+
+  def install
+    system "make", "install", "PREFIX=#{prefix}"
+    doc.install "README", "TODO"
+    (pkgshare/"examples").install "usertags", "engines"
+  end
+
+  def caveats
+    <<~EOS
+      sbm expects a menu, a clipboard command and an opener. On macOS:
+
+        * The menu is fzf. dmenu is an X11 program; if you run XQuartz and
+          build dmenu yourself, set SBM_MENU=dmenu.
+        * bm does not auto-detect pbcopy/pbpaste/open, so set these yourself:
+
+            export SBM_COPY=pbcopy
+            export SBM_PASTE=pbpaste
+            export SBM_OPEN=open
+
+      Example tag and search-engine files are installed in
+
+        #{pkgshare}/examples
+
+      Copy them if you want them:
+
+        mkdir -p ~/.local/share/sbm
+        cp #{pkgshare}/examples/usertags #{pkgshare}/examples/engines ~/.local/share/sbm/
+
+      Optional programs: jq (bm-import of Chromium bookmark files).
+      curl and git come with macOS and the Command Line Tools.
+    EOS
+  end
+
+  test do
+    ENV["BOOKMARKS"] = testpath/"bookmarks"
+    ENV["USERTAGS"] = testpath/"usertags"
+    ENV["SBM_GIT"] = "0"
+    ENV["SBM_FETCH"] = "0"
+
+    # --list and --merge are the two actions that never open a menu.
+    merged = pipe_output("#{bin}/bm --merge",
+                         "https://equwal.com\tSpenser Truex's website\tusers\n")
+    assert_match "added 1", merged
+
+    # Merging the same URL again must be a no-op.
+    again = pipe_output("#{bin}/bm --merge",
+                        "https://equwal.com/\tsame bookmark, trailing slash\tusers\n")
+    assert_match "skipped 1", again
+
+    assert_equal "https://equwal.com\tSpenser Truex's website\tusers",
+                 shell_output("#{bin}/bm --list").strip
+
+    assert_match "<li>", shell_output("#{bin}/bm-html")
+  end
+end
diff --git a/packaging/macports/Portfile b/packaging/macports/Portfile
new file mode 100644
index 0000000..d8cc83e
--- /dev/null
+++ b/packaging/macports/Portfile
@@ -0,0 +1,85 @@
+# -*- coding: utf-8; mode: tcl; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- vim:fenc=utf-8:et:sw=4:ts=4:sts=4
+
+PortSystem          1.0
+PortGroup           github 1.0
+
+github.setup        equwal sbm 0.3 v
+categories          www
+platforms           any
+supported_archs     noarch
+maintainers         {equwal.com:truex @equwal}
+
+description         Bookmark manager made of POSIX sh scripts, driven by fzf
+
+long_description    sbm keeps bookmarks in one tab separated file: URL, \
+                    description, tags. bm picks one with fzf and opens it, \
+                    copies it, edits it or deletes it. Text that is no \
+                    bookmark is opened as an address or searched for on the \
+                    web. The rest are small filters over bookmark lines: \
+                    bm-import, bm-check, bm-html, bm-migrate, bm-title and \
+                    bm-commit. Everything is POSIX sh and POSIX utilities; \
+                    nothing is compiled.
+
+homepage            https://github.com/equwal/sbm
+
+# Upstream has not chosen a licence yet and ships no LICENSE file. Replace
+# SBM_LICENSE_TBD with a name from
+# https://trac.macports.org/wiki/PortfileRecipes#license
+# "port lint" rejects the token, which is intended.
+license             SBM_LICENSE_TBD
+
+# No release tag exists yet, so there is nothing to hash. Replace all three
+# tokens at once by running: port -v checksum sbm
+checksums           rmd160  SBM_RMD160_TBD \
+                    sha256  SBM_SHA256_TBD \
+                    size    SBM_SIZE_TBD
+
+# dmenu is an X11 program; on macOS fzf is the practical menu, so it is the
+# only hard runtime dependency. bm picks fzf on its own when there is no
+# display.
+depends_run         port:fzf
+
+use_configure       no
+
+# Nothing is compiled; the upstream "all" target only prints a reminder.
+build {}
+
+# destroot.destdir already carries DESTDIR=${destroot}. PREFIX has to be on
+# the command line: config.mk assigns it plainly, and a plain assignment in a
+# makefile wins over the environment.
+destroot.args       PREFIX=${prefix}
+
+post-destroot {
+    xinstall -d ${destroot}${prefix}/share/doc/${name}
+    xinstall -m 0644 -W ${worksrcpath} README TODO \
+        ${destroot}${prefix}/share/doc/${name}
+
+    xinstall -d ${destroot}${prefix}/share/examples/${name}
+    xinstall -m 0644 -W ${worksrcpath} usertags engines \
+        ${destroot}${prefix}/share/examples/${name}
+}
+
+# The suite scripts the menu, the clipboard and curl, so it needs no display
+# and no network. "make check" is not used: it also runs shellcheck when one
+# happens to be installed, which would make the result depend on the host.
+test.run            yes
+test.cmd            /bin/sh
+test.target
+test.args           test/run.sh
+
+notes "
+bm does not auto-detect the macOS clipboard or opener. Set them yourself:
+
+    export SBM_COPY=pbcopy
+    export SBM_PASTE=pbpaste
+    export SBM_OPEN=open
+
+The menu is fzf. dmenu needs X11 and is not installed by this port.
+
+Example tag and search-engine files are in
+${prefix}/share/examples/${name}; copy them to ~/.local/share/sbm/ if you
+want them.
+
+Optional: jq, for importing Chromium JSON bookmark files with bm-import.
+curl and git come with macOS and the Command Line Tools.
+"
diff --git a/packaging/nix/default.nix b/packaging/nix/default.nix
new file mode 100644
index 0000000..026da55
--- /dev/null
+++ b/packaging/nix/default.nix
@@ -0,0 +1,106 @@
+{ lib
+, stdenvNoCC
+, fetchFromGitHub
+, makeWrapper
+, gawk
+, coreutils
+, fzf
+, dmenu
+, xclip
+, wl-clipboard
+, xdg-utils
+, curl
+, jq
+, git
+  # dmenu is X11 only, so it is off on Darwin and fzf carries the menu there.
+, withDmenu ? stdenvNoCC.hostPlatform.isLinux
+, withWaylandClipboard ? stdenvNoCC.hostPlatform.isLinux
+  # Optional at runtime: bm-title and bm-check (curl), bm-import of Chromium
+  # JSON files (jq), bm-commit (git). Cheap enough to keep on by default.
+, withCurl ? true
+, withJq ? true
+, withGit ? true
+}:
+
+stdenvNoCC.mkDerivation (finalAttrs: {
+  pname = "sbm";
+  version = "0.3";
+
+  src = fetchFromGitHub {
+    owner = "equwal";
+    repo = "sbm";
+    rev = "v${finalAttrs.version}";
+    # No release tag exists yet, so there is nothing to hash. Replace
+    # SBM_SHA256_TBD with the SRI hash; lib.fakeHash makes the build print
+    # the real one. The token is left here so it shows up in a grep.
+    hash = "SBM_SHA256_TBD";
+  };
+
+  nativeBuildInputs = [ makeWrapper ];
+
+  # Nothing is compiled. The upstream "all" target only prints a reminder.
+  dontBuild = true;
+
+  doCheck = true;
+
+  checkPhase = ''
+    runHook preCheck
+    # "make check" also runs shellcheck when it is installed, which would
+    # make the result depend on what is in the build environment. The suite
+    # itself scripts the menu, the clipboard and curl: no display, no network.
+    sh test/run.sh
+    runHook postCheck
+  '';
+
+  installPhase = ''
+    runHook preInstall
+    make install PREFIX="$out"
+    install -Dm444 README -t "$out/share/doc/sbm"
+    install -Dm444 TODO -t "$out/share/doc/sbm"
+    install -Dm444 usertags engines -t "$out/share/doc/sbm/examples"
+    runHook postInstall
+  '';
+
+  postFixup =
+    let
+      runtimeDeps =
+        # fzf is always there: it is the menu that needs no display.
+        # xclip, wl-clipboard, dmenu and xdg-utils are X11 or Wayland
+        # programs and do not evaluate on Darwin.
+        [ gawk coreutils fzf ]
+        ++ lib.optionals stdenvNoCC.hostPlatform.isLinux [ xclip xdg-utils ]
+        ++ lib.optional withDmenu dmenu
+        ++ lib.optional withWaylandClipboard wl-clipboard
+        ++ lib.optional withCurl curl
+        ++ lib.optional withJq jq
+        ++ lib.optional withGit git;
+    in
+    ''
+      # bm calls bm-title and bm-commit by name, so $out/bin goes on the
+      # PATH as well as the runtime programs.
+      for f in "$out"/bin/*; do
+        wrapProgram "$f" \
+          --prefix PATH : "$out/bin:${lib.makeBinPath runtimeDeps}"
+      done
+    '';
+
+  meta = {
+    description = "Bookmark manager made of POSIX sh scripts, driven by dmenu or fzf";
+    longDescription = ''
+      sbm keeps bookmarks in one tab separated file: URL, description, tags.
+      bm picks one from dmenu, or from fzf on a bare terminal, and opens it,
+      copies it, edits it or deletes it. Text that is no bookmark is opened as
+      an address or searched for on the web. The other programs are small
+      filters over bookmark lines: bm-import, bm-check, bm-html, bm-migrate,
+      bm-title and bm-commit.
+    '';
+    homepage = "https://github.com/equwal/sbm";
+    # Upstream has not chosen a licence yet and ships no LICENSE file.
+    # This must become lib.licenses.<id> (an attribute, not a string) before
+    # the package can go into nixpkgs; a string fails the meta check.
+    license = "SBM_LICENSE_TBD";
+    mainProgram = "bm";
+    maintainers = [ ];
+    platforms = lib.platforms.unix;
+  };
+})
diff --git a/packaging/nix/flake.nix b/packaging/nix/flake.nix
new file mode 100644
index 0000000..a883747
--- /dev/null
+++ b/packaging/nix/flake.nix
@@ -0,0 +1,34 @@
+{
+  description = "sbm: bookmarks in a plain file, picked with dmenu or fzf";
+
+  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
+
+  outputs = { self, nixpkgs }:
+    let
+      systems = [
+        "x86_64-linux"
+        "aarch64-linux"
+        "x86_64-darwin"
+        "aarch64-darwin"
+      ];
+      forAllSystems = f:
+        nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
+    in
+    {
+      packages = forAllSystems (pkgs: rec {
+        sbm = pkgs.callPackage ./default.nix { };
+        default = sbm;
+      });
+
+      # Build the working tree instead of the tagged release:
+      #   nix build .#sbm --override-input nixpkgs ...
+      # or edit src in default.nix to fetchGit ../..
+      # What "make check" wants, plus the runtime programs. dmenu is left out
+      # on purpose: it is X11 only and does not evaluate on Darwin.
+      devShells = forAllSystems (pkgs: {
+        default = pkgs.mkShellNoCC {
+          packages = with pkgs; [ shellcheck fzf curl jq git ];
+        };
+      });
+    };
+}
diff --git a/packaging/rpm/sbm.spec b/packaging/rpm/sbm.spec
new file mode 100644
index 0000000..773b68c
--- /dev/null
+++ b/packaging/rpm/sbm.spec
@@ -0,0 +1,87 @@
+Name:           sbm
+Version:        0.3
+Release:        1%{?dist}
+Summary:        Bookmark manager made of POSIX sh scripts, driven by dmenu or fzf
+
+# Upstream has not chosen a licence yet and ships no LICENSE file. Replace
+# SBM_LICENSE_TBD with the SPDX identifier, and add the LICENSE file to the
+# %%files section with %%license once upstream ships one. rpmlint reports
+# the token as an invalid licence, which is intended.
+License:        SBM_LICENSE_TBD
+URL:            https://github.com/equwal/sbm
+# No release tag exists yet. Once v0.3 is tagged, fetch with:
+#   spectool -g sbm.spec
+Source0:        %{url}/archive/refs/tags/v%{version}.tar.gz#/%{name}-%{version}.tar.gz
+
+BuildArch:      noarch
+
+BuildRequires:  make
+# make check runs shellcheck -s sh over every script before the test suite.
+# Both Fedora and openSUSE call the package ShellCheck.
+BuildRequires:  ShellCheck
+
+# Rich dependencies need rpm >= 4.13: Fedora, and openSUSE Leap 15 and later.
+# They are the only honest way to say "dmenu or fzf".
+Requires:       (dmenu or fzf)
+Requires:       /bin/sh
+Requires:       awk
+
+Recommends:     (xclip or xsel or wl-clipboard)
+Recommends:     xdg-utils
+
+# bm-title and bm-check
+Suggests:       curl
+# bm-import, for Chromium JSON bookmark files only
+Suggests:       jq
+# bm-commit
+Suggests:       git-core
+
+%description
+sbm keeps bookmarks in one tab separated file: URL, description, tags. Lines
+starting with # are ignored, so cut, grep, awk and an editor work on it as
+well as the tools do.
+
+bm picks a bookmark with dmenu, or with fzf on a bare terminal, and then
+opens it, copies it, edits it or deletes it. Text that is no bookmark is
+opened as an address or searched for on the web.
+
+The rest are small filters that read and write bookmark lines: bm-import
+turns a browser's bookmarks into bookmark lines, bm-check reports dead links,
+bm-html writes a searchable web page, bm-migrate converts the old file
+format, bm-title prints a page's title and bm-commit keeps the bookmark
+file's history in git.
+
+Everything is POSIX sh and POSIX utilities. Nothing is compiled.
+
+%prep
+%autosetup
+
+%build
+# Nothing is compiled; the package is a set of sh scripts.
+
+%install
+%make_install PREFIX=%{_prefix}
+
+# Shipped as documentation, not as configuration: the user copies what they
+# want to ~/.local/share/sbm/.
+mkdir -p examples
+cp -p usertags engines examples/
+
+%check
+# The suite scripts the menu, the clipboard and curl, so it needs no display
+# and no network.
+make check
+
+%files
+%doc README TODO examples
+%{_bindir}/bm
+%{_bindir}/bm-check
+%{_bindir}/bm-commit
+%{_bindir}/bm-html
+%{_bindir}/bm-import
+%{_bindir}/bm-migrate
+%{_bindir}/bm-title
+
+%changelog
+* Sun Sep 20 2026 Spenser Truex <truex@equwal.com> - 0.3-1
+- Initial package.
diff --git a/packaging/scoop/sbm.json b/packaging/scoop/sbm.json
new file mode 100644
index 0000000..36b7243
--- /dev/null
+++ b/packaging/scoop/sbm.json
@@ -0,0 +1,68 @@
+{
+    "##": [
+        "sbm is POSIX shell. This manifest does not port it to PowerShell: it",
+        "installs the scripts and generates a .cmd per script that runs them",
+        "through the sh.exe that comes with Git for Windows. That is why git is",
+        "a dependency. The menu is fzf; dmenu is X11 only and has no Windows",
+        "build, so it is neither installed nor mentioned as an alternative.",
+        "",
+        "license: upstream has not chosen one yet and ships no LICENSE file.",
+        "Replace SBM_LICENSE_TBD with the SPDX identifier.",
+        "hash: no release tag exists yet. Replace SBM_SHA256_TBD with the",
+        "sha256 of the tarball, or run: scoop checkver sbm <bucket> -u"
+    ],
+    "version": "0.3",
+    "description": "Bookmark manager made of POSIX sh scripts, driven by fzf",
+    "homepage": "https://github.com/equwal/sbm",
+    "license": "SBM_LICENSE_TBD",
+    "depends": [
+        "git",
+        "fzf"
+    ],
+    "suggest": {
+        "JSON processing (bm-import of Chromium bookmark files)": "jq"
+    },
+    "url": "https://github.com/equwal/sbm/archive/refs/tags/v0.3.tar.gz",
+    "hash": "SBM_SHA256_TBD",
+    "extract_dir": "sbm-0.3",
+    "pre_install": [
+        "$lines = @(",
+        "  '@echo off',",
+        "  'setlocal',",
+        "  'if not defined SBM_COPY set \"SBM_COPY=clip\"',",
+        "  'if not defined SBM_PASTE set \"SBM_PASTE=powershell -NoProfile -Command Get-Clipboard\"',",
+        "  'set \"SBM_SH=\"',",
+        "  'for %%I in (sh.exe) do if not defined SBM_SH if not \"%%~$PATH:I\"==\"\" set \"SBM_SH=%%~$PATH:I\"',",
+        "  'for %%I in (\"%SCOOP%\\apps\\git\\current\\bin\\sh.exe\" \"%USERPROFILE%\\scoop\\apps\\git\\current\\bin\\sh.exe\" \"%ProgramFiles%\\Git\\bin\\sh.exe\" \"%LOCALAPPDATA%\\Programs\\Git\\bin\\sh.exe\") do if not defined SBM_SH if exist %%I set \"SBM_SH=%%~I\"',",
+        "  'if not defined SBM_SH echo sbm: no POSIX sh found. Install Git: scoop install git 1>&2',",
+        "  'if not defined SBM_SH exit /b 127'",
+        ")",
+        "foreach ($t in 'bm','bm-migrate','bm-import','bm-check','bm-html','bm-title','bm-commit') {",
+        "  ($lines + ('\"%SBM_SH%\" \"%~dp0' + $t + '\" %*')) | Set-Content -LiteralPath \"$dir\\$t.cmd\" -Encoding ASCII",
+        "}"
+    ],
+    "bin": [
+        "bm.cmd",
+        "bm-migrate.cmd",
+        "bm-import.cmd",
+        "bm-check.cmd",
+        "bm-html.cmd",
+        "bm-title.cmd",
+        "bm-commit.cmd"
+    ],
+    "notes": [
+        "The menu is fzf. dmenu is an X11 program and does not exist here.",
+        "SBM_COPY and SBM_PASTE default to clip and Get-Clipboard.",
+        "Set an opener yourself, for example:",
+        "  setx SBM_OPEN \"powershell -NoProfile -Command Start-Process\"",
+        "Example tag and engine files are in the app directory; copy usertags",
+        "and engines to ~/.local/share/sbm/ (the HOME that Git Bash uses)."
+    ],
+    "checkver": {
+        "github": "https://github.com/equwal/sbm"
+    },
+    "autoupdate": {
+        "url": "https://github.com/equwal/sbm/archive/refs/tags/v$version.tar.gz",
+        "extract_dir": "sbm-$version"
+    }
+}
diff --git a/packaging/void/srcpkgs/sbm/template b/packaging/void/srcpkgs/sbm/template
new file mode 100644
index 0000000..a7cb217
--- /dev/null
+++ b/packaging/void/srcpkgs/sbm/template
@@ -0,0 +1,44 @@
+# Template file for 'sbm'
+pkgname=sbm
+version=0.3
+revision=1
+build_style=gnu-makefile
+make_install_args="PREFIX=/usr"
+# xbps-src has no "a or b" operator. A menu is required; fzf needs no display
+# and is the hard dependency. dmenu is in the tree too: install it as well if
+# you run X11.
+depends="fzf"
+checkdepends="shellcheck"
+short_desc="Bookmark manager made of POSIX sh scripts, driven by fzf"
+maintainer="Spenser Truex <truex@equwal.com>"
+# Upstream has not chosen a licence yet and ships no LICENSE file. Replace
+# SBM_LICENSE_TBD with the SPDX identifier and add "vlicense LICENSE" to
+# post_install. xlint rejects the token, which is intended.
+license="SBM_LICENSE_TBD"
+homepage="https://github.com/equwal/sbm"
+distfiles="https://github.com/equwal/sbm/archive/refs/tags/v${version}.tar.gz"
+# No release tag exists yet, so there is nothing to hash. Replace
+# SBM_SHA256_TBD with the output of: xgensum -i srcpkgs/sbm/template
+checksum=SBM_SHA256_TBD
+
+# Optional at runtime, none of them pulled in:
+#   curl       bm-title, bm-check
+#   jq         bm-import of Chromium JSON bookmark files
+#   git        bm-commit
+#   xclip      clipboard under X11 (or xsel, or wl-clipboard under Wayland)
+#   xdg-utils  opening URLs with xdg-open
+
+do_check() {
+	# make check runs shellcheck -s sh over every script, then the test
+	# suite. The suite scripts the menu, the clipboard and curl: no display,
+	# no network.
+	make check
+}
+
+post_install() {
+	vdoc README
+	vdoc TODO
+	vmkdir usr/share/examples/sbm
+	vcopy usertags usr/share/examples/sbm
+	vcopy engines usr/share/examples/sbm
+}