Skip to content

Latest commit

 

History

History
1051 lines (868 loc) · 36.6 KB

File metadata and controls

1051 lines (868 loc) · 36.6 KB

Emacs Configuration

Package Management

Set up package management with MELPA Stable as the default preference, plus regular MELPA for packages that are only published there.

(require 'package)
(add-to-list 'package-archives '("melpa" . "https://melpa.org/packages/") t)
(add-to-list 'package-archives '("melpa-stable" . "https://stable.melpa.org/packages/") t)
(setq package-archive-priorities
      '(("gnu" . 30)
        ("nongnu" . 20)
        ("melpa-stable" . 10)
        ("melpa" . 0)))
(setq package-install-upgrade-built-in t)
(package-initialize)

(require 'use-package)
(setq use-package-always-ensure t)

;; Local libraries under ~/.config/emacs/lisp/
(add-to-list 'load-path (expand-file-name "lisp" user-emacs-directory))

Environment

macOS GUI Emacs (launched from Finder or the Dock) receives a minimal default PATH that misses ~/.cargo/bin and /opt/homebrew/bin, so Eglot cannot find rust-analyzer (the “Searching for program” warning) and cargo, lua-language-server and terminal-notifier would fail the same way. exec-path-from-shell imports the login shell’s PATH into both exec-path and the PATH environment variable for subprocesses. It must run early: the ERC section below resolves terminal-notifier with executable-find at config load time.

(use-package exec-path-from-shell
  :demand t
  :config
  ;; GUI sessions on macOS lack the login shell PATH;
  ;; batch and terminal sessions already inherit it.
  (when (memq window-system '(mac ns))
    (exec-path-from-shell-initialize)))

Remote Files

The shared SSH configuration forces TERM=xterm-256color so interactive Ghostty sessions work on machines without Ghostty terminfo. During TRAMP’s initial login this makes zsh enable its line editor and leave an application keypad escape sequence after the prompt, preventing TRAMP from recognizing the prompt. Override TERM only for TRAMP’s SSH login process.

Tools installed with uv tool (basedpyright-langserver, ruff) live in ~/.local/bin, which the non-interactive SSH PATH on electron and tau does not include. tramp-remote-path makes them discoverable for remote Eglot servers and TRAMP shell commands.

(use-package tramp
  :ensure nil
  :defer t
  :init
  (require 'tramp-cache)
  (add-to-list
   'tramp-connection-properties
   (list (rx bos "/" (or "ssh" "scp") ":")
         "login-args"
         '(("-o" "SetEnv=TERM=dumb")
           ("-l" "%u") ("-p" "%p") ("%c")
           ("-e" "none") ("%h"))))
  ;; uv tool installs (basedpyright-langserver, ruff) land in
  ;; ~/.local/bin, absent from the non-interactive SSH PATH on the
  ;; remote hosts.
  (add-to-list 'tramp-remote-path "/home/codingquark/.local/bin" t))

Appearance

Toolbars and startup

Clean up the interface by removing unnecessary UI elements and configuring the startup behavior.

(use-package emacs
  :init
  ;; Remove UI clutter
  (menu-bar-mode -1)
  (tool-bar-mode -1)
  (scroll-bar-mode -1)

  ;; Startup configuration
  (setq inhibit-startup-message t)
  (setq initial-scratch-message nil)
  (setq initial-buffer-choice
        (lambda ()
          (require 'denote-journal)
          (denote-journal-new-or-existing-entry)))

  (defun cq-kill-initial-scratch-buffer ()
    "Kill the unused initial scratch buffer."
    (when-let* ((buffer (get-buffer "*scratch*")))
      (kill-buffer buffer)))

  (add-hook 'emacs-startup-hook #'cq-kill-initial-scratch-buffer)
  )

Emacs Server

Run the Emacs server over TCP on regular hosts so remote `emacsclient` calls can use the generated server auth file. Omarchy uses the standard Unix socket expected by its local restart and synchronization hooks.

(defconst cq-omarchy-integration-file
  (expand-file-name "omarchy.el" user-emacs-directory)
  "Host-provided Omarchy integration shim, when available.")

(use-package server
  :ensure nil
  :init
  ;; Omarchy's local hooks expect the standard Unix socket. Keep TCP on
  ;; regular hosts for remote emacsclient access.
  (setq server-use-tcp
        (not (file-readable-p cq-omarchy-integration-file)))
  :config
  (unless (or noninteractive (server-running-p))
    (server-start)))

Theme

Use Modus Vivendi by default and keep the usual manual toggle on regular hosts. Omarchy owns the theme on systems where its host integration shim is installed; that shim is loaded after the rest of this configuration.

(unless (file-readable-p cq-omarchy-integration-file)
  (use-package modus-themes
    :bind (("<f5>" . modus-themes-toggle))
    :init
    (load-theme 'modus-vivendi)))

Line Highlighting

Lin is Prot’s opinionated wrapper around hl-line-mode. In modes that are primarily navigational — dired, ibuffer, magit lists, occur, elfeed-search, etc. — the current line behaves as a selection indicator rather than a reading aid, and lin restyles hl-line to match. lin-global-mode enables it in the curated set of modes Prot maintains upstream, so new buffers get the treatment automatically without per-hook wiring.

(use-package lin
  :custom
  (lin-face 'lin-blue)
  :config
  (lin-global-mode 1))

Fonts

Configure the core font faces: keep code and tabular buffers on IBM Plex Mono, and use Charter for variable-pitch reading buffers such as `shr` renderers.

(defconst cq-variable-pitch-font "Charter"
  "Font family used by variable-pitch faces.")

(when (find-font (font-spec :family cq-variable-pitch-font))
  ;; Reading-oriented modes like elfeed-show rely on `variable-pitch`.
  (set-face-attribute 'variable-pitch nil :family cq-variable-pitch-font))

(when (or (string= system-name "muon.local") (string= system-name "photon"))
  (set-face-attribute 'default nil :font "IBM Plex Mono" :height 160))

Mode line

Keep the mode line minimal. The left side carries the signals that are easy to miss: %* for modified/read-only state, an @ only for remote buffers, and the buffer name (hovering shows the full path). %e retains the warning, while %[ and %] retain recursive-edit state and %n retains the narrowing indicator without a per-redisplay function. ERC’s tracked-channel indicator is included directly, without restoring other minor-mode lighters. The right side is right-aligned and only grows when something is active: the cached VC branch (vc-mode, updated by the VC hooks — never vc-status), the Flymake diagnostic counters (the counters construct alone, so no title or exception string when idle), the major mode, and the exact line and column. Minor-mode lighters and process status are dropped on purpose; the branch supplies project context in version-controlled buffers. No third-party modeline package is involved — plain Emacs built-ins.

(use-package emacs
  :init
  (setq-default mode-line-format
                '("%*"
                  (:eval (when (file-remote-p default-directory) "@"))
                  " "
                  mode-line-buffer-identification
                  "%e"
                  "%[" "%n" "%]"
                  erc-modified-channels-object
                  mode-line-format-right-align
                  (vc-mode ("  " vc-mode))
                  (flymake-mode ("  " flymake-mode-line-counters))
                  "  "
                  mode-name
                  " L%l:C%c")))

Editor

Defaults

Configure basic editor behavior including indentation, backups, and visual aids.

(use-package emacs
  :init
  ;; Indentation
  (setq-default indent-tabs-mode nil)
  (setq-default tab-width 2)

  ;; File management
  (setq backup-directory-alist '(("." . "~/.config/emacs/backups")))
  (setq auto-save-file-name-transforms '((".*" "~/.config/emacs/auto-save-list/" t)))

  ;; Visual aids
  (show-paren-mode 1)
  ;; macOS compatibility
  (setq dired-use-ls-dired nil)

  :hook ((dired-mode . auto-revert-mode)
         (prog-mode . auto-revert-mode)
         (text-mode . auto-revert-mode))

  ;; Better keybindings
  :bind
  ("C-x C-b" . ibuffer))

Dired

Configure dired with denote integration.

(use-package dired
  :ensure nil
  :hook ((dired-mode . denote-dired-mode)
         (dired-mode . dired-hide-details-mode)))

Completion

Set up a modern minibuffer completion stack for navigation, search, and actions.

(use-package vertico
  :init
  (vertico-mode 1)
  :bind (:map vertico-map
              ("RET" . vertico-directory-enter)
              ("DEL" . vertico-directory-delete-char)
              ("M-DEL" . vertico-directory-delete-word))
  :hook (rfn-eshadow-update-overlay . vertico-directory-tidy))

(use-package orderless
  :custom
  (completion-styles '(orderless basic))
  (completion-category-defaults nil)
  (completion-category-overrides '((file (styles partial-completion)))))

(use-package prescient
  :config
  (prescient-persist-mode 1))

(use-package vertico-prescient
  :after (vertico prescient)
  :custom
  (vertico-prescient-enable-filtering nil)
  :config
  (vertico-prescient-mode 1))

(use-package marginalia
  :init
  (marginalia-mode 1))

(use-package consult
  :bind (("C-s" . consult-line)
         ("C-x b" . consult-buffer)
         ("C-x 4 b" . consult-buffer-other-window)
         ("M-y" . consult-yank-pop)
         ("M-g g" . consult-goto-line)
         ("M-g i" . consult-imenu)
         ("M-s g" . consult-grep)
         ("M-s r" . consult-ripgrep)))

(use-package embark
  :bind (("C-." . embark-act)
         ("C-;" . embark-dwim)
         ("C-h B" . embark-bindings))
  :init
  (setq prefix-help-command #'embark-prefix-help-command))

(use-package embark-consult
  :after (embark consult)
  :hook (embark-collect-mode . consult-preview-at-point-mode))

In-buffer completion

Corfu is the in-buffer counterpart to the minibuffer stack above: it shows candidates in a small overlay at point instead of the minibuffer, so completions never pull focus away from the code. It uses the same completion-at-point machinery and completion-styles, so the Orderless matching configured above applies unchanged. Eglot registers a completion-at-point backend, so rust-analyzer candidates flow into the popup with no extra glue.

Completion is explicit-only: the popup appears when you request it (TAB / C-M-i), never on idle. corfu-auto is nil by default; the :custom below states that intent. TAB also cycles candidates (corfu-cycle) rather than leaving them open in a list.

(use-package corfu
  :init
  (global-corfu-mode 1)
  :custom
  ;; Explicit-only completion: popup on TAB / C-M-i, never on idle.
  (corfu-auto nil)
  (corfu-cycle t))

Help

Improve keybinding discovery and help buffers.

(use-package which-key
  :init
  (which-key-mode 1)
  :custom
  (which-key-idle-delay 0.5))

(use-package helpful
  :bind (([remap describe-command] . helpful-command)
         ([remap describe-function] . helpful-callable)
         ([remap describe-key] . helpful-key)
         ([remap describe-symbol] . helpful-symbol)
         ([remap describe-variable] . helpful-variable)))

Session

Persist useful session state across restarts and make window layouts undoable.

(use-package savehist
  :ensure nil
  :init
  (savehist-mode 1))

(use-package recentf
  :ensure nil
  :init
  (recentf-mode 1)
  :custom
  (recentf-max-saved-items 200))

(use-package saveplace
  :ensure nil
  :init
  (save-place-mode 1))

(use-package winner
  :ensure nil
  :init
  (winner-mode 1))

Git

Set up Magit for repository status and everyday Git workflows.

(use-package magit
  :bind (("C-x g" . magit-status)))

(use-package magit-delta
  :after magit
  :hook (magit-mode . magit-delta-mode))

AI

The AI integration is built around gptel, a lightweight LLM client that speaks several provider protocols. Rather than subscribe to a single vendor, I route everything through OpenRouter, which re-exports dozens of models behind one OpenAI-compatible endpoint. That decision drives the rest of this section: the backend is OpenAI-shaped but pointed at OpenRouter’s host, and the model list is a curated slice of what OpenRouter offers.

A second backend points at a local llama.cpp server running Gemma 3 12B on a LAN machine (gluon). Switch between backends with gptel-menu.

Backend

gptel-make-openai registers a backend and returns the backend struct; assigning that return value to gptel-backend makes it the active one for new chats. The API key lives outside this file in gptel-api-key (set in a private init fragment) so the config can be committed without leaking secrets.

Streaming is left on because in an interactive chat buffer you want tokens to appear as they arrive. Note that gptel-magit will selectively override this below, for reasons explained there.

The model list is deliberately short. Additional entries are available interactively via gptel-menu. The default model used for new requests is gptel-model, which gptel initialises to gpt-4o-mini globally — a name that has nothing to do with the OpenRouter backend registered here. gptel does sanitise an unsupported model by falling back to the first entry of the active backend’s list, so things happen to work, but relying on that is fragile. Setting gptel-model explicitly makes the default honest.

(use-package gptel
  :commands (gptel gptel-send gptel-menu gptel-tools)
  :custom
  (gptel-default-mode 'org-mode)
  (gptel-model 'Qwen/Qwen3.8-27B)
  :config
  (gptel-make-openai "OpenRouter"
    :host "openrouter.ai"
    :endpoint "/api/v1/chat/completions"
    :stream t
    :key gptel-api-key
    :models '((z-ai/glm-5.3-flash :request-params (:reasoning (:effort "high")))
              (deepseek/deepseek-v4-flash-0731 :request-params (:reasoning (:effort "high")))))

  (gptel-make-openai "Gluon"
    :protocol "http"
    :host "gluon.home.arpa:8001"
    :endpoint "/v1/chat/completions"
    :stream t
    :key "sk-noop"
    :models '(Qwen/Qwen3.8-27B))

  (setq gptel-backend (gptel-get-backend "Gluon")))

Prompts

gptel-prompts manages gptel-directives as individual files under gptel-prompts-directory (defaults to ~/.config/emacs/prompts). Each file becomes a named directive; supported formats include plain text (.txt, .md, .org), Emacs Lisp data (.eld) and code (.el), JSON (.json), and Prompt Poet / Jinja templates (.poet, .j2). File watchers keep gptel-directives in sync automatically.

(add-to-list 'load-path
             (expand-file-name "lisp/gptel-prompts" user-emacs-directory))

(use-package gptel-prompts
  :ensure nil
  :after (gptel)
  :demand t
  :config
  (gptel-prompts-update)
  (gptel-prompts-add-update-watchers))

Commit messages with gptel-magit

gptel-magit hooks into magit so that M-g in a commit buffer calls out to gptel with the staged diff and a commit-message prompt, inserting the result into the commit buffer. The plain installation would be a one-liner; most of what follows is an advice wrapper that exists for a reason worth explaining.

Why an advice wrapper is needed

gptel-magit was written against a simple callback contract: gptel calls the callback once with the finished assistant message as a string, and gptel-magit inserts that string into the commit buffer. Two unrelated gptel behaviours break that contract.

The first is generic to any streamed response. With streaming on, gptel invokes the callback repeatedly, once per text chunk, and a final time with t to signal end-of-stream (see gptel-request.el around line 1873). gptel-magit’s insert-once callback would therefore fire many times — inserting partial tokens into the commit buffer — and then explode on the t.

The second is specific to reasoning models. In addition to the text stream, gptel exposes reasoning tokens as separate callback events packaged as (reasoning . TEXT) cons cells. gptel-magit receives one of those, tries to insert it, and dies inside a process sentinel with char-or-string-p. Importantly, reasoning events can arrive even on a non-streaming request: if the final response contains a <think> block, or the backend stashed reasoning in the info plist, gptel emits a reasoning callback before the final string (see gptel-request.el around lines 2419 and 2429). Turning streaming off therefore does not make the reasoning problem go away.

Note that gptel-include-reasoning does not help here: that variable only governs how reasoning is displayed in gptel’s own chat buffers, not what a user-supplied :callback receives.

Solution: force non-streaming, filter non-strings

Two changes address the two problems, one each. Forcing :stream nil solves the chunking problem: gptel’s non-streaming path produces a single final-answer callback invocation instead of a sequence of chunks. That alone is enough to stop gptel-magit inserting partial tokens.

It is not enough to stop the reasoning callback. For that, the advice wraps the callback in a filter that passes strings through and silently drops everything else. This filter is load-bearing, not defensive: non-streamed requests still emit (reasoning . TEXT) ahead of the final answer whenever reasoning is present, and without the filter gptel-magit would crash on exactly the kind of model you most want commit messages from.

There is one subtlety that shapes how the wrapper is constructed. config.el is tangled without a lexical-binding cookie, so a plain (lambda (response info) ...) closing over a local cb would not survive the async gap — by the time gptel invokes it, the let has exited and cb is unbound. apply-partially sidesteps this by building a real closure through function composition rather than variable capture, so it works the same in dynamic and lexical scope. The helper it partially applies is therefore a plain named function that takes the original callback as an extra first argument.

A second detail: gptel-magit--request takes the prompt as a positional first argument and the rest as keyword args, so the advice must split prompt off before touching the plist — otherwise plist-get would try to read the diff string as a key and fail.

(use-package gptel-magit
  :after (gptel magit)
  :hook (magit-mode . gptel-magit-install)
  :config
  (defun cq-gptel-magit--forward-if-string (cb response info)
    (when (stringp response)
      (funcall cb response info)))

  (defun cq-gptel-magit--request-advice (orig-fn prompt &rest args)
    (let* ((cb (plist-get args :callback))
           (wrapped (apply-partially #'cq-gptel-magit--forward-if-string cb))
           (args (plist-put args :stream nil))
           (args (plist-put args :callback wrapped)))
      (apply orig-fn prompt args)))

  (advice-add 'gptel-magit--request :around #'cq-gptel-magit--request-advice))

IRC

Configure ERC for Libera.Chat with macOS desktop notifications on nick mentions and private messages. Credentials live in ~/.authinfo (ERC’s erc-auth-source-search will pull SASL/NickServ passwords from there), so no secrets appear in this file.

Notifications are delivered via terminal-notifier (install with brew install terminal-notifier). The built-in erc-match module flags messages matching your nick and private queries; a hook then shells out to the notifier. Fired only for background buffers so an active channel window does not spam the notification center.

(use-package erc
  :ensure nil
  :commands (erc erc-tls cq-erc-libera)
  :custom
  (erc-nick "codingquark")
  (erc-user-full-name "codingquark")
  (erc-server "irc.libera.chat")
  (erc-port 6697)
  (erc-prompt-for-password nil)
  (erc-use-auth-source-for-nickserv-password t)
  (erc-autojoin-timing 'ident)
  (erc-autojoin-channels-alist '(("Libera.Chat" "#emacs")))
  (erc-hide-list '("JOIN" "PART" "QUIT" "NICK"))
  (erc-track-exclude-types '("JOIN" "PART" "QUIT" "NICK" "MODE"
                             "324" "329" "332" "333" "353" "477"))
  (erc-kill-buffer-on-part t)
  (erc-kill-queries-on-quit t)
  (erc-kill-server-buffer-on-quit t)
  (erc-fill-function 'erc-fill-static)
  (erc-fill-static-center 18)
  :config
  (require 'erc-services)
  (require 'erc-match)
  (erc-services-mode 1)
  (erc-match-mode 1)
  (erc-track-mode 1)
  (erc-autojoin-mode 1)

  (add-hook 'erc-mode-hook (lambda () (display-line-numbers-mode -1)))

  (defun cq-erc-libera ()
    "Connect to Libera.Chat over TLS, reading creds from auth-source."
    (interactive)
    (erc-tls :server "irc.libera.chat" :port 6697
             :nick erc-nick :full-name erc-user-full-name)))

macOS notifications

cq-erc-notify posts a system notification via terminal-notifier. It is wired into both erc-text-matched-hook (fires for nick mentions when erc-match-mode is on) and a private-message hook that detects queries regardless of whether the sender mentioned your nick.

The guard on get-buffer-window suppresses notifications for the currently-visible buffer, since you are already looking at it.

  (defvar cq-terminal-notifier
    (executable-find "terminal-notifier")
    "Path to terminal-notifier, or nil if unavailable.")

  (defun cq-erc-notify (title message)
    "Post a macOS notification with TITLE and MESSAGE via terminal-notifier."
    (when cq-terminal-notifier
      (call-process cq-terminal-notifier nil 0 nil
                    "-title" (format "ERC: %s" title)
                    "-message" message
                    "-group" "emacs-erc"
                    "-sender" "org.gnu.Emacs")))

  (defun cq-erc-notify-match (match-type nickuserhost message)
    "Notify on `erc-match' hits for current-nick and keywords."
    (when (and (memq match-type '(current-nick keyword))
               (not (get-buffer-window (current-buffer) 'visible)))
      (let ((nick (car (split-string (or nickuserhost "") "!"))))
        (cq-erc-notify (format "%s in %s" nick (buffer-name))
                       message))))

  (defun cq-erc-notify-query (proc parsed)
    "Notify when a private message lands in a query buffer.
Returns nil so ERC keeps processing the message normally."
    (let* ((nick (car (erc-parse-user (erc-response.sender parsed))))
           (target (car (erc-response.command-args parsed)))
           (msg (erc-response.contents parsed)))
      (when (and target
                 (erc-current-nick-p target)
                 (not (erc-current-nick-p nick)))
        (let ((buf (erc-get-buffer nick proc)))
          (unless (and buf (get-buffer-window buf 'visible))
            (cq-erc-notify (format "PM from %s" nick) msg)))))
    nil)

  (with-eval-after-load 'erc-match
    (add-hook 'erc-text-matched-hook #'cq-erc-notify-match))

  (with-eval-after-load 'erc
    (add-hook 'erc-server-PRIVMSG-functions #'cq-erc-notify-query))

Reading

Set up Elfeed for RSS reading and keep the feed list in a separate file.

(defvar cq-elfeed-feeds-file
  (expand-file-name "elfeed-feeds.el" user-emacs-directory)
  "Path to the personal Elfeed feed list.")

(use-package elfeed
  :bind (("C-c f" . elfeed)
         ("C-c F" . cq-open-elfeed-feeds-file))
  :custom
  (elfeed-db-directory (expand-file-name "elfeed" user-emacs-directory))
  (elfeed-search-filter "@2-months-ago +unread")
  :config
  ;; Keep subscriptions outside the main config to make feed edits low-friction.
  (load cq-elfeed-feeds-file 'noerror 'nomessage)

  (defun cq-open-elfeed-feeds-file ()
    "Open the Elfeed subscriptions file."
    (interactive)
    (find-file cq-elfeed-feeds-file)))

Org Mode

Configure Org and related built-in integrations.

(defconst cq-reading-list-file
  (expand-file-name
   "Documents/notes/20230206T124634--reading-list__lists_productivity.org"
   "~")
  "Denote note used as the reading-list capture target.")

(use-package org-capture
  :ensure nil
  :demand t
  :config
  (add-to-list 'org-capture-templates
               `("w" "Web page for reading list" entry
                 (file+headline ,cq-reading-list-file "Reading list")
                 "* TODO %:description\n:PROPERTIES:\n:CAPTURED: %U\n:URL: %:link\n:END:\n\n%:annotation\n\n#+begin_quote\n%i\n#+end_quote\n\n%?"
                 :empty-lines 1)))

(use-package org-protocol
  :ensure nil
  :demand t
  :custom
  (org-protocol-default-template-key "w"))

Note Taking

Configure Denote for simple note-taking and knowledge management.

(use-package denote
  :hook (text-mode . denote-fontify-links-mode-maybe)
  :bind (
    ("C-c n n" . denote)
    ("C-c n D" . cq-open-denote-directory)
    ("C-c n N" . denote-type)
    ("C-c n i" . denote-link)
    ("C-c n I" . denote-add-links)
    ("C-c n b" . denote-backlinks)
    ("C-c n f b" . denote-find-backlink)
    ("C-c n r" . denote-rename-file)
    ("C-c n R" . denote-rename-file-using-front-matter)
    ;; ("C-c n ." . cq-insert-time-stamp)
    )
  :custom
  (denote-directory "~/Documents/notes")
  (denote-infer-keywords t)
  (denote-sort-keywords t)
  (denote-file-type 'text)
  (denote-prompts '(title keywords))
  :config
  (setq crm-separator ",")
  (defun cq-open-denote-directory ()
    (interactive)
    (revert-buffer (dired denote-directory)))
  (defun cq-insert-time-stamp ()
    "Insert a timestamp with a newline character."
    (interactive)
    (insert (current-time-string))
    (newline)))

(use-package denote-journal
  :after denote
  :bind
  (("C-c n j" . denote-journal-new-or-existing-entry))
  :custom
  (denote-journal-title-format 'day-date-month-year))

(use-package denote-menu
  :after denote)

Writing

Olivetti Mode

Configure olivetti for distraction-free writing with centered text.

(use-package olivetti
  :config
  (setq olivetti-body-width 80))

Markdown

Configure markdown-mode for editing markdown files.

(use-package markdown-mode
  :custom
  (markdown-command "pandoc")
  :mode (("README\\.md\\'" . gfm-mode)
         ("\\.md\\'" . markdown-mode)
         ("\\.markdown\\'" . markdown-mode)))

Home Assistant

Talk to the local Home Assistant REST API from inside Emacs. The token stays in ~/.authinfo.gpg so nothing secret lands in this file:

machine homeassistant.home.arpa login homeassistant password YOUR_LONG_LIVED_ACCESS_TOKEN

The implementation lives in lisp/cq-home-assistant.el so it can be loaded independently. The main entry point is the global minor mode cq-home-assistant-mode, which displays the configured entity in the mode line. The default entity below is the BME280 temperature sensor, but it can be changed interactively.

(use-package cq-home-assistant
  :ensure nil
  :demand t
  :config
  (keymap-set global-map "C-c h" cq-home-assistant-prefix-map))

Eglot

eglot is Emacs’ built-in language-server frontend, shared by Lua, Rust, and Python: each language mode starts its own server (lua-language-server, rust-analyzer, and basedpyright, respectively) when a file is opened.

Server settings must live in the global eglot-workspace-configuration: Eglot builds the initial workspace configuration from a scratch buffer without running mode hooks, so per-mode buffer-locals are never seen. The rust-analyzer section tells it to run clippy for its check, so lint warnings surface as you type; other servers ignore the foreign section.

Rust buffers additionally hook in formatting before save: Eglot’s eglot-format-buffer delegates to rustfmt through rust-analyzer, so style stays enforced without a separate formatter step. The hook is toggled, so it is removed again when Eglot disconnects, and saving never calls into a dead server.

;; Require eglot before the :custom keyword is processed: with :ensure
;; nil use-package defers its require until after :custom, and the
;; value would then be silently skipped by custom-theme-set-variables.
(require 'eglot)

(defun cq-python-eglot-server (&optional _interactive _project)
  "Return the basedpyright command for local or TRAMP Python buffers."
  (list (if (file-remote-p default-directory)
            ;; uv installs this server here on electron and tau.  Use an
            ;; absolute path because their non-interactive SSH PATH omits it.
            "/home/codingquark/.local/bin/basedpyright-langserver"
          "basedpyright-langserver")
        "--stdio"))

(use-package eglot
  :ensure nil
  :custom
  (eglot-workspace-configuration
   '(:rust-analyzer (:check (:command "clippy"))))
  :config
  ;; Prefer basedpyright even when another Python server is installed.
  (add-to-list 'eglot-server-programs
               '((python-mode python-ts-mode) . cq-python-eglot-server)))

(defun cq-eglot-disable-python-inlay-hints ()
  "Keep Eglot inlay hints off initially in Python buffers."
  (when (and (eglot-managed-p)
             (derived-mode-p 'python-base-mode))
    (eglot-inlay-hints-mode -1)))

(add-hook 'eglot-managed-mode-hook
          #'cq-eglot-disable-python-inlay-hints)

(defun cq-eglot-format-on-save ()
  "Toggle Eglot formatting before save in Rust buffers."
  (if (eglot-managed-p)
      (when (derived-mode-p 'rust-ts-mode)
        (add-hook 'before-save-hook #'eglot-format-buffer nil t))
    (remove-hook 'before-save-hook #'eglot-format-buffer t)))

(add-hook 'eglot-managed-mode-hook #'cq-eglot-format-on-save)

Lua

Edit Lua with lua-mode and let Eglot start the language server (lua-language-server) automatically. Two-space indentation.

(use-package lua-mode
  :mode "\\.lua\\'"
  :hook (lua-mode . eglot-ensure)
  :config
  (setq lua-indent-level 2))

Rust

Edit Rust with the built-in rust-ts-mode (tree-sitter) and let Eglot start rust-analyzer automatically. Diagnostics come from rust-analyzer, configured to run clippy for its check so lint warnings surface as you type (see eglot-workspace-configuration in the Eglot section). Saving runs rustfmt through Eglot (see cq-eglot-format-on-save above). rust-ts-mode’s four-space default indent matches rustfmt, so it is intentionally left alone despite the global tab-width of 2.

(use-package rust-ts-mode
  :ensure nil
  :mode "\\.rs\\'"
  :hook (rust-ts-mode . eglot-ensure))

Python

Edit Python with the built-in python-ts-mode (tree-sitter) when its grammar is available, falling back to python-mode otherwise. The grammar is not installed at startup: the source recipe below lets M-x treesit-install-language-grammarpython build it once, landing it in tree-sitter/ next to the Rust grammar. Restart Emacs after installing it so the conditional major-mode remap takes effect.

Eglot starts basedpyright-langserver for both python-mode and python-ts-mode; Corfu picks up its completion backend with no extra glue. The server is installed once per machine (uv tool install basedpyright): GUI sessions find it on PATH via exec-path-from-shell; on the remote hosts (electron, tau) the same ~/.local/bin install is reachable through tramp-remote-path (see the Remote Files section). Basedpyright automatically uses a .venv at the project root. This matches uv’s default project layout, so no per-project environment configuration is normally needed. Use the project’s pyrightconfig.json or [tool.basedpyright] in pyproject.toml for type-checking policy or when the environment lives at a nonstandard location, rather than putting project-specific settings in this global config.

Inlay hints start disabled in Python buffers. Toggle inferred types and argument names for the current buffer with M-x eglot-inlay-hints-mode.

Ruff (installed with uv tool install ruff) provides lint diagnostics through flymake-ruff in local Python buffers. Formatting is manual: M-x apheleia-format-buffer runs Ruff’s import sorting and formatter without enabling format-on-save. Remote buffers rely on basedpyright diagnostics; neither flymake-ruff nor Apheleia runs automatically over TRAMP. Rust keeps its existing Eglot-based on-save formatting.

python-pytest runs pytest from a results buffer: C-c p opens its transient menu (all tests, file, test at point, last failed). It runs the pytest on PATH at the project root, so for a uv-managed project point it at the project interpreter by setting python-pytest-executable to "uv run pytest" (e.g. in .dir-locals.el).

;; python-base-mode is the shared parent of python-mode and
;; python-ts-mode, so one hook covers both, locally and over TRAMP.
(use-package python
  :ensure nil
  :hook (python-base-mode . eglot-ensure))

;; Tree-sitter: let `treesit-install-language-grammar' find the Python
;; grammar, and remap python-mode only when that grammar is ready.
(add-to-list 'treesit-language-source-alist
             '(python "https://github.com/tree-sitter/tree-sitter-python"))
(when (treesit-language-available-p 'python)
  (add-to-list 'major-mode-remap-alist '(python-mode . python-ts-mode)))

(use-package flymake-ruff)

;; Ruff lint diagnostics in local Python buffers only: flymake-ruff
;; reads the local pyproject.toml, so it must not run on remote files.
(defun cq-flymake-ruff-local-python ()
  "Load flymake-ruff in local Python file buffers only."
  (when (and (buffer-file-name)
             (not (file-remote-p (buffer-file-name))))
    (flymake-ruff-load)))

(add-hook 'python-base-mode-hook #'cq-flymake-ruff-local-python)

;; Configure Ruff for explicit `M-x apheleia-format-buffer' calls.
;; Do not enable apheleia-mode: Python formatting is never automatic.
(use-package apheleia
  :commands apheleia-format-buffer
  :config
  (setf (alist-get 'python-mode apheleia-mode-alist) '(ruff-isort ruff)
        (alist-get 'python-ts-mode apheleia-mode-alist) '(ruff-isort ruff)))

;; pytest runner. python-mode and python-ts-mode inherit this base map.
;; Pin to regular MELPA because the stable archive currently advertises
;; a python-pytest tarball that is no longer available.
(use-package python-pytest
  :pin melpa
  :after python
  :bind (:map python-base-mode-map
              ("C-c p" . python-pytest)))

Projects

Discover projects from usual directories across different hosts. Some of these may not exist on “this” machine.

The mode line already carries project context where it matters (see the Appearance → Mode line section), so Projectile’s own lighter and its dynamic mode-line updates are turned off. Setting the default lighter before Projectile loads prevents its defvar-local initializer from adding text, and disabling dynamic updates removes the find-file and window-configuration recomputation entirely.

(use-package projectile
  :init
  (setq projectile-dynamic-mode-line nil)
  (setq-default projectile--mode-line "")
  :config
  (projectile-mode +1)
  (define-key projectile-mode-map (kbd "s-p") 'projectile-command-map)
  (setq projectile-project-search-path
        '("~/workspace" "~/.config" "~/Projects" "~/Work")))

;; Group ibuffer buffers by Projectile project root. Each open
;; project gets its own section (named "Projectile: <name>"), and
;; buffers without a project fall into the default group. Rebuilds
;; the groups every time an ibuffer window is created.
(use-package ibuffer-projectile
  :after projectile
  :hook (ibuffer-mode . ibuffer-projectile-set-filter-groups))

Omarchy Integration

Omarchy supplies a user-local shim that tracks its active theme and font. Load it last so the shared configuration initializes first while Omarchy remains the appearance authority on that host. The file is absent on regular hosts.

(when (file-readable-p cq-omarchy-integration-file)
  (mapc #'disable-theme custom-enabled-themes)
  (load cq-omarchy-integration-file nil 'nomessage)
  ;; Avoid switching away from the Omarchy-managed theme independently.
  (global-unset-key (kbd "<f5>")))