▍ humdrum codex / glint v1.0.2
license AGPL-3.0

feat: glint config — interactive setup walkthrough

90e7280d09ee129804f59364209b54d23968deed
humdrum <me@humdrum.me> · 2026-06-28 21:17

parent e6085a1f

feat: glint config — interactive setup walkthrough

Add a huh-based 'glint config' subcommand that loads the current config, walks
through every setting (theme, vault_dir, inbox_dir, daily subfolder, daily
filename format via friendly presets, preview style), and writes
~/.config/glint/config.toml — no hand-editing TOML and no raw Go time layouts.
Adds config.Path()/config.Save() (round-trip tested) and the huh dependency.

7 files changed

README.md +4 −1
@@ -30,6 +30,7 @@ glint vault           # fuzzy picker over your configured vault, from anywhere
 glint --daily         # open (and create) today's daily note
 glint new             # start a blank scratch document
 glint new ideas       # create and open <inbox>/ideas.md
+glint config          # interactive setup walkthrough (writes the config file)
 glint --version       # print version
 ```
 
@@ -51,7 +52,9 @@ | `Esc` | back to the editor |
 
 ## Configuration
 
-glint reads `~/.config/glint/config.toml` (all keys optional):
+Run `glint config` for an interactive walkthrough that writes the file for you
+(no hand-editing, daily-format presets instead of raw Go layouts). The settings
+live in `~/.config/glint/config.toml` (all keys optional):
 
 ```toml
 vault_dir     = ""           # the vault `glint vault` opens from anywhere (e.g. "~/Notes"); unset = current dir
go.mod +5 −0
@@ -7,6 +7,7 @@ 	github.com/BurntSushi/toml v1.6.0
 	github.com/charmbracelet/bubbles v1.0.0
 	github.com/charmbracelet/bubbletea v1.3.10
 	github.com/charmbracelet/glamour v1.0.0
+	github.com/charmbracelet/huh v1.0.0
 	github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
 )
 
@@ -15,15 +16,18 @@ 	github.com/alecthomas/chroma/v2 v2.20.0 // indirect
 	github.com/atotto/clipboard v0.1.4 // indirect
 	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
 	github.com/aymerick/douceur v0.2.0 // indirect
+	github.com/catppuccin/go v0.3.0 // indirect
 	github.com/charmbracelet/colorprofile v0.4.1 // indirect
 	github.com/charmbracelet/x/ansi v0.11.6 // indirect
 	github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
 	github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
+	github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
 	github.com/charmbracelet/x/term v0.2.2 // indirect
 	github.com/clipperhouse/displaywidth v0.9.0 // indirect
 	github.com/clipperhouse/stringish v0.1.1 // indirect
 	github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
 	github.com/dlclark/regexp2 v1.11.5 // indirect
+	github.com/dustin/go-humanize v1.0.1 // indirect
 	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
 	github.com/gorilla/css v1.0.1 // indirect
 	github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
@@ -31,6 +35,7 @@ 	github.com/mattn/go-isatty v0.0.20 // indirect
 	github.com/mattn/go-localereader v0.0.1 // indirect
 	github.com/mattn/go-runewidth v0.0.19 // indirect
 	github.com/microcosm-cc/bluemonday v1.0.27 // indirect
+	github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
 	github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
 	github.com/muesli/cancelreader v0.2.2 // indirect
 	github.com/muesli/reflow v0.3.0 // indirect
go.sum +22 −0
@@ -1,5 +1,7 @@
 github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
 github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
+github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
 github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
 github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
 github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
@@ -14,6 +16,8 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
 github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
 github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
+github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
+github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
 github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
 github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
 github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
@@ -22,26 +26,42 @@ github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
 github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
 github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
 github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
+github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
+github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
 github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
 github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
 github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
 github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
+github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
+github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
+github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
+github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
 github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
 github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
 github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
 github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
+github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
+github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
 github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
 github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
+github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
+github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
+github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
+github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
 github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
 github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
 github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
 github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
 github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
 github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
+github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
+github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
 github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
 github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
 github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
@@ -59,6 +79,8 @@ github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
 github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
 github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
 github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
+github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
+github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
 github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
internal/config/config.go +25 −4
@@ -53,13 +53,34 @@ 	}
 	return c.WorkingDir()
 }
 
-// Load reads ~/.config/glint/config.toml and overlays it onto the defaults.
+// Path is the config file location: ~/.config/glint/config.toml.
+func Path() string {
+	home, err := os.UserHomeDir()
+	if err != nil {
+		return filepath.Join(".config", "glint", "config.toml")
+	}
+	return filepath.Join(home, ".config", "glint", "config.toml")
+}
+
+// Load reads the config file and overlays it onto the defaults.
 func Load() (Config, error) {
-	home, err := os.UserHomeDir()
+	return loadFromFile(Path())
+}
+
+// Save writes cfg to path as TOML, creating the parent directory.
+func Save(cfg Config, path string) error {
+	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+		return err
+	}
+	f, err := os.Create(path)
 	if err != nil {
-		return Default(), nil
+		return err
 	}
-	return loadFromFile(filepath.Join(home, ".config", "glint", "config.toml"))
+	if err := toml.NewEncoder(f).Encode(cfg); err != nil {
+		_ = f.Close()
+		return err
+	}
+	return f.Close()
 }
 
 // loadFromFile is the testable core of Load. A missing file yields the
internal/config/config_test.go +27 −0
@@ -159,3 +159,30 @@ 	if got := (Config{}).Vault(); got != "/work" {
 		t.Errorf("without vault_dir: Vault = %q, want working dir /work", got)
 	}
 }
+
+func TestPathEndsAtGlintConfig(t *testing.T) {
+	p := Path()
+	if filepath.Base(p) != "config.toml" || filepath.Base(filepath.Dir(p)) != "glint" {
+		t.Errorf("Path() = %q, want .../glint/config.toml", p)
+	}
+}
+
+func TestSaveRoundTrips(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "sub", "config.toml") // parent created by Save
+	cfg := Default()
+	cfg.VaultDir = "/my/vault"
+	cfg.InboxDir = "Inbox"
+	cfg.DailyFormat = "20060102"
+	cfg.Theme = "charm"
+	if err := Save(cfg, path); err != nil {
+		t.Fatal(err)
+	}
+	got, err := loadFromFile(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if got.VaultDir != "/my/vault" || got.InboxDir != "Inbox" || got.DailyFormat != "20060102" || got.Theme != "charm" {
+		t.Errorf("round-trip mismatch: %+v", got)
+	}
+}
internal/configui/configui.go +128 −0
@@ -0,0 +1,128 @@
+// Package configui is the interactive `glint config` walkthrough: a huh form
+// that loads the current config, walks the user through every setting, and
+// writes the result to the config file.
+package configui
+
+import (
+	"fmt"
+
+	"glint/internal/config"
+
+	"github.com/charmbracelet/huh"
+)
+
+// dailyPresets are friendly daily-note filename formats (label → Go layout), so
+// users never have to write a raw Go time layout by hand.
+var dailyPresets = []struct{ label, layout string }{
+	{"2026-06-28", "2006-01-02"},
+	{"2026-06-28 Sun", "2006-01-02 Mon"},
+	{"20260628", "20060102"},
+	{"2026.06.28", "2006.01.02"},
+	{"28-06-2026", "02-01-2006"},
+}
+
+const customLayout = "__custom__"
+
+// Run walks the user through the settings and saves them to the config file.
+func Run() error {
+	cfg, _ := config.Load() // defaults on error
+
+	theme := orDefault(cfg.Theme, "auto")
+	vaultDir := cfg.VaultDir
+	inboxDir := cfg.InboxDir
+	dailySubdir := orDefault(cfg.DailySubdir, "Daily")
+	glamour := cfg.GlamourStyle
+
+	// Match the current layout to a preset; otherwise offer it as custom.
+	fmtChoice := customLayout
+	for _, p := range dailyPresets {
+		if p.layout == cfg.DailyFormat {
+			fmtChoice = p.layout
+		}
+	}
+	customFmt := cfg.DailyFormat
+
+	fmtOpts := make([]huh.Option[string], 0, len(dailyPresets)+1)
+	for _, p := range dailyPresets {
+		fmtOpts = append(fmtOpts, huh.NewOption(p.label+"  ("+p.layout+")", p.layout))
+	}
+	fmtOpts = append(fmtOpts, huh.NewOption("Custom…", customLayout))
+
+	form := huh.NewForm(
+		huh.NewGroup(
+			huh.NewSelect[string]().
+				Title("Theme").
+				Description("Colors; auto follows your macOS appearance.").
+				Options(
+					huh.NewOption("auto", "auto"),
+					huh.NewOption("flexoki-light", "flexoki-light"),
+					huh.NewOption("flexoki-dark", "flexoki-dark"),
+					huh.NewOption("charm", "charm"),
+				).Value(&theme),
+			huh.NewInput().
+				Title("Vault directory").
+				Description("Where `glint vault` opens, from anywhere. Blank = none.").
+				Placeholder("/Users/you/Notes").
+				Value(&vaultDir),
+			huh.NewInput().
+				Title("Inbox directory").
+				Description("Where `glint new` and save-as land. Blank = the directory you run glint in.").
+				Value(&inboxDir),
+			huh.NewInput().
+				Title("Daily notes subfolder").
+				Description("Daily notes live in <dir>/<this>/.").
+				Value(&dailySubdir),
+			huh.NewSelect[string]().
+				Title("Daily filename format").
+				Options(fmtOpts...).
+				Value(&fmtChoice),
+			huh.NewSelect[string]().
+				Title("Preview style").
+				Description("Glamour style for the read preview. Blank follows the theme.").
+				Options(
+					huh.NewOption("follow theme", ""),
+					huh.NewOption("dark", "dark"),
+					huh.NewOption("light", "light"),
+					huh.NewOption("dracula", "dracula"),
+					huh.NewOption("tokyo-night", "tokyo-night"),
+				).Value(&glamour),
+		),
+		huh.NewGroup(
+			huh.NewInput().
+				Title("Custom daily format (Go layout)").
+				Description("Reference date is Mon Jan 2 2006 — e.g. 2006-01-02 or 20060102.").
+				Value(&customFmt),
+		).WithHideFunc(func() bool { return fmtChoice != customLayout }),
+	)
+
+	if err := form.Run(); err != nil {
+		return err
+	}
+
+	dailyFmt := fmtChoice
+	if fmtChoice == customLayout {
+		dailyFmt = customFmt
+	}
+
+	out := config.Config{
+		Theme:        theme,
+		VaultDir:     vaultDir,
+		InboxDir:     inboxDir,
+		DailySubdir:  dailySubdir,
+		DailyFormat:  dailyFmt,
+		GlamourStyle: glamour,
+	}
+	path := config.Path()
+	if err := config.Save(out, path); err != nil {
+		return err
+	}
+	fmt.Println("Saved", path)
+	return nil
+}
+
+func orDefault(v, def string) string {
+	if v == "" {
+		return def
+	}
+	return v
+}
main.go +7 −0
@@ -8,6 +8,7 @@ 	"os"
 
 	"glint/internal/app"
 	"glint/internal/config"
+	"glint/internal/configui"
 
 	tea "github.com/charmbracelet/bubbletea"
 )
@@ -48,6 +49,12 @@ 				fmt.Fprintln(os.Stderr, "glint:", err)
 				os.Exit(1)
 			}
 			run(a)
+			return
+		case "config": // `glint config` — interactive setup walkthrough
+			if err := configui.Run(); err != nil {
+				fmt.Fprintln(os.Stderr, "glint:", err)
+				os.Exit(1)
+			}
 			return
 		}
 	}