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 5f825e6db438ce06f8abb759d529ac39068d0819
Spenser Truex <truex@equwal.com>
2026-09-21 00:26:03 -0700

bm-watch: bring new browser bookmarks into bm

bm-watch imports the bookmarks of all profiles of the given browsers,
and imports them again each time that a Bookmarks file changes. It uses
bm-import and bm --merge, so it only adds bookmarks, and it never
writes to the browser. It reads the files with one cksum every 5
seconds, and it looks for new profiles once a minute.

bm-import --files prints the Bookmarks file of each profile of a
browser. The packages list bm-watch with the other tools.

 Makefile                                           |  2 +-
 README                                             |  7 ++
 bm-import                                          | 20 ++++-
 bm-watch                                           | 87 ++++++++++++++++++++++
 config.mk                                          |  3 +-
 packaging/README.md                                |  2 +-
 packaging/chocolatey/tools/chocolateyInstall.ps1   |  2 +-
 packaging/chocolatey/tools/chocolateyUninstall.ps1 |  2 +-
 packaging/freebsd/deskutils/sbm/pkg-plist          |  1 +
 packaging/rpm/sbm.spec                             |  1 +
 packaging/scoop/sbm.json                           |  5 +-
 test/run.sh                                        | 44 ++++++++++-
 12 files changed, 165 insertions(+), 11 deletions(-)
diff --git a/Makefile b/Makefile
index 1ff7f7c..d4dd1c8 100644
--- a/Makefile
+++ b/Makefile
@@ -1,6 +1,6 @@
 include config.mk
 
-SCRIPTS = bm bm-migrate bm-import bm-check bm-html bm-title bm-commit
+SCRIPTS = bm bm-migrate bm-import bm-check bm-html bm-title bm-commit bm-watch
 TESTS = test/run.sh test/fakemenu test/fakefzf
 
 all:
diff --git a/README b/README
index 4194f55..d0f37b9 100644
--- a/README
+++ b/README
@@ -23,6 +23,7 @@ other Unix tools, and you install only the ones you use:
     bm-migrate  the old sbm file format  -> bookmark lines
     bm-title    a URL                    -> the page's title
     bm-commit   commit the bookmark file to git
+    bm-watch    browser bookmarks        -> bm, now and after each change
 
     bm-import bookmarks.html | bm --merge
     bm --tag code --list | bm-check -
@@ -163,6 +164,12 @@ dev-tools). bm --merge skips what you already have, so importing twice adds
 nothing. To sync new browser bookmarks into bm, run the same command again.
 Look before you merge: bm-import prints plain bookmark lines.
 
+bm-watch does this import at once, and again each time that the bookmark
+file of a profile changes. It only adds bookmarks, and it never writes to
+the browser:
+
+    bm-watch brave &
+
 == Dead links ==
     bm-check
 
diff --git a/bm-import b/bm-import
index 3ceaa9b..32ba27e 100755
--- a/bm-import
+++ b/bm-import
@@ -10,10 +10,12 @@
 # "Dev Tools" is dev-tools. Only http, https, ftp and file URLs are kept.
 # A browser name reads the JSON files of all profiles of that browser, in
 # the directory that the browser uses on Linux, macOS or Windows (Cygwin).
+# --files prints the names of these files, for bm-watch.
 
 usage () {
     cat <<'EOF2'
 usage: bm-import <file> | vivaldi | chrome | brave | edge | chromium
+       bm-import --files vivaldi | chrome | brave | edge | chromium
 EOF2
 }
 
@@ -22,13 +24,15 @@ die () {
     exit 1
 }
 
+files=
+case ${1-} in
+    -h|--help) usage; exit 0 ;;
+    --files)   files=1; shift ;;
+esac
 if [ $# -ne 1 ]; then
     usage >&2
     exit 2
 fi
-case $1 in
-    -h|--help) usage; exit 0 ;;
-esac
 
 # browser_dir <linux> <macos> <windows>: the first directory of the browser
 # that exists. The names are relative to ~/.config, to ~/Library/Application
@@ -58,6 +62,16 @@ case $1 in
     chromium) dir=$(browser_dir chromium Chromium Chromium) ;;
     *)        dir= ;;
 esac || die "found no profiles of $1"
+if [ -n "$files" ]; then
+    if [ -z "$dir" ]; then
+        usage >&2
+        exit 2
+    fi
+    for file in "$dir"/*/Bookmarks; do
+        if [ -r "$file" ]; then printf '%s\n' "$file"; fi
+    done
+    exit 0
+fi
 if [ -n "$dir" ]; then
     command -v jq >/dev/null 2>&1 || die "reading $1 needs jq"
 else
diff --git a/bm-watch b/bm-watch
new file mode 100755
index 0000000..0062bc4
--- /dev/null
+++ b/bm-watch
@@ -0,0 +1,87 @@
+#!/bin/sh
+
+# bm-watch: bring the bookmarks of browsers into bm, and keep them coming.
+#
+#     bm-watch brave &
+#     bm-watch -i 30 brave vivaldi
+#
+# At the start, and each time that the bookmark file of a profile changes,
+# bm-watch runs "bm-import <browser> | bm --merge". So a bookmark that you
+# add in the browser comes into bm a few seconds later. bm-watch only adds:
+# it does not remove from bm what you delete in the browser, and it never
+# writes to the browser. It checks the files every 5 seconds (-i), and it
+# looks for new profiles once a minute. -1 checks once and stops.
+
+usage () {
+    printf 'usage: bm-watch [-1] [-i seconds] browser...\n'
+}
+
+die () {
+    printf 'bm-watch: %s\n' "$*" >&2
+    exit 1
+}
+
+interval=5
+once=
+while getopts 1i: opt; do
+    case $opt in
+        1) once=1 ;;
+        i) interval=$OPTARG ;;
+        *) usage >&2; exit 2 ;;
+    esac
+done
+shift $((OPTIND - 1))
+if [ $# -eq 0 ]; then
+    usage >&2
+    exit 2
+fi
+case $interval in
+    ''|*[!0-9]*|0) usage >&2; exit 2 ;;
+esac
+
+# files <browser>...: the bookmark files of all profiles, one per line.
+files () {
+    for browser; do
+        bm-import --files "$browser" 2>/dev/null
+    done
+}
+
+# sync <browser>...: merge the bookmarks of the browsers that have profiles.
+sync () {
+    for browser; do
+        if [ -n "$(bm-import --files "$browser" 2>/dev/null)" ]; then
+            bm-import "$browser" | bm --merge
+        fi
+    done
+}
+
+# stamp <files>: the checksum of each file. One cksum reads them all.
+stamp () {
+    [ -n "$1" ] || return 0
+    (
+        IFS='
+'
+        set -f
+        # shellcheck disable=SC2086  # one file per line, on purpose
+        cksum $1 2>/dev/null
+    )
+}
+
+list=$(files "$@")
+[ -n "$list" ] || die "found no profiles of $*"
+last=
+ticks=0
+while :; do
+    if [ "$ticks" -ge 60 ]; then
+        list=$(files "$@")
+        ticks=0
+    fi
+    now=$(stamp "$list")
+    if [ "$now" != "$last" ]; then
+        sync "$@"
+        last=$now
+    fi
+    [ -z "$once" ] || exit 0
+    sleep "$interval"
+    ticks=$((ticks + interval))
+done
diff --git a/config.mk b/config.mk
index 28ad308..656f6af 100644
--- a/config.mk
+++ b/config.mk
@@ -11,4 +11,5 @@ MANPREFIX=${PREFIX}/share/man
 #   bm-html     the bookmarks as a searchable web page
 #   bm-title    lets bm --add suggest the page title as description (curl)
 #   bm-commit   lets bm keep the file's history in git
-TOOLS = bm bm-migrate bm-import bm-check bm-html bm-title bm-commit
+#   bm-watch    brings new browser bookmarks into bm (bm-import, jq)
+TOOLS = bm bm-migrate bm-import bm-check bm-html bm-title bm-commit bm-watch
diff --git a/packaging/README.md b/packaging/README.md
index 9ea3518..13385a6 100644
--- a/packaging/README.md
+++ b/packaging/README.md
@@ -24,7 +24,7 @@ mentions a sync client.
 
 `make install` copies the programs in `TOOLS` to `${DESTDIR}${PREFIX}/bin`:
 
-    bm  bm-migrate  bm-import  bm-check  bm-html  bm-title  bm-commit
+    bm  bm-migrate  bm-import  bm-check  bm-html  bm-title  bm-commit  bm-watch
 
 Nothing is compiled. There are no man pages. Every package calls
 `make DESTDIR=... PREFIX=... install` rather than copying the scripts itself,
diff --git a/packaging/chocolatey/tools/chocolateyInstall.ps1 b/packaging/chocolatey/tools/chocolateyInstall.ps1
index 26d3b08..32a08b6 100644
--- a/packaging/chocolatey/tools/chocolateyInstall.ps1
+++ b/packaging/chocolatey/tools/chocolateyInstall.ps1
@@ -6,7 +6,7 @@ $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')
+$scripts = @('bm', 'bm-migrate', 'bm-import', 'bm-check', 'bm-html', 'bm-title', 'bm-commit', 'bm-watch')
 
 # ---------------------------------------------------------------------------
 # Fetch and unpack.
diff --git a/packaging/chocolatey/tools/chocolateyUninstall.ps1 b/packaging/chocolatey/tools/chocolateyUninstall.ps1
index 2f40f0c..e5788b0 100644
--- a/packaging/chocolatey/tools/chocolateyUninstall.ps1
+++ b/packaging/chocolatey/tools/chocolateyUninstall.ps1
@@ -2,7 +2,7 @@ $ErrorActionPreference = 'Stop'
 
 $toolsDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
 
-$scripts = @('bm', 'bm-migrate', 'bm-import', 'bm-check', 'bm-html', 'bm-title', 'bm-commit')
+$scripts = @('bm', 'bm-migrate', 'bm-import', 'bm-check', 'bm-html', 'bm-title', 'bm-commit', 'bm-watch')
 
 foreach ($s in $scripts) {
   $cmdPath = Join-Path $toolsDir "$s.cmd"
diff --git a/packaging/freebsd/deskutils/sbm/pkg-plist b/packaging/freebsd/deskutils/sbm/pkg-plist
index 6edba24..5447749 100644
--- a/packaging/freebsd/deskutils/sbm/pkg-plist
+++ b/packaging/freebsd/deskutils/sbm/pkg-plist
@@ -5,6 +5,7 @@ bin/bm-html
 bin/bm-import
 bin/bm-migrate
 bin/bm-title
+bin/bm-watch
 %%PORTDOCS%%%%DOCSDIR%%/README
 %%PORTDOCS%%%%DOCSDIR%%/TODO
 %%PORTEXAMPLES%%%%EXAMPLESDIR%%/engines
diff --git a/packaging/rpm/sbm.spec b/packaging/rpm/sbm.spec
index 773b68c..39359b6 100644
--- a/packaging/rpm/sbm.spec
+++ b/packaging/rpm/sbm.spec
@@ -81,6 +81,7 @@ make check
 %{_bindir}/bm-import
 %{_bindir}/bm-migrate
 %{_bindir}/bm-title
+%{_bindir}/bm-watch
 
 %changelog
 * Sun Sep 20 2026 Spenser Truex <truex@equwal.com> - 0.3-1
diff --git a/packaging/scoop/sbm.json b/packaging/scoop/sbm.json
index 36b7243..a846f65 100644
--- a/packaging/scoop/sbm.json
+++ b/packaging/scoop/sbm.json
@@ -37,7 +37,7 @@
         "  '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') {",
+        "foreach ($t in 'bm','bm-migrate','bm-import','bm-check','bm-html','bm-title','bm-commit','bm-watch') {",
         "  ($lines + ('\"%SBM_SH%\" \"%~dp0' + $t + '\" %*')) | Set-Content -LiteralPath \"$dir\\$t.cmd\" -Encoding ASCII",
         "}"
     ],
@@ -48,7 +48,8 @@
         "bm-check.cmd",
         "bm-html.cmd",
         "bm-title.cmd",
-        "bm-commit.cmd"
+        "bm-commit.cmd",
+        "bm-watch.cmd"
     ],
     "notes": [
         "The menu is fzf. dmenu is an X11 program and does not exist here.",
diff --git a/test/run.sh b/test/run.sh
index b83ecc8..b61e96f 100755
--- a/test/run.sh
+++ b/test/run.sh
@@ -308,7 +308,7 @@ for gone in '--import x' --check --migrate '-s x'; do
 done
 
 bad=
-for script in bm bm-migrate bm-import bm-check bm-html bm-title bm-commit; do
+for script in bm bm-migrate bm-import bm-check bm-html bm-title bm-commit bm-watch; do
     [ "$(sed -n 1p "$top/$script")" = '#!/bin/sh' ] || bad="$bad $script"
 done
 eq 'every script asks for /bin/sh' "$bad" ''
@@ -521,6 +521,48 @@ if command -v jq >/dev/null 2>&1; then
         "$(HOME=$work/nohome XDG_CONFIG_HOME='' LOCALAPPDATA=$work/win $IMPORT edge)" "$json_rows"
     HOME=$work/nohome XDG_CONFIG_HOME='' LOCALAPPDATA='' $IMPORT brave 2>/dev/null
     eq 'bm-import fails when it finds no profiles of the browser' "$?" '1'
+    eq 'bm-import --files lists the bookmark file of each profile' \
+        "$(HOME=$work/linux XDG_CONFIG_HOME='' LOCALAPPDATA='' $IMPORT --files brave | sed "s|^$work/linux/||")" \
+        "$(printf '%s\n' '.config/BraveSoftware/Brave-Browser/Default/Bookmarks' \
+            '.config/BraveSoftware/Brave-Browser/Profile 1/Bookmarks')"
+    $IMPORT --files "$here/fixtures/chromium.json" 2>/dev/null
+    eq 'bm-import --files wants a browser name' "$?" '2'
+
+    # bm-watch runs bm-import and bm by name.
+    WATCH="${SBM_SH:-sh} $top/bm-watch"
+    reset
+    eq 'bm-watch -1 imports the bookmarks of every profile' \
+        "$(PATH="$top:$PATH" HOME=$work/linux XDG_CONFIG_HOME='' LOCALAPPDATA='' $WATCH -1 brave)" \
+        'added 3, skipped 5 duplicates'
+    eq 'bm-watch -1 again adds nothing' \
+        "$(PATH="$top:$PATH" HOME=$work/linux XDG_CONFIG_HOME='' LOCALAPPDATA='' $WATCH -1 brave)" \
+        'added 0, skipped 8 duplicates'
+    $WATCH -1 2>/dev/null
+    eq 'bm-watch wants a browser' "$?" '2'
+    PATH="$top:$PATH" HOME=$work/nohome XDG_CONFIG_HOME='' LOCALAPPDATA='' $WATCH -1 brave 2>/dev/null
+    eq 'bm-watch fails when it finds no profiles' "$?" '1'
+
+    reset
+    PATH="$top:$PATH" HOME=$work/linux XDG_CONFIG_HOME='' LOCALAPPDATA='' \
+        $WATCH -i 1 brave >/dev/null 2>&1 &
+    watcher=$!
+    trap 'kill "$watcher" 2>/dev/null; rm -rf "$work"' EXIT INT TERM
+    i=0
+    while [ $i -lt 30 ] && ! grep -q deep.example "$BOOKMARKS"; do
+        sleep 1
+        i=$((i + 1))
+    done
+    sed 's|https://deep.example/|https://new.example/|' "$here/fixtures/chromium.json" \
+        > "$work/linux/.config/BraveSoftware/Brave-Browser/Profile 1/Bookmarks"
+    i=0
+    while [ $i -lt 30 ] && ! grep -q new.example "$BOOKMARKS"; do
+        sleep 1
+        i=$((i + 1))
+    done
+    kill "$watcher" 2>/dev/null
+    trap 'rm -rf "$work"' EXIT INT TERM
+    eq 'bm-watch imports again when a bookmark file changes' \
+        "$(grep -c new.example "$BOOKMARKS")" '1'
 else
     printf 'skip bm-import json: no jq\n'
 fi