detached 71e13df

Merge pull request #75 from ekoeppen/xdg-state-home

Use XDG_STATE_HOME on all platforms
commit
71e13dfdd69155f0eb773e541b19ebff4a1f412e
author
Patric Eckhart · · 2026-07-12 07:55 UTC
committer
GitHub · · 2026-07-12 07:55 UTC
merge of
dbd0dfa + d1e93a7 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
8 files · +46 -19
M README.md +5 -4
@@ -73,9 +73,10 @@ The easiest way is to just run `zot` and type `/login`. The TUI opens even witho
7373 3. `$ZOT_HOME/auth.json` (API key or OAuth token; mode 0600)
7474
7575 `$ZOT_HOME` defaults to:
76-- macOS: `~/Library/Application Support/zot`
77-- Linux: `$XDG_STATE_HOME/zot` or `~/.local/state/zot`
78-- Windows: `%LOCALAPPDATA%\zot`
76+- All platforms: `$XDG_STATE_HOME/zot` when `XDG_STATE_HOME` is set
77+- macOS fallback: `~/Library/Application Support/zot`
78+- Linux fallback: `~/.local/state/zot`
79+- Windows fallback: `%LOCALAPPDATA%\zot`
7980
8081 ### `/login` flow
8182
@@ -392,7 +393,7 @@ No configuration is required — the candidate list is built dynamically from yo
392393
393394 ### Custom models
394395
395-Place a `models.json` in `$ZOT_HOME` (macOS: `~/Library/Application Support/zot/`, Linux: `~/.local/state/zot/`) to add models that aren't in the baked-in catalog or to override existing entries:
396+Place a `models.json` in `$ZOT_HOME` (`$XDG_STATE_HOME/zot/` when set, otherwise the platform default above) to add models that aren't in the baked-in catalog or to override existing entries:
396397
397398 ```json
398399 {
@@ -73,9 +73,10 @@ The easiest way is to just run `zot` and type `/login`. The TUI opens even witho
73 3. `$ZOT_HOME/auth.json` (API key or OAuth token; mode 0600)73 3. `$ZOT_HOME/auth.json` (API key or OAuth token; mode 0600)
74 74
75 `$ZOT_HOME` defaults to:75 `$ZOT_HOME` defaults to:
76-- macOS: `~/Library/Application Support/zot`76+- All platforms: `$XDG_STATE_HOME/zot` when `XDG_STATE_HOME` is set
77-- Linux: `$XDG_STATE_HOME/zot` or `~/.local/state/zot`77+- macOS fallback: `~/Library/Application Support/zot`
78-- Windows: `%LOCALAPPDATA%\zot`78+- Linux fallback: `~/.local/state/zot`
79+- Windows fallback: `%LOCALAPPDATA%\zot`
79 80
80 ### `/login` flow81 ### `/login` flow
81 82
@@ -392,7 +393,7 @@ No configuration is required — the candidate list is built dynamically from yo
392 393
393 ### Custom models394 ### Custom models
394 395
395-Place a `models.json` in `$ZOT_HOME` (macOS: `~/Library/Application Support/zot/`, Linux: `~/.local/state/zot/`) to add models that aren't in the baked-in catalog or to override existing entries:396+Place a `models.json` in `$ZOT_HOME` (`$XDG_STATE_HOME/zot/` when set, otherwise the platform default above) to add models that aren't in the baked-in catalog or to override existing entries:
396 397
397 ```json398 ```json
398 {399 {
M docs/extensions.md +3 -2
@@ -97,8 +97,9 @@ zot scans two directories on startup, in this order:
9797 2. **Global**: `$ZOT_HOME/extensions/<name>/extension.json`
9898
9999 A project-local extension with the same name wins over a global one.
100-On macOS `$ZOT_HOME` defaults to `~/Library/Application Support/zot/`;
101-on Linux it's `$XDG_STATE_HOME/zot` or `~/.local/state/zot`.
100+When `XDG_STATE_HOME` is set on any platform, `$ZOT_HOME` defaults to
101+`$XDG_STATE_HOME/zot`. Otherwise it defaults to `~/Library/Application Support/zot/`
102+on macOS, `~/.local/state/zot` on Linux, or `%LOCALAPPDATA%\zot` on Windows.
102103
103104 Because each extension owns its own directory, the recommended place
104105 for extension state is inside that directory itself (for example
@@ -97,8 +97,9 @@ zot scans two directories on startup, in this order:
97 2. **Global**: `$ZOT_HOME/extensions/<name>/extension.json`97 2. **Global**: `$ZOT_HOME/extensions/<name>/extension.json`
98 98
99 A project-local extension with the same name wins over a global one.99 A project-local extension with the same name wins over a global one.
100-On macOS `$ZOT_HOME` defaults to `~/Library/Application Support/zot/`;100+When `XDG_STATE_HOME` is set on any platform, `$ZOT_HOME` defaults to
101-on Linux it's `$XDG_STATE_HOME/zot` or `~/.local/state/zot`.101+`$XDG_STATE_HOME/zot`. Otherwise it defaults to `~/Library/Application Support/zot/`
102+on macOS, `~/.local/state/zot` on Linux, or `%LOCALAPPDATA%\zot` on Windows.
102 103
103 Because each extension owns its own directory, the recommended place104 Because each extension owns its own directory, the recommended place
104 for extension state is inside that directory itself (for example105 for extension state is inside that directory itself (for example
M docs/skills.md +3 -2
@@ -64,8 +64,9 @@ skill ecosystem works in zot unchanged. Drop your existing
6464 `.claude/skills/` or `.agents/skills/` directories into a project and
6565 zot will pick them up.
6666
67-`$ZOT_HOME` defaults to `~/Library/Application Support/zot/` on macOS,
68-`$XDG_STATE_HOME/zot` on Linux, `%LOCALAPPDATA%\zot` on Windows.
67+When `XDG_STATE_HOME` is set on any platform, `$ZOT_HOME` defaults to
68+`$XDG_STATE_HOME/zot`. Otherwise it defaults to `~/Library/Application Support/zot/`
69+on macOS, `~/.local/state/zot` on Linux, or `%LOCALAPPDATA%\zot` on Windows.
6970
7071 ## Inspecting installed skills
7172
@@ -64,8 +64,9 @@ skill ecosystem works in zot unchanged. Drop your existing
64 `.claude/skills/` or `.agents/skills/` directories into a project and64 `.claude/skills/` or `.agents/skills/` directories into a project and
65 zot will pick them up.65 zot will pick them up.
66 66
67-`$ZOT_HOME` defaults to `~/Library/Application Support/zot/` on macOS,67+When `XDG_STATE_HOME` is set on any platform, `$ZOT_HOME` defaults to
68-`$XDG_STATE_HOME/zot` on Linux, `%LOCALAPPDATA%\zot` on Windows.68+`$XDG_STATE_HOME/zot`. Otherwise it defaults to `~/Library/Application Support/zot/`
69+on macOS, `~/.local/state/zot` on Linux, or `%LOCALAPPDATA%\zot` on Windows.
69 70
70 ## Inspecting installed skills71 ## Inspecting installed skills
71 72
M examples/extensions/mcp-bridge/Makefile +3 -3
@@ -4,10 +4,10 @@ BIN := $(notdir $(CURDIR))
44 # Must match "name" in extension.json (slash command/logical extension name).
55 EXT := mcp
66 ZOT_HOME ?= $(shell \
7- if [ "$$(uname -s)" = "Darwin" ]; then \
8- printf '%s/Library/Application Support/zot' "$$HOME"; \
9- elif [ -n "$$XDG_STATE_HOME" ]; then \
7+ if [ -n "$$XDG_STATE_HOME" ]; then \
108 printf '%s/zot' "$$XDG_STATE_HOME"; \
9+ elif [ "$$(uname -s)" = "Darwin" ]; then \
10+ printf '%s/Library/Application Support/zot' "$$HOME"; \
1111 else \
1212 printf '%s/.local/state/zot' "$$HOME"; \
1313 fi)
@@ -4,10 +4,10 @@ BIN := $(notdir $(CURDIR))
4 # Must match "name" in extension.json (slash command/logical extension name).4 # Must match "name" in extension.json (slash command/logical extension name).
5 EXT := mcp5 EXT := mcp
6 ZOT_HOME ?= $(shell \6 ZOT_HOME ?= $(shell \
7- if [ "$$(uname -s)" = "Darwin" ]; then \7+ if [ -n "$$XDG_STATE_HOME" ]; then \
8- printf '%s/Library/Application Support/zot' "$$HOME"; \
9- elif [ -n "$$XDG_STATE_HOME" ]; then \
10 printf '%s/zot' "$$XDG_STATE_HOME"; \8 printf '%s/zot' "$$XDG_STATE_HOME"; \
9+ elif [ "$$(uname -s)" = "Darwin" ]; then \
10+ printf '%s/Library/Application Support/zot' "$$HOME"; \
11 else \11 else \
12 printf '%s/.local/state/zot' "$$HOME"; \12 printf '%s/.local/state/zot' "$$HOME"; \
13 fi)13 fi)
M examples/extensions/mcp-bridge/README.md +1 -1
@@ -61,7 +61,7 @@ Config files are loaded from two locations (project overrides global per-server)
6161
6262 | Location | Scope |
6363 |---|---|
64-| `$ZOT_HOME/mcp.json` | Global (macOS: `~/Library/Application Support/zot/mcp.json`) |
64+| `$ZOT_HOME/mcp.json` | Global (`$XDG_STATE_HOME/zot/mcp.json` when `XDG_STATE_HOME` is set) |
6565 | `.zot/mcp.json` | Project-level (in your project root) |
6666
6767 ### Config Format
@@ -61,7 +61,7 @@ Config files are loaded from two locations (project overrides global per-server)
61 61
62 | Location | Scope |62 | Location | Scope |
63 |---|---|63 |---|---|
64-| `$ZOT_HOME/mcp.json` | Global (macOS: `~/Library/Application Support/zot/mcp.json`) |64+| `$ZOT_HOME/mcp.json` | Global (`$XDG_STATE_HOME/zot/mcp.json` when `XDG_STATE_HOME` is set) |
65 | `.zot/mcp.json` | Project-level (in your project root) |65 | `.zot/mcp.json` | Project-level (in your project root) |
66 66
67 ### Config Format67 ### Config Format
M examples/extensions/mcp-bridge/config.go +4 -4
@@ -3,7 +3,7 @@
33 // Reads standard MCP config files (same format as Claude Desktop, Cursor, etc.)
44 // from two locations:
55 //
6-// 1. Global: $ZOT_HOME/mcp.json (macOS: ~/Library/Application Support/zot/mcp.json)
6+// 1. Global: $ZOT_HOME/mcp.json
77 // 2. Project: .zot/mcp.json (in the current working directory)
88 //
99 // Project config overrides global config per-server (shallow merge).
@@ -47,6 +47,9 @@ func zotHome() string {
4747 if h := os.Getenv("ZOT_HOME"); h != "" {
4848 return h
4949 }
50+ if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" {
51+ return filepath.Join(xdg, "zot")
52+ }
5053 switch runtime.GOOS {
5154 case "darwin":
5255 home, _ := os.UserHomeDir()
@@ -54,9 +57,6 @@ func zotHome() string {
5457 case "windows":
5558 return filepath.Join(os.Getenv("APPDATA"), "zot")
5659 default: // linux, freebsd, etc.
57- if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" {
58- return filepath.Join(xdg, "zot")
59- }
6060 home, _ := os.UserHomeDir()
6161 return filepath.Join(home, ".local", "state", "zot")
6262 }
@@ -3,7 +3,7 @@
3 // Reads standard MCP config files (same format as Claude Desktop, Cursor, etc.)3 // Reads standard MCP config files (same format as Claude Desktop, Cursor, etc.)
4 // from two locations:4 // from two locations:
5 //5 //
6-// 1. Global: $ZOT_HOME/mcp.json (macOS: ~/Library/Application Support/zot/mcp.json)6+// 1. Global: $ZOT_HOME/mcp.json
7 // 2. Project: .zot/mcp.json (in the current working directory)7 // 2. Project: .zot/mcp.json (in the current working directory)
8 //8 //
9 // Project config overrides global config per-server (shallow merge).9 // Project config overrides global config per-server (shallow merge).
@@ -47,6 +47,9 @@ func zotHome() string {
47 if h := os.Getenv("ZOT_HOME"); h != "" {47 if h := os.Getenv("ZOT_HOME"); h != "" {
48 return h48 return h
49 }49 }
50+ if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" {
51+ return filepath.Join(xdg, "zot")
52+ }
50 switch runtime.GOOS {53 switch runtime.GOOS {
51 case "darwin":54 case "darwin":
52 home, _ := os.UserHomeDir()55 home, _ := os.UserHomeDir()
@@ -54,9 +57,6 @@ func zotHome() string {
54 case "windows":57 case "windows":
55 return filepath.Join(os.Getenv("APPDATA"), "zot")58 return filepath.Join(os.Getenv("APPDATA"), "zot")
56 default: // linux, freebsd, etc.59 default: // linux, freebsd, etc.
57- if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" {
58- return filepath.Join(xdg, "zot")
59- }
60 home, _ := os.UserHomeDir()60 home, _ := os.UserHomeDir()
61 return filepath.Join(home, ".local", "state", "zot")61 return filepath.Join(home, ".local", "state", "zot")
62 }62 }
M packages/agent/config.go +3 -3
@@ -108,6 +108,9 @@ func ZotHome() string {
108108 if v := os.Getenv("ZOT_HOME"); v != "" {
109109 return v
110110 }
111+ if v := os.Getenv("XDG_STATE_HOME"); v != "" {
112+ return filepath.Join(v, "zot")
113+ }
111114 switch runtime.GOOS {
112115 case "darwin":
113116 if home, err := os.UserHomeDir(); err == nil {
@@ -118,9 +121,6 @@ func ZotHome() string {
118121 return filepath.Join(v, "zot")
119122 }
120123 }
121- if v := os.Getenv("XDG_STATE_HOME"); v != "" {
122- return filepath.Join(v, "zot")
123- }
124124 if home, err := os.UserHomeDir(); err == nil {
125125 return filepath.Join(home, ".local", "state", "zot")
126126 }
@@ -108,6 +108,9 @@ func ZotHome() string {
108 if v := os.Getenv("ZOT_HOME"); v != "" {108 if v := os.Getenv("ZOT_HOME"); v != "" {
109 return v109 return v
110 }110 }
111+ if v := os.Getenv("XDG_STATE_HOME"); v != "" {
112+ return filepath.Join(v, "zot")
113+ }
111 switch runtime.GOOS {114 switch runtime.GOOS {
112 case "darwin":115 case "darwin":
113 if home, err := os.UserHomeDir(); err == nil {116 if home, err := os.UserHomeDir(); err == nil {
@@ -118,9 +121,6 @@ func ZotHome() string {
118 return filepath.Join(v, "zot")121 return filepath.Join(v, "zot")
119 }122 }
120 }123 }
121- if v := os.Getenv("XDG_STATE_HOME"); v != "" {
122- return filepath.Join(v, "zot")
123- }
124 if home, err := os.UserHomeDir(); err == nil {124 if home, err := os.UserHomeDir(); err == nil {
125 return filepath.Join(home, ".local", "state", "zot")125 return filepath.Join(home, ".local", "state", "zot")
126 }126 }
A packages/agent/config_home_test.go +24 -0
@@ -0,0 +1,24 @@
1+package agent
2+
3+import (
4+ "path/filepath"
5+ "testing"
6+)
7+
8+func TestZotHomePrefersExplicitHomeOverXDGStateHome(t *testing.T) {
9+ t.Setenv("ZOT_HOME", filepath.Join("explicit", "zot"))
10+ t.Setenv("XDG_STATE_HOME", filepath.Join("xdg", "state"))
11+
12+ if got, want := ZotHome(), filepath.Join("explicit", "zot"); got != want {
13+ t.Fatalf("ZotHome() = %q, want %q", got, want)
14+ }
15+}
16+
17+func TestZotHomeUsesXDGStateHome(t *testing.T) {
18+ t.Setenv("ZOT_HOME", "")
19+ t.Setenv("XDG_STATE_HOME", filepath.Join("xdg", "state"))
20+
21+ if got, want := ZotHome(), filepath.Join("xdg", "state", "zot"); got != want {
22+ t.Fatalf("ZotHome() = %q, want %q", got, want)
23+ }
24+}
@@ -0,0 +1,24 @@
1+package agent
2+
3+import (
4+ "path/filepath"
5+ "testing"
6+)
7+
8+func TestZotHomePrefersExplicitHomeOverXDGStateHome(t *testing.T) {
9+ t.Setenv("ZOT_HOME", filepath.Join("explicit", "zot"))
10+ t.Setenv("XDG_STATE_HOME", filepath.Join("xdg", "state"))
11+
12+ if got, want := ZotHome(), filepath.Join("explicit", "zot"); got != want {
13+ t.Fatalf("ZotHome() = %q, want %q", got, want)
14+ }
15+}
16+
17+func TestZotHomeUsesXDGStateHome(t *testing.T) {
18+ t.Setenv("ZOT_HOME", "")
19+ t.Setenv("XDG_STATE_HOME", filepath.Join("xdg", "state"))
20+
21+ if got, want := ZotHome(), filepath.Join("xdg", "state", "zot"); got != want {
22+ t.Fatalf("ZotHome() = %q, want %q", got, want)
23+ }
24+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close