Files
dotfiles/emacs/.config/doom/config.el

548 lines
23 KiB
EmacsLisp

;;; $DOOMDIR/config.el -*- lexical-binding: t; -*-
(setq user-full-name "Colin Powell"
user-mail-address "colin@unbl.ink")
(nyan-mode)
(after! envrc
(envrc-global-mode))
;; Force user ssh-agent socket for all Emacs subprocesses.
(defun my/apply-ssh-agent-env ()
"Set SSH_AUTH_SOCK in Emacs using ~/.local/bin/ssh-agent-env."
(let* ((cmd (expand-file-name "~/.local/bin/ssh-agent-env"))
(out (and (file-executable-p cmd)
(string-trim (shell-command-to-string cmd)))))
;; out looks like: export SSH_AUTH_SOCK='...'
(when (and out (string-match "SSH_AUTH_SOCK='\\([^']+\\)'" out))
(setenv "SSH_AUTH_SOCK" (match-string 1 out)))))
(my/apply-ssh-agent-env)
;; load pinentry
(when (require 'pinentry nil t)
(pinentry-start))
(require 'server)
(unless (server-running-p)
(server-start))
(setq doom-theme 'doom-xcode
doom-font (font-spec :family "Iosevka" :size 14 :weight 'regular)
doom-big-font (font-spec :family "Iosevka" :size 18 :weight 'regular)
doom-variable-pitch-font (font-spec :family "Overpass" :size 12))
(setq display-line-numbers-type t)
;; change `org-directory'. It must be set before org loads!
(setq org-directory "~/var/org/")
(load! "+agenda-fix")
(defun vulpea-agenda-files-update (&rest _)
(setq org-agenda-files vulpea-project-files))
(setq org-roam-directory "~/var/org/"
org-roam-dailies-directory "dailies")
(advice-add 'org-agenda :before #'vulpea-agenda-files-update)
(advice-add 'org-todo-list :before #'vulpea-agenda-files-update)
(load! "+django-tests")
(map! :after python
:map python-mode-map
:localleader
(:prefix ("t" . "tests")
:desc "django test at point" "t" #'django-run-test-at-point
:desc "django tests for file" "f" #'django-run-tests-for-current-file
:desc "django all tests" "a" (cmd! (django-run-tests "" nil))))
(setq +format-on-save-disabled-modes (add-to-list '+format-on-save-disabled-modes 'typescript-mode))
(map! ;; Easier window movement
:n "C-h" 'evil-window-left
:n "C-j" 'evil-window-down
:n "C-k" 'evil-window-up
:n "C-l" 'evil-window-right
(:map evil-treemacs-state-map
"C-h" 'evil-window-left
"C-l" 'evil-window-right)
:leader
(:prefix "f"
:desc "Find file in dotfiles" "t" #'+hlissner/find-in-dotfiles
:desc "Browse dotfiles" "T" #'+hlissner/browse-dotfiles)
(:prefix "b"
:desc "Black format buffer" "f" #'blacken-buffer
:desc "isort buffer" "I" #'py-isort-buffer
:desc "Links in buffer" "l" #'ace-link-org))
(defun unfill-paragraph ()
"Takes a multi-line paragraph and makes it into a single line of text."
(interactive)
(let ((fill-column (point-max)))
(fill-paragraph nil)))
(define-key global-map "\M-z" 'unfill-paragraph)
(defun file-notify-rm-all-watches ()
"Remove all existing file notification watches from Emacs."
(interactive)
(maphash
(lambda (key _value)
(file-notify-rm-watch key))
file-notify-descriptors))
(setq frame-title-format
'(""
(:eval
(if (s-contains-p org-roam-directory (or buffer-file-name ""))
(replace-regexp-in-string
".*/[0-9]*-?" ""
(subst-char-in-string ?_ ? buffer-file-name))
"%b"))
(:eval
(let ((project-name (projectile-project-name)))
(unless (string= "-" project-name)
(format (if (buffer-modified-p) " ◉ %s" "  ●  %s") project-name))))))
(setq mm-text-html-renderer 'w3m)
(setq w3m-fill-column 88)
(setq lsp-lens-enable 1
lsp-ui-sideline-enable 1
lsp-enable-links 1
lsp-headerline-breadcrumb-enable 1
lsp-modeline-code-actions-enable 1
lsp-modeline-diagnostics-enable 1
lsp-completion-show-detail 1
lsp-file-watch-threshold nil)
;; check for hosts folder and find any init-HOSTNAME.el files in there and load them
(defvar host (substring (shell-command-to-string "hostname") 0 -1))
(defvar host-dir "~/.config/doom/hosts/")
(add-load-path! host-dir)
;; Setup nov.el mode for epubs and change font
(add-to-list 'auto-mode-alist '("\\.epub\\'" . nov-mode))
(defun my-nov-font-setup ()
(face-remap-add-relative 'variable-pitch :family "Overpass"
:height 1.0))
(add-hook 'nov-mode-hook 'my-nov-font-setup)
;;(let ((init-host-feature (intern (concat "init-" host ".el"))))
;; (load-file init-host-feature))
(defvar host-init (concat "~/.config/doom/hosts/init-" host ".el"))
(if (file-exists-p host-init)
(load-file host-init))
(load-file "~/.config/doom/+agenda-fix.el")
;; Enable org-modern mode per buffer
;(add-hook 'org-mode-hook #'org-modern-mode)
;(add-hook 'org-agenda-finalize-hook #'org-modern-agenda)
;; Or globally
(with-eval-after-load 'org (global-org-modern-mode))
(require 'cl-lib)
(defun eshell-load-bash-aliases ()
"Read Bash aliases and add them to the list of eshell aliases."
;; Bash needs to be run - temporarily - interactively
;; in order to get the list of aliases.
(with-temp-buffer
(call-process "bash" nil '(t nil) nil "-ci" "alias")
(goto-char (point-min))
(cl-letf (((symbol-function 'eshell-write-aliases-list) #'ignore))
(while (re-search-forward "alias \\(.+\\)='\\(.+\\)'$" nil t)
(eshell/alias (match-string 1) (match-string 2))))
(eshell-write-aliases-list)))
;; We only want Bash aliases to be loaded when Eshell loads its own aliases,
;; rather than every time `eshell-mode' is enabled.
(add-hook 'eshell-alias-load-hook 'eshell-load-bash-aliases)
(defun eshell-run-direnv-allow()
(direnv-allow))
(add-hook 'eshell-directory-change-hook 'eshell-run-direnv-allow)
(defun org-raw-timestamp-to-iso (raw-ts)
"Convert Org RAW-TS like `<2025-06-12 Thu 14:00>` to `YYYY-MM-DDThh:mm:ss`."
(when raw-ts
(let* ((ts (org-parse-time-string raw-ts))
(year (nth 5 ts)) (mon (nth 4 ts)) (day (nth 3 ts))
(hour (nth 2 ts) 0) (min (nth 1 ts) 0))
(format "%04d-%02d-%02dT%02d:%02d:00" year mon day hour min))))
(defun org-extract-labeled-timestamps ()
"Return an alist of labeled ISO-formatted timestamps in the current Org subtree."
(save-restriction
(org-narrow-to-subtree)
(let ((parsed (org-element-parse-buffer))
(labeled-ts '()))
(org-element-map parsed '(timestamp)
(lambda (ts)
(let* ((type (org-element-property :type ts))
(raw (org-element-property :raw-value ts))
(time (org-parse-time-string raw t))
(date (format "%04d-%02d-%02d"
(nth 5 time) (nth 4 time) (nth 3 time)))
(hour (nth 2 time))
(min (nth 1 time))
(with-time (and hour min (format "%sT%02d:%02d" date hour min)))
(label (cond
((eq type 'active) "timestamp")
((eq type 'inactive) "inactive-timestamp")
(t "timestamp"))))
(push (cons label (or with-time date)) labeled-ts))))
;; Add planning info from heading (DEADLINE, SCHEDULED, CLOSED)
(dolist (key '("DEADLINE" "SCHEDULED" "CLOSED"))
(let ((raw (org-entry-get nil key t)))
(when raw
(let* ((ts (org-parse-time-string raw t))
(date (format "%04d-%02d-%02d" (nth 5 ts) (nth 4 ts) (nth 3 ts)))
(hour (nth 2 ts))
(min (nth 1 ts))
(with-time (and hour min (format "%sT%02d:%02d" date hour min))))
(push (cons (downcase key) (or with-time date)) labeled-ts)))))
(delete-dups labeled-ts))))
(defun org-get-body ()
"Return the body text under the current Org heading as a string."
(save-excursion
(org-back-to-heading t)
(let ((start (progn (forward-line) (point)))
(end (progn (org-end-of-subtree t t) (point))))
(buffer-substring-no-properties start end))))
(defun org-strip-timestamps-from-text (text)
"Remove Org timestamps and planning lines from TEXT."
(let* ((timestamp-re (rx (or (seq "<" (+ (not (any ">"))) ">")
(seq "[" (+ (not (any "]"))) "]"))))
(planning-line-re (rx line-start (zero-or-more space)
(or "DEADLINE:" "SCHEDULED:" "CLOSED:") " "))
;; Step 1: remove full planning lines
(without-planning-lines
(replace-regexp-in-string
(concat planning-line-re ".*\n?") "" text))
;; Step 2: remove inline timestamps
(without-inline
(replace-regexp-in-string timestamp-re "" without-planning-lines)))
(string-trim without-inline)))
(defun org-extract-drawers ()
"Extract all drawers (like LOGBOOK, PROPERTIES, etc.) from current org entry.
Returns an alist of (DRAWER-NAME . CONTENT) pairs.
- PROPERTIES content is parsed into (KEY . VALUE)
- Other drawers are returned as lists of lines (strings)"
(save-excursion
(org-back-to-heading t)
(let ((end (save-excursion (org-end-of-subtree t t)))
(drawers '()))
(while (re-search-forward "^\\s-*:\\([A-Z]+\\):\\s-*$" end t)
(let* ((name (match-string 1))
(start (match-end 0))
(drawer-end (when (re-search-forward "^\\s-*:END:\\s-*$" end t)
(match-beginning 0))))
(when drawer-end
(let ((content (buffer-substring-no-properties start drawer-end)))
(setq drawers
(cons
(cons name
(if (string= name "PROPERTIES")
;; parse :KEY: VALUE
(org-parse-properties content)
;; just return line list
(split-string content "\n" t "[ \t]+")))
drawers))))))
(reverse drawers))))
(defun org-parse-properties (content)
"Parse PROPERTIES drawer content into an alist."
(let ((lines (split-string content "\n" t))
(props '()))
(dolist (line lines)
(when (string-match "^\\s-*:\\([^:]+\\):\\s-*\\(.*\\)$" line)
(push (cons (match-string 1 line) (match-string 2 line)) props)))
(reverse props)))
(defun org-clean-body-text (text)
"Remove planning lines, timestamps, and drawers from TEXT."
(let* ((timestamp-re
(rx (or (seq "<" (+ (not (any ">"))) ">")
(seq "[" (+ (not (any "]"))) "]"))))
(planning-re
(rx line-start (zero-or-more space)
(or "DEADLINE:" "SCHEDULED:" "CLOSED:") " " (* nonl) "\n"))
(text (replace-regexp-in-string planning-re "" text))
(text (replace-regexp-in-string timestamp-re "" text))
(text (org-strip-all-drawers text)))
(string-trim text)))
(defun org-strip-timestamps-drawers-notes-from-text (text)
"Strip timestamps, planning lines, drawers, and note blocks from Org TEXT."
(let* ((timestamp-re
(rx (or (seq "<" (+ (not (any ">"))) ">")
(seq "[" (+ (not (any "]"))) "]"))))
(planning-re
(rx line-start (zero-or-more space)
(or "DEADLINE:" "SCHEDULED:" "CLOSED:") " " (* nonl) "\n"))
(drawer-re
"^\\s-*:[A-Z]+:\\(?:.\\|\n\\)*?:END:\n?")
(note-block-re
(rx-to-string
`(and bol (* space) "- Note taken on "
(or "[" "<") (+ (not (any "]>"))) (or "]" ">")
(*? anything)
(or "\n\n" eos))
t)))
;; Strip drawers first
(setq text (replace-regexp-in-string drawer-re "" text))
;; Strip entire note blocks (greedy match up to next blank line or end)
(setq text (replace-regexp-in-string note-block-re "" text))
;; Strip planning lines and timestamps
(setq text (replace-regexp-in-string planning-re "" text))
(setq text (replace-regexp-in-string timestamp-re "" text))
;; Trim and return
(string-trim text)))
(defun org-get-body-stripped ()
"Get cleaned Org entry body without timestamps, planning lines, drawers, or notes."
(org-strip-timestamps-drawers-notes-from-text (org-get-body)))
(defun org-extract-notes ()
"Extract notes from Org entry, each as an alist with `timestamp` and `content`."
(save-excursion
(org-back-to-heading t)
(let ((start (progn (forward-line) (point)))
(end (progn (org-end-of-subtree t t) (point)))
result) ;; ✅ initialize result list
(save-restriction
(narrow-to-region start end)
(goto-char (point-min))
(while (re-search-forward "^\\s-*[-+] Note taken on \\[\\([^]]+\\)\\]\\s-*\\(?:\\\\\\\\\\)?\\s-*$" nil t)
(let* ((raw-ts (match-string 1))
(timestamp (let* ((ts (org-parse-time-string raw-ts t)))
(format "%04d-%02d-%02dT%02d:%02d"
(nth 5 ts) (nth 4 ts) (nth 3 ts)
(nth 2 ts) (nth 1 ts))))
(note-start (progn
(forward-line)
;; allow one optional blank line
(when (looking-at-p "^\\s-*$") (forward-line))
(point)))
(note-end (or (save-excursion
(re-search-forward "^\\s-*[-+] Note taken on \\[" nil t))
(point-max)))
(content (string-trim
(buffer-substring-no-properties note-start (1- note-end)))))
(push `(("timestamp" . ,timestamp)
("content" . ,content))
result))))
(nreverse result))))
(require 'subr-x) ;; for string-trim and string functions, usually loaded by default
(defun my-org-generate-uuid ()
"Generate a random UUID string."
(let ((uuid (md5 (format "%s%s%s%s%s"
(user-uid)
(emacs-pid)
(float-time)
(random)
(emacs-pid)))))
(concat (substring uuid 0 8) "-"
(substring uuid 8 12) "-"
(substring uuid 12 16) "-"
(substring uuid 16 20) "-"
(substring uuid 20 32))))
(defun my-org-get-or-create-id ()
"Get the ID property of the current Org heading, or create and set one if missing.
Returns the ID string."
(let ((id (org-entry-get nil "ID")))
(unless id
(setq id (my-org-generate-uuid))
(org-entry-put nil "ID" id)
(save-buffer)) ;; optional: save file after inserting ID
id))
(defun org-clock-on-state-change ()
"Clock in when state is STRT, clock out otherwise."
(when (and (derived-mode-p 'org-mode)
(not (org-before-first-heading-p)))
(pcase org-state
("STRT"
(unless org-clock-marker
(org-clock-in)))
((or "DONE" "CANC" "WAIT" "HOLD" "TODO")
(when org-clock-marker
(org-clock-out))))))
(defun send-org-todo-to-endpoint-on-state-change ()
"Send the current Org-mode TODO item to an HTTP endpoint."
(interactive)
(when (org-at-heading-p)
(let ((state (org-get-todo-state)))
(when (member state '("STRT" "DONE"))
(let* ((heading (org-get-heading t t t t))
(current-time (format-time-string "%Y-%m-%dT%H:%M:%SZ" (current-time) t)) ;; UTC ISO8601
(tags (org-get-tags))
(timestamps (org-extract-labeled-timestamps))
(drawers (org-extract-drawers))
(properties (cdr (assoc "PROPERTIES" drawers)))
(todo-id (my-org-get-or-create-id))
(body (org-get-body-stripped))
(notes (org-extract-notes))
(properties (org-entry-properties))
(endpoint "https://life.lab.unbl.ink/webhook/emacs/")
(data `(("description" . ,heading)
("labels" . ,tags)
("state" . ,state)
("timestamps" . ,timestamps)
("notes" . ,notes)
("drawers" . ,drawers)
("emacs_id" . ,todo-id)
("updated_at" . ,current-time)
("source" . "orgmode")
("properties" . ,properties)
("body" . ,body))))
(request
endpoint
:type "POST"
:headers '(("Content-Type" . "application/json"))
:data (json-encode data)
:headers '(("Authorization" . "Token 58e898c0e88bd6333b1a9e8de82e81f36c4b64e")
("Content-Type" . "application/json"))
:parser 'json-read
:success (cl-function
(lambda (&key data &allow-other-keys)
(message "Sent TODO: %s" data)))
:error (cl-function
(lambda (&rest args &key error-thrown &allow-other-keys)
(message "Error sending TODO: %S" error-thrown))))))
(org-clock-on-state-change)
)))
(add-hook 'org-after-todo-state-change-hook #'send-org-todo-to-endpoint-on-state-change)
(defun life-scrobble-url ()
"Open https://life.lab.unbl.ink/ with scrobble_url set to a URL.
- If in an `eww` buffer, use its current URL.
- Otherwise, use the clipboard/kill ring.
Always open the result in `eww`."
(interactive)
(let* ((url (cond
;; In eww, grab current page URL
((derived-mode-p 'eww-mode)
(plist-get eww-data :url))
;; Else clipboard
(t (current-kill 0 t))))
(encoded (url-hexify-string url))
(scrobble-url (concat "https://life.lab.unbl.ink/?scrobble_url=" encoded)))
(eww scrobble-url)))
;; Bind globally to C-c l
(global-set-key (kbd "C-c l") #'life-scrobble-url)
; (after! magit
; (defvar my/ssh-key-injector-script (expand-file-name "~/.local/bin/load_keys"))
;
; (defun my/ssh-add-status ()
; (call-process "ssh-add" nil nil nil "-l"))
;
; (defun my/run-ssh-key-injector ()
; (my/apply-ssh-agent-env)
; (let* ((buf (get-buffer-create "*ssh-key-injector*"))
; (process-connection-type t)
; (process-environment (copy-sequence process-environment)))
; (unless (file-executable-p my/ssh-key-injector-script)
; (user-error "SSH injector script not executable: %s" my/ssh-key-injector-script))
; (with-current-buffer buf
; (erase-buffer)
; (insert (format "Emacs SSH_AUTH_SOCK=%s\n\n" (or (getenv "SSH_AUTH_SOCK") "<unset>"))))
; (let ((proc (make-process
; :name "ssh-key-injector"
; :buffer buf
; :stderr buf
; :command (list my/ssh-key-injector-script)
; :noquery t)))
; (while (process-live-p proc)
; (accept-process-output proc 0.05))
; (unless (eq (process-exit-status proc) 0)
; (display-buffer buf)
; (user-error "SSH key injection failed. See *ssh-key-injector*.")))))
;
; (defun my/ensure-ssh-keys-loaded (&rest _ignore)
; (pcase (my/ssh-add-status)
; (0 nil)
; (1 (my/run-ssh-key-injector))
; (2 (user-error "No reachable ssh-agent (SSH_AUTH_SOCK=%s)"
; (or (getenv "SSH_AUTH_SOCK") "<unset>")))
; (_ (my/run-ssh-key-injector))))
;
; (dolist (fn '(magit-status magit-fetch magit-push magit-pull))
; (advice-remove fn #'my/ensure-ssh-keys-loaded)
; (advice-add fn :before #'my/ensure-ssh-keys-loaded)))
(defun my/disable-apheleia-in-certain-projects ()
(when-let ((root (and (fboundp 'project-root)
(project-root (project-current)))))
(when (member (file-name-nondirectory
(directory-file-name root))
'("hungryroot"))
(apheleia-mode -1))))
(add-hook 'find-file-hook #'my/disable-apheleia-in-certain-projects)
(require 'acp)
(require 'agent-shell)
;; --- pass integration ---
(after! auth-source-pass
;; Ensure pass is used as an auth-source backend
(auth-source-pass-enable))
(defun +pass-line1 (entry)
"Return first line from `pass show ENTRY` (trimmed)."
(string-trim
(shell-command-to-string (format "pass show %s 2>/dev/null | head -n 1" entry))))
(after! agent-shell
;; OpenAI (ChatGPT API)
(setq agent-shell-openai-authentication
(agent-shell-openai-make-authentication
:api-key (lambda () (+pass-line1 "work/hungryroot/apikeys/chatgpt"))))
;; Anthropic (Claude)
(setq agent-shell-anthropic-authentication
(agent-shell-anthropic-make-authentication
:api-key (lambda () (+pass-line1 "work/hungryroot/apikeys/anthropic"))))
;; Google (Gemini)
(setq agent-shell-google-authentication
(agent-shell-google-make-authentication
:api-key (lambda () (+pass-line1 "work/hungryroot/apikeys/gemini")))))
;;(use-package agent-shell
;; :config
;; ;; Evil state-specific RET behavior: insert mode = newline, normal mode = send
;; (evil-define-key 'insert agent-shell-mode-map (kbd "RET") #'newline)
;; (evil-define-key 'normal agent-shell-mode-map (kbd "RET") #'comint-send-input)
;;
;; ;; Configure *agent-shell-diff* buffers to start in Emacs state
;; (add-hook 'diff-mode-hook
;; (lambda ()
;; (when (string-match-p "\\*agent-shell-diff\\*" (buffer-name))
;; (evil-emacs-state)))))