Files
azos/features/lauretta/emacs/config.org
T
aner f079a39cbc Make elfeed-protocol Nextcloud password fetch retriable
A failed pass/gpg fetch (e.g. YubiKey not plugged in yet) used to pass
process output straight to string-trim, which signals instead of
returning nil on empty output, aborting the sentinel before
elfeed-feeds or elfeed-protocol-enable ran. That left elfeed-feeds nil
for the rest of the session with no visible error beyond a *Messages*
line, and every later elfeed-update silently had nothing to fetch.

Wrap the fetch in azos/elfeed-protocol-init-feeds so a failed attempt
can be retried interactively instead of requiring a full reload of the
elfeed-protocol feature.
2026-10-08 13:14:14 +03:00

557 lines
23 KiB
Org Mode

#+title: Aner's Emacs Lauretta Configuration
#+property: header-args :results silent
* Base setup
** Require
#+begin_src emacs-lisp
(require 'azos-emacs-base)
(require 'azos-emacs-dev)
(require 'azos-emacs-editor)
(require 'azos-emacs-exwm)
(require 'azos-emacs-station)
#+end_src
* Lauretta specific
** Weather
#+begin_src emacs-lisp
(setq wttrin-default-cities '("Meitar, Israel"))
#+end_src
** LLM
#+begin_src emacs-lisp
(setq
gptel-model 'openai/gpt-oss-120b
gptel-backend
(gptel-make-openai "Groq"
:host "api.groq.com"
:endpoint "/openai/v1/chat/completions"
:stream t
:key "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"
:models '(openai/gpt-oss-120b)))
#+end_src
** GPG Pinentry
gpg-agent is configured with =pinentry.package = pkgs.pinentry-emacs=, which
needs a running Emacs-side pinentry service to hand it prompts over a
loopback socket. Without =pinentry-start=, the pinentry-emacs binary can't
reach Emacs and falls back to the curses pinentry (visible as a TUI prompt
when committing from vterm) or hangs entirely (as seen from Magit).
#+begin_src emacs-lisp
(require 'pinentry)
(pinentry-start)
#+end_src
** Agent Shell
#+begin_src emacs-lisp
(setq agent-shell-preferred-agent-config 'claude-code)
#+end_src
** Headphones
#+begin_src emacs-lisp
(defun azos/connect-headphones (&optional arg)
"Connect to headphones via bluetoothctl.
Without prefix ARG, connects to the first device in the list.
With prefix ARG, prompts to choose from all configured devices."
(interactive "P")
(if (not arg)
(start-process-shell-command "connect-headphones" nil "azos-connect-headphones")
(let* ((raw (shell-command-to-string "azos-connect-headphones --list"))
(lines (split-string (string-trim raw) "\n" t))
(choices (mapcar (lambda (line)
(let ((space (string-match " " line)))
(cons (substring line (1+ space))
(substring line 0 space))))
lines))
(name (completing-read "Connect headphones: " (mapcar #'car choices) nil t))
(mac (cdr (assoc name choices))))
(start-process-shell-command "connect-headphones" nil
(concat "azos-connect-headphones --mac " mac)))))
(define-key azos/global-minor-mode/open-keymap
(kbd "h") 'azos/connect-headphones)
#+end_src
** Tab bar setup
#+begin_src emacs-lisp
(when (fboundp #'azos/bat/enable-tab-display)
(azos/bat/enable-tab-display))
(when (fboundp #'azos/network/enable-tab-display)
(azos/network/enable-tab-display))
(when (fboundp #'azos/audio/enable-tab-display)
(azos/audio/enable-tab-display))
#+end_src
** Nixpkgs Search
#+begin_src emacs-lisp
(defun azos/nixpkgs-parse-json-lines (str)
"Parse JSON Lines format (one JSON object per line) from STR."
(mapcar (lambda (line)
(and (not (string-empty-p line))
(string-match "^{" line)
(condition-case nil
(json-parse-string line)
(error nil))))
(split-string (string-trim str) "\n" t)))
(defun azos/nixpkgs-g (key hash)
"Get KEY from HASH table."
(when (hash-table-p hash)
(gethash key hash)))
(defun azos/nixpkgs-search ()
"Search for a nixpkgs package and show its info."
(interactive)
(let* ((pattern (read-string "Search pattern: "))
(json-output (shell-command-to-string
(format "nix-search --json --max-results 50 '%s'" pattern)))
(results (and json-output
(not (string-empty-p json-output))
(azos/nixpkgs-parse-json-lines json-output)))
(packages (delq nil (mapcar (lambda (item)
(let ((pname (azos/nixpkgs-g "package_pname" item)))
(when pname
(cons pname item))))
results))))
(if packages
(let* ((selected (completing-read "Package: " (mapcar #'car packages) nil t))
(package-data (cdr (assoc-string selected packages))))
(let ((buf (get-buffer-create "*nixpkgs-package-info*")))
(with-current-buffer buf
(erase-buffer)
(if package-data
(progn
(insert (format "Package: %s\n\n" selected))
(insert (format "Version: %s\n" (or (azos/nixpkgs-g "package_pversion" package-data) "unknown")))
(insert (format "Attr: %s\n" (or (azos/nixpkgs-g "package_attr_name" package-data) "unknown")))
(insert (format "\nDescription: %s\n"
(or (azos/nixpkgs-g "package_description" package-data) "none")))
(let ((programs (azos/nixpkgs-g "package_programs" package-data)))
(when (vectorp programs)
(insert (format "\nPrograms: %s\n"
(mapconcat #'identity (append programs nil) " ")))))
(let ((homepage (let ((h (azos/nixpkgs-g "package_homepage" package-data)))
(when (vectorp h) (aref h 0)))))
(when homepage
(insert (format "\nHomepage: %s\n" homepage))))
(let ((licenses (azos/nixpkgs-g "package_license" package-data)))
(when (vectorp licenses)
(insert (format "\nLicense: %s\n"
(mapconcat (lambda (l)
(or (azos/nixpkgs-g "fullName" l) ""))
(append licenses nil) ", ")))))
(insert (format "Package: %s\n\n" selected)
"\nNo additional info available.")))
(goto-char (point-min)))
(display-buffer buf)))
(message "No packages found matching '%s'" pattern))))
(define-key azos/global-minor-mode/open-keymap
(kbd "n") 'azos/nixpkgs-search)
#+end_src
** CalDAV / Org Sync
#+begin_src emacs-lisp
(require 'org-caldav)
(defvar azos/lauretta/nextcloud-user "aner"
"Nextcloud username for CalDAV sync.")
(setq org-caldav-url "https://nextcloud.zakobar.com/remote.php/dav/calendars/1ddd03a6-4c2d-103c-9f7b-27b20313341d"
org-caldav-calendar-id "personal"
org-caldav-inbox "~/org/caldav-inbox.org"
org-caldav-files '("~/org/todo.org")
org-icalendar-timezone "Asia/Jerusalem")
(defun azos/caldav-sync ()
"Sync org-caldav with Nextcloud calendar, reading password from pass."
(interactive)
(let* ((password (string-trim
(shell-command-to-string
"pass zakobar.com | head -1")))
(url-http-real-basic-auth-storage
(list (list "nextcloud.zakobar.com:443"
(cons azos/lauretta/nextcloud-user password)))))
(org-caldav-sync)))
(define-key azos/global-minor-mode/open-keymap
(kbd "C") 'azos/caldav-sync)
#+end_src
** Elfeed Nextcloud News Sync
[[https://github.com/fasheng/elfeed-protocol][elfeed-protocol]] adds an =owncloud+= protocol that speaks the
ownCloud News API, which the Nextcloud News app also implements, so
it syncs elfeed's feed list and read/unread state with the Nextcloud
News app instead of just reading a local feed list one-way.
Reuses the same =zakobar.com= pass entry as the CalDAV sync above,
since it's the same Nextcloud account.
As of elfeed-protocol 1.0.0 (paired with elfeed >= 4.0.0), the
credentials go directly on =elfeed-feeds= — the older
=elfeed-protocol-feeds= variable is dead: setting it only logs a
deprecation warning, and =elfeed-feeds= ends up with the bare URL
and no =:password=, so requests silently authenticate with an empty
password and nothing ever lands in the db.
Fetching the password with =shell-command-to-string= used to
deadlock Emacs whenever the GPG PIN cache had expired: that call
blocks Emacs's single thread on the =pass|gpg= subprocess, but
=pinentry-emacs= (see the GPG Pinentry section above) needs Emacs's
command loop running to answer its loopback prompt — so Emacs waits
on gpg-agent while gpg-agent waits on Emacs. Fetching the password
with =make-process= instead keeps the command loop alive, so
=pinentry-emacs= can prompt normally; =elfeed-feeds= and
=elfeed-protocol-enable= are only set once the process sentinel
fires. Same stderr-redirect reasoning as before, to keep a stray
gpg/pass warning off the captured password.
The fetch is wrapped in a named, interactive function rather than
inlined in =:config= so a failed attempt (e.g. =pass= produced no
output because the YubiKey wasn't plugged in yet) can be retried
with =M-x azos/elfeed-protocol-init-feeds= — re-running =:config=
itself would require reloading the =elfeed-protocol= feature
entirely. An earlier version passed =(process-get proc 'output)=
straight to =string-trim=, which signals (not just returns nil) when
no output was ever read, aborting the sentinel before =elfeed-feeds=
or =elfeed-protocol-enable= ran — so a failed first attempt left
=elfeed-feeds= permanently =nil= for the rest of the session with no
visible error beyond a =*Messages*= line, and every subsequent
=elfeed-update= silently had nothing to fetch.
#+begin_src emacs-lisp
(defun azos/elfeed-protocol-init-feeds ()
"Fetch the Nextcloud password and (re)enable elfeed-protocol sync.
Unlike the one-shot `use-package' :config form this is called from,
safe to call again (e.g. via \\[azos/elfeed-protocol-init-feeds]) if
a previous attempt failed, such as the YubiKey not being plugged in
yet — it re-spawns the `pass' process and re-prompts for the PIN."
(interactive)
(make-process
:name "elfeed-protocol-pass"
:command '("sh" "-c" "pass zakobar.com 2>/dev/null | head -1")
:noquery t
:filter (lambda (proc chunk)
(process-put proc 'output (concat (or (process-get proc 'output) "") chunk)))
:sentinel (lambda (proc _event)
(let ((password (string-trim (or (process-get proc 'output) ""))))
(if (string-empty-p password)
(message "elfeed-protocol: `pass zakobar.com' produced no output; retry with M-x azos/elfeed-protocol-init-feeds")
(setq elfeed-feeds
`(("owncloud+https://aner@nextcloud.zakobar.com"
:password ,password)))
(elfeed-protocol-enable)
(elfeed-update))))))
(use-package elfeed-protocol
:after elfeed
:config
(setq elfeed-protocol-enabled-protocols '(owncloud))
(azos/elfeed-protocol-init-feeds))
#+end_src
** Elfeed Full Article Fetch
Many feeds only publish a short excerpt. elfeed >= 4.0 has built-in
commands for this — =f= (=elfeed-show-fetch-link=) fetches the
linked page and appends it to the show buffer, =R=
(=elfeed-show-readable=) strips the fetched page down to
article text via eww's readability scorer — and both can run
automatically via =elfeed-show-update-hook=, gated on a feed's
=:fetch-link=/=:readable= metadata.
That gating keys off an exact match on the feed's URL/id in
=elfeed-feeds=, which breaks for ownCloud/Nextcloud subfeeds:
elfeed-protocol synthesizes a per-subfeed id of the form
=<host-url>::<subfeed-url>= that never appears in =elfeed-feeds=
(only the bare host URL does), so the metadata lookup always misses
and the stock hooks never fire. Replacing them with unconditional
versions makes full-text + readable-mode the default for every
entry instead.
#+begin_src emacs-lisp
(defun azos/elfeed-show-auto-readable ()
"Like `elfeed-show-auto-readable', but unconditional."
(unless (local-variable-p 'elfeed-transform-html-functions)
(let ((inhibit-message t))
(elfeed-show-readable))))
(defun azos/elfeed-show-auto-fetch-link ()
"Like `elfeed-show-auto-fetch-link', but unconditional."
(elfeed-show-fetch-link))
(setq elfeed-show-update-hook
(list #'azos/elfeed-show-auto-readable #'azos/elfeed-show-auto-fetch-link))
#+end_src
** Lichess
=lichess.org= in pass already holds the website login password, and
lichess.el's =auth-source-pass= lookup resolves by bare hostname, so
the API token can't live under that same entry without colliding.
Store it separately under =lichess.org/token= (=pass insert
lichess.org/token=) and set the =lichess-token= variable directly —
=lichess-token= (the function) checks this variable before falling
back to =auth-source=, so this takes precedence without disturbing
the existing =lichess.org= entry or its consumers (e.g. browserpass).
=shell-command-to-string= merges stderr into its return value, so any
gpg/pass warning on stderr (e.g. an unsafe-permissions notice) would
otherwise get concatenated onto the token and break auth silently.
Redirect stderr away so only the token itself is ever captured.
#+begin_src emacs-lisp
(setq lichess-token
(string-trim (shell-command-to-string "pass lichess.org/token 2>/dev/null | head -1")))
#+end_src
=gpg-agent= may not have a cached passphrase yet when the =setq= above
runs at Emacs startup, in which case =pass= fails silently (stderr is
redirected away) and =lichess-token= is left as =""=. Retry the fetch
lazily right before each actual use, so a cold agent at startup
doesn't permanently wedge the token for the rest of the session.
#+begin_src emacs-lisp
(with-eval-after-load 'lichess
(advice-add 'lichess-token :before
(lambda ()
(when (string-empty-p lichess-token)
(setq lichess-token
(string-trim (shell-command-to-string "pass lichess.org/token 2>/dev/null | head -1")))))))
#+end_src
** Beacon Remote Jobs
#+begin_src emacs-lisp
(defvar azos/beacon/host "aner@192.168.1.200"
"SSH connection for beacon machine.")
(defvar azos/beacon/max-logs 5
"Number of most-recent logs to keep per project on beacon.")
(defvar azos/beacon/keymap (make-sparse-keymap)
"Keymap for beacon remote job commands (M-o b prefix).")
(define-key azos/global-minor-mode/open-keymap (kbd "b") azos/beacon/keymap)
(defun azos/beacon--project-root ()
(or (and (fboundp 'projectile-project-root) (projectile-project-root))
(error "Not in a projectile project")))
(defun azos/beacon--project-name (root)
(file-name-nondirectory (directory-file-name root)))
(defun azos/beacon--list-python-files (root)
(split-string
(shell-command-to-string
(format "cd %s && git ls-files | grep '\\.py$'" (shell-quote-argument root)))
"\n" t))
(defun azos/beacon--list-executables (root)
(split-string
(shell-command-to-string
(format "cd %s && git ls-files | while IFS= read -r f; do [ -x \"$f\" ] && echo \"$f\"; done"
(shell-quote-argument root)))
"\n" t))
(defun azos/beacon--ssh-run (script)
"Pipe SCRIPT as bash to beacon over SSH stdin. Error on non-zero exit."
(with-temp-buffer
(insert script)
(let ((ret (call-process-region (point-min) (point-max) "ssh" nil t nil
azos/beacon/host "bash")))
(unless (zerop ret)
(error "Beacon SSH error (exit %d): %s" ret (string-trim (buffer-string)))))))
(defun azos/beacon--ssh-query (cmd &optional allow-exit-one)
"Run CMD on beacon, return output string. Error on failure.
With ALLOW-EXIT-ONE, exit code 1 is also treated as success (for tmux ls)."
(with-temp-buffer
(let ((ret (call-process "ssh" nil t nil azos/beacon/host cmd)))
(unless (or (zerop ret) (and allow-exit-one (= ret 1)))
(error "Beacon SSH error (exit %d): %s" ret (string-trim (buffer-string)))))
(buffer-string)))
(defun azos/beacon--dispatch (root cmd label)
"Sync ROOT to beacon and run CMD in project dir under direnv.
LABEL names the tmux session and log file."
(let* ((project-name (azos/beacon--project-name root))
(timestamp (format-time-string "%Y%m%d-%H%M%S"))
(session (format "%s-%s-%s" project-name label timestamp))
(log-name (format "%s-%s.log" label timestamp)))
(message "Syncing %s to beacon..." project-name)
(azos/beacon--ssh-run (format "mkdir -p ~/beacon-projects/%s\n" project-name))
(with-temp-buffer
(let ((ret (call-process-shell-command
(format "bash -c %s"
(shell-quote-argument
(format "cd %s && git ls-files | rsync -avz --files-from=- . %s:~/beacon-projects/%s/"
root azos/beacon/host project-name)))
nil t nil)))
(unless (zerop ret)
(error "Beacon rsync failed (exit %d): %s" ret (string-trim (buffer-string))))))
(azos/beacon--ssh-run
(format "RDIR=~/beacon-projects/%s
LDIR=~/beacon-logs/%s
mkdir -p \"$LDIR\"
direnv allow \"$RDIR\"
tmux new-session -d -s %s -- bash -c \"cd $RDIR && direnv exec . %s 2>&1 | tee $LDIR/%s\"
"
project-name project-name
(shell-quote-argument session)
cmd log-name))
(azos/beacon--ssh-run
(format "find ~/beacon-logs/%s -name '*.log' -printf '%%T@ %%p\\n' 2>/dev/null | sort -rn | tail -n +%d | cut -d' ' -f2- | xargs -r rm -f\n"
project-name (1+ azos/beacon/max-logs)))
(message "Beacon job started: %s" session)))
(defun azos/beacon/run-python ()
"Sync current project to beacon and run a selected Python file."
(interactive)
(let* ((root (azos/beacon--project-root))
(files (azos/beacon--list-python-files root))
(file (completing-read "Python file: " files nil t))
(label (file-name-sans-extension (file-name-nondirectory file))))
(azos/beacon--dispatch root (format "python3 %s" file) label)))
(defun azos/beacon/run-exec ()
"Sync current project to beacon and run a selected executable."
(interactive)
(let* ((root (azos/beacon--project-root))
(files (azos/beacon--list-executables root))
(file (completing-read "Executable: " files nil t))
(label (file-name-sans-extension (file-name-nondirectory file))))
(azos/beacon--dispatch root (format "./%s" file) label)))
(defun azos/beacon/run-command ()
"Sync current project to beacon and run an arbitrary command."
(interactive)
(let* ((root (azos/beacon--project-root))
(cmd (read-string "Command: "))
(label (replace-regexp-in-string
"[^a-zA-Z0-9-]" "-"
(substring cmd 0 (min 20 (length cmd))))))
(azos/beacon--dispatch root cmd label)))
(defun azos/beacon/list-jobs ()
"Show all running beacon tmux sessions."
(interactive)
(async-shell-command
(format "ssh %s 'tmux ls 2>/dev/null || echo \"No jobs running\"'" azos/beacon/host)
"*beacon-jobs*"))
(defun azos/beacon/kill-job ()
"Kill a beacon tmux session selected interactively."
(interactive)
(let* ((output (azos/beacon--ssh-query "tmux ls -F '#S' 2>/dev/null" t))
(sessions (split-string (string-trim output) "\n" t))
(session (completing-read "Kill job: " sessions nil t)))
(azos/beacon--ssh-run (format "tmux kill-session -t %s\n" (shell-quote-argument session)))
(message "Killed beacon job: %s" session)))
(defun azos/beacon--tail-path (path)
(async-shell-command
(format "ssh %s %s" azos/beacon/host
(shell-quote-argument (format "tail -f %s" path)))
(format "*beacon-tail-%s*" (file-name-nondirectory path))))
(defun azos/beacon/tail-log (&optional arg)
"Tail the most recent beacon log.
With prefix ARG, prompt for a pattern then select from matches."
(interactive "P")
(let* ((sorted-output (azos/beacon--ssh-query
"find ~/beacon-logs -name '*.log' -printf '%T@ %P\n' 2>/dev/null | sort -rn | cut -d' ' -f2-"))
(logs (split-string (string-trim sorted-output) "\n" t)))
(when (null logs) (error "No logs found on beacon"))
(if (not arg)
(azos/beacon--tail-path (format "~/beacon-logs/%s" (car logs)))
(let* ((pattern (read-string "Log pattern: "))
(matches (seq-filter (lambda (l) (string-match-p pattern l)) logs))
(log (if (= (length matches) 1)
(car matches)
(completing-read "Tail log: " matches nil t))))
(azos/beacon--tail-path (format "~/beacon-logs/%s" log))))))
(define-key azos/beacon/keymap (kbd "p") #'azos/beacon/run-python)
(define-key azos/beacon/keymap (kbd "e") #'azos/beacon/run-exec)
(define-key azos/beacon/keymap (kbd "c") #'azos/beacon/run-command)
(define-key azos/beacon/keymap (kbd "l") #'azos/beacon/list-jobs)
(define-key azos/beacon/keymap (kbd "k") #'azos/beacon/kill-job)
(define-key azos/beacon/keymap (kbd "t") #'azos/beacon/tail-log)
#+end_src
** Roam Backup
=roam-backup= signs its commit with gpg, which can prompt for the YubiKey PIN
via pinentry. =async-shell-command= spawns the process with =TERM=dumb= and
no real controlling tty, so pinentry's curses fallback fails with
\"Inappropriate ioctl for device\" whenever the PIN cache has expired. Run it
in a =vterm= instead, which gives pinentry a real pty to prompt on.
#+begin_src emacs-lisp
(defun azos/roam/backup ()
"Commit and push ~/roam to the remote git repo."
(interactive)
(let ((vterm-buffer-name "*roam-backup*"))
(with-current-buffer (vterm-other-window)
(vterm-send-string "roam-backup")
(vterm-send-return))))
(define-key azos/roam-keymap (kbd "B") #'azos/roam/backup)
#+end_src
** Printing
#+begin_src emacs-lisp
(defun azos/print-buffer (&optional arg)
"Print the current buffer.
For image/PDF/doc buffers, sends the file path to lp directly.
For text buffers, uses lpr-buffer.
With C-u, interactively prompt for scaling, B&W, and orientation options."
(interactive "P")
(let* ((image-p (derived-mode-p 'image-mode 'pdf-view-mode 'doc-view-mode))
(scale (when arg (y-or-n-p "Fit to page? ")))
(bw (when arg (y-or-n-p "Black and white? ")))
(landscape (when (and arg image-p) (y-or-n-p "Landscape? ")))
(lp-opts (concat
(when scale " -o fit-to-page")
(when bw " -o print-color-mode=monochrome")
(when landscape " -o landscape"))))
(if image-p
(if buffer-file-name
(progn
(shell-command
(concat "lp" lp-opts " " (shell-quote-argument buffer-file-name)))
(message "Sent %s to printer%s%s%s."
(file-name-nondirectory buffer-file-name)
(if scale " [scaled]" "")
(if bw " [B&W]" "")
(if landscape " [landscape]" "")))
(error "Buffer has no associated file"))
(let ((lpr-switches
(append lpr-switches
(when bw '("-o print-color-mode=monochrome")))))
(lpr-buffer)))))
(define-key azos/global-minor-mode/open-keymap (kbd "P") #'azos/print-buffer)
#+end_src
* Provide
#+begin_src emacs-lisp
(provide 'azos-emacs-lauretta)
#+end_src