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))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)))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))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)
)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)))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)))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))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))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")))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))Configure dired with denote integration.
(use-package dired
:ensure nil
:hook ((dired-mode . denote-dired-mode)
(dired-mode . dired-hide-details-mode)))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))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))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)))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))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))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.
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")))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))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.
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.
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))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)))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))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)))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"))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)Configure olivetti for distraction-free writing with centered text.
(use-package olivetti
:config
(setq olivetti-body-width 80))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)))
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 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)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))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))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-grammar → python 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)))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 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>")))