Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ graph TD
main --> loader
main --> logx
main --> version
main --> configdir
model[internal/model] --> loader[internal/loader]
model --> version[internal/version]
model --> updater[internal/updater]
Expand All @@ -25,27 +26,32 @@ graph TD
version --> loader
version --> logx[internal/logx]
version --> proc
version --> configdir
updater --> loader
updater --> proc
ui --> loader
loader --> logx
loader --> configdir
logx --> configdir[internal/configdir]
```

| Package | Responsibility |
|---|---|
| `internal/configdir` | Resolve the base user-config dir: `~/.config/keeptui` on macOS/Linux (`$XDG_CONFIG_HOME` else `~/.config`), `%AppData%\keeptui` on Windows. Pure `baseFor(goos, …)` core + `Base()` wrapper; stdlib-only bottom leaf shared by `loader`/`version`/`logx`/`main` |
| `internal/launcher` | Decide how to run a tracked tool in a new terminal tab: pure `planFor(env, command, toolName)` → `Plan{Argv, Fallback, Terminal}`, detection chain tmux → iTerm2 → Terminal.app → kitty → WezTerm → fallback; env-only, no subprocesses |
| `internal/loader` | Tracker persistence (`meta.yaml`), status lifecycle (`active → trying → inactive`, legacy values migrated on read), the one-tag-per-tool invariant (a legacy multi-tag list is truncated to its first entry on read), GitHub ref parsing (`NormalizeRepo`, `ParseToolRef`) |
| `internal/logx` | Session error journal: dependency-free, errors only, one lazily created file per session. Package-level state — any package can log without threading a logger through |
| `internal/logx` | Session error journal: errors only, one lazily created file per session, imports only the stdlib-only `configdir` leaf. Package-level state — any package can log without threading a logger through |
| `internal/model` | The entire Bubble Tea model: TUI state, key handling, rendering |
| `internal/proc` | `DetachTTY` — run probes without a controlling terminal; `KillGroup` — process-group SIGKILL (plain `Kill` on Windows) |
| `internal/ui` | Lip Gloss styles, `PlaceOverlay`, `StripANSI` |
| `internal/updater` | Detect the package manager that owns an installed binary and produce an update `Plan{Manager, Argv, Display}` |
| `internal/version` | Detect the installed version locally; GitHub API with a 24-hour cache; semver comparison (`IsNewer`) |

`launcher`, `logx`, `proc`, `ui`, `updater` and `version` sit at the bottom of the import graph:
`configdir`, `launcher`, `logx`, `proc`, `ui`, `updater` and `version` sit at the bottom of the import graph:
they know nothing about the TUI (`ui`, `updater` and `version` reach only into
`loader`/`proc`/`logx`). GitHub ref parsing is owned by `loader` (otherwise a
`version ↔ loader` cycle would appear).
`loader`/`proc`/`logx`). `configdir` is the lowest leaf (stdlib only), shared by
`loader`/`version`/`logx`/`main` for one config-dir resolution. GitHub ref parsing
is owned by `loader` (otherwise a `version ↔ loader` cycle would appear).

The `model` package is split across files within a single package:

Expand Down Expand Up @@ -233,6 +239,8 @@ caller left it empty). Token: `GITHUB_TOKEN` from the environment always wins ov

## Storage

The base dir comes from `configdir.Base()`: `~/.config/keeptui` on macOS and Linux (`$XDG_CONFIG_HOME` else `~/.config`), `%AppData%\keeptui` on Windows. Paths below use the macOS/Linux form.

| Data | Path |
|---|---|
| Tracker metadata | `~/.config/keeptui/meta.yaml` |
Expand Down
7 changes: 5 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,10 @@ CI (`.github/workflows/ci.yml`) runs build / vet / `test -race` / golangci-lint

| Package | Purpose |
|---|---|
| `internal/configdir` | Resolve keeptui's base user-config dir (parent of the `keeptui/` subdir holding `meta.yaml`, `cache.json`, `token`, `logs/`). Pure `baseFor(goos, getenv, userConfigDir, userHomeDir)` core + thin `Base()` wrapper (the `shellCommand`/`planFor` idiom). **Windows** → `os.UserConfigDir()` (`%AppData%`); **macOS and Linux** → `$XDG_CONFIG_HOME` else `~/.config` — deliberately *not* `os.UserConfigDir()`, which is `~/Library/Application Support` on macOS. The **bottom leaf of the import graph** (stdlib only), so `loader`/`version`/`logx`/`main` all share one resolution without a cycle |
| `internal/launcher` | Decide how to run a tracked tool in a new terminal tab: pure `planFor(env, command, toolName)` over an injected env lookup → `Plan{Argv, Fallback, Terminal}`, thin `Detect` wrapper over `os.Getenv`. Detection chain (first hit wins): `$TMUX` → iTerm2 → Terminal.app → kitty → WezTerm → `Fallback: true` (no scripting API — run in the current window). `$TMUX` is deliberately first: inside tmux `TERM_PROGRAM` names the *outer* terminal and a tmux window is the correct "tab" there. Terminal.app opens a **window**, not a tab (tabs aren't scriptable without System Events — honest degradation). tmux/kitty/wezterm plans carry command and tool name as argv elements (no escaping; the tmux plan puts `--` before the command so a `-`-leading edit isn't eaten as a flag); the two osascript plans interpolate into script source through `appleScriptQuote` (backslashes, then double quotes, then `\n`/`\r`/`\t` — the control characters an AppleScript literal cannot carry raw), the single escaping point. The kitty plan needs a remote-control socket (`listen_on` → `KITTY_LISTEN_ON`): the adapter runs detached, so tty-transport remote control cannot work — without the socket it degrades to the auto-fallback. Env-only — no subprocesses — so `Detect` is safe inside `Update()`. Bottom of the import graph like `updater`: no TUI knowledge |
| `internal/loader` | Persist tracker metadata (`meta.yaml`: name, status, tag, note, github ref, optional `update_cmd` override); own the tool-status lifecycle (`active → trying → inactive` via `NextStatus`; legacy `forgotten`/`archived` values are migrated to `inactive` in `LoadMeta` — in-memory, the file keeps the old value until the next `SaveMeta`); hold the **one tag per tool** invariant (`Tags` stays a `[]string` so the yaml schema is unchanged, but `LoadMeta` truncates a legacy multi-tag list to `Tags[:1]` in the same migration loop — first tag wins, matching what the editor's `parseTag` does to comma-separated input. Unlike the status migration, which swaps a retired value for its successor, this one **discards user-authored data** and the next `SaveMeta` — any note edit or status cycle — makes it permanent, so a load that actually dropped tags stashes the pre-migration file as `meta.yaml.bak` first: best-effort, logged and swallowed on failure, and not rewritten by later already-migrated loads); parse GitHub refs (`NormalizeRepo`, `ParseToolRef` in `github.go`) |
| `internal/logx` | Dependency-free, errors-only session logger; one lazily-created plain-text file per session under `~/.config/keeptui/logs`. Package-level state (`mu`/`file`/`path`/`header`), so any package can log without threading a logger through constructors |
| `internal/logx` | Errors-only session logger (imports only the stdlib-only `configdir` leaf); one lazily-created plain-text file per session under `<configdir.Base>/keeptui/logs`. Package-level state (`mu`/`file`/`path`/`header`), so any package can log without threading a logger through constructors |
| `internal/model` | Entire Bubble Tea model — all TUI state, key handling, and rendering |
| `internal/proc` | `DetachTTY` — runs tool probe subprocesses without a controlling terminal (`Setsid` on unix, `DETACHED_PROCESS` on Windows); `KillGroup` — process-group SIGKILL (negative pid; plain `Process.Kill` on Windows) for the update streamer's timeout path |
| `internal/ui` | Lip Gloss styles and `PlaceOverlay` helper |
Expand Down Expand Up @@ -105,6 +106,8 @@ The model is a three-panel layout with focus cycling via `→/←` between `focu

### File storage

The base dir is resolved by `configdir.Base()`: `~/.config/keeptui` on **macOS and Linux** (`$XDG_CONFIG_HOME` else `~/.config` — same path on both, deliberately not macOS's `~/Library/Application Support`), `%AppData%\keeptui` on **Windows**. Paths below use the macOS/Linux form.

| Data | Location |
|---|---|
| Tracker metadata | `~/.config/keeptui/meta.yaml` |
Expand All @@ -127,7 +130,7 @@ The internal `testConfigDir`/`testCacheDir`/`testTokenDir`/`testAPIBase`/`testBr

### Session error log (`internal/logx`)

An errors-only journal so bugs can be researched after the fact instead of reconstructed from memory. One plain-text file per session, created **lazily on the first write** — a session with no errors leaves no file at all, so the presence of a file is itself the signal. The timestamp is colon-free (Windows filenames) and zero-padded, so lexicographic order equals chronological order, which is what `Cleanup()` (keep newest 20) relies on. The header (`keeptui <version> <goos>/<goarch> tools=<n> token=<source>`) is assembled in `main.go` via `logx.SetHeader` and written as the file's first line — it stays in `main` because pulling version/tool-count/token-source into `logx` would invert the import graph (`logx` imports nothing from the project, keeping it at the bottom of the graph).
An errors-only journal so bugs can be researched after the fact instead of reconstructed from memory. One plain-text file per session, created **lazily on the first write** — a session with no errors leaves no file at all, so the presence of a file is itself the signal. The timestamp is colon-free (Windows filenames) and zero-padded, so lexicographic order equals chronological order, which is what `Cleanup()` (keep newest 20) relies on. The header (`keeptui <version> <goos>/<goarch> tools=<n> token=<source>`) is assembled in `main.go` via `logx.SetHeader` and written as the file's first line — it stays in `main` because pulling version/tool-count/token-source into `logx` would invert the import graph (`logx`'s only project import is the stdlib-only `configdir` leaf, keeping it near the bottom of the graph).

**Why our `recover` sits deeper than Bubble Tea's:** `recover()` consumes a panic wherever it fires first, and Bubble Tea's own recover in each `tea.Cmd` goroutine prints the trace *after* `p.cancel()` (async) but *before* the terminal is restored — so the trace lands in the alt-screen buffer and vanishes on exit. `logx.Recover(context)` is therefore deferred *inside* `Update`, `View` and every command (via the `safeCmd` wrapper; `execToolCmd` is the one unwrapped cmd — `tea.ExecProcess` only constructs the exec message, nothing there can panic), records the value plus `debug.Stack()` (which still contains the real panic site from inside the defer), then **re-panics** so Bubble Tea still catches it, restores the terminal and returns `ErrProgramPanic`. `tea.WithoutCatchPanics` is deliberately **not** used — terminal restoration is Bubble Tea's job and it does it correctly. Errors-only by design: there is no debug level, no `KEYS_DEBUG`, no env reading, no JSON, no size-based rotation — without a debug level the file appears only when something went wrong. The logger never breaks the app: its own failures (file won't open, disk full) are swallowed silently.

Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,9 @@ atomic.
| Session error log | `~/.config/keeptui/logs/keeptui-<timestamp>.log` |
| Copy of the tracker before the one-tag migration | `~/.config/keeptui/meta.yaml.bak` |

The paths above are the macOS and Linux locations (`$XDG_CONFIG_HOME` if set, otherwise
`~/.config`). On Windows the base directory is `%AppData%\keeptui\` instead.

The log is created lazily — only on the first error. A session with no errors leaves
no file at all, so the presence of a file is itself the signal. The 20 most recent
logs are kept.
Expand Down
41 changes: 41 additions & 0 deletions internal/configdir/configdir.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Package configdir resolves keeptui's base user-config directory — the parent
// of the keeptui/ subdir that holds meta.yaml, cache.json, the token and logs.
// It is the bottom leaf of the import graph (stdlib only), so every package that
// owns one of those files can share the resolution without an import cycle.
//
// The resolution deliberately differs from os.UserConfigDir(): on macOS that
// returns ~/Library/Application Support, but keeptui keeps macOS and Linux on
// the same ~/.config/keeptui path (honoring $XDG_CONFIG_HOME). Windows still
// follows os.UserConfigDir() (%AppData%), where there is no ~/.config convention.
package configdir

import (
"os"
"path/filepath"
"runtime"
)

// Base returns keeptui's base user-config directory. Callers append the
// "keeptui" subdir (and the file) themselves, matching what os.UserConfigDir()
// callers did before. See baseFor for the per-GOOS rules.
func Base() (string, error) {
return baseFor(runtime.GOOS, os.Getenv, os.UserConfigDir, os.UserHomeDir)
}

// baseFor is the pure core behind Base, parameterized on GOOS and the env/dir
// lookups so both branches are table-testable (the shellCommand/planFor idiom).
// Windows → os.UserConfigDir() (%AppData%); macOS and Linux → $XDG_CONFIG_HOME
// if set, else ~/.config.
func baseFor(goos string, getenv func(string) string, userConfigDir, userHomeDir func() (string, error)) (string, error) {
if goos == "windows" {
return userConfigDir()
}
if xdg := getenv("XDG_CONFIG_HOME"); xdg != "" {
return xdg, nil
}
home, err := userHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".config"), nil
}
75 changes: 75 additions & 0 deletions internal/configdir/configdir_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package configdir

import (
"errors"
"path/filepath"
"testing"
)

func TestBaseFor(t *testing.T) {
const (
home = "/home/user"
appData = `C:\Users\user\AppData\Roaming`
xdgDir = "/custom/xdg"
)
userConfig := func() (string, error) { return appData, nil }
homeDir := func() (string, error) { return home, nil }
noEnv := func(string) string { return "" }
xdgEnv := func(k string) string {
if k == "XDG_CONFIG_HOME" {
return xdgDir
}
return ""
}

tests := []struct {
name string
goos string
getenv func(string) string
want string
}{
{"windows uses UserConfigDir (%AppData%)", "windows", noEnv, appData},
{"windows ignores XDG", "windows", xdgEnv, appData},
{"linux without XDG uses ~/.config", "linux", noEnv, filepath.Join(home, ".config")},
{"linux honors XDG_CONFIG_HOME", "linux", xdgEnv, xdgDir},
{"darwin without XDG uses ~/.config (not Application Support)", "darwin", noEnv, filepath.Join(home, ".config")},
{"darwin honors XDG_CONFIG_HOME", "darwin", xdgEnv, xdgDir},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := baseFor(tt.goos, tt.getenv, userConfig, homeDir)
if err != nil {
t.Fatalf("baseFor returned error: %v", err)
}
if got != tt.want {
t.Errorf("baseFor(%q) = %q, want %q", tt.goos, got, tt.want)
}
})
}
}

// TestBaseForHomeError: on the unix path with no XDG, a UserHomeDir failure
// propagates instead of returning a bogus ".config" relative path.
func TestBaseForHomeError(t *testing.T) {
boom := errors.New("no home")
failHome := func() (string, error) { return "", boom }
okConfig := func() (string, error) { return "/whatever", nil }
noEnv := func(string) string { return "" }

if _, err := baseFor("linux", noEnv, okConfig, failHome); !errors.Is(err, boom) {
t.Errorf("err = %v, want the home-dir error propagated", err)
}
}

// TestBaseForWindowsConfigError: the Windows branch surfaces a UserConfigDir
// error rather than falling back to ~/.config.
func TestBaseForWindowsConfigError(t *testing.T) {
boom := errors.New("no appdata")
failConfig := func() (string, error) { return "", boom }
okHome := func() (string, error) { return `C:\Users\user`, nil }
noEnv := func(string) string { return "" }

if _, err := baseFor("windows", noEnv, failConfig, okHome); !errors.Is(err, boom) {
t.Errorf("err = %v, want the UserConfigDir error propagated", err)
}
}
3 changes: 2 additions & 1 deletion internal/loader/meta.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (

"gopkg.in/yaml.v3"

"github.com/stanlyzoolo/keeptui/internal/configdir"
"github.com/stanlyzoolo/keeptui/internal/logx"
)

Expand Down Expand Up @@ -76,7 +77,7 @@ func MetaPath() string {
if testConfigDir != "" {
return filepath.Join(testConfigDir, "keeptui", "meta.yaml")
}
configDir, err := os.UserConfigDir()
configDir, err := configdir.Base()
if err != nil {
return ""
}
Expand Down
8 changes: 5 additions & 3 deletions internal/logx/logx.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import (
"strings"
"sync"
"time"

"github.com/stanlyzoolo/keeptui/internal/configdir"
)

var (
Expand All @@ -27,13 +29,13 @@ var (
dirOverride string // test seam; empty in production
)

// logDir resolves ~/.config/keeptui/logs via os.UserConfigDir, honoring the test
// override. Mirrors loader.MetaPath's resolution. Caller holds mu.
// logDir resolves <configdir.Base>/keeptui/logs, honoring the test override.
// Mirrors loader.MetaPath's resolution. Caller holds mu.
func logDir() string {
if dirOverride != "" {
return dirOverride
}
base, err := os.UserConfigDir()
base, err := configdir.Base()
if err != nil {
return ""
}
Expand Down
3 changes: 2 additions & 1 deletion internal/version/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"sync"
"time"

"github.com/stanlyzoolo/keeptui/internal/configdir"
"github.com/stanlyzoolo/keeptui/internal/loader"
"github.com/stanlyzoolo/keeptui/internal/logx"
)
Expand Down Expand Up @@ -737,7 +738,7 @@ func cacheFilePath() (string, error) {
if testCacheDir != "" {
return filepath.Join(testCacheDir, "cache.json"), nil
}
base, err := os.UserConfigDir()
base, err := configdir.Base()
if err != nil {
return "", err
}
Expand Down
4 changes: 3 additions & 1 deletion internal/version/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"path/filepath"
"strings"
"sync"

"github.com/stanlyzoolo/keeptui/internal/configdir"
)

// testTokenDir overrides the token file directory in tests.
Expand All @@ -22,7 +24,7 @@ func tokenFilePath() (string, error) {
if testTokenDir != "" {
return filepath.Join(testTokenDir, "token"), nil
}
base, err := os.UserConfigDir()
base, err := configdir.Base()
if err != nil {
return "", err
}
Expand Down
16 changes: 12 additions & 4 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"runtime/debug"

tea "github.com/charmbracelet/bubbletea"
"github.com/stanlyzoolo/keeptui/internal/configdir"
"github.com/stanlyzoolo/keeptui/internal/loader"
"github.com/stanlyzoolo/keeptui/internal/logx"
"github.com/stanlyzoolo/keeptui/internal/model"
Expand Down Expand Up @@ -83,16 +84,23 @@ func resolveVersion(ldflag, modVersion string) string {
}

// migrateConfigDir renames the pre-rename config directory (<UserConfigDir>/keys,
// from when the app was called "keys") to <UserConfigDir>/keeptui. One-shot and
// conservative: if the new directory already exists — even empty — nothing is
// touched, so a half-adopted new install is never overwritten by old data.
// from when the app was called "keys") to the current keeptui config dir. The
// old "keys" data was written under os.UserConfigDir(); the new dir resolves via
// configdir.Base() (~/.config/keeptui on macOS/Linux, %AppData%\keeptui on
// Windows), i.e. wherever the app now reads. One-shot and conservative: if the
// new directory already exists — even empty — nothing is touched, so a
// half-adopted new install is never overwritten by old data.
func migrateConfigDir() {
base, err := os.UserConfigDir()
if err != nil {
return
}
newBase, err := configdir.Base()
if err != nil {
return
}
oldDir := filepath.Join(base, "keys")
newDir := filepath.Join(base, "keeptui")
newDir := filepath.Join(newBase, "keeptui")
if _, err := os.Stat(newDir); err == nil {
return
}
Expand Down
Loading