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

23 KiB

Aner's Emacs Lauretta Configuration

Base setup

Require

(require 'azos-emacs-base)
(require 'azos-emacs-dev)
(require 'azos-emacs-editor)
(require 'azos-emacs-exwm)
(require 'azos-emacs-station)

Lauretta specific

Weather

(setq wttrin-default-cities '("Meitar, Israel"))

LLM

(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)))

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).

(require 'pinentry)
(pinentry-start)

Agent Shell

(setq agent-shell-preferred-agent-config 'claude-code)

Headphones

(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)

Tab bar setup

(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))

Nixpkgs Search

(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)

CalDAV / Org Sync

(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)

Elfeed Nextcloud News Sync

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.

(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))

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.

(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))

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.

(setq lichess-token
      (string-trim (shell-command-to-string "pass lichess.org/token 2>/dev/null | head -1")))

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.

(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")))))))

Beacon Remote Jobs

(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)

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.

(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)

Printing

(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)

Provide

(provide 'azos-emacs-lauretta)