about summary refs log tree commit diff
path: root/tools
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--tools/blog_cli/README.md41
-rw-r--r--tools/blog_cli/default.nix9
-rw-r--r--tools/blog_cli/main.go209
-rw-r--r--tools/emacs-pkgs/dottime/default.nix7
-rw-r--r--tools/emacs-pkgs/dottime/dottime.el59
-rw-r--r--tools/emacs-pkgs/nix-util/default.nix7
-rw-r--r--tools/emacs-pkgs/nix-util/nix-util.el67
-rw-r--r--tools/emacs-pkgs/term-switcher/default.nix14
-rw-r--r--tools/emacs-pkgs/term-switcher/term-switcher.el56
-rw-r--r--tools/emacs/.gitignore11
-rw-r--r--tools/emacs/README.md7
-rw-r--r--tools/emacs/config/bindings.el44
-rw-r--r--tools/emacs/config/custom.el52
-rw-r--r--tools/emacs/config/desktop.el213
-rw-r--r--tools/emacs/config/eshell-setup.el68
-rw-r--r--tools/emacs/config/functions.el250
-rw-r--r--tools/emacs/config/init.el219
-rw-r--r--tools/emacs/config/look-and-feel.el114
-rw-r--r--tools/emacs/config/mail-setup.el90
-rw-r--r--tools/emacs/config/modes.el36
-rw-r--r--tools/emacs/config/settings.el50
-rw-r--r--tools/emacs/default.nix128
22 files changed, 1751 insertions, 0 deletions
diff --git a/tools/blog_cli/README.md b/tools/blog_cli/README.md
new file mode 100644
index 0000000000..7afa0fe920
--- /dev/null
+++ b/tools/blog_cli/README.md
@@ -0,0 +1,41 @@
+tazblog CLI
+===========
+
+My blog stores its content in DNS, spread out over three types of `TXT` entries:
+
+* `TXT _posts.blog.tazj.in.`: A sorted list of posts, serialised as a JSON list of
+  strings (e.g. `["1486830338", "1476807384"]`)
+
+* `TXT _chunks.$postID.blog.tazj.in`: JSON chunks containing the blog post text
+
+* `TXT _meta.$postID.blog.tazj.in`: JSON blob with blog post metadata
+
+All JSON blobs are base64-encoded.
+
+This CLI tool helps to update those records.
+
+Each blog post data is a series of JSON-encoded structures which follow one of
+these formats:
+
+```
+struct metadata {
+    chunks: int
+    title: string
+    date: date
+}
+```
+
+Where `chunks` describes the number of chunks following this format:
+
+```
+struct chunk {
+    c: int
+    t: string
+}
+```
+
+Writing a blog post to DNS means taking its text and metadata, chunking it up
+and writing the chunks.
+
+Reading a blog post means retrieving all data, reading the metadata and then
+assembling the chunks in order.
diff --git a/tools/blog_cli/default.nix b/tools/blog_cli/default.nix
new file mode 100644
index 0000000000..c22e4c949b
--- /dev/null
+++ b/tools/blog_cli/default.nix
@@ -0,0 +1,9 @@
+{ pkgs, ... }:
+
+pkgs.buildGo.program {
+  name = "blog_cli";
+  srcs = [ ./main.go ];
+  deps = with pkgs.third_party; [
+    gopkgs."google.golang.org".api.dns.v1.gopkg
+  ];
+} // { meta.enableCI = true; }
diff --git a/tools/blog_cli/main.go b/tools/blog_cli/main.go
new file mode 100644
index 0000000000..db64f8378e
--- /dev/null
+++ b/tools/blog_cli/main.go
@@ -0,0 +1,209 @@
+// The tazblog CLI implements updating my blog records in DNS, see the
+// README in this folder for details.
+//
+// The post input format is a file with the title on one line,
+// followed by the date on a line, followed by an empty line, followed
+// by the post text.
+package main
+
+import (
+	"context"
+	"encoding/base64"
+	"encoding/json"
+	"flag"
+	"fmt"
+	"io/ioutil"
+	"log"
+	"time"
+
+	"google.golang.org/api/dns/v1"
+)
+
+var (
+	project = flag.String("project", "tazjins-infrastructure", "Target GCP project")
+	zone    = flag.String("zone", "blog-tazj-in", "Target Cloud DNS zone")
+	title   = flag.String("title", "", "Title of the blog post")
+	date    = flag.String("date", "", "Date the post was written on")
+	infile  = flag.String("text", "", "Text file containing the blog post")
+	id      = flag.String("id", "", "Post ID - will be generated if unset")
+)
+
+// Number of runes to include in a single chunk. If any chunks exceed
+// the limit of what can be encoded, the chunk size is reduced and we
+// try again.
+var chunkSize = 200
+
+type day time.Time
+
+func (d day) MarshalJSON() ([]byte, error) {
+	j := (time.Time(d)).Format(`"2006-01-02"`)
+	return []byte(j), nil
+}
+
+type metadata struct {
+	Chunks int    `json:"c"`
+	Title  string `json:"t"`
+	Date   day    `json:"d"`
+}
+
+type chunk struct {
+	Chunk int
+	Text  string
+}
+
+type post struct {
+	ID     string
+	Meta   metadata
+	Chunks []string
+}
+
+func (p *post) writeToDNS() error {
+	var additions []*dns.ResourceRecordSet
+	additions = append(additions, &dns.ResourceRecordSet{
+		Name: fmt.Sprintf("_meta.%s.blog.tazj.in.", p.ID),
+		Type: "TXT",
+		Ttl:  1200,
+		Rrdatas: []string{
+			encodeJSON(p.Meta),
+		},
+	})
+
+	for i, c := range p.Chunks {
+		additions = append(additions, &dns.ResourceRecordSet{
+			Name:    fmt.Sprintf("_%v.%s.blog.tazj.in.", i, p.ID),
+			Type:    "TXT",
+			Ttl:     1200,
+			Rrdatas: []string{c},
+		})
+	}
+
+	ctx := context.Background()
+	dnsSvc, err := dns.NewService(ctx)
+	if err != nil {
+		return err
+	}
+
+	change := dns.Change{
+		Additions: additions,
+	}
+
+	_, err = dnsSvc.Changes.Create(*project, *zone, &change).Do()
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// Encode given value as JSON and base64-encode it.
+func encodeJSON(v interface{}) string {
+	outer, err := json.Marshal(v)
+	if err != nil {
+		log.Fatalln("Failed to encode JSON", err)
+	}
+
+	return base64.RawStdEncoding.EncodeToString(outer)
+}
+
+// Encode a chunk and check whether it is too large
+func encodeChunk(c chunk) (string, bool) {
+	tooLarge := false
+	s := base64.RawStdEncoding.EncodeToString([]byte(c.Text))
+
+	if len(s) >= 255 {
+		tooLarge = true
+	}
+
+	return s, tooLarge
+}
+
+func createPost(id, title, text string, date day) post {
+	runes := []rune(text)
+	n := 0
+	tooLarge := false
+
+	var chunks []string
+
+	for chunkSize < len(runes) {
+		c, l := encodeChunk(chunk{
+			Chunk: n,
+			Text:  string(runes[0:chunkSize:chunkSize]),
+		})
+
+		tooLarge = tooLarge || l
+		chunks = append(chunks, c)
+		runes = runes[chunkSize:]
+		n++
+	}
+
+	if len(runes) > 0 {
+		c, l := encodeChunk(chunk{
+			Chunk: n,
+			Text:  string(runes),
+		})
+
+		tooLarge = tooLarge || l
+		chunks = append(chunks, c)
+		n++
+	}
+
+	if tooLarge {
+		log.Println("Too large at chunk size", chunkSize)
+		chunkSize -= 5
+		return createPost(id, title, text, date)
+	}
+
+	return post{
+		ID: id,
+		Meta: metadata{
+			Chunks: n,
+			Title:  title,
+			Date:   date,
+		},
+		Chunks: chunks,
+	}
+}
+
+func main() {
+	flag.Parse()
+
+	if *title == "" {
+		log.Fatalln("Post title must be set (-title)")
+	}
+
+	if *infile == "" {
+		log.Fatalln("Post text file must be set (-text)")
+	}
+
+	if *id == "" {
+		log.Fatalln("Post ID must be set (-id)")
+	}
+
+	var postDate day
+	if *date != "" {
+		t, err := time.Parse("2006-01-02", *date)
+		if err != nil {
+			log.Fatalln("Invalid post date", err)
+		}
+
+		postDate = day(t)
+	} else {
+		postDate = day(time.Now())
+	}
+
+	t, err := ioutil.ReadFile(*infile)
+	if err != nil {
+		log.Fatalln("Failed to read post:", err)
+	}
+
+	post := createPost(*id, *title, string(t), postDate)
+
+	log.Println("Writing post to DNS ...")
+	err = post.writeToDNS()
+
+	if err != nil {
+		log.Fatalln("Failed to write post:", err)
+	}
+
+	log.Println("Successfully wrote entries")
+}
diff --git a/tools/emacs-pkgs/dottime/default.nix b/tools/emacs-pkgs/dottime/default.nix
new file mode 100644
index 0000000000..b09756dea5
--- /dev/null
+++ b/tools/emacs-pkgs/dottime/default.nix
@@ -0,0 +1,7 @@
+{ pkgs, ... }:
+
+pkgs.third_party.emacsPackagesNg.trivialBuild rec {
+  pname = "dottime";
+  version = "1.0";
+  src = ./dottime.el;
+}
diff --git a/tools/emacs-pkgs/dottime/dottime.el b/tools/emacs-pkgs/dottime/dottime.el
new file mode 100644
index 0000000000..7caeb2f2c4
--- /dev/null
+++ b/tools/emacs-pkgs/dottime/dottime.el
@@ -0,0 +1,59 @@
+;;; dottime.el --- use dottime in the modeline
+;;
+;; Copyright (C) 2019 Google Inc.
+;;
+;; Author: Vincent Ambo <tazjin@google.com>
+;; Version: 1.0
+;; Package-Requires: (cl-lib)
+;;
+;;; Commentary:
+;;
+;; This package changes the display of time in the modeline to use
+;; dottime (see https://dotti.me/) instead of the standard time
+;; display.
+;;
+;; Modeline dottime display is enabled by calling
+;; `dottime-display-mode' and dottime can be used in Lisp code via
+;; `dottime-format'.
+
+(require 'cl-lib)
+(require 'time)
+
+(defun dottime--format-string ()
+  "Creates the dottime format string for `format-time-string'
+  based on the local timezone."
+
+  (let* ((offset-sec (car (current-time-zone)))
+         (offset-hours (/ offset-sec 60 60)))
+    (if (/= offset-hours 0)
+        (concat "%m-%dT%H·%M" (format "%0+3d" offset-hours))
+      "%m-%dT%H·%M")))
+
+(defun dottime--display-time-update-advice (orig)
+  "Function used as advice to `display-time-update' with a
+  rebound definition of `format-time-string' that renders all
+  timestamps as dottime."
+
+  (cl-letf* ((format-orig (symbol-function 'format-time-string))
+             ((symbol-function 'format-time-string)
+              (lambda (&rest _)
+                (funcall format-orig (dottime--format-string) nil t))))
+    (funcall orig)))
+
+(defun dottime-format (&optional time)
+  "Format the given TIME in dottime. If TIME is nil, the current
+  time will be used."
+
+  (format-time-string (dottime--format-string) time t))
+
+(defun dottime-display-mode (arg)
+  "Enable time display as dottime. Disables dottime if called
+  with prefix 0 or nil."
+
+  (interactive "p")
+  (if (or (eq arg 0) (eq arg nil))
+      (advice-remove 'display-time-update #'dottime--display-time-update-advice)
+    (advice-add 'display-time-update :around #'dottime--display-time-update-advice))
+  (display-time-update))
+
+(provide 'dottime)
diff --git a/tools/emacs-pkgs/nix-util/default.nix b/tools/emacs-pkgs/nix-util/default.nix
new file mode 100644
index 0000000000..0e314ae719
--- /dev/null
+++ b/tools/emacs-pkgs/nix-util/default.nix
@@ -0,0 +1,7 @@
+{ pkgs, ... }:
+
+pkgs.third_party.emacsPackagesNg.trivialBuild rec {
+  pname = "nix-util";
+  version = "1.0";
+  src = ./nix-util.el;
+}
diff --git a/tools/emacs-pkgs/nix-util/nix-util.el b/tools/emacs-pkgs/nix-util/nix-util.el
new file mode 100644
index 0000000000..533e7e6f34
--- /dev/null
+++ b/tools/emacs-pkgs/nix-util/nix-util.el
@@ -0,0 +1,67 @@
+;;; nix-util.el --- Utilities for dealing with Nix code. -*- lexical-binding: t; -*-
+;;
+;; Copyright (C) 2019 Google Inc.
+;;
+;; Author: Vincent Ambo <tazjin@google.com>
+;; Version: 1.0
+;; Package-Requires: (json map)
+;;
+;;; Commentary:
+;;
+;; This package adds some functionality that I find useful when
+;; working in Nix buffers.
+
+(require 'json)
+(require 'map)
+
+(defun nix/prefetch-github (owner repo) ; TODO(tazjin): support different branches
+  "Fetch the master branch of a GitHub repository and insert the
+  call to `fetchFromGitHub' at point."
+
+  (interactive "sOwner: \nsRepository: ")
+
+  (let* (;; Keep these vars around for output insertion
+         (point (point))
+         (buffer (current-buffer))
+         (name (concat "github-fetcher/" owner "/" repo))
+         (outbuf (format "*%s*" name))
+         (errbuf (get-buffer-create "*github-fetcher/errors*"))
+         (cleanup (lambda ()
+                    (kill-buffer outbuf)
+                    (kill-buffer errbuf)
+                    (with-current-buffer buffer
+                      (read-only-mode -1))))
+         (prefetch-handler
+          (lambda (_process event)
+            (unwind-protect
+                (pcase event
+                  ("finished\n"
+                   (let* ((json-string (with-current-buffer outbuf
+                                         (buffer-string)))
+                          (result (json-parse-string json-string)))
+                     (with-current-buffer buffer
+                       (goto-char point)
+                       (map-let (("rev" rev) ("sha256" sha256)) result
+                         (read-only-mode -1)
+                         (insert (format "fetchFromGitHub {
+  owner = \"%s\";
+  repo = \"%s\";
+  rev = \"%s\";
+  sha256 = \"%s\";
+};" owner repo rev sha256))
+                         (indent-region point (point))))))
+                  (_ (with-current-buffer errbuf
+                       (error "Failed to prefetch %s/%s: %s"
+                              owner repo (buffer-string)))))
+              (funcall cleanup)))))
+
+    ;; Fetching happens asynchronously, but we'd like to make sure the
+    ;; point stays in place while that happens.
+    (read-only-mode)
+    (make-process :name name
+                  :buffer outbuf
+                  :command `("nix-prefetch-github" ,owner ,repo)
+                  :stderr errbuf
+                  :sentinel prefetch-handler)))
+
+(provide 'nix-util)
diff --git a/tools/emacs-pkgs/term-switcher/default.nix b/tools/emacs-pkgs/term-switcher/default.nix
new file mode 100644
index 0000000000..09b5353dc4
--- /dev/null
+++ b/tools/emacs-pkgs/term-switcher/default.nix
@@ -0,0 +1,14 @@
+{ pkgs, ... }:
+
+with pkgs.third_party.emacsPackagesNg;
+
+melpaBuild rec {
+  pname = "term-switcher";
+  version = "1.0";
+  src = ./term-switcher.el;
+  packageRequires = [ dash ivy s vterm ];
+
+  recipe = builtins.toFile "recipe" ''
+    (term-switcher :fetcher github :repo "tazjin/depot")
+  '';
+}
diff --git a/tools/emacs-pkgs/term-switcher/term-switcher.el b/tools/emacs-pkgs/term-switcher/term-switcher.el
new file mode 100644
index 0000000000..67595474fa
--- /dev/null
+++ b/tools/emacs-pkgs/term-switcher/term-switcher.el
@@ -0,0 +1,56 @@
+;;; term-switcher.el --- Easily switch between open vterms
+;;
+;; Copyright (C) 2019 Google Inc.
+;;
+;; Author: Vincent Ambo <tazjin@google.com>
+;; Version: 1.1
+;; Package-Requires: (dash ivy s vterm)
+;;
+;;; Commentary:
+;;
+;; This package adds a function that lets users quickly switch between
+;; different open vterms via ivy.
+
+(require 'dash)
+(require 'ivy)
+(require 's)
+(require 'vterm)
+
+(defgroup term-switcher nil
+  "Customization options `term-switcher'.")
+
+(defcustom term-switcher-buffer-prefix "vterm<"
+  "String prefix for vterm terminal buffers. For example, if you
+  set your titles to match `vterm<...>' a useful prefix might be
+  `vterm<'."
+  :type '(string)
+  :group 'term-switcher)
+
+(defun ts/open-or-create-vterm (buffer-name)
+  "Switch to the buffer with BUFFER-NAME or create a new vterm
+  buffer."
+  (let ((buffer (get-buffer buffer-name)))
+    (if (not buffer)
+        (vterm)
+      (switch-to-buffer buffer))))
+
+(defun ts/is-vterm-buffer (buffer)
+  "Determine whether BUFFER runs a vterm."
+  (equal 'vterm-mode (buffer-local-value 'major-mode buffer)))
+
+(defun ts/switch-to-terminal ()
+  "Switch to an existing vterm buffer or create a new one."
+
+  (interactive)
+  (let ((terms (-map #'buffer-name
+                     (-filter #'ts/is-vterm-buffer (buffer-list)))))
+    (if terms
+        (ivy-read "Switch to vterm: "
+                  (cons "New vterm" terms)
+                  :caller 'ts/switch-to-terminal
+                  :preselect (s-concat "^" term-switcher-buffer-prefix)
+                  :require-match t
+                  :action #'ts/open-or-create-vterm)
+      (vterm))))
+
+(provide 'term-switcher)
diff --git a/tools/emacs/.gitignore b/tools/emacs/.gitignore
new file mode 100644
index 0000000000..7b666905f8
--- /dev/null
+++ b/tools/emacs/.gitignore
@@ -0,0 +1,11 @@
+.smex-items
+*token*
+auto-save-list/
+clones/
+elpa/
+irc.el
+local.el
+other/
+scripts/
+themes/
+*.elc
diff --git a/tools/emacs/README.md b/tools/emacs/README.md
new file mode 100644
index 0000000000..5c66733396
--- /dev/null
+++ b/tools/emacs/README.md
@@ -0,0 +1,7 @@
+tools/emacs
+===========
+
+This sub-folder builds my Emacs configuration, supplying packages from
+Nix and configuration from this folder.
+
+I use Emacs for many things (including as my desktop environment).
diff --git a/tools/emacs/config/bindings.el b/tools/emacs/config/bindings.el
new file mode 100644
index 0000000000..e77af33895
--- /dev/null
+++ b/tools/emacs/config/bindings.el
@@ -0,0 +1,44 @@
+;; Font size
+(define-key global-map (kbd "C-=") 'increase-default-text-scale) ;; '=' because there lies '+'
+(define-key global-map (kbd "C--") 'decrease-default-text-scale)
+(define-key global-map (kbd "C-x C-0") 'set-default-text-scale)
+
+;; What does <tab> do? Well, it depends ...
+(define-key prog-mode-map (kbd "<tab>") #'company-indent-or-complete-common)
+
+;; imenu instead of insert-file
+(global-set-key (kbd "C-x i") 'imenu)
+
+;; Window switching. (C-x o goes to the next window)
+(windmove-default-keybindings) ;; Shift+direction
+
+;; Start eshell or switch to it if it's active.
+(global-set-key (kbd "C-x m") 'eshell)
+
+;; Start a new eshell even if one is active.
+(global-set-key (kbd "C-x C-p") 'ivy-browse-repositories)
+(global-set-key (kbd "M-g M-g") 'goto-line-with-feedback)
+
+;; Miscellaneous editing commands
+(global-set-key (kbd "C-c w") 'whitespace-cleanup)
+(global-set-key (kbd "C-c a") 'align-regexp)
+(global-set-key (kbd "C-c m") 'mc/mark-dwim)
+
+;; Browse URLs (very useful for Gitlab's SSH output!)
+(global-set-key (kbd "C-c b p") 'browse-url-at-point)
+(global-set-key (kbd "C-c b b") 'browse-url)
+
+;; C-x REALLY QUIT (idea by @magnars)
+(global-set-key (kbd "C-x r q") 'save-buffers-kill-terminal)
+(global-set-key (kbd "C-x C-c") 'ignore)
+
+;; Open Fefes Blog
+(global-set-key (kbd "C-c C-f") 'fefes-blog)
+
+;; Open a file in project:
+(global-set-key (kbd "C-c f") 'project-find-file)
+
+;; Insert TODO comments
+(global-set-key (kbd "C-c t") 'insert-todo-comment)
+
+(provide 'bindings)
diff --git a/tools/emacs/config/custom.el b/tools/emacs/config/custom.el
new file mode 100644
index 0000000000..a157c7a5fa
--- /dev/null
+++ b/tools/emacs/config/custom.el
@@ -0,0 +1,52 @@
+(custom-set-variables
+ ;; custom-set-variables was added by Custom.
+ ;; If you edit it by hand, you could mess it up, so be careful.
+ ;; Your init file should contain only one such instance.
+ ;; If there is more than one, they won't work right.
+ '(ac-auto-show-menu 0.8)
+ '(ac-delay 0.2)
+ '(avy-background t)
+ '(cargo-process--custom-path-to-bin "env CARGO_INCREMENTAL=1 cargo")
+ '(cargo-process--enable-rust-backtrace 1)
+ '(company-auto-complete (quote (quote company-explicit-action-p)))
+ '(company-idle-delay 0.5)
+ '(custom-enabled-themes (quote (gruber-darker)))
+ '(custom-safe-themes
+   (quote
+    ("d61fc0e6409f0c2a22e97162d7d151dee9e192a90fa623f8d6a071dbf49229c6" "3c83b3676d796422704082049fc38b6966bcad960f896669dfc21a7a37a748fa" "89336ca71dae5068c165d932418a368a394848c3b8881b2f96807405d8c6b5b6" default)))
+ '(display-time-default-load-average nil)
+ '(display-time-interval 30)
+ '(elnode-send-file-program "/run/current-system/sw/bin/cat")
+ '(frame-brackground-mode (quote dark))
+ '(global-auto-complete-mode t)
+ '(kubernetes-commands-display-buffer-function (quote display-buffer))
+ '(lsp-gopls-server-path "/home/tazjin/go/bin/gopls")
+ '(magit-log-show-gpg-status t)
+ '(ns-alternate-modifier (quote none))
+ '(ns-command-modifier (quote control))
+ '(ns-right-command-modifier (quote meta))
+ '(require-final-newline (quote visit-save))
+ '(tls-program (quote ("gnutls-cli --x509cafile %t -p %p %h"))))
+(custom-set-faces
+ ;; custom-set-faces was added by Custom.
+ ;; If you edit it by hand, you could mess it up, so be careful.
+ ;; Your init file should contain only one such instance.
+ ;; If there is more than one, they won't work right.
+ '(default ((t (:foreground "#e4e4ef" :background "#181818"))))
+ '(rainbow-delimiters-depth-1-face ((t (:foreground "#2aa198"))))
+ '(rainbow-delimiters-depth-2-face ((t (:foreground "#b58900"))))
+ '(rainbow-delimiters-depth-3-face ((t (:foreground "#268bd2"))))
+ '(rainbow-delimiters-depth-4-face ((t (:foreground "#dc322f"))))
+ '(rainbow-delimiters-depth-5-face ((t (:foreground "#859900"))))
+ '(rainbow-delimiters-depth-6-face ((t (:foreground "#268bd2"))))
+ '(rainbow-delimiters-depth-7-face ((t (:foreground "#cb4b16"))))
+ '(rainbow-delimiters-depth-8-face ((t (:foreground "#d33682"))))
+ '(rainbow-delimiters-depth-9-face ((t (:foreground "#839496"))))
+ '(term-color-black ((t (:background "#282828" :foreground "#282828"))))
+ '(term-color-blue ((t (:background "#96a6c8" :foreground "#96a6c8"))))
+ '(term-color-cyan ((t (:background "#1fad83" :foreground "#1fad83"))))
+ '(term-color-green ((t (:background "#73c936" :foreground "#73c936"))))
+ '(term-color-magenta ((t (:background "#9e95c7" :foreground "#9e95c7"))))
+ '(term-color-red ((t (:background "#f43841" :foreground "#f43841"))))
+ '(term-color-white ((t (:background "#f5f5f5" :foreground "#f5f5f5"))))
+ '(term-color-yellow ((t (:background "#ffdd33" :foreground "#ffdd33")))))
diff --git a/tools/emacs/config/desktop.el b/tools/emacs/config/desktop.el
new file mode 100644
index 0000000000..d923ab584c
--- /dev/null
+++ b/tools/emacs/config/desktop.el
@@ -0,0 +1,213 @@
+;; -*- lexical-binding: t; -*-
+;;
+;; Configure desktop environment settings, including both
+;; window-management (EXWM) as well as additional system-wide
+;; commands.
+
+(require 's)
+(require 'f)
+(require 'dash)
+(require 'exwm)
+(require 'exwm-config)
+(require 'exwm-randr)
+(require 'exwm-systemtray)
+
+(defun pactl (cmd)
+  (shell-command (concat "pactl " cmd))
+  (message "Volume command: %s" cmd))
+
+(defun volume-mute () (interactive) (pactl "set-sink-mute @DEFAULT_SINK@ toggle"))
+(defun volume-up () (interactive) (pactl "set-sink-volume @DEFAULT_SINK@ +5%"))
+(defun volume-down () (interactive) (pactl "set-sink-volume @DEFAULT_SINK@ -5%"))
+
+(defun brightness-up ()
+  (interactive)
+  (shell-command "xbacklight -inc 5")
+  (message "Brightness increased"))
+
+(defun brightness-down ()
+  (interactive)
+  (shell-command "xbacklight -dec 5")
+  (message "Brightness decreased"))
+
+(defun lock-screen ()
+  (interactive)
+  ;; A sudoers configuration is in place that lets me execute this
+  ;; particular command without having to enter a password.
+  ;;
+  ;; The reason for things being set up this way is that I want
+  ;; xsecurelock.service to be started as a system-wide service that
+  ;; is tied to suspend.target.
+  (shell-command "/usr/bin/sudo /usr/bin/systemctl start xsecurelock.service"))
+
+(defun generate-randr-config (primary secondary)
+  (-flatten `(,(-map (lambda (n) (list n primary)) (number-sequence 1 7))
+              (0 secondary)
+              ,(-map (lambda (n) (list n secondary)) (number-sequence 8 9)))))
+
+(defun randr-layout-dp1-extend ()
+  "Layout for connecting my X1 Carbon to my screen at home."
+
+  (interactive)
+  (setq exwm-randr-workspace-monitor-plist (generate-randr-config "DP1-1" "eDP1"))
+  (exwm-randr-refresh)
+  (shell-command "xrandr --output DP1-1 --right-of eDP1 --auto --primary"))
+
+(defun randr-layout-hdmi1-extend ()
+  "Office layout for The Big Screen(tm)"
+
+  (interactive)
+  (setq exwm-randr-workspace-monitor-plist (generate-randr-config "HDMI1" "eDP1"))
+  (exwm-randr-refresh)
+  (shell-command "xrandr --output HDMI1 --dpi 144 --auto --right-of eDP1 --primary")
+  (set-default-text-scale nil 165))
+
+(defun randr-layout-single ()
+  "Laptop screen only!"
+
+  (interactive)
+  (shell-command "xrandr --output HDMI1 --off")
+  (shell-command "xrandr --output DP1-1 --off")
+  (exwm-randr-refresh)
+  (set-default-text-scale nil))
+
+(defun set-xkb-layout (layout)
+  "Set the current X keyboard layout."
+
+  (shell-command (format "setxkbmap %s" layout))
+  (message "Set X11 keyboard layout to '%s'" layout))
+
+(defun create-window-name ()
+  "Construct window names to be used for EXWM buffers by
+  inspecting the window's X11 class and title.
+
+  A lot of commonly used applications either create titles that
+  are too long by default, or in the case of web
+  applications (such as Cider) end up being constructed in
+  awkward ways.
+
+  To avoid this issue, some rewrite rules are applied for more
+  human-accessible titles."
+
+  (pcase (list (or exwm-class-name "unknown") (or exwm-title "unknown"))
+    ;; In Cider windows, rename the class and keep the workspace/file
+    ;; as the title.
+    (`("Google-chrome" ,(and (pred (lambda (title) (s-ends-with? " - Cider" title))) title))
+     (format "Cider<%s>" (s-chop-suffix " - Cider" title)))
+
+    ;; Attempt to detect IRCCloud windows via their title, which is a
+    ;; combination of the channel name and network.
+    ;;
+    ;; This is what would often be referred to as a "hack". The regexp
+    ;; will not work if a network connection buffer is selected in
+    ;; IRCCloud, but since the title contains no other indication that
+    ;; we're dealing with an IRCCloud window
+    (`("Google-chrome"
+       ,(and (pred (lambda (title)
+                     (s-matches? "^[\*\+]\s#[a-zA-Z0-9/\-]+\s\|\s[a-zA-Z\.]+$" title)))
+             title))
+     (format "IRCCloud<%s>" title))
+
+    ;; For other Chrome windows, make the title shorter.
+    (`("Google-chrome" ,title)
+     (format "Chrome<%s>" (s-truncate 42 (s-chop-suffix " - Google Chrome" title))))
+
+    ;; Gnome-terminal -> Term
+    (`("Gnome-terminal" ,title)
+     ;; fish-shell buffers contain some unnecessary whitespace and
+     ;; such before the current working directory. This can be
+     ;; stripped since most of my terminals are fish shells anyways.
+     (format "Term<%s>" (s-trim-left (s-chop-prefix "fish" title))))
+
+    ;; For any other application, a name is constructed from the
+    ;; window's class and name.
+    (`(,class ,title) (format "%s<%s>" class (s-truncate 12 title)))))
+
+;; EXWM launch configuration
+;;
+;; This used to use use-package, but when something breaks use-package
+;; it doesn't exactly make debugging any easier.
+
+(let ((titlef (lambda ()
+                (exwm-workspace-rename-buffer (create-window-name)))))
+  (add-hook 'exwm-update-class-hook titlef)
+  (add-hook 'exwm-update-title-hook titlef))
+
+(fringe-mode 3)
+(exwm-enable)
+
+;; 's-N': Switch to certain workspace
+(setq exwm-workspace-number 10)
+(dotimes (i 10)
+  (exwm-input-set-key (kbd (format "s-%d" i))
+                      `(lambda ()
+                         (interactive)
+                         (exwm-workspace-switch-create ,i))))
+
+;; Launch applications / any command  with completion (dmenu style!)
+(exwm-input-set-key (kbd "s-d") #'counsel-linux-app)
+(exwm-input-set-key (kbd "s-x") #'ivy-run-external-command)
+(exwm-input-set-key (kbd "s-p") #'ivy-password-store)
+
+;; Add X11 terminal selector to a key
+(exwm-input-set-key (kbd "C-x t") #'ts/switch-to-terminal)
+
+;; Toggle between line-mode / char-mode
+(exwm-input-set-key (kbd "C-c C-t C-t") #'exwm-input-toggle-keyboard)
+
+;; Volume keys
+(exwm-input-set-key (kbd "<XF86AudioMute>") #'volume-mute)
+(exwm-input-set-key (kbd "<XF86AudioRaiseVolume>") #'volume-up)
+(exwm-input-set-key (kbd "<XF86AudioLowerVolume>") #'volume-down)
+
+;; Brightness keys
+(exwm-input-set-key (kbd "<XF86MonBrightnessDown>") #'brightness-down)
+(exwm-input-set-key (kbd "<XF86MonBrightnessUp>") #'brightness-up)
+(exwm-input-set-key (kbd "<XF86Display>") #'lock-screen)
+
+;; Keyboard layouts (these are bound separately in Cyrillic
+;; because I don't use reverse-im)
+;; (-map
+;;  (lambda (pair)
+;;    (exwm-input-set-key
+;;     (kbd (format "s-%s" (cadr pair)))
+;;     `(lambda () (interactive) (set-xkb-layout ,(car pair)))))
+;;  '(("de" "k d")
+;;    ("de" "л в")
+;;    ("no" "k n")
+;;    ("no" "л т")
+;;    ("ru" "k r")
+;;    ("ru" "л к")
+;;    ("us" "k u")
+;;    ("us" "л г")))
+
+;; Line-editing shortcuts
+(exwm-input-set-simulation-keys
+ '(([?\C-d] . delete)
+   ([?\C-w] . ?\C-c)))
+
+;; Show time & battery status in the mode line
+(display-time-mode)
+(display-battery-mode)
+
+;; enable display of X11 system tray within Emacs
+(exwm-systemtray-enable)
+
+;; Configure xrandr (multi-monitor setup)
+(setq exwm-randr-workspace-monitor-plist (generate-randr-config "HDMI1" "eDP1"))
+(exwm-randr-enable)
+
+;; Let buffers move seamlessly between workspaces by making them
+;; accessible in selectors on all frames.
+(setq exwm-workspace-show-all-buffers t)
+(setq exwm-layout-show-all-buffers t)
+
+;; Monitor layouts
+;;
+;; TODO(tazjin): Desired layout should be inferred based on
+;; connected screens - autorandr or something?
+(exwm-input-set-key (kbd "s-m d") #'randr-layout-dp1-extend)
+(exwm-input-set-key (kbd "s-m h") #'randr-layout-hdmi1-extend)
+(exwm-input-set-key (kbd "s-m s") #'randr-layout-single)
+
+(provide 'desktop)
diff --git a/tools/emacs/config/eshell-setup.el b/tools/emacs/config/eshell-setup.el
new file mode 100644
index 0000000000..0b23c5a2d1
--- /dev/null
+++ b/tools/emacs/config/eshell-setup.el
@@ -0,0 +1,68 @@
+;; EShell configuration
+
+(require 'eshell)
+
+;; Generic settings
+;; Hide banner message ...
+(setq eshell-banner-message "")
+
+;; Prompt configuration
+(defun clean-pwd (path)
+  "Turns a path of the form /foo/bar/baz into /f/b/baz
+   (inspired by fish shell)"
+  (let* ((hpath (replace-regexp-in-string home-dir
+                                          "~"
+                                          path))
+         (current-dir (split-string hpath "/"))
+	 (cdir (last current-dir))
+	 (head (butlast current-dir)))
+    (concat (mapconcat (lambda (s)
+			 (if (string= "" s) nil
+			   (substring s 0 1)))
+		       head
+		       "/")
+	    (if head "/" nil)
+	    (car cdir))))
+
+(defun vcprompt (&optional args)
+  "Call the external vcprompt command with optional arguments.
+   VCPrompt"
+  (replace-regexp-in-string
+   "\n" ""
+   (shell-command-to-string (concat  "vcprompt" args))))
+
+(defmacro with-face (str &rest properties)
+  `(propertize ,str 'face (list ,@properties)))
+
+(defun prompt-f ()
+  "EShell prompt displaying VC info and such"
+  (concat
+   (with-face (concat (clean-pwd (eshell/pwd)) " ") :foreground  "#96a6c8")
+   (if (= 0 (user-uid))
+       (with-face "#" :foreground "#f43841")
+     (with-face "$" :foreground "#73c936"))
+   (with-face " " :foreground "#95a99f")))
+
+
+(setq eshell-prompt-function 'prompt-f)
+(setq eshell-highlight-prompt nil)
+(setq eshell-prompt-regexp "^.+? \\((\\(git\\|svn\\|hg\\|darcs\\|cvs\\|bzr\\):.+?) \\)?[$#] ")
+
+;; Ignore version control folders in autocompletion
+(setq eshell-cmpl-cycle-completions nil
+      eshell-save-history-on-exit t
+      eshell-cmpl-dir-ignore "\\`\\(\\.\\.?\\|CVS\\|\\.svn\\|\\.git\\)/\\'")
+
+;; Load some EShell extensions
+(eval-after-load 'esh-opt
+  '(progn
+     (require 'em-term)
+     (require 'em-cmpl)
+     ;; More visual commands!
+     (add-to-list 'eshell-visual-commands "ssh")
+     (add-to-list 'eshell-visual-commands "tail")
+     (add-to-list 'eshell-visual-commands "sl")))
+
+(setq eshell-directory-name "~/.config/eshell/")
+
+(provide 'eshell-setup)
diff --git a/tools/emacs/config/functions.el b/tools/emacs/config/functions.el
new file mode 100644
index 0000000000..193e1a7412
--- /dev/null
+++ b/tools/emacs/config/functions.el
@@ -0,0 +1,250 @@
+(defun load-file-if-exists (filename)
+  (if (file-exists-p filename)
+      (load filename)))
+
+(defun goto-line-with-feedback ()
+  "Show line numbers temporarily, while prompting for the line number input"
+  (interactive)
+  (unwind-protect
+      (progn
+        (setq-local display-line-numbers t)
+        (let ((target (read-number "Goto line: ")))
+          (avy-push-mark)
+          (goto-line target)))
+    (setq-local display-line-numbers nil)))
+
+;; These come from the emacs starter kit
+
+(defun esk-add-watchwords ()
+  (font-lock-add-keywords
+   nil '(("\\<\\(FIX\\(ME\\)?\\|TODO\\|DEBUG\\|HACK\\|REFACTOR\\|NOCOMMIT\\)"
+          1 font-lock-warning-face t))))
+
+(defun esk-sudo-edit (&optional arg)
+  (interactive "p")
+  (if (or arg (not buffer-file-name))
+      (find-file (concat "/sudo:root@localhost:" (read-file-name "File: ")))
+    (find-alternate-file (concat "/sudo:root@localhost:" buffer-file-name))))
+
+;; Open Fefes blog
+(defun fefes-blog ()
+  (interactive)
+  (eww "https://blog.fefe.de/"))
+
+;; Open the NixOS man page
+(defun nixos-man ()
+  (interactive)
+  (man "configuration.nix"))
+
+;; Open my monorepo in magit
+(defun depot-status ()
+  (interactive)
+  (magit-status "~/depot"))
+
+;; Get the nix store path for a given derivation.
+;; If the derivation has not been built before, this will trigger a build.
+(defun nix-store-path (derivation)
+  (let ((expr (concat "with import <nixos> {}; " derivation)))
+    (s-chomp (shell-command-to-string (concat "nix-build -E '" expr "'")))))
+
+(defun insert-nix-store-path ()
+  (interactive)
+  (let ((derivation (read-string "Derivation name (in <nixos>): ")))
+    (insert (nix-store-path derivation))))
+
+(defun toggle-force-newline ()
+  "Buffer-local toggle for enforcing final newline on save."
+  (interactive)
+  (setq-local require-final-newline (not require-final-newline))
+  (message "require-final-newline in buffer %s is now %s"
+           (buffer-name)
+           require-final-newline))
+
+;; Helm includes a command to run external applications, which does
+;; not seem to exist in ivy. This implementation uses some of the
+;; logic from Helm to provide similar functionality using ivy.
+(defun list-external-commands ()
+  "Creates a list of all external commands available on $PATH
+  while filtering NixOS wrappers."
+  (cl-loop
+   for dir in (split-string (getenv "PATH") path-separator)
+   when (and (file-exists-p dir) (file-accessible-directory-p dir))
+   for lsdir = (cl-loop for i in (directory-files dir t)
+                        for bn = (file-name-nondirectory i)
+                        when (and (not (s-contains? "-wrapped" i))
+                                  (not (member bn completions))
+                                  (not (file-directory-p i))
+                                  (file-executable-p i))
+                        collect bn)
+   append lsdir into completions
+   finally return (sort completions 'string-lessp)))
+
+(defvar external-command-flag-overrides
+  '(("google-chrome" . "--force-device-scale-factor=1.4"))
+
+  "This setting lets me add additional flags to specific commands
+  that are run interactively via `ivy-run-external-command'.")
+
+(defun run-external-command (cmd)
+  "Execute the specified command and notify the user when it
+  finishes."
+    (let* ((extra-flags (cdr (assoc cmd external-command-flag-overrides)))
+           (cmd (if extra-flags (s-join " " (list cmd extra-flags)) cmd)))
+      (message "Starting %s..." cmd)
+      (set-process-sentinel
+       (start-process-shell-command cmd nil cmd)
+       (lambda (process event)
+         (when (string= event "finished\n")
+           (message "%s process finished." process))))))
+
+(defun ivy-run-external-command ()
+  "Prompts the user with a list of all installed applications and
+  lets them select one to launch."
+
+  (interactive)
+  (let ((external-commands-list (list-external-commands)))
+    (ivy-read "Command:" external-commands-list
+              :require-match t
+              :history 'external-commands-history
+              :action #'run-external-command)))
+
+(defun ivy-password-store (&optional password-store-dir)
+  "Custom version of password-store integration with ivy that
+  actually uses the GPG agent correctly."
+
+  (interactive)
+  (ivy-read "Copy password of entry: "
+            (password-store-list (or password-store-dir (password-store-dir)))
+            :require-match t
+            :keymap ivy-pass-map
+            :action (lambda (entry)
+                      (let ((password (auth-source-pass-get 'secret entry)))
+                        (password-store-clear)
+                        (kill-new password)
+                        (setq password-store-kill-ring-pointer kill-ring-yank-pointer)
+                        (message "Copied %s to the kill ring. Will clear in %s seconds."
+                                 entry (password-store-timeout))
+                        (setq password-store-timeout-timer
+                              (run-at-time (password-store-timeout)
+                                           nil 'password-store-clear))))))
+
+(defun ivy-browse-repositories ()
+  "Select a git repository and open its associated magit buffer."
+
+  (interactive)
+  (ivy-read "Repository: "
+            (magit-list-repos)
+            :require-match t
+            :sort t
+            :action #'magit-status))
+
+(defun warmup-gpg-agent (arg &optional exit)
+  "Function used to warm up the GPG agent before use. This is
+   useful in cases where there is no easy way to make pinentry run
+   in the correct context (such as when sending email)."
+  (interactive)
+  (message "Warming up GPG agent")
+  (epg-sign-string (epg-make-context) "dummy")
+  nil)
+
+(defun bottom-right-window-p ()
+  "Determines whether the last (i.e. bottom-right) window of the
+  active frame is showing the buffer in which this function is
+  executed."
+  (let* ((frame (selected-frame))
+         (right-windows (window-at-side-list frame 'right))
+         (bottom-windows (window-at-side-list frame 'bottom))
+         (last-window (car (seq-intersection right-windows bottom-windows))))
+    (eq (current-buffer) (window-buffer last-window))))
+
+(defhydra mc/mark-more-hydra (:color pink)
+  ("<up>" mmlte--up "Mark previous like this")
+  ("<down>" mc/mmlte--down "Mark next like this")
+  ("<left>" mc/mmlte--left (if (eq mc/mark-more-like-this-extended-direction 'up)
+                               "Skip past the cursor furthest up"
+                             "Remove the cursor furthest down"))
+  ("<right>" mc/mmlte--right (if (eq mc/mark-more-like-this-extended-direction 'up)
+                                 "Remove the cursor furthest up"
+                               "Skip past the cursor furthest down"))
+  ("f" nil "Finish selecting"))
+
+;; Mute the message that mc/mmlte wants to print on its own
+(advice-add 'mc/mmlte--message :around (lambda (&rest args) (ignore)))
+
+(defun mc/mark-dwim (arg)
+  "Select multiple things, but do what I mean."
+
+  (interactive "p")
+  (if (not (region-active-p)) (mc/mark-next-lines arg)
+    (if (< 1 (count-lines (region-beginning)
+                          (region-end)))
+        (mc/edit-lines arg)
+      ;; The following is almost identical to `mc/mark-more-like-this-extended',
+      ;; but uses a hydra (`mc/mark-more-hydra') instead of a transient key map.
+      (mc/mmlte--down)
+      (mc/mark-more-hydra/body))))
+
+(defun memespace-region ()
+  "Make a meme out of it."
+
+  (interactive)
+  (let* ((start (region-beginning))
+         (end (region-end))
+         (memed
+          (message
+           (s-trim-right
+            (apply #'string
+                   (-flatten
+                    (nreverse
+                     (-reduce-from (lambda (acc x)
+                                     (cons (cons x (-repeat (+ 1 (length acc)) 32)) acc))
+                                   '()
+                                   (string-to-list (buffer-substring-no-properties start end))))))))))
+
+    (save-excursion (delete-region start end)
+                    (goto-char start)
+                    (insert memed))))
+
+(defun insert-todo-comment (prefix todo)
+  "Insert a comment at point with something for me to do."
+
+  (interactive "P\nsWhat needs doing? ")
+  (save-excursion
+    (move-end-of-line nil)
+    (insert (format " %s TODO(%s): %s"
+                    comment-start
+                    (if prefix (read-string "Who needs to do this? ")
+                      (getenv "USER"))
+                    todo))))
+
+;; Custom text scale adjustment functions that operate on the entire instance
+(defun modify-text-scale (factor)
+  (set-face-attribute 'default nil
+                      :height (+ (* factor 5) (face-attribute 'default :height))))
+
+(defun increase-default-text-scale (prefix)
+  "Increase default text scale in all Emacs frames, or just the
+  current frame if PREFIX is set."
+
+  (interactive "P")
+  (if prefix (text-scale-increase 1)
+    (modify-text-scale 1)))
+
+(defun decrease-default-text-scale (prefix)
+  "Increase default text scale in all Emacs frames, or just the
+  current frame if PREFIX is set."
+
+  (interactive "P")
+  (if prefix (text-scale-decrease 1)
+    (modify-text-scale -1)))
+
+(defun set-default-text-scale (prefix &optional to)
+  "Set the default text scale to the specified value, or the
+  default. Restores current frame's text scale only, if PREFIX is
+  set."
+
+  (interactive "P")
+  (if prefix (text-scale-adjust 0)
+    (set-face-attribute 'default nil :height (or to 120))))
+
+(provide 'functions)
diff --git a/tools/emacs/config/init.el b/tools/emacs/config/init.el
new file mode 100644
index 0000000000..d2f8ebd9ac
--- /dev/null
+++ b/tools/emacs/config/init.el
@@ -0,0 +1,219 @@
+;;; init.el --- Package bootstrapping. -*- lexical-binding: t; -*-
+
+;; Packages are installed via Nix configuration, this file only
+;; initialises the newly loaded packages.
+
+(require 'use-package)
+(require 'seq)
+
+(package-initialize)
+
+;; Initialise all packages installed via Nix.
+;;
+;; TODO: Generate this section in Nix for all packages that do not
+;; require special configuration.
+
+;;
+;; Packages providing generic functionality.
+;;
+
+(use-package ace-window
+  :bind (("C-x o" . ace-window))
+  :config
+  (setq aw-keys '(?f ?j ?d ?k ?s ?l ?a)
+        aw-scope 'frame))
+
+(use-package auth-source-pass :config (auth-source-pass-enable))
+
+(use-package avy
+  :bind (("M-j" . avy-goto-char)
+         ("M-p" . avy-pop-mark)
+         ("M-g g" . avy-goto-line)))
+
+(use-package browse-kill-ring)
+
+(use-package company
+  :hook ((prog-mode . company-mode))
+  :config (setq company-tooltip-align-annotations t))
+
+(use-package counsel
+  :after (ivy)
+  :config (counsel-mode 1)
+  :bind (("C-c r g" . counsel-rg)))
+
+(use-package dash)
+(use-package dash-functional)
+(use-package dottime :config (dottime-display-mode t))
+(use-package gruber-darker-theme)
+(use-package ht)
+(use-package hydra)
+(use-package idle-highlight-mode :hook ((prog-mode . idle-highlight-mode)))
+
+(use-package ivy
+  :config
+  (ivy-mode 1)
+  (setq enable-recursive-minibuffers t)
+  (setq ivy-use-virtual-buffers t))
+
+(use-package ivy-pass :after (ivy))
+
+(use-package ivy-prescient
+  :after (ivy prescient)
+  :config
+  (ivy-prescient-mode)
+  ;; Fixes an issue with how regexes are passed to ripgrep from counsel,
+  ;; see raxod502/prescient.el#43
+  (setf (alist-get 'counsel-rg ivy-re-builders-alist) #'ivy--regex-plus))
+
+(use-package multiple-cursors)
+
+(use-package paredit :hook ((lisp-mode . paredit-mode)
+                            (emacs-lisp-mode . paredit-mode)))
+
+(use-package pinentry
+  :config
+  (setq epa-pinentry-mode 'loopback)
+  (pinentry-start))
+
+(use-package prescient
+  :after (ivy counsel)
+  :config (prescient-persist-mode))
+
+(use-package rainbow-delimiters :hook (prog-mode . rainbow-delimiters-mode))
+(use-package rainbow-mode)
+(use-package s)
+(use-package string-edit)
+
+(use-package swiper
+  :after (counsel ivy)
+  :bind (("C-s" . swiper)))
+
+(use-package telephone-line) ;; configuration happens outside of use-package
+(use-package term-switcher)
+(use-package undo-tree :config (global-undo-tree-mode))
+(use-package uuidgen)
+(use-package which-key :config (which-key-mode t))
+
+;;
+;; Applications in emacs
+;;
+
+(use-package magit
+  :bind ("C-c g" . magit-status)
+  :config (setq magit-repository-directories '(("/home/tazjin/projects" . 2)
+                                               ("/home/tazjin" . 1))))
+
+(use-package password-store)
+(use-package pg)
+(use-package restclient)
+
+(use-package vterm
+  :config (progn
+            (setq vterm-shell "/usr/bin/fish")
+            (setq vterm-exit-functions
+                  (lambda (&rest _) (kill-buffer (current-buffer))))
+            (setq vterm-set-title-functions
+                  (lambda (title)
+                    (rename-buffer
+                     (generate-new-buffer-name
+                      (format "vterm<%s>"
+                              (s-trim-left
+                               (s-chop-prefix "fish" title)))))))))
+
+;;
+;; Packages providing language-specific functionality
+;;
+
+(use-package cargo
+  :hook ((rust-mode . cargo-minor-mode)
+         (cargo-process-mode . visual-line-mode))
+  :bind (:map cargo-minor-mode-map ("C-c C-c C-l" . ignore)))
+
+(use-package dockerfile-mode)
+
+(use-package erlang
+  :hook ((erlang-mode . (lambda ()
+                          ;; Don't indent after '>' while I'm writing
+                          (local-set-key ">" 'self-insert-command)))))
+
+(use-package f)
+
+(use-package go-mode
+  :bind (:map go-mode-map ("C-c C-r" . recompile))
+  :hook ((go-mode . (lambda ()
+                      (setq tab-width 2)
+                      (setq-local compile-command
+                                  (concat "go build " buffer-file-name))))))
+
+(use-package haskell-mode)
+
+(use-package jq-mode
+  :config (add-to-list 'auto-mode-alist '("\\.jq\\'" . jq-mode)))
+
+(use-package kotlin-mode
+  :hook ((kotlin-mode . (lambda ()
+                          (setq indent-line-function #'indent-relative)))))
+
+(use-package lsp-mode)
+
+(use-package markdown-mode
+  :config
+  (add-to-list 'auto-mode-alist '("\\.txt\\'" . markdown-mode))
+  (add-to-list 'auto-mode-alist '("\\.markdown\\'" . markdown-mode))
+  (add-to-list 'auto-mode-alist '("\\.md\\'" . markdown-mode)))
+
+(use-package markdown-toc)
+
+(use-package nix-mode
+  :hook ((nix-mode . (lambda ()
+                       (setq indent-line-function #'nix-indent-line)))))
+
+(use-package nix-util)
+(use-package nginx-mode)
+(use-package rust-mode)
+
+(use-package telega
+  :bind (:map global-map ("s-t" . telega))
+  :config (telega-mode-line-mode 1))
+
+(use-package terraform-mode)
+(use-package toml-mode)
+(use-package web-mode)
+(use-package yaml-mode)
+
+;; Configuration changes in `customize` can not actually be persisted
+;; to the customise file that Emacs is currently using (since it comes
+;; from the Nix store).
+;;
+;; The way this will work for now is that Emacs will *write*
+;; configuration to the file tracked in my repository, while not
+;; actually *reading* it from there (unless Emacs is rebuilt).
+(setq custom-file (expand-file-name "~/depot/tools/emacs/config/custom.el"))
+(load-library "custom")
+
+(defvar home-dir (expand-file-name "~"))
+
+;; Seed RNG
+(random t)
+
+;; Load all other Emacs configuration. These configurations are
+;; added to `load-path' by Nix.
+(mapc 'require '(desktop
+                 mail-setup
+                 look-and-feel
+                 functions
+                 settings
+                 modes
+                 bindings
+                 eshell-setup))
+(telephone-line-setup)
+(ace-window-display-mode)
+
+;; If a local configuration library exists, it should be loaded.
+;;
+;; This can be provided by calling my Emacs derivation with
+;; `withLocalConfig'.
+(if-let (local-file (locate-library "local"))
+    (load local-file))
+
+(provide 'init)
diff --git a/tools/emacs/config/look-and-feel.el b/tools/emacs/config/look-and-feel.el
new file mode 100644
index 0000000000..98716dde64
--- /dev/null
+++ b/tools/emacs/config/look-and-feel.el
@@ -0,0 +1,114 @@
+;;; -*- lexical-binding: t; -*-
+
+;; Hide those ugly tool bars:
+(tool-bar-mode 0)
+(scroll-bar-mode 0)
+(menu-bar-mode 0)
+(add-hook 'after-make-frame-functions
+          (lambda (frame) (scroll-bar-mode 0)))
+
+;; Don't do any annoying things:
+(setq ring-bell-function 'ignore)
+(setq initial-scratch-message "")
+
+;; Remember layout changes
+(winner-mode 1)
+
+;; Usually emacs will run as a proper GUI application, in which case a few
+;; extra settings are nice-to-have:
+(when window-system
+  (setq frame-title-format '(buffer-file-name "%f" ("%b")))
+  (mouse-wheel-mode t)
+  (blink-cursor-mode -1))
+
+;; Configure editor fonts
+(let ((font (format "Input Mono-%d" 12)))
+  (setq default-frame-alist `((font-backend . "xft")
+                              (font . ,font)))
+  (set-frame-font font t t))
+
+;; Configure telephone-line
+(defun telephone-misc-if-last-window ()
+  "Renders the mode-line-misc-info string for display in the
+  mode-line if the currently active window is the last one in the
+  frame.
+
+  The idea is to not display information like the current time,
+  load, battery levels on all buffers."
+
+  (when (bottom-right-window-p)
+      (telephone-line-raw mode-line-misc-info t)))
+
+(defun telephone-line-setup ()
+  (telephone-line-defsegment telephone-line-last-window-segment ()
+    (telephone-misc-if-last-window))
+
+  ;; Display the current EXWM workspace index in the mode-line
+  (telephone-line-defsegment telephone-line-exwm-workspace-index ()
+    (when (bottom-right-window-p)
+      (format "[%s]" exwm-workspace-current-index)))
+
+  ;; Define a highlight font for ~ important ~ information in the last
+  ;; window.
+  (defface special-highlight '((t (:foreground "white" :background "#5f627f"))) "")
+  (add-to-list 'telephone-line-faces
+               '(highlight . (special-highlight . special-highlight)))
+
+  (setq telephone-line-lhs
+        '((nil . (telephone-line-position-segment))
+          (accent . (telephone-line-buffer-segment))))
+
+  (setq telephone-line-rhs
+        '((accent . (telephone-line-major-mode-segment))
+          (nil . (telephone-line-last-window-segment
+                  telephone-line-exwm-workspace-index))
+
+          ;; TODO(tazjin): lets not do this particular thing while I
+          ;; don't actually run notmuch, there are too many things
+          ;; that have a dependency on the modeline drawing correctly
+          ;; (including randr operations!)
+          ;;
+          ;; (highlight . (telephone-line-notmuch-counts))
+          ))
+
+  (setq telephone-line-primary-left-separator 'telephone-line-tan-left
+        telephone-line-primary-right-separator 'telephone-line-tan-right
+        telephone-line-secondary-left-separator 'telephone-line-tan-hollow-left
+        telephone-line-secondary-right-separator 'telephone-line-tan-hollow-right)
+
+  (telephone-line-mode 1))
+
+;; Auto refresh buffers
+(global-auto-revert-mode 1)
+
+;; Use clipboard properly
+(setq select-enable-clipboard t)
+
+;; Show in-progress chords in minibuffer
+(setq echo-keystrokes 0.1)
+
+;; Show column numbers in all buffers
+(column-number-mode t)
+
+(defalias 'yes-or-no-p 'y-or-n-p)
+(defalias 'auto-tail-revert-mode 'tail-mode)
+
+;; Style line numbers (shown with M-g g)
+(setq linum-format
+      (lambda (line)
+        (propertize
+         (format (concat " %"
+                         (number-to-string
+                          (length (number-to-string
+                                   (line-number-at-pos (point-max)))))
+                         "d ")
+                 line)
+         'face 'linum)))
+
+;; Display tabs as 2 spaces
+(setq tab-width 2)
+
+;; Don't wrap around when moving between buffers
+(setq windmove-wrap-around nil)
+
+(provide 'look-and-feel)
diff --git a/tools/emacs/config/mail-setup.el b/tools/emacs/config/mail-setup.el
new file mode 100644
index 0000000000..cc182d346c
--- /dev/null
+++ b/tools/emacs/config/mail-setup.el
@@ -0,0 +1,90 @@
+(require 'notmuch)
+(require 'counsel-notmuch)
+
+;; (global-set-key (kbd "C-c m") 'notmuch-hello)
+;; (global-set-key (kbd "C-c C-m") 'counsel-notmuch)
+;; (global-set-key (kbd "C-c C-e n") 'notmuch-mua-new-mail)
+
+(setq notmuch-cache-dir (format "%s/.cache/notmuch" (getenv "HOME")))
+(make-directory notmuch-cache-dir t)
+
+;; Cache addresses for completion:
+(setq notmuch-address-save-filename (concat notmuch-cache-dir "/addresses"))
+
+;; Don't spam my home folder with drafts:
+(setq notmuch-draft-folder "drafts") ;; relative to notmuch database
+
+;; Mark things as read when archiving them:
+(setq notmuch-archive-tags '("-inbox" "-unread" "+archive"))
+
+;; Show me saved searches that I care about:
+(setq notmuch-saved-searches
+      '((:name "inbox" :query "tag:inbox" :count-query "tag:inbox AND tag:unread" :key "i")
+        (:name "sent" :query "tag:sent" :key "t")
+        (:name "drafts" :query "tag:draft")))
+(setq notmuch-show-empty-saved-searches t)
+
+;; Mail sending configuration
+(setq send-mail-function 'sendmail-send-it) ;; sendmail provided by MSMTP
+(setq notmuch-always-prompt-for-sender t)
+(setq notmuch-mua-user-agent-function
+      (lambda () (format "Emacs %s; notmuch.el %s" emacs-version notmuch-emacs-version)))
+(setq mail-host-address (system-name))
+(setq notmuch-mua-cite-function #'message-cite-original-without-signature)
+
+;; Close mail buffers after sending mail
+(setq message-kill-buffer-on-exit t)
+
+;; Ensure sender is correctly passed to msmtp
+(setq mail-specify-envelope-from t
+      message-sendmail-envelope-from 'header
+      mail-envelope-from 'header)
+
+;; Store sent mail in the correct folder per account
+(setq notmuch-maildir-use-notmuch-insert nil)
+(setq notmuch-fcc-dirs '(("mail@tazj.in" . "tazjin/Sent")))
+
+;; I don't use drafts but I instinctively hit C-x C-s constantly, lets
+;; handle that gracefully.
+(define-key notmuch-message-mode-map (kbd "C-x C-s") #'ignore)
+
+;; MSMTP decrypts passwords using pass, but pinentry doesn't work
+;; correctly in that setup. This forces a warmup of the GPG agent
+;; before sending the message.
+;;
+;; Note that the sending function is advised because the provided hook
+;; for this seems to run at the wrong time.
+(advice-add 'notmuch-mua-send-common :before 'warmup-gpg-agent)
+
+;; Define a telephone-line segment for displaying the count of unread,
+;; important mails in the last window's mode-line:
+(defvar *last-notmuch-count-redraw* 0)
+(defvar *current-notmuch-count* nil)
+
+(defun update-display-notmuch-counts ()
+  "Update and render the current state of the notmuch unread
+  count for display in the mode-line.
+
+  The offlineimap-timer runs every 2 minutes, so it does not make
+  sense to refresh this much more often than that."
+
+  (when (> (- (float-time) *last-notmuch-count-redraw*) 30)
+    (setq *last-notmuch-count-redraw* (float-time))
+    (let* ((inbox-unread (notmuch-saved-search-count "tag:inbox and tag:unread"))
+           (notmuch-count (format "I: %s; D: %s" inbox-unread)))
+      (setq *current-notmuch-count* notmuch-count)))
+
+  (when (and (bottom-right-window-p)
+             ;; Only render if the initial update is done and there
+             ;; are unread mails:
+             *current-notmuch-count*
+             (not (equal *current-notmuch-count* "I: 0; D: 0")))
+    *current-notmuch-count*))
+
+(telephone-line-defsegment telephone-line-notmuch-counts ()
+  "This segment displays the count of unread notmuch messages in
+  the last window's mode-line (if unread messages are present)."
+
+  (update-display-notmuch-counts))
+
+(provide 'mail-setup)
diff --git a/tools/emacs/config/modes.el b/tools/emacs/config/modes.el
new file mode 100644
index 0000000000..8d47f2f9a5
--- /dev/null
+++ b/tools/emacs/config/modes.el
@@ -0,0 +1,36 @@
+;; Initializes modes I use.
+
+(add-hook 'prog-mode-hook 'esk-add-watchwords)
+(add-hook 'prog-mode-hook 'hl-line-mode)
+
+;; Use auto-complete as completion at point
+(defun set-auto-complete-as-completion-at-point-function ()
+  (setq completion-at-point-functions '(auto-complete)))
+
+(add-hook 'auto-complete-mode-hook
+          'set-auto-complete-as-completion-at-point-function)
+
+;; Enable rainbow-delimiters for all things programming
+(add-hook 'prog-mode-hook 'rainbow-delimiters-mode)
+
+;; Enable Paredit & Company in Emacs Lisp mode
+(add-hook 'emacs-lisp-mode-hook 'company-mode)
+
+;; Always highlight matching brackets
+(show-paren-mode 1)
+
+;; Always auto-close parantheses and other pairs
+(electric-pair-mode)
+
+;; Keep track of recent files
+(recentf-mode)
+
+;; Easily navigate sillycased words
+(global-subword-mode 1)
+
+;; Transparently open compressed files
+(auto-compression-mode t)
+
+;; Show available key chord completions
+
+(provide 'modes)
diff --git a/tools/emacs/config/settings.el b/tools/emacs/config/settings.el
new file mode 100644
index 0000000000..274dcdde35
--- /dev/null
+++ b/tools/emacs/config/settings.el
@@ -0,0 +1,50 @@
+(require 'uniquify)
+
+;; Move files to trash when deleting
+(setq delete-by-moving-to-trash t)
+
+;; We don't live in the 80s, but we're also not a shitty web app.
+(setq gc-cons-threshold 20000000)
+
+(setq uniquify-buffer-name-style 'forward)
+
+; Fix some defaults
+(setq visible-bell nil
+      inhibit-startup-message t
+      color-theme-is-global t
+      sentence-end-double-space nil
+      shift-select-mode nil
+      uniquify-buffer-name-style 'forward
+      whitespace-style '(face trailing lines-tail tabs)
+      whitespace-line-column 80
+      default-directory "~"
+      fill-column 80
+      ediff-split-window-function 'split-window-horizontally)
+
+(add-to-list 'safe-local-variable-values '(lexical-binding . t))
+(add-to-list 'safe-local-variable-values '(whitespace-line-column . 80))
+
+(set-default 'indent-tabs-mode nil)
+
+;; UTF-8 please
+(setq locale-coding-system 'utf-8) ; pretty
+(set-terminal-coding-system 'utf-8) ; pretty
+(set-keyboard-coding-system 'utf-8) ; pretty
+(set-selection-coding-system 'utf-8) ; please
+(prefer-coding-system 'utf-8) ; with sugar on top
+
+;; Make emacs behave sanely (overwrite selected text)
+(delete-selection-mode 1)
+
+;; Keep your temporary files in tmp, emacs!
+(setq auto-save-file-name-transforms
+      `((".*" ,temporary-file-directory t)))
+(setq backup-directory-alist
+      `((".*" . ,temporary-file-directory)))
+
+(remove-hook 'kill-buffer-query-functions 'server-kill-buffer-query-function)
+
+;; Show time in 24h format
+(setq display-time-24hr-format t)
+
+(provide 'settings)
diff --git a/tools/emacs/default.nix b/tools/emacs/default.nix
new file mode 100644
index 0000000000..d36673e363
--- /dev/null
+++ b/tools/emacs/default.nix
@@ -0,0 +1,128 @@
+# This file builds an Emacs pre-configured with the packages I need
+# and my personal Emacs configuration.
+
+{ pkgs, ... }:
+
+with pkgs;
+with third_party.emacsPackagesNg;
+with third_party.emacs;
+
+let
+  localPackages = pkgs.tools.emacs-pkgs;
+  emacsWithPackages = (third_party.emacsPackagesNgGen third_party.emacs26).emacsWithPackages;
+
+  # $PATH for binaries that need to be available to Emacs
+  emacsBinPath = lib.makeBinPath [ third_party.telega ];
+
+  identity = x: x;
+  tazjinsEmacs = pkgfun: (emacsWithPackages(epkgs: pkgfun(
+  # Actual ELPA packages (the enlightened!)
+  (with epkgs.elpaPackages; [
+    ace-window
+    avy
+    pinentry
+    rainbow-mode
+    undo-tree
+  ]) ++
+
+  # MELPA packages:
+  (with epkgs.melpaPackages; [
+    browse-kill-ring
+    cargo
+    clojure-mode
+    counsel
+    counsel-notmuch
+    dash-functional
+    direnv
+    dockerfile-mode
+    elixir-mode
+    elm-mode
+    erlang
+    exwm
+    go-mode
+    gruber-darker-theme
+    haskell-mode
+    ht
+    hydra
+    idle-highlight-mode
+    intero
+    ivy
+    ivy-pass
+    ivy-prescient
+    jq-mode
+    kotlin-mode
+    lsp-mode
+    magit
+    markdown-toc
+    multi-term
+    multiple-cursors
+    nginx-mode
+    nix-mode
+    notmuch # this comes from pkgs.third_party
+    paredit
+    password-store
+    pg
+    prescient
+    racket-mode
+    rainbow-delimiters
+    refine
+    restclient
+    request
+    sly
+    string-edit
+    swiper
+    telega
+    telephone-line
+    terraform-mode
+    toml-mode
+    transient
+    use-package
+    uuidgen
+    vterm
+    web-mode
+    websocket
+    which-key
+    xelb
+    yaml-mode
+  ]) ++
+
+  # Custom packages
+  (with localPackages; [
+    carp-mode
+    dottime
+    nix-util
+    term-switcher
+  ]))));
+in lib.fix(self: l: f: third_party.writeShellScriptBin "tazjins-emacs" ''
+  export PATH="${emacsBinPath}:$PATH"
+  exec ${tazjinsEmacs f}/bin/emacs \
+    --debug-init \
+    --no-site-file \
+    --no-site-lisp \
+    --no-init-file \
+    --directory ${./config} ${if l != null then "--directory ${l}" else ""} \
+    --eval "(require 'init)" $@
+  '' // {
+    # Call overrideEmacs with a function (pkgs -> pkgs) to modify the
+    # packages that should be included in this Emacs distribution.
+    overrideEmacs = f': self l f';
+
+    # Call withLocalConfig with the path to a *folder* containing a
+    # `local.el` which provides local system configuration.
+    withLocalConfig = confDir: self confDir f;
+
+    # Build a derivation that uses the specified local Emacs (i.e.
+    # built outside of Nix) instead
+    withLocalEmacs = emacsBin: third_party.writeShellScriptBin "tazjins-emacs" ''
+      export PATH="${emacsBinPath}:$PATH"
+      export EMACSLOADPATH="${(tazjinsEmacs f).deps}/share/emacs/site-lisp:"
+      exec ${emacsBin} \
+        --debug-init \
+        --no-site-file \
+        --no-site-lisp \
+        --no-init-file \
+        --directory ${./config} \
+        ${if l != null then "--directory ${l}" else ""} \
+        --eval "(require 'init)" $@
+    '';
+  }) null identity