#+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. This =:config= block runs synchronously the first time =elfeed= is loaded (i.e. the first =M-x elfeed= of the session), with no user keypress behind it the way =azos/caldav-sync= has. Fetching the password with =shell-command-to-string= there 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. #+begin_src emacs-lisp (use-package elfeed-protocol :after elfeed :config (setq elfeed-protocol-enabled-protocols '(owncloud)) (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) (setq elfeed-feeds `(("owncloud+https://aner@nextcloud.zakobar.com" :password ,(string-trim (process-get proc 'output))))) (elfeed-protocol-enable)))) #+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 =::= 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