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

feat: glint vault + working-dir/vault split

b029710a57d5c4429e726e6182e11831ea365914
humdrum <me@humdrum.me> · 2026-06-28 17:20

parent 035eb328

feat: glint vault + working-dir/vault split

Decouple the working directory from the vault so glint serves both 'work in the
repo I'm in' and 'open my vault from anywhere':
- bare glint / glint new / --daily operate on the working dir ($GLINT_VAULT, else
  cwd); inbox and daily resolve there.
- vault_dir (config) is now the vault that the new `glint vault` subcommand opens
  from anywhere; it no longer roots bare glint.
- picker Ctrl+N (new note from query) uses the inbox root, matching save-as and
  `glint new`.

Adds Config.WorkingDir()/Vault(), App.openVault()/StartVault(), the vault
subcommand, tests, and README docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

7 files changed

README.md +10 −6
@@ -24,8 +24,9 @@
 ## Usage
 
 ```bash
-glint                 # open the fuzzy picker over your vault
+glint                 # fuzzy picker over the current directory
 glint notes.md        # open a file
+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
@@ -51,16 +52,19 @@
 glint reads `~/.config/glint/config.toml` (all keys optional):
 
 ```toml
-vault_dir     = ""           # notes root; default: $GLINT_VAULT, else the current directory
-inbox_dir     = ""           # where `glint new` / save-as land; "" = vault root, relative = under vault
-daily_subdir  = "Daily"      # daily notes live in <vault>/<daily_subdir>/
+vault_dir     = ""           # the vault `glint vault` opens from anywhere (e.g. "~/Notes"); unset = current dir
+inbox_dir     = ""           # where `glint new` / save-as land; "" = working dir, relative = under it
+daily_subdir  = "Daily"      # daily notes live in <working dir>/<daily_subdir>/
 daily_format  = "2006-01-02" # Go time layout for daily-note filenames
 theme         = "auto"       # auto | flexoki-light | flexoki-dark | charm  (auto detects macOS appearance)
 glamour_style = ""           # override the preview style; "" follows the theme
 ```
 
-The vault resolves as `vault_dir` (config) → `$GLINT_VAULT` → the current working
-directory, so `glint` works in whatever folder you launch it from.
+**Two roots.** Bare `glint`, `glint new`, and `--daily` operate on the **working
+directory** — the folder you launched from (or `$GLINT_VAULT` if set), so glint
+works in whatever repo you're in. **`glint vault`** opens the picker on your
+**configured `vault_dir`** from anywhere — set it to your notes vault to reach it
+without `cd`-ing there.
 
 ## Themes
 
Testing.md +3 −0
@@ -0,0 +1,3 @@
+# Testing to see where this goes
+
+- Does it work as expected?
\ No newline at end of file
internal/app/app.go +20 −3
@@ -381,9 +381,19 @@ 		a.picker.SetSize(w, h-1) // picker keeps its full-width split
 	}
 }
 
-// openPicker builds a fresh picker over the vault and switches to it.
+// openPicker builds a fresh picker over the working directory and switches to it.
 func (a *App) openPicker() (tea.Model, tea.Cmd) {
-	p, err := picker.New(a.cfg.VaultDir, a.theme, a.cfg.DailyPath(time.Now()), a.glamourStyle())
+	return a.openPickerAt(a.cfg.WorkingDir())
+}
+
+// openVault opens the picker over the configured vault, from anywhere.
+func (a *App) openVault() (tea.Model, tea.Cmd) {
+	return a.openPickerAt(a.cfg.Vault())
+}
+
+// openPickerAt opens the picker rooted at dir.
+func (a *App) openPickerAt(dir string) (tea.Model, tea.Cmd) {
+	p, err := picker.New(dir, a.theme, a.cfg.DailyPath(time.Now()), a.glamourStyle())
 	if err != nil {
 		a.status = "Picker failed: " + err.Error()
 		return a, nil
@@ -392,6 +402,13 @@ 	p.SetSize(a.width, a.height-1)
 	a.picker = p
 	a.mode = ModePicker
 	return a, nil
+}
+
+// StartVault is the entry point for `glint vault`: open the picker over the
+// configured vault directory.
+func (a *App) StartVault() error {
+	_, _ = a.openVault()
+	return nil
 }
 
 // openDaily opens today's daily note, creating the file and directory if needed.
@@ -415,7 +432,7 @@ }
 
 // newNote creates a note named after the picker query and opens it.
 func (a *App) newNote() (tea.Model, tea.Cmd) {
-	p := picker.NewNotePath(a.cfg.VaultDir, a.picker.Query())
+	p := picker.NewNotePath(a.cfg.InboxRoot(), a.picker.Query())
 	if p == "" {
 		a.status = "Type a name first"
 		return a, nil
internal/app/app_test.go +30 −11
@@ -165,7 +165,7 @@ func TestCtrlFOpensPicker(t *testing.T) {
 	dir := t.TempDir()
 	os.WriteFile(filepath.Join(dir, "note.md"), []byte("x"), 0o644)
 	cfg := config.Default()
-	cfg.VaultDir = dir
+	t.Setenv("GLINT_VAULT", dir)
 	a := New(cfg)
 	a.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
 	a.Update(tea.KeyMsg{Type: tea.KeyCtrlF})
@@ -177,7 +177,7 @@
 func TestCtrlDCreatesAndOpensDaily(t *testing.T) {
 	dir := t.TempDir()
 	cfg := config.Default()
-	cfg.VaultDir = dir
+	t.Setenv("GLINT_VAULT", dir)
 	cfg.DailySubdir = "Daily"
 	cfg.DailyFormat = "2006-01-02"
 	a := New(cfg)
@@ -198,7 +198,7 @@ func TestStartInPickerWhenNoPath(t *testing.T) {
 	dir := t.TempDir()
 	os.WriteFile(filepath.Join(dir, "n.md"), []byte("x"), 0o644)
 	cfg := config.Default()
-	cfg.VaultDir = dir
+	t.Setenv("GLINT_VAULT", dir)
 	a := New(cfg)
 	if err := a.Start("", false); err != nil {
 		t.Fatal(err)
@@ -224,7 +224,7 @@
 func TestStartDailyCreatesFile(t *testing.T) {
 	dir := t.TempDir()
 	cfg := config.Default()
-	cfg.VaultDir = dir
+	t.Setenv("GLINT_VAULT", dir)
 	a := New(cfg)
 	if err := a.Start("", true); err != nil {
 		t.Fatal(err)
@@ -241,7 +241,7 @@ func TestCtrlFDirtyNeedsConfirm(t *testing.T) {
 	dir := t.TempDir()
 	os.WriteFile(filepath.Join(dir, "note.md"), []byte("x"), 0o644)
 	cfg := config.Default()
-	cfg.VaultDir = dir
+	t.Setenv("GLINT_VAULT", dir)
 	a := New(cfg)
 	a.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
 	// Make dirty
@@ -265,7 +265,7 @@ func TestCtrlFDirtyDisarmedByOtherKey(t *testing.T) {
 	dir := t.TempDir()
 	os.WriteFile(filepath.Join(dir, "note.md"), []byte("x"), 0o644)
 	cfg := config.Default()
-	cfg.VaultDir = dir
+	t.Setenv("GLINT_VAULT", dir)
 	a := New(cfg)
 	a.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
 	// Make dirty
@@ -284,7 +284,7 @@
 func TestCtrlDDirtyNeedsConfirm(t *testing.T) {
 	dir := t.TempDir()
 	cfg := config.Default()
-	cfg.VaultDir = dir
+	t.Setenv("GLINT_VAULT", dir)
 	cfg.DailySubdir = "Daily"
 	cfg.DailyFormat = "2006-01-02"
 	a := New(cfg)
@@ -333,7 +333,7 @@
 func TestCtrlNCreatesNoteFromQuery(t *testing.T) {
 	dir := t.TempDir()
 	cfg := config.Default()
-	cfg.VaultDir = dir
+	t.Setenv("GLINT_VAULT", dir)
 	a := New(cfg)
 	a.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
 	a.Update(tea.KeyMsg{Type: tea.KeyCtrlF}) // open picker (Ctrl+F)
@@ -417,7 +417,7 @@
 func TestStartNewWithNameLandsInInbox(t *testing.T) {
 	dir := t.TempDir()
 	cfg := config.Default()
-	cfg.VaultDir = dir
+	t.Setenv("GLINT_VAULT", dir)
 	cfg.InboxDir = "Inbox"
 	a := New(cfg)
 	if err := a.StartNew("foo"); err != nil {
@@ -435,7 +435,7 @@
 func TestSaveAsPromptOnPathlessBuffer(t *testing.T) {
 	dir := t.TempDir()
 	cfg := config.Default()
-	cfg.VaultDir = dir
+	t.Setenv("GLINT_VAULT", dir)
 	cfg.InboxDir = "" // vault root
 	a := New(cfg)
 	a.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
@@ -473,7 +473,7 @@ 	dir := t.TempDir()
 	src := filepath.Join(dir, "a.md")
 	os.WriteFile(src, []byte("body"), 0o644)
 	cfg := config.Default()
-	cfg.VaultDir = dir
+	t.Setenv("GLINT_VAULT", dir)
 	a := New(cfg)
 	a.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
 	a.Load(src)
@@ -511,3 +511,22 @@ 	if got := a.preview.Style(); got != "dark" {
 		t.Errorf("preview style after cycle = %q, want dark (re-rendered)", got)
 	}
 }
+
+func TestStartVaultOpensVaultPicker(t *testing.T) {
+	vault := t.TempDir()
+	os.WriteFile(filepath.Join(vault, "vaultnote.md"), []byte("x"), 0o644)
+	t.Setenv("GLINT_VAULT", t.TempDir()) // working dir is somewhere else
+	cfg := config.Default()
+	cfg.VaultDir = vault
+	a := New(cfg)
+	a.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
+	if err := a.StartVault(); err != nil {
+		t.Fatal(err)
+	}
+	if a.mode != ModePicker {
+		t.Fatalf("mode = %d, want ModePicker", a.mode)
+	}
+	if sel := a.picker.Selected(); !strings.HasPrefix(sel, vault) {
+		t.Errorf("vault picker selected %q, want a file under the vault %q", sel, vault)
+	}
+}
internal/config/config.go +31 −17
@@ -13,7 +13,7 @@ )
 
 // Config holds the user-tunable settings glint reads at startup.
 type Config struct {
-	VaultDir     string `toml:"vault_dir"`
+	VaultDir     string `toml:"vault_dir"` // the vault `glint vault` opens from anywhere
 	DailySubdir  string `toml:"daily_subdir"`
 	DailyFormat  string `toml:"daily_format"`
 	GlamourStyle string `toml:"glamour_style"`
@@ -21,24 +21,38 @@ 	Theme        string `toml:"theme"`
 	InboxDir     string `toml:"inbox_dir"`
 }
 
-// Default returns the built-in configuration used when no file is present. The
-// vault defaults to $GLINT_VAULT, or the current working directory when that is
-// unset — a config-file vault_dir (applied in loadFromFile) overrides both.
+// Default returns the built-in configuration used when no file is present.
+// VaultDir is empty by default: bare glint works in the current directory; set
+// vault_dir (config) to give `glint vault` a fixed vault to open from anywhere.
 func Default() Config {
-	vault := os.Getenv("GLINT_VAULT")
-	if vault == "" {
-		if wd, err := os.Getwd(); err == nil {
-			vault = wd
-		}
-	}
 	return Config{
-		VaultDir:    vault,
 		DailySubdir: "Daily",
 		DailyFormat: "2006-01-02",
 		Theme:       "auto",
 	}
 }
 
+// WorkingDir is where bare `glint`, `glint new`, and `--daily` operate: the
+// $GLINT_VAULT pin if set, otherwise the current working directory.
+func (c Config) WorkingDir() string {
+	if v := os.Getenv("GLINT_VAULT"); v != "" {
+		return v
+	}
+	if wd, err := os.Getwd(); err == nil {
+		return wd
+	}
+	return "."
+}
+
+// Vault is the directory `glint vault` opens from anywhere: the configured
+// vault_dir, or the working directory when none is set.
+func (c Config) Vault() string {
+	if c.VaultDir != "" {
+		return c.VaultDir
+	}
+	return c.WorkingDir()
+}
+
 // Load reads ~/.config/glint/config.toml and overlays it onto the defaults.
 func Load() (Config, error) {
 	home, err := os.UserHomeDir()
@@ -84,20 +98,20 @@ 	}
 	return cfg, nil
 }
 
-// InboxRoot is the directory new notes default into: the vault root when
+// InboxRoot is the directory new notes default into: the working directory when
 // InboxDir is empty, an absolute InboxDir as-is, or InboxDir resolved under the
-// vault when relative.
+// working directory when relative.
 func (c Config) InboxRoot() string {
 	if c.InboxDir == "" {
-		return c.VaultDir
+		return c.WorkingDir()
 	}
 	if filepath.IsAbs(c.InboxDir) {
 		return c.InboxDir
 	}
-	return filepath.Join(c.VaultDir, c.InboxDir)
+	return filepath.Join(c.WorkingDir(), c.InboxDir)
 }
 
-// DailyPath builds the absolute path to the daily note for time t.
+// DailyPath builds the path to the daily note for time t, under the working dir.
 func (c Config) DailyPath(t time.Time) string {
-	return filepath.Join(c.VaultDir, c.DailySubdir, t.Format(c.DailyFormat)+".md")
+	return filepath.Join(c.WorkingDir(), c.DailySubdir, t.Format(c.DailyFormat)+".md")
 }
internal/config/config_test.go +26 −11
@@ -21,8 +21,11 @@ 	}
 	if d.Theme != "auto" {
 		t.Errorf("Theme = %q, want auto", d.Theme)
 	}
-	if d.VaultDir == "" {
-		t.Error("VaultDir should not be empty")
+	if d.VaultDir != "" {
+		t.Errorf("VaultDir default = %q, want empty (set vault_dir for `glint vault`)", d.VaultDir)
+	}
+	if d.WorkingDir() == "" {
+		t.Error("WorkingDir should not be empty")
 	}
 }
 
@@ -92,7 +95,8 @@ 	}
 }
 
 func TestDailyPath(t *testing.T) {
-	c := Config{VaultDir: "/v", DailySubdir: "Daily", DailyFormat: "2006-01-02"}
+	t.Setenv("GLINT_VAULT", "/v") // pins the working dir
+	c := Config{DailySubdir: "Daily", DailyFormat: "2006-01-02"}
 	got := c.DailyPath(time.Date(2026, 6, 27, 0, 0, 0, 0, time.UTC))
 	want := filepath.Join("/v", "Daily", "2026-06-27.md")
 	if got != want {
@@ -119,9 +123,10 @@ 	}
 }
 
 func TestInboxRootResolves(t *testing.T) {
-	c := Config{VaultDir: "/v"}
+	t.Setenv("GLINT_VAULT", "/v") // pins the working dir
+	c := Config{}
 	if got := c.InboxRoot(); got != "/v" {
-		t.Errorf("empty InboxDir → %q, want /v (vault root)", got)
+		t.Errorf("empty InboxDir → %q, want /v (working dir)", got)
 	}
 	c.InboxDir = "Inbox"
 	if got := c.InboxRoot(); got != filepath.Join("/v", "Inbox") {
@@ -133,14 +138,24 @@ 		t.Errorf("absolute InboxDir → %q, want /abs/inbox", got)
 	}
 }
 
-func TestDefaultVaultUsesEnvThenCwd(t *testing.T) {
-	t.Setenv("GLINT_VAULT", "/explicit/vault")
-	if d := Default(); d.VaultDir != "/explicit/vault" {
-		t.Errorf("with GLINT_VAULT set: VaultDir = %q, want /explicit/vault", d.VaultDir)
+func TestWorkingDirUsesEnvThenCwd(t *testing.T) {
+	t.Setenv("GLINT_VAULT", "/explicit/dir")
+	if got := (Config{}).WorkingDir(); got != "/explicit/dir" {
+		t.Errorf("with GLINT_VAULT set: WorkingDir = %q, want /explicit/dir", got)
 	}
 	t.Setenv("GLINT_VAULT", "")
 	wd, _ := os.Getwd()
-	if d := Default(); d.VaultDir != wd {
-		t.Errorf("without GLINT_VAULT: VaultDir = %q, want cwd %q", d.VaultDir, wd)
+	if got := (Config{}).WorkingDir(); got != wd {
+		t.Errorf("without GLINT_VAULT: WorkingDir = %q, want cwd %q", got, wd)
+	}
+}
+
+func TestVaultPrefersConfigThenWorkingDir(t *testing.T) {
+	t.Setenv("GLINT_VAULT", "/work")
+	if got := (Config{VaultDir: "/the/vault"}).Vault(); got != "/the/vault" {
+		t.Errorf("with vault_dir set: Vault = %q, want /the/vault", got)
+	}
+	if got := (Config{}).Vault(); got != "/work" {
+		t.Errorf("without vault_dir: Vault = %q, want working dir /work", got)
 	}
 }
main.go +21 −12
@@ -28,19 +28,28 @@ 		fmt.Fprintln(os.Stderr, "glint: config:", err)
 	}
 	a := app.New(cfg)
 
-	// `glint new [name]` — start a fresh document: a blank unnamed buffer, or a
-	// new note created under the inbox when a name is given.
-	if args := os.Args[1:]; len(args) > 0 && args[0] == "new" {
-		name := ""
-		if len(args) > 1 {
-			name = args[1]
-		}
-		if err := a.StartNew(name); err != nil {
-			fmt.Fprintln(os.Stderr, "glint:", err)
-			os.Exit(1)
+	// Subcommands run before flag parsing.
+	if args := os.Args[1:]; len(args) > 0 {
+		switch args[0] {
+		case "new": // `glint new [name]` — blank buffer, or a named note under the inbox
+			name := ""
+			if len(args) > 1 {
+				name = args[1]
+			}
+			if err := a.StartNew(name); err != nil {
+				fmt.Fprintln(os.Stderr, "glint:", err)
+				os.Exit(1)
+			}
+			run(a)
+			return
+		case "vault": // `glint vault` — open the picker over the configured vault, from anywhere
+			if err := a.StartVault(); err != nil {
+				fmt.Fprintln(os.Stderr, "glint:", err)
+				os.Exit(1)
+			}
+			run(a)
+			return
 		}
-		run(a)
-		return
 	}
 
 	daily := flag.Bool("daily", false, "open today's daily note")