From 83e4581ef19478999020da9d194112305f225334 Mon Sep 17 00:00:00 2001 From: kai CI Date: Tue, 8 Sep 2026 22:12:55 +0300 Subject: [PATCH 1/2] Isolate CLI user state for Kai Dev --- cmd/kai/log_ingest.go | 3 ++- cmd/kai/main.go | 2 +- go.mod | 2 +- go.sum | 2 ++ internal/kitlauncher/kitlauncher.go | 3 ++- internal/tui/app.go | 3 ++- 6 files changed, 10 insertions(+), 5 deletions(-) diff --git a/cmd/kai/log_ingest.go b/cmd/kai/log_ingest.go index 169620c..134d4f6 100644 --- a/cmd/kai/log_ingest.go +++ b/cmd/kai/log_ingest.go @@ -19,6 +19,7 @@ import ( "bufio" "encoding/json" "fmt" + "github.com/kaicontext/kai-engine/kaipath" "io" "os" "path/filepath" @@ -655,7 +656,7 @@ func stableIngestCommand() string { // binary if it's not in a temp dir, then a bare "kai" (PATH lookup). func stableKaiPath() string { if home, err := os.UserHomeDir(); err == nil { - if p := filepath.Join(home, ".kai", "bin", "kai"); isExecutableFile(p) { + if p := kaipath.UserPath(home, "bin", "kai"); isExecutableFile(p) { return p } } diff --git a/cmd/kai/main.go b/cmd/kai/main.go index 7b978e7..5368b60 100644 --- a/cmd/kai/main.go +++ b/cmd/kai/main.go @@ -168,7 +168,7 @@ var verbose bool var authLoginToken string // updateCheckFile is the path to the cached update check result. -var updateCheckFile = filepath.Join(os.Getenv("HOME"), ".kai", "update-check.json") +var updateCheckFile = kaipath.UserPath(os.Getenv("HOME"), "update-check.json") type updateCheck struct { LatestVersion string `json:"latest_version"` diff --git a/go.mod b/go.mod index 3bdac4a..2b01f25 100644 --- a/go.mod +++ b/go.mod @@ -61,7 +61,7 @@ require ( github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect - github.com/kaicontext/kai-engine v0.6.56 + github.com/kaicontext/kai-engine v0.6.59-0.20260908191034-bdf8837ffaf2 github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/klauspost/cpuid/v2 v2.0.9 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect diff --git a/go.sum b/go.sum index 703dce2..71357cf 100644 --- a/go.sum +++ b/go.sum @@ -111,6 +111,8 @@ github.com/kaicontext/kai-core v0.1.0 h1:jDnRes5rSNtXUPJ7+K510SLUTpqGbIoff4eNhcc github.com/kaicontext/kai-core v0.1.0/go.mod h1:N46/skG6wLkqDUarqnu9VamofK3rgLVA3sfsd6ltfIg= github.com/kaicontext/kai-engine v0.6.56 h1:TOMBTzEijt6VMKjL/mlhwS3NinDemK9Crj8QeqnCAeU= github.com/kaicontext/kai-engine v0.6.56/go.mod h1:a7vLmfPifbByHuIy9npyThoEM4L7s3bHgb6eBEM1+x0= +github.com/kaicontext/kai-engine v0.6.59-0.20260908191034-bdf8837ffaf2 h1:/AZcHhhvimdkH8XAYdPzI1nxB2zlazecbYdWukeUfsk= +github.com/kaicontext/kai-engine v0.6.59-0.20260908191034-bdf8837ffaf2/go.mod h1:9/NGcS0pyBvkweuafe8zZozETV2qVxXbNh6u2jMtBJs= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= diff --git a/internal/kitlauncher/kitlauncher.go b/internal/kitlauncher/kitlauncher.go index 80febd6..72f55e7 100644 --- a/internal/kitlauncher/kitlauncher.go +++ b/internal/kitlauncher/kitlauncher.go @@ -19,6 +19,7 @@ import ( "context" "errors" "fmt" + "github.com/kaicontext/kai-engine/kaipath" "io" "net/http" "os" @@ -151,7 +152,7 @@ func Default() *Launcher { // downloaded to ~/.kai/bin and shadowing it. binDir := os.Getenv("KAI_INSTALL_DIR") if binDir == "" { - binDir = filepath.Join(home, ".kai", "bin") + binDir = kaipath.UserPath(home, "bin") } return &Launcher{ BaseURL: "https://app.kaicontext.com/dl/", diff --git a/internal/tui/app.go b/internal/tui/app.go index 95bc2a0..96a2d83 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -9,6 +9,7 @@ package tui import ( + "github.com/kaicontext/kai-engine/kaipath" "context" "fmt" "log" @@ -986,7 +987,7 @@ func logTUIPanic(m model, msg tea.Msg, panicVal any) { } if dir == "" { if home, err := os.UserHomeDir(); err == nil { - dir = filepath.Join(home, ".kai") + dir = kaipath.UserPath(home) } } if dir == "" { From d925fb736efe41f670d14aee9b2477beb796a802 Mon Sep 17 00:00:00 2001 From: kai CI Date: Tue, 8 Sep 2026 22:27:25 +0300 Subject: [PATCH 2/2] Address review with isolated-state regression coverage --- cmd/kai/log_ingest.go | 2 +- cmd/kai/main.go | 16 +++---- go.mod | 2 +- go.sum | 2 + internal/kitlauncher/kitlauncher.go | 3 +- internal/kitlauncher/kitlauncher_test.go | 21 +++++++++ internal/tui/app.go | 60 ++++++++++++------------ 7 files changed, 63 insertions(+), 43 deletions(-) diff --git a/cmd/kai/log_ingest.go b/cmd/kai/log_ingest.go index 134d4f6..44a0072 100644 --- a/cmd/kai/log_ingest.go +++ b/cmd/kai/log_ingest.go @@ -19,7 +19,6 @@ import ( "bufio" "encoding/json" "fmt" - "github.com/kaicontext/kai-engine/kaipath" "io" "os" "path/filepath" @@ -28,6 +27,7 @@ import ( "strings" "time" + "github.com/kaicontext/kai-engine/kaipath" "github.com/spf13/cobra" ) diff --git a/cmd/kai/main.go b/cmd/kai/main.go index 5368b60..8503e8b 100644 --- a/cmd/kai/main.go +++ b/cmd/kai/main.go @@ -13,6 +13,9 @@ import ( "encoding/xml" "fmt" "io" + "kai/internal/config" + "kai/internal/kitlauncher" + tuierrors "kai/internal/tui/errors" "net/http" "net/url" "os" @@ -29,14 +32,8 @@ import ( "unicode" "github.com/bmatcuk/doublestar/v4" - "github.com/mattn/go-isatty" - "github.com/sergi/go-diff/diffmatchpatch" - "github.com/spf13/cobra" - "gopkg.in/yaml.v3" - "github.com/kaicontext/kai-core/diff" "github.com/kaicontext/kai-core/merge" - "github.com/kaicontext/kai-engine/ai" "github.com/kaicontext/kai-engine/authorship" "github.com/kaicontext/kai-engine/classify" @@ -64,9 +61,10 @@ import ( "github.com/kaicontext/kai-engine/telemetry" "github.com/kaicontext/kai-engine/util" "github.com/kaicontext/kai-engine/workspace" - "kai/internal/config" - "kai/internal/kitlauncher" - tuierrors "kai/internal/tui/errors" + "github.com/mattn/go-isatty" + "github.com/sergi/go-diff/diffmatchpatch" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" ) const ( diff --git a/go.mod b/go.mod index 2b01f25..fc9a77f 100644 --- a/go.mod +++ b/go.mod @@ -61,7 +61,7 @@ require ( github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect - github.com/kaicontext/kai-engine v0.6.59-0.20260908191034-bdf8837ffaf2 + github.com/kaicontext/kai-engine v0.6.59-0.20260908192613-5ab1b102fc2f github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/klauspost/cpuid/v2 v2.0.9 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect diff --git a/go.sum b/go.sum index 71357cf..0378158 100644 --- a/go.sum +++ b/go.sum @@ -113,6 +113,8 @@ github.com/kaicontext/kai-engine v0.6.56 h1:TOMBTzEijt6VMKjL/mlhwS3NinDemK9Crj8Q github.com/kaicontext/kai-engine v0.6.56/go.mod h1:a7vLmfPifbByHuIy9npyThoEM4L7s3bHgb6eBEM1+x0= github.com/kaicontext/kai-engine v0.6.59-0.20260908191034-bdf8837ffaf2 h1:/AZcHhhvimdkH8XAYdPzI1nxB2zlazecbYdWukeUfsk= github.com/kaicontext/kai-engine v0.6.59-0.20260908191034-bdf8837ffaf2/go.mod h1:9/NGcS0pyBvkweuafe8zZozETV2qVxXbNh6u2jMtBJs= +github.com/kaicontext/kai-engine v0.6.59-0.20260908192613-5ab1b102fc2f h1:/Yo+axEITfrwZAsJm3PF2ydEoqLkcnM/W/aGW8ARNxk= +github.com/kaicontext/kai-engine v0.6.59-0.20260908192613-5ab1b102fc2f/go.mod h1:9/NGcS0pyBvkweuafe8zZozETV2qVxXbNh6u2jMtBJs= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= diff --git a/internal/kitlauncher/kitlauncher.go b/internal/kitlauncher/kitlauncher.go index 72f55e7..4cb37ca 100644 --- a/internal/kitlauncher/kitlauncher.go +++ b/internal/kitlauncher/kitlauncher.go @@ -19,7 +19,6 @@ import ( "context" "errors" "fmt" - "github.com/kaicontext/kai-engine/kaipath" "io" "net/http" "os" @@ -28,6 +27,8 @@ import ( "runtime" "strings" "time" + + "github.com/kaicontext/kai-engine/kaipath" ) // Sentinel errors classify resolveKitPath / install outcomes so callers diff --git a/internal/kitlauncher/kitlauncher_test.go b/internal/kitlauncher/kitlauncher_test.go index c27e10e..563aa20 100644 --- a/internal/kitlauncher/kitlauncher_test.go +++ b/internal/kitlauncher/kitlauncher_test.go @@ -797,3 +797,24 @@ func TestResolveKitPath_EnvOverride(t *testing.T) { t.Fatal("a broken override must error, not fall back") } } + +func TestDefaultDataDirectory(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("KAI_DATA_DIR", "") + t.Setenv("KAI_INSTALL_DIR", "") + if got := Default().BinDir; got != filepath.Join(home, ".kai", "bin") { + t.Fatal(got) + } + dev := t.TempDir() + t.Setenv("KAI_DATA_DIR", dev) + if got := Default().BinDir; got != filepath.Join(dev, "bin") { + t.Fatal(got) + } + bundle := t.TempDir() + t.Setenv("KAI_INSTALL_DIR", bundle) + if got := Default().BinDir; got != bundle { + t.Fatalf("bundle override lost: %s", got) + } +} diff --git a/internal/tui/app.go b/internal/tui/app.go index 96a2d83..3b6e72a 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -9,9 +9,14 @@ package tui import ( - "github.com/kaicontext/kai-engine/kaipath" "context" "fmt" + "kai/api/graph" + "kai/api/memstat" + "kai/api/projects" + "kai/api/provider" + "kai/api/watcher" + "kai/internal/tui/views" "log" "os" "os/signal" @@ -23,13 +28,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - - "kai/api/graph" - "kai/api/provider" - "kai/api/memstat" - "kai/api/projects" - "kai/internal/tui/views" - "kai/api/watcher" + "github.com/kaicontext/kai-engine/kaipath" ) // Options configures a TUI session. The TUI reads from a live graph @@ -590,10 +589,10 @@ type model struct { width int height int - repl views.REPL - gate views.Gate - sync views.Sync - status views.StatusBar + repl views.REPL + gate views.Gate + sync views.Sync + status views.StatusBar syncCh <-chan views.SyncEvent chatCh <-chan views.ChatActivityEvent hostProcCh <-chan views.HostProcEvent @@ -634,11 +633,11 @@ func initialModel(opts Options, syncCh <-chan views.SyncEvent, chatCh <-chan vie // non-primary roots in a multi-root workspace. gate.SetProjects(opts.Projects) return model{ - opts: opts, - repl: views.NewREPLWithSession(opts.Binary, opts.WorkDir, opts.Planner, opts.ResumeSessionID), - gate: gate, - sync: s, - status: status, + opts: opts, + repl: views.NewREPLWithSession(opts.Binary, opts.WorkDir, opts.Planner, opts.ResumeSessionID), + gate: gate, + sync: s, + status: status, syncCh: syncCh, chatCh: chatCh, hostProcCh: hostProcCh, @@ -682,7 +681,7 @@ func (m model) Init() tea.Cmd { // short transient error line in the REPL so the user sees that // SOMETHING went wrong without seeing the stack. // -// Stack traces are written to ~/.kai/tui-panic.log so a developer +// Stack traces are written to the user Kai state directory (KAI_DATA_DIR or ~/.kai) so a developer // can post-mortem without disturbing the user's session. func (m model) Update(msg tea.Msg) (resultModel tea.Model, resultCmd tea.Cmd) { defer func() { @@ -693,7 +692,7 @@ func (m model) Update(msg tea.Msg) (resultModel tea.Model, resultCmd tea.Cmd) { // state stays consistent; only the error display is // added. m.repl = m.repl.AppendSystemError(fmt.Sprintf( - "internal error suppressed (see ~/.kai/tui-panic.log) — continuing")) + "internal error suppressed (see the user Kai state directory (KAI_DATA_DIR or ~/.kai)) — continuing")) resultModel = m resultCmd = nil } @@ -974,8 +973,7 @@ func (m *model) setFocus(f focus) { } } - -// logTUIPanic appends a stack trace to ~/.kai/tui-panic.log so a +// logTUIPanic appends a stack trace to the user Kai state directory (KAI_DATA_DIR or ~/.kai) so a // developer can post-mortem the panic that just got swallowed by // the recover in Update. Best-effort: failing to open the log // must not itself panic. Falls back to UserHomeDir when the @@ -1006,7 +1004,6 @@ func logTUIPanic(m model, msg tea.Msg, panicVal any) { _, _ = f.Write(debug.Stack()) } - // firstNonEmptyLine returns the first non-empty trimmed line of s, // restoreTerminalForSafety emits the ANSI sequences that revert the // modes Bubble Tea sets (alt-screen, mouse tracking, bracketed paste, @@ -1019,15 +1016,16 @@ func logTUIPanic(m model, msg tea.Msg, panicVal any) { // to stderr. // // Sequences: -// 1049l exit alternate screen buffer (return to the user's -// normal scrollback) -// 25h show cursor (Bubble Tea hides it during the run) -// 1000l disable basic mouse tracking -// 1002l disable cell-motion mouse tracking (what -// WithMouseCellMotion turned on) -// 1003l disable any-event mouse tracking (defensive) -// 2004l disable bracketed-paste mode -// ?7h re-enable line wrap (the default; some TUIs disable it) +// +// 1049l exit alternate screen buffer (return to the user's +// normal scrollback) +// 25h show cursor (Bubble Tea hides it during the run) +// 1000l disable basic mouse tracking +// 1002l disable cell-motion mouse tracking (what +// WithMouseCellMotion turned on) +// 1003l disable any-event mouse tracking (defensive) +// 2004l disable bracketed-paste mode +// ?7h re-enable line wrap (the default; some TUIs disable it) func restoreTerminalForSafety() { const reset = "\x1b[?1049l\x1b[?25h\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?2004l\x1b[?7h" fmt.Fprint(os.Stderr, reset)