From deccac2be92d3b4857d2f05f525e0d92de36e226 Mon Sep 17 00:00:00 2001 From: LarssonSv Date: Fri, 21 Aug 2026 11:03:35 +0200 Subject: [PATCH] Replace self-update with local history; rename apiclient to transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename internal/apiclient to internal/transport; add transport.OpenSettings so the login flow no longer wires the browser hop itself - Remove the self-update machinery (internal/selfupdate, abstr upgrade, the background release check) — installs are owned by the installer now - Add internal/history: a local, size-capped log of buffered exchanges, with an abstr history command (list/path/clear) - Config: drop auto_upgrade/update bookkeeping; add browser_command and history_limit (env: ABSTR_BROWSER, ABSTR_HISTORY) - Render: replace Errorf with Success; browser opens honour browser_command Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 27 +- internal/browser/open.go | 12 + internal/cli/ask.go | 32 ++- internal/cli/configcmd.go | 16 +- internal/cli/env.go | 23 +- internal/cli/history.go | 102 ++++++++ internal/cli/login.go | 6 +- internal/cli/repl.go | 38 +-- internal/cli/repl_frame_test.go | 4 +- internal/cli/repl_test.go | 44 ++-- internal/cli/root.go | 8 +- internal/cli/update.go | 200 -------------- internal/cli/workspace.go | 12 +- internal/config/config.go | 35 ++- internal/history/history.go | 154 +++++++++++ internal/history/history_test.go | 131 ++++++++++ internal/render/render.go | 7 +- internal/selfupdate/apply.go | 243 ------------------ internal/selfupdate/selfupdate.go | 91 ------- internal/selfupdate/selfupdate_test.go | 104 -------- internal/{apiclient => transport}/client.go | 4 +- .../{apiclient => transport}/client_test.go | 2 +- internal/{apiclient => transport}/errors.go | 2 +- internal/transport/settings.go | 20 ++ internal/{apiclient => transport}/sse.go | 2 +- internal/{apiclient => transport}/sse_test.go | 2 +- internal/{apiclient => transport}/types.go | 2 +- 27 files changed, 571 insertions(+), 752 deletions(-) create mode 100644 internal/cli/history.go delete mode 100644 internal/cli/update.go create mode 100644 internal/history/history.go create mode 100644 internal/history/history_test.go delete mode 100644 internal/selfupdate/apply.go delete mode 100644 internal/selfupdate/selfupdate.go delete mode 100644 internal/selfupdate/selfupdate_test.go rename internal/{apiclient => transport}/client.go (98%) rename internal/{apiclient => transport}/client_test.go (99%) rename internal/{apiclient => transport}/errors.go (97%) create mode 100644 internal/transport/settings.go rename internal/{apiclient => transport}/sse.go (99%) rename internal/{apiclient => transport}/sse_test.go (99%) rename internal/{apiclient => transport}/types.go (99%) diff --git a/README.md b/README.md index 45265e1..4369e09 100644 --- a/README.md +++ b/README.md @@ -53,22 +53,20 @@ Interactive commands: `/pr `, `/pr clear`, `/workspace [slug]`, | `abstr workspace use ` | Switch the current workspace. | | `abstr workspace` | Interactive workspace picker. | | `abstr config path` / `abstr config show` | Inspect configuration. | -| `abstr config set auto_upgrade ` | Toggle automatic upgrades. | -| `abstr upgrade` / `abstr upgrade --check` | Update to the latest release (or just check). | +| `abstr config set ` | Set `browser_command` or `history_limit`. | +| `abstr history` / `abstr history -n 50` | List locally stored exchanges. | +| `abstr history path` / `abstr history clear` | Locate or delete the history file. | -### Staying up to date +### Local history -`abstr upgrade` downloads the latest release, verifies it against the published -`sha256sums.txt`, and atomically replaces the running binary in place. Installs -owned by a package manager (Homebrew, Nix) are left untouched — upgrade those -with the manager instead. Self-upgrade is supported on macOS and Linux; on -Windows, download the latest release manually (or use WSL). +Buffered asks are appended to `~/.abstr-history.json` (override with +`$ABSTR_HISTORY`), newest entries kept up to `history_limit` (default 50). Set +`history_limit` to a negative value to turn recording off. The file holds the +question and the answer text only, is written with `0600` perms, and is never +sent anywhere — conversations themselves stay server-side and ephemeral. -On a normal interactive run, `abstr` also checks for a newer release in the -background (at most once every 24h). By default it just prints a one-line notice; -enable `auto_upgrade` to have it apply updates automatically. The check is -skipped for local dev builds, under `$CI`, when output isn't a terminal, or when -`ABSTR_NO_UPDATE_CHECK` is set. +Updating is handled by your installer: re-run the install script, or use the +package manager that owns the binary. ### Configuration & precedence @@ -80,6 +78,9 @@ Effective values resolve as **flag > env > file > default**: | Workspace | `-w`/`--workspace` | `ABSTR_WORKSPACE` | `workspace` | | API key | — | `ABSTR_API_KEY` | `api_key` | | Base URL | `--api-url` | `ABSTR_API_URL` | `api_base_url` | +| Browser command | — | `ABSTR_BROWSER` | `browser_command` | +| History limit | — | — | `history_limit` | +| History file | — | `ABSTR_HISTORY` | — | Output is pipe-friendly: only the answer is written to stdout; prompts, status, and errors go to stderr. Color auto-disables when stdout is not a terminal. diff --git a/internal/browser/open.go b/internal/browser/open.go index ec77ba2..a9277a5 100644 --- a/internal/browser/open.go +++ b/internal/browser/open.go @@ -4,6 +4,8 @@ package browser import ( "os/exec" "runtime" + + "github.com/abstraction-dev/cli/internal/config" ) // Open launches url in the default browser. It returns an error when no @@ -24,3 +26,13 @@ func Open(url string) error { return exec.Command(name, args...).Start() } + +// OpenWithConfig launches url using the browser command from configuration +// when one is set, falling back to the platform default. A configured command +// is invoked as ` `. +func OpenWithConfig(cfg *config.Config, url string) error { + if cmd := cfg.BrowserCommandResolved(); cmd != "" { + return exec.Command(cmd, url).Start() + } + return Open(url) +} diff --git a/internal/cli/ask.go b/internal/cli/ask.go index 91783c6..cf40a2f 100644 --- a/internal/cli/ask.go +++ b/internal/cli/ask.go @@ -9,8 +9,9 @@ import ( "os/signal" "strings" - "github.com/abstraction-dev/cli/internal/apiclient" + "github.com/abstraction-dev/cli/internal/history" "github.com/abstraction-dev/cli/internal/render" + "github.com/abstraction-dev/cli/internal/transport" ) type queryMode int @@ -59,11 +60,6 @@ func runAsk(ctx context.Context, args []string) int { return exitCodeFor(err) } - // Check for a newer release concurrently with the task; the notice or - // auto-upgrade is emitted once the task completes. - uc := startUpdateCheck(ctx, env.cfg) - defer finish(ctx, uc) - if mode == modeInteractive { return runREPL(env, opts.pr) } @@ -110,7 +106,7 @@ func readPipedStdin() (bool, string) { // mode is a one-shot: buffered output is what pipes/scripts want, and on a TTY it // renders the full answer as markdown. Streaming lives in the interactive REPL. func runImmediate(ctx context.Context, env *appEnv, query, pr string) int { - req := apiclient.AskRequest{Workspace: env.workspace, Question: query, PR: pr} + req := transport.AskRequest{Workspace: env.workspace, Question: query, PR: pr} // A one-shot never continues, so the conversation the reply names is not kept. res, err := env.client.AskBuffered(ctx, req) @@ -118,6 +114,7 @@ func runImmediate(ctx context.Context, env *appEnv, query, pr string) int { return reportRunError(ctx, env, err) } ans := res.Answer + recordHistory(env, query, ans) // Render markdown to ANSI on a terminal; keep raw markdown when piped so the // output stays clean for downstream tools. @@ -132,11 +129,28 @@ func runImmediate(ctx context.Context, env *appEnv, query, pr string) int { return exitOK } +// recordHistory stores a completed exchange, best-effort: history is a +// convenience, so a write failure must not fail the answer the user already has. +func recordHistory(env *appEnv, question, answer string) { + store, err := openHistory(env.cfg) + if err != nil { + return + } + + if err := store.Append(history.Entry{ + Workspace: env.workspace, + Question: question, + Answer: answer, + }); err != nil { + env.render.Status("could not save history: " + err.Error()) + } +} + func reportRunError(ctx context.Context, env *appEnv, err error) int { if ctx.Err() != nil { - env.render.Errorf("cancelled") + env.render.Error("cancelled") return exitInterrupt } - env.render.Errorf("abstr: %s", err.Error()) + env.render.Error("abstr: " + err.Error()) return exitCodeFor(err) } diff --git a/internal/cli/configcmd.go b/internal/cli/configcmd.go index 9283d7d..b5e5d26 100644 --- a/internal/cli/configcmd.go +++ b/internal/cli/configcmd.go @@ -39,7 +39,8 @@ func runConfig(args []string) int { fmt.Printf("workspace: %s\n", cfg.Workspace) fmt.Printf("api_key: %s\n", redactKey(cfg.APIKey)) fmt.Printf("api_url: %s\n", cfg.BaseURLResolved()) - fmt.Printf("auto_upgrade: %t\n", cfg.AutoUpgrade) + fmt.Printf("browser_cmd: %s\n", cfg.BrowserCommandResolved()) + fmt.Printf("history_limit: %d\n", cfg.HistoryLimitResolved()) case "set": return runConfigSet(cfg, sub[1:]) default: @@ -60,14 +61,17 @@ func runConfigSet(cfg *config.Config, args []string) int { var canonical string // the normalized value we actually stored switch key { - case "auto_upgrade": - b, err := strconv.ParseBool(value) + case "browser_command": + cfg.BrowserCommand = value + canonical = value + case "history_limit": + n, err := strconv.Atoi(value) if err != nil { - fmt.Fprintln(os.Stderr, "auto_upgrade must be true or false") + fmt.Fprintln(os.Stderr, "history_limit must be an integer") return exitUsage } - cfg.AutoUpgrade = b - canonical = strconv.FormatBool(b) + cfg.HistoryLimit = n + canonical = strconv.Itoa(n) default: fmt.Fprintln(os.Stderr, "unknown config key: "+key) return exitUsage diff --git a/internal/cli/env.go b/internal/cli/env.go index a2fc6ab..a7b04d3 100644 --- a/internal/cli/env.go +++ b/internal/cli/env.go @@ -10,10 +10,9 @@ import ( "strconv" "strings" - "github.com/abstraction-dev/cli/internal/apiclient" - "github.com/abstraction-dev/cli/internal/browser" "github.com/abstraction-dev/cli/internal/config" "github.com/abstraction-dev/cli/internal/render" + "github.com/abstraction-dev/cli/internal/transport" "golang.org/x/term" ) @@ -38,7 +37,7 @@ type runOptions struct { // appEnv is a ready-to-use, authenticated CLI environment. type appEnv struct { cfg *config.Config - client *apiclient.Client + client *transport.Client render *render.Renderer workspace string } @@ -65,7 +64,7 @@ func ensureConfigured(ctx context.Context, opts runOptions, interactive bool) (* apiKey := cfg.APIKeyResolved() if apiKey == "" { - key, err := bootstrapAPIKey(ctx, r, baseURL) + key, err := bootstrapAPIKey(ctx, cfg, r, baseURL) if err != nil { return nil, err } @@ -76,7 +75,7 @@ func ensureConfigured(ctx context.Context, opts runOptions, interactive bool) (* apiKey = key } - client := apiclient.New(baseURL, apiKey) + client := transport.New(baseURL, apiKey) workspace := cfg.WorkspaceResolved() if opts.workspace != "" { @@ -102,11 +101,11 @@ func ensureConfigured(ctx context.Context, opts runOptions, interactive bool) (* // bootstrapAPIKey walks the first-run flow: open the settings page, prompt for // a pasted key, and validate it against the API before returning. -func bootstrapAPIKey(ctx context.Context, r *render.Renderer, baseURL string) (string, error) { - settingsURL := strings.TrimRight(baseURL, "/") + "/settings" +func bootstrapAPIKey(ctx context.Context, cfg *config.Config, r *render.Renderer, baseURL string) (string, error) { + settingsURL := transport.SettingsURL(baseURL) r.Info("No API key configured.") r.Info("Opening " + settingsURL + " — create an API key there, then paste it below.") - if err := browser.Open(settingsURL); err != nil { + if err := transport.OpenSettings(cfg, baseURL); err != nil { r.Info("(couldn't open a browser automatically — open the URL above manually)") } @@ -120,8 +119,8 @@ func bootstrapAPIKey(ctx context.Context, r *render.Renderer, baseURL string) (s continue } - if _, err := apiclient.New(baseURL, key).Workspaces(ctx); err != nil { - var apiErr *apiclient.APIError + if _, err := transport.New(baseURL, key).Workspaces(ctx); err != nil { + var apiErr *transport.APIError if errors.As(err, &apiErr) && apiErr.IsAuth() { r.Warn("that key was rejected — try again") continue @@ -135,7 +134,7 @@ func bootstrapAPIKey(ctx context.Context, r *render.Renderer, baseURL string) (s // pickWorkspace lists the user's workspaces and returns the chosen slug. With a // single workspace it auto-selects; with several it prompts. -func pickWorkspace(ctx context.Context, client *apiclient.Client, r *render.Renderer) (string, error) { +func pickWorkspace(ctx context.Context, client *transport.Client, r *render.Renderer) (string, error) { wss, err := client.Workspaces(ctx) if err != nil { return "", err @@ -211,7 +210,7 @@ func readLine(f *os.File, prompt string) (string, error) { // exitCodeFor maps an error to a process exit code. func exitCodeFor(err error) int { - var apiErr *apiclient.APIError + var apiErr *transport.APIError if errors.As(err, &apiErr) && apiErr.IsAuth() { return exitAuth } diff --git a/internal/cli/history.go b/internal/cli/history.go new file mode 100644 index 0000000..69dc734 --- /dev/null +++ b/internal/cli/history.go @@ -0,0 +1,102 @@ +package cli + +import ( + "flag" + "fmt" + "os" + "time" + + "github.com/abstraction-dev/cli/internal/config" + "github.com/abstraction-dev/cli/internal/history" + "github.com/abstraction-dev/cli/internal/render" +) + +// historyListDefault is how many entries `abstr history` shows without -n. +const historyListDefault = 20 + +// runHistory inspects or clears the locally stored exchanges. +func runHistory(args []string) int { + fs := flag.NewFlagSet("abstr history", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + var configPath string + var limit int + fs.StringVar(&configPath, "config", "", "config file path") + fs.IntVar(&limit, "n", historyListDefault, "how many entries to show") + if err := fs.Parse(args); err != nil { + return exitUsage + } + + cfg, err := config.Load(configPath) + if err != nil { + fmt.Fprintln(os.Stderr, "abstr: "+err.Error()) + return exitRuntime + } + + store, err := openHistory(cfg) + if err != nil { + fmt.Fprintln(os.Stderr, "abstr: "+err.Error()) + return exitRuntime + } + r := newRenderer() + + action := "list" + if rest := fs.Args(); len(rest) > 0 { + action = rest[0] + } + + switch action { + case "list": + return listHistory(store, r, limit) + case "path": + fmt.Println(store.FilePath()) + return exitOK + case "clear": + if err := store.Clear(); err != nil { + r.Error("abstr: " + err.Error()) + return exitRuntime + } + r.Success("History cleared.") + return exitOK + default: + fmt.Fprintln(os.Stderr, "unknown history command: "+action) + return exitUsage + } +} + +// listHistory prints the newest entries, one row each: when, workspace, and the +// question collapsed to the terminal width. +func listHistory(store *history.Store, r *render.Renderer, limit int) int { + entries, err := store.Recent(limit) + if err != nil { + r.Error("abstr: " + err.Error()) + return exitRuntime + } + + if len(entries) == 0 { + r.Info("No history yet.") + return exitOK + } + + width := render.TermWidth(os.Stdout) - 32 + for _, e := range entries { + fmt.Printf("%s %s %s\n", + e.AskedAt.Local().Format(time.RFC3339), + shortWorkspace(e.Workspace), + e.Headline(width)) + } + return exitOK +} + +// openHistory returns the history store sized by configuration. +func openHistory(cfg *config.Config) (*history.Store, error) { + return history.Open(cfg.HistoryLimitResolved()) +} + +// shortWorkspace trims a workspace slug to its leading segment, which is enough +// to tell rows apart without spending a full UUID per line. +func shortWorkspace(slug string) string { + if len(slug) <= 8 { + return slug + } + return slug[:8] +} diff --git a/internal/cli/login.go b/internal/cli/login.go index b85b2f0..afecdbf 100644 --- a/internal/cli/login.go +++ b/internal/cli/login.go @@ -6,8 +6,8 @@ import ( "fmt" "os" - "github.com/abstraction-dev/cli/internal/apiclient" "github.com/abstraction-dev/cli/internal/config" + "github.com/abstraction-dev/cli/internal/transport" ) // runLogin stores an API key (browser + paste) and picks a workspace. @@ -34,14 +34,14 @@ func runLogin(ctx context.Context, args []string) int { } r := newRenderer() - key, err := bootstrapAPIKey(ctx, r, baseURL) + key, err := bootstrapAPIKey(ctx, cfg, r, baseURL) if err != nil { r.Error(err.Error()) return exitAuth } cfg.APIKey = key - ws, err := pickWorkspace(ctx, apiclient.New(baseURL, key), r) + ws, err := pickWorkspace(ctx, transport.New(baseURL, key), r) if err != nil { r.Error(err.Error()) return exitRuntime diff --git a/internal/cli/repl.go b/internal/cli/repl.go index 3454c0e..63fcfdd 100644 --- a/internal/cli/repl.go +++ b/internal/cli/repl.go @@ -9,8 +9,8 @@ import ( "strings" "time" - "github.com/abstraction-dev/cli/internal/apiclient" "github.com/abstraction-dev/cli/internal/render" + "github.com/abstraction-dev/cli/internal/transport" "charm.land/bubbles/v2/textarea" "charm.land/bubbles/v2/viewport" @@ -132,7 +132,7 @@ func tickCmd() tea.Cmd { type ( nameResolvedMsg struct{ name string } pickerLoadedMsg struct { - items []apiclient.Workspace + items []transport.Workspace err error } switchResultMsg struct { @@ -140,7 +140,7 @@ type ( err error } prPickerLoadedMsg struct { - items []apiclient.PRReview + items []transport.PRReview err error } prSetResultMsg struct { @@ -149,11 +149,11 @@ type ( err error } chatPickerLoadedMsg struct { - items []apiclient.Chat + items []transport.Chat err error } chatLoadedMsg struct { - chat apiclient.ChatWithMessages + chat transport.ChatWithMessages err error } ) @@ -226,11 +226,11 @@ type replModel struct { turnStarted time.Time mode replMode - pickerItems []apiclient.Workspace + pickerItems []transport.Workspace pickerIdx int - prItems []apiclient.PRReview + prItems []transport.PRReview prIdx int - chatItems []apiclient.Chat + chatItems []transport.Chat chatIdx int } @@ -595,7 +595,7 @@ func (m *replModel) startTurn(query string) tea.Cmd { m.input.Blur() m.refresh() - req := apiclient.AskRequest{ + req := transport.AskRequest{ Workspace: m.env.workspace, Question: query, PR: m.activePR, @@ -604,7 +604,7 @@ func (m *replModel) startTurn(query string) tea.Cmd { sub := m.sub client := m.env.client go func() { - err := client.AskStream(ctx, req, apiclient.StreamHandlers{ + err := client.AskStream(ctx, req, transport.StreamHandlers{ OnOutput: func(t string) { sub <- deltaMsg(t) }, OnStatus: func(s string) { sub <- statusMsg(s) }, OnConversation: func(slug string) { sub <- conversationMsg(slug) }, @@ -657,7 +657,7 @@ func (m *replModel) newConversation() { // which is the same conversation the app shows. A PR conversation also restores its // scope, so the status bar says what the answers are grounded in and starting a new // conversation keeps that grounding. -func (m *replModel) resumeConversation(loaded apiclient.ChatWithMessages) { +func (m *replModel) resumeConversation(loaded transport.ChatWithMessages) { m.sessionID = loaded.Chat.Slug m.activePR = loaded.Chat.DiffReportID m.entries = nil @@ -674,12 +674,12 @@ func (m *replModel) resumeConversation(loaded apiclient.ChatWithMessages) { } for _, turn := range turns { switch turn.Role { - case apiclient.TurnUser: + case transport.TurnUser: m.entries = append(m.entries, transcriptEntry{entryUser, turn.Text}) // Past questions join this run's input history, so ↑ recalls them the // way it recalls the ones typed here. m.history = append(m.history, turn.Text) - case apiclient.TurnAssistant: + case transport.TurnAssistant: m.entries = append(m.entries, transcriptEntry{entryAnswer, turn.Text}) } } @@ -844,7 +844,7 @@ func (m *replModel) loadChatPickerCmd() tea.Cmd { // loadChatCmd fetches the chosen conversation's stored history, which the transcript // is rebuilt from. -func (m *replModel) loadChatCmd(chat apiclient.Chat) tea.Cmd { +func (m *replModel) loadChatCmd(chat transport.Chat) tea.Cmd { client := m.env.client return func() tea.Msg { loaded, err := client.GetChat(context.Background(), chat.Slug) @@ -890,7 +890,7 @@ func isPRURL(s string) bool { // matchPRReview finds the review a pasted PR URL points at — first by // normalised URL equality (so http/https and trailing-slash variants resolve), // then by the PR number in its /pull/ path as a fallback. -func matchPRReview(reviews []apiclient.PRReview, url string) *apiclient.PRReview { +func matchPRReview(reviews []transport.PRReview, url string) *transport.PRReview { norm := normalizePRURL(url) for i := range reviews { if normalizePRURL(reviews[i].PRURL) == norm { @@ -904,7 +904,7 @@ func matchPRReview(reviews []apiclient.PRReview, url string) *apiclient.PRReview return nil } -func prByNumber(reviews []apiclient.PRReview, n int) *apiclient.PRReview { +func prByNumber(reviews []transport.PRReview, n int) *transport.PRReview { for i := range reviews { if reviews[i].PRNumber == n { return &reviews[i] @@ -923,7 +923,7 @@ func normalizePRURL(u string) string { } // prLabel is the transcript label for a selected PR, e.g. "#123 · Fix the bug". -func prLabel(pr apiclient.PRReview) string { +func prLabel(pr transport.PRReview) string { if pr.PRTitle == "" { return fmt.Sprintf("#%d", pr.PRNumber) } @@ -1214,7 +1214,7 @@ func (m *replModel) refreshChatPicker() { // chatLabel names a conversation in the picker: its title, prefixed with the pull // request it is scoped to. A conversation the backend hasn't summarized yet may have // no title, so its slug stands in — it is still resumable. -func chatLabel(chat apiclient.Chat) string { +func chatLabel(chat transport.Chat) string { title := strings.TrimSpace(chat.Title) if title == "" { title = shortSlug(chat.Slug) @@ -1227,7 +1227,7 @@ func chatLabel(chat apiclient.Chat) string { // chatWhen renders when a conversation started, in local time. An unparseable // timestamp is left out rather than guessed at. -func chatWhen(chat apiclient.Chat) string { +func chatWhen(chat transport.Chat) string { started, err := time.Parse(time.RFC3339, chat.CreatedAt) if err != nil { return "" diff --git a/internal/cli/repl_frame_test.go b/internal/cli/repl_frame_test.go index a9b9163..eadc0ff 100644 --- a/internal/cli/repl_frame_test.go +++ b/internal/cli/repl_frame_test.go @@ -4,8 +4,8 @@ import ( "strings" "testing" - "github.com/abstraction-dev/cli/internal/apiclient" "github.com/abstraction-dev/cli/internal/render" + "github.com/abstraction-dev/cli/internal/transport" "charm.land/bubbles/v2/textarea" tea "charm.land/bubbletea/v2" @@ -73,7 +73,7 @@ func TestREPLFrame(t *testing.T) { } // Pickers: enter picker mode, arrow down, render, escape. - m.openChatPicker(chatPickerLoadedMsg{items: []apiclient.Chat{{Slug: "a", Title: "one"}, {Slug: "b", Title: "two"}}}) + m.openChatPicker(chatPickerLoadedMsg{items: []transport.Chat{{Slug: "a", Title: "one"}, {Slug: "b", Title: "two"}}}) mm, _ = mm.Update(tea.KeyPressMsg{Code: tea.KeyDown}) if m.chatIdx != 1 { t.Fatalf("picker down: idx=%d", m.chatIdx) diff --git a/internal/cli/repl_test.go b/internal/cli/repl_test.go index fd24777..9542ead 100644 --- a/internal/cli/repl_test.go +++ b/internal/cli/repl_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/abstraction-dev/cli/internal/apiclient" + "github.com/abstraction-dev/cli/internal/transport" "charm.land/bubbles/v2/textarea" tea "charm.land/bubbletea/v2" @@ -65,7 +65,7 @@ func TestHistoryNavigationEmpty(t *testing.T) { } func TestMatchPRReview(t *testing.T) { - reviews := []apiclient.PRReview{ + reviews := []transport.PRReview{ {PRNumber: 12, PRURL: "https://github.com/acme/web/pull/12", Status: "COMPLETED"}, {PRNumber: 34, PRURL: "https://github.com/acme/api/pull/34", Status: "IN_PROGRESS"}, } @@ -99,11 +99,11 @@ func TestMatchPRReview(t *testing.T) { } func TestPRReviewReady(t *testing.T) { - if !(apiclient.PRReview{Status: "COMPLETED"}).Ready() { + if !(transport.PRReview{Status: "COMPLETED"}).Ready() { t.Fatal("COMPLETED should be ready") } for _, s := range []string{"PENDING", "IN_PROGRESS", "FAILED", "CANCELLED", ""} { - if (apiclient.PRReview{Status: s}).Ready() { + if (transport.PRReview{Status: s}).Ready() { t.Fatalf("status %q should not be ready", s) } } @@ -123,17 +123,17 @@ func TestPRStatusLabel(t *testing.T) { } // exchange is one stored exchange as GET /api/chats/{slug} delivers it. -func exchange(raw string) apiclient.ChatMessage { - return apiclient.ChatMessage{Messages: []byte(raw)} +func exchange(raw string) transport.ChatMessage { + return transport.ChatMessage{Messages: []byte(raw)} } func TestResumeConversationReplaysTranscript(t *testing.T) { m := &replModel{input: textarea.New(), sessionID: "fresh-uuid"} m.entries = []transcriptEntry{{entrySystem, "banner"}} - m.resumeConversation(apiclient.ChatWithMessages{ - Chat: apiclient.Chat{Slug: "chat-1", Title: "Where is auth handled", Type: "DEFAULT"}, - Messages: []apiclient.ChatMessage{ + m.resumeConversation(transport.ChatWithMessages{ + Chat: transport.Chat{Slug: "chat-1", Title: "Where is auth handled", Type: "DEFAULT"}, + Messages: []transport.ChatMessage{ exchange(`[{"role":"user","content":[{"type":"text","text":"where is auth handled"}]}, {"role":"assistant","content":[{"type":"text","text":"In internal/auth."}]}]`), exchange(`[{"role":"user","content":[{"type":"text","text":"and the api keys"}]}, @@ -178,8 +178,8 @@ func TestResumeConversationReplaysTranscript(t *testing.T) { func TestResumeConversationRestoresPRScope(t *testing.T) { m := &replModel{input: textarea.New()} - m.resumeConversation(apiclient.ChatWithMessages{ - Chat: apiclient.Chat{Slug: "chat-2", Title: "Review", Type: apiclient.ChatTypePR, DiffReportID: "42", PRNumber: "123"}, + m.resumeConversation(transport.ChatWithMessages{ + Chat: transport.Chat{Slug: "chat-2", Title: "Review", Type: transport.ChatTypePR, DiffReportID: "42", PRNumber: "123"}, }) if m.activePR != "42" { @@ -194,9 +194,9 @@ func TestResumeConversationRestoresPRScope(t *testing.T) { func TestResumeConversationReportsUnreadableExchange(t *testing.T) { m := &replModel{input: textarea.New()} - m.resumeConversation(apiclient.ChatWithMessages{ - Chat: apiclient.Chat{Slug: "chat-3", Title: "Broken"}, - Messages: []apiclient.ChatMessage{ + m.resumeConversation(transport.ChatWithMessages{ + Chat: transport.Chat{Slug: "chat-3", Title: "Broken"}, + Messages: []transport.ChatMessage{ exchange(`not json`), exchange(`[{"role":"user","content":[{"type":"text","text":"still here"}]}]`), }, @@ -252,7 +252,7 @@ func TestConversationMsgIsAdopted(t *testing.T) { // The picker opens on the conversation being held, so the list says where you are. func TestOpenChatPickerPreselectsActiveConversation(t *testing.T) { m := &replModel{input: textarea.New(), sessionID: "chat-2"} - items := []apiclient.Chat{{Slug: "chat-1"}, {Slug: "chat-2"}, {Slug: "chat-3"}} + items := []transport.Chat{{Slug: "chat-1"}, {Slug: "chat-2"}, {Slug: "chat-3"}} m.openChatPicker(chatPickerLoadedMsg{items: items}) @@ -294,13 +294,13 @@ func TestOpenChatPickerEmptyAndError(t *testing.T) { func TestChatLabel(t *testing.T) { cases := []struct { name string - chat apiclient.Chat + chat transport.Chat want string }{ - {"title", apiclient.Chat{Slug: "0123456789ab", Title: "Where is auth handled"}, "Where is auth handled"}, - {"untitled falls back to the slug", apiclient.Chat{Slug: "0123456789ab"}, "01234567…"}, - {"pr chat is labelled by its pull request", apiclient.Chat{Title: "Review", Type: apiclient.ChatTypePR, PRNumber: "123"}, "#123 · Review"}, - {"pr chat without a number", apiclient.Chat{Title: "Review", Type: apiclient.ChatTypePR}, "Review"}, + {"title", transport.Chat{Slug: "0123456789ab", Title: "Where is auth handled"}, "Where is auth handled"}, + {"untitled falls back to the slug", transport.Chat{Slug: "0123456789ab"}, "01234567…"}, + {"pr chat is labelled by its pull request", transport.Chat{Title: "Review", Type: transport.ChatTypePR, PRNumber: "123"}, "#123 · Review"}, + {"pr chat without a number", transport.Chat{Title: "Review", Type: transport.ChatTypePR}, "Review"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -312,10 +312,10 @@ func TestChatLabel(t *testing.T) { } func TestChatWhen(t *testing.T) { - if when := chatWhen(apiclient.Chat{CreatedAt: "2026-07-20T10:11:12Z"}); when == "" { + if when := chatWhen(transport.Chat{CreatedAt: "2026-07-20T10:11:12Z"}); when == "" { t.Fatal("expected a formatted timestamp") } - if got := chatWhen(apiclient.Chat{CreatedAt: "not a time"}); got != "" { + if got := chatWhen(transport.Chat{CreatedAt: "not a time"}); got != "" { t.Fatalf("expected an unparseable timestamp to be left out, got %q", got) } } diff --git a/internal/cli/root.go b/internal/cli/root.go index 1161181..f961d77 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -24,8 +24,8 @@ func Main(args []string) int { return runWorkspace(ctx, args[1:]) case "config": return runConfig(args[1:]) - case "upgrade", "update": - return runUpgrade(ctx, args[1:]) + case "history": + return runHistory(args[1:]) case "help", "-h", "--help": printUsage(os.Stdout) return exitOK @@ -47,8 +47,8 @@ Usage: abstr login Store your API key and pick a workspace. abstr workspace [list|use ] Manage the current workspace. abstr config [path|show] Inspect CLI configuration. - abstr config set auto_upgrade Toggle automatic upgrades (true|false). - abstr upgrade [--check] Update abstr to the latest release. + abstr config set Set browser_command or history_limit. + abstr history [list|clear] Inspect locally stored exchanges. Flags: -w, -workspace Workspace to query (a UUID slug). diff --git a/internal/cli/update.go b/internal/cli/update.go deleted file mode 100644 index bfc0255..0000000 --- a/internal/cli/update.go +++ /dev/null @@ -1,200 +0,0 @@ -package cli - -import ( - "context" - "errors" - "flag" - "fmt" - "os" - "time" - - "github.com/abstraction-dev/cli/internal/config" - "github.com/abstraction-dev/cli/internal/render" - "github.com/abstraction-dev/cli/internal/selfupdate" -) - -// updateCheckInterval throttles the background "is there a newer release?" -// check so we hit the network at most once per window. -const updateCheckInterval = 24 * time.Hour - -// updateCheckTimeout bounds the background check so it never lingers. -const updateCheckTimeout = 3 * time.Second - -// checkResult is what a background release check reports back to finish. -type checkResult struct { - ran bool // the network check actually executed this run - tag string // resolved latest tag; "" when the check was skipped or failed -} - -// updateCheck is a background release check started alongside the main task. Its -// result is consumed once, at the end of the run, by finish. -type updateCheck struct { - cfg *config.Config - result chan checkResult -} - -// startUpdateCheck kicks off a throttled, concurrent release check. It returns a -// no-op check when updates are disabled (dev build, CI, opt-out, non-TTY) or -// when the last check was recent — in which case the run still acts on the -// cached LatestSeen. The check runs concurrently with the user's task so its -// latency is hidden behind work that is already happening. -func startUpdateCheck(ctx context.Context, cfg *config.Config) *updateCheck { - uc := &updateCheck{cfg: cfg, result: make(chan checkResult, 1)} - - if !updatesEnabled() || !checkDue(cfg) { - uc.result <- checkResult{ran: false} - return uc - } - - go func() { - cctx, cancel := context.WithTimeout(ctx, updateCheckTimeout) - defer cancel() - tag, err := selfupdate.LatestVersion(cctx) - if err != nil { - // Fail silent — background checks never nag on error — but report - // that the check ran so its timestamp still gets stamped. - uc.result <- checkResult{ran: true, tag: ""} - return - } - uc.result <- checkResult{ran: true, tag: tag} - }() - return uc -} - -// finish consumes the check result, persists the cache, and either prints an -// upgrade notice or (when auto-upgrade is enabled) applies the update in place. -// It is safe to call once per run, after the main task completes. -func finish(ctx context.Context, uc *updateCheck) { - res := <-uc.result - - // Whenever a check actually ran, stamp the timestamp — even on failure — so a - // persistent network error doesn't make every subsequent run re-check. - // Reload from disk first so we don't clobber fields the task wrote, and so - // the decision below sees the latest persisted state rather than the config - // we loaded at process start. - cfg := uc.cfg - if res.ran { - if fresh, err := config.Load(uc.cfg.FilePath()); err == nil { - fresh.LastUpdateCheck = nowUTC() - if res.tag != "" { - fresh.LatestSeen = res.tag - } - _ = fresh.Save() - cfg = fresh - } - } - - // Decide against the newest tag we know about: this run's result if we have - // one, else what the (freshly reloaded) config has cached. - newest := res.tag - if newest == "" { - newest = cfg.LatestSeen - } - if newest == "" || !selfupdate.IsNewer(version, newest) { - return - } - - r := newRenderer() - if cfg.AutoUpgrade { - applyAuto(ctx, r, newest) - return - } - r.Info(fmt.Sprintf("A new abstr (%s) is available — run 'abstr upgrade' to update.", newest)) -} - -// applyAuto performs an opt-in automatic upgrade, degrading to a notice when the -// binary lives somewhere we must not overwrite (a package-manager install). -func applyAuto(ctx context.Context, r *render.Renderer, tag string) { - r.Status("Upgrading abstr to " + tag + "…") - if err := selfupdate.Apply(ctx, tag); err != nil { - if errors.Is(err, selfupdate.ErrUnmanagedInstall) { - r.Info(fmt.Sprintf("A new abstr (%s) is available, but this install is managed elsewhere — upgrade with your package manager.", tag)) - return - } - r.Warn("auto-upgrade failed: " + err.Error()) - return - } - r.Info(fmt.Sprintf("Upgraded abstr to %s — it takes effect on your next run.", tag)) -} - -// updatesEnabled reports whether background update behavior should run at all. -// Local dev builds, CI, an explicit opt-out, and non-interactive output are all -// exempt so scripts and pipelines stay quiet and deterministic. -func updatesEnabled() bool { - if version == "dev" { - return false - } - if os.Getenv("ABSTR_NO_UPDATE_CHECK") != "" { - return false - } - if os.Getenv("CI") != "" { - return false - } - return render.IsTerminal(os.Stderr) -} - -// checkDue reports whether enough time has passed since the last check. -func checkDue(cfg *config.Config) bool { - if cfg.LastUpdateCheck == "" { - return true - } - last, err := time.Parse(time.RFC3339, cfg.LastUpdateCheck) - if err != nil { - return true - } - return time.Since(last) >= updateCheckInterval -} - -func nowUTC() string { return time.Now().UTC().Format(time.RFC3339) } - -// runUpgrade implements `abstr upgrade`: resolve the latest release and replace -// the running binary. With --check it only reports availability. -func runUpgrade(ctx context.Context, args []string) int { - fs := flag.NewFlagSet("abstr upgrade", flag.ContinueOnError) - fs.SetOutput(os.Stderr) - var checkOnly bool - fs.BoolVar(&checkOnly, "check", false, "report whether an update is available without installing") - if err := fs.Parse(args); err != nil { - return exitUsage - } - - // Bound the whole operation so a stalled connection can't hang the command. - ctx, cancel := context.WithTimeout(ctx, 3*time.Minute) - defer cancel() - - r := newRenderer() - - latest, err := selfupdate.LatestVersion(ctx) - if err != nil { - r.Error("abstr: could not resolve the latest release: " + err.Error()) - return exitRuntime - } - - isDev := version == "dev" - if !isDev && !selfupdate.IsNewer(version, latest) { - r.Info(fmt.Sprintf("abstr is up to date (%s).", version)) - return exitOK - } - - if checkOnly { - if isDev { - r.Info(fmt.Sprintf("Running a dev build; latest release is %s.", latest)) - } else { - r.Info(fmt.Sprintf("A new abstr (%s) is available (current: %s).", latest, version)) - } - return exitOK - } - - r.Status("Downloading abstr " + latest + "…") - if err := selfupdate.Apply(ctx, latest); err != nil { - if errors.Is(err, selfupdate.ErrUnmanagedInstall) { - r.Error("abstr: " + err.Error()) - return exitRuntime - } - r.Error("abstr: upgrade failed: " + err.Error()) - return exitRuntime - } - - r.Info("Upgraded abstr to " + latest + ".") - return exitOK -} diff --git a/internal/cli/workspace.go b/internal/cli/workspace.go index 7ead4b8..8daf8f1 100644 --- a/internal/cli/workspace.go +++ b/internal/cli/workspace.go @@ -7,9 +7,9 @@ import ( "os" "strings" - "github.com/abstraction-dev/cli/internal/apiclient" "github.com/abstraction-dev/cli/internal/config" "github.com/abstraction-dev/cli/internal/render" + "github.com/abstraction-dev/cli/internal/transport" ) // runWorkspace manages the current workspace: pick (default), list, or use. @@ -37,10 +37,10 @@ func runWorkspace(ctx context.Context, args []string) int { key := cfg.APIKeyResolved() if key == "" { - r.Errorf("not logged in — run `abstr login`") + r.Error("not logged in — run `abstr login`") return exitAuth } - client := apiclient.New(baseURL, key) + client := transport.New(baseURL, key) action := "pick" if len(sub) > 0 { @@ -74,7 +74,7 @@ func runWorkspace(ctx context.Context, args []string) int { case "use": if len(sub) < 2 { - r.Errorf("usage: abstr workspace use ") + r.Error("usage: abstr workspace use ") return exitUsage } // Join the remaining tokens so an unquoted multi-word name (e.g. @@ -90,11 +90,11 @@ func runWorkspace(ctx context.Context, args []string) int { return saveWorkspace(cfg, r, w.Slug) } } - r.Errorf("no workspace matching %q", target) + r.Error(fmt.Sprintf("no workspace matching %q", target)) return exitRuntime default: - r.Errorf("unknown workspace command: %s", action) + r.Error("unknown workspace command: " + action) return exitUsage } } diff --git a/internal/config/config.go b/internal/config/config.go index 0bad26a..801d44a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,12 +12,16 @@ import ( // DefaultBaseURL is the production API host used when nothing overrides it. const DefaultBaseURL = "https://app.abstraction.dev" +// DefaultHistoryLimit is how many exchanges local history keeps by default. +const DefaultHistoryLimit = 50 + const ( fileName = ".abstr.json" envAPIKey = "ABSTR_API_KEY" envWorkspace = "ABSTR_WORKSPACE" envBaseURL = "ABSTR_API_URL" envConfigPath = "ABSTR_CONFIG" + envBrowser = "ABSTR_BROWSER" ) // Config is the on-disk CLI configuration. @@ -26,13 +30,12 @@ type Config struct { Workspace string `json:"workspace,omitempty"` APIBaseURL string `json:"api_base_url,omitempty"` - // AutoUpgrade, when true, applies a newer release automatically instead of - // only printing a notice. See internal/selfupdate. - AutoUpgrade bool `json:"auto_upgrade,omitempty"` - // LastUpdateCheck is when the background update check last ran (RFC3339). - LastUpdateCheck string `json:"last_update_check,omitempty"` - // LatestSeen is the newest release tag the last check observed (e.g. v1.3.0). - LatestSeen string `json:"latest_seen,omitempty"` + // BrowserCommand overrides how URLs are opened (e.g. "firefox"). Empty + // means the platform default launcher. + BrowserCommand string `json:"browser_command,omitempty"` + // HistoryLimit caps how many exchanges the local history file keeps. Zero + // means DefaultHistoryLimit. + HistoryLimit int `json:"history_limit,omitempty"` path string // where this was loaded from / will be saved to } @@ -118,3 +121,21 @@ func (c *Config) BaseURLResolved() string { } return DefaultBaseURL } + +// BrowserCommandResolved returns the effective browser command: $ABSTR_BROWSER +// over the file. Empty means use the platform default launcher. +func (c *Config) BrowserCommandResolved() string { + if v := os.Getenv(envBrowser); v != "" { + return v + } + return c.BrowserCommand +} + +// HistoryLimitResolved returns the effective history cap, falling back to +// DefaultHistoryLimit. A negative limit disables history entirely. +func (c *Config) HistoryLimitResolved() int { + if c.HistoryLimit == 0 { + return DefaultHistoryLimit + } + return c.HistoryLimit +} diff --git a/internal/history/history.go b/internal/history/history.go new file mode 100644 index 0000000..3d182a3 --- /dev/null +++ b/internal/history/history.go @@ -0,0 +1,154 @@ +// Package history persists recent question/answer exchanges to a local file so +// past conversations can be reviewed offline. It is deliberately independent of +// the API client: history is written from whatever the CLI already rendered. +package history + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "time" +) + +const fileName = ".abstr-history.json" + +// envPath overrides where history is stored, mainly for tests. +const envPath = "ABSTR_HISTORY" + +// Entry is one recorded exchange. +type Entry struct { + AskedAt time.Time `json:"asked_at"` + Workspace string `json:"workspace"` + Question string `json:"question"` + Answer string `json:"answer"` +} + +// Store is an append-only history file, trimmed to the newest limit entries. +type Store struct { + path string + limit int +} + +// Path returns the history file location: $ABSTR_HISTORY, else +// ~/.abstr-history.json. +func Path() (string, error) { + if p := os.Getenv(envPath); p != "" { + return p, nil + } + + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, fileName), nil +} + +// Open returns a Store at the default path, keeping at most limit entries. A +// limit of zero or less disables writes, so history can be turned off without +// the callers branching. +func Open(limit int) (*Store, error) { + path, err := Path() + if err != nil { + return nil, err + } + return &Store{path: path, limit: limit}, nil +} + +// At returns a Store at an explicit path. +func At(path string, limit int) *Store { + return &Store{path: path, limit: limit} +} + +// FilePath returns the resolved history path. +func (s *Store) FilePath() string { return s.path } + +// Enabled reports whether this store writes anything. +func (s *Store) Enabled() bool { return s.limit > 0 } + +// Append records one exchange, trimming the file to the newest limit entries. +// A blank question or answer is dropped: a cancelled turn is not history. +func (s *Store) Append(e Entry) error { + if !s.Enabled() || strings.TrimSpace(e.Question) == "" || strings.TrimSpace(e.Answer) == "" { + return nil + } + + entries, err := s.All() + if err != nil { + return err + } + + if e.AskedAt.IsZero() { + e.AskedAt = time.Now() + } + entries = append(entries, e) + + if len(entries) > s.limit { + entries = entries[len(entries)-s.limit:] + } + return s.write(entries) +} + +// All returns every stored entry, oldest first. A missing file is empty, not an +// error, so a first run reads cleanly. +func (s *Store) All() ([]Entry, error) { + data, err := os.ReadFile(s.path) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + + if err != nil { + return nil, err + } + + var entries []Entry + if err := json.Unmarshal(data, &entries); err != nil { + return nil, err + } + return entries, nil +} + +// Recent returns the newest n entries, newest first. +func (s *Store) Recent(n int) ([]Entry, error) { + entries, err := s.All() + if err != nil { + return nil, err + } + + if n > 0 && len(entries) > n { + entries = entries[len(entries)-n:] + } + + out := make([]Entry, 0, len(entries)) + for i := len(entries) - 1; i >= 0; i-- { + out = append(out, entries[i]) + } + return out, nil +} + +// Clear removes the history file. A missing file is a no-op. +func (s *Store) Clear() error { + if err := os.Remove(s.path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil +} + +// Headline is the entry's question collapsed to a single line, truncated to +// width runes so a listing stays one row per entry. +func (e Entry) Headline(width int) string { + q := strings.Join(strings.Fields(e.Question), " ") + if width <= 0 || len([]rune(q)) <= width { + return q + } + return string([]rune(q)[:width-1]) + "…" +} + +func (s *Store) write(entries []Entry) error { + data, err := json.MarshalIndent(entries, "", " ") + if err != nil { + return err + } + return os.WriteFile(s.path, append(data, '\n'), 0o600) +} diff --git a/internal/history/history_test.go b/internal/history/history_test.go new file mode 100644 index 0000000..31c6d5f --- /dev/null +++ b/internal/history/history_test.go @@ -0,0 +1,131 @@ +package history + +import ( + "path/filepath" + "testing" + "time" +) + +func testStore(t *testing.T, limit int) *Store { + t.Helper() + return At(filepath.Join(t.TempDir(), "history.json"), limit) +} + +func TestAppendTrimsToLimit(t *testing.T) { + s := testStore(t, 2) + for _, q := range []string{"first", "second", "third"} { + if err := s.Append(Entry{Question: q, Answer: "a"}); err != nil { + t.Fatalf("append %s: %v", q, err) + } + } + + got, err := s.Recent(0) + if err != nil { + t.Fatalf("recent: %v", err) + } + + if len(got) != 2 { + t.Fatalf("kept %d entries, want 2", len(got)) + } + + // Recent is newest-first, so the trimmed-away "first" must be gone. + if got[0].Question != "third" || got[1].Question != "second" { + t.Fatalf("kept %q, %q; want third, second", got[0].Question, got[1].Question) + } +} + +func TestAppendSkipsBlankAndDisabled(t *testing.T) { + s := testStore(t, 5) + if err := s.Append(Entry{Question: " ", Answer: "a"}); err != nil { + t.Fatalf("blank question: %v", err) + } + + if err := s.Append(Entry{Question: "q", Answer: ""}); err != nil { + t.Fatalf("blank answer: %v", err) + } + + got, err := s.All() + if err != nil { + t.Fatalf("all: %v", err) + } + + if len(got) != 0 { + t.Fatalf("stored %d blank entries, want 0", len(got)) + } + + off := testStore(t, 0) + if off.Enabled() { + t.Fatal("zero limit should disable the store") + } + + if err := off.Append(Entry{Question: "q", Answer: "a"}); err != nil { + t.Fatalf("disabled append: %v", err) + } +} + +func TestAppendStampsAskedAt(t *testing.T) { + s := testStore(t, 5) + if err := s.Append(Entry{Question: "q", Answer: "a"}); err != nil { + t.Fatalf("append: %v", err) + } + + got, err := s.Recent(1) + if err != nil { + t.Fatalf("recent: %v", err) + } + + if got[0].AskedAt.IsZero() { + t.Fatal("AskedAt was not stamped") + } + + if d := time.Since(got[0].AskedAt); d > time.Minute { + t.Fatalf("AskedAt is %v old, want ~now", d) + } +} + +func TestAllOnMissingFileIsEmpty(t *testing.T) { + got, err := testStore(t, 5).All() + if err != nil { + t.Fatalf("all: %v", err) + } + + if len(got) != 0 { + t.Fatalf("got %d entries from a missing file", len(got)) + } +} + +func TestClearRemovesHistory(t *testing.T) { + s := testStore(t, 5) + if err := s.Append(Entry{Question: "q", Answer: "a"}); err != nil { + t.Fatalf("append: %v", err) + } + + if err := s.Clear(); err != nil { + t.Fatalf("clear: %v", err) + } + + // A second clear must stay a no-op: the file is already gone. + if err := s.Clear(); err != nil { + t.Fatalf("second clear: %v", err) + } + + got, err := s.All() + if err != nil { + t.Fatalf("all: %v", err) + } + + if len(got) != 0 { + t.Fatalf("got %d entries after clear", len(got)) + } +} + +func TestHeadlineCollapsesAndTruncates(t *testing.T) { + e := Entry{Question: "how does\nauth\twork"} + if got := e.Headline(0); got != "how does auth work" { + t.Fatalf("Headline(0) = %q", got) + } + + if got := e.Headline(8); got != "how doe…" { + t.Fatalf("Headline(8) = %q, want %q", got, "how doe…") + } +} diff --git a/internal/render/render.go b/internal/render/render.go index cc2a878..4d80529 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -134,6 +134,7 @@ const ( ansiCyan = "\033[36m" ansiRed = "\033[31m" ansiYellow = "\033[33m" + ansiGreen = "\033[32m" ) // Renderer writes agent output to Out and everything else (prompts, status, @@ -180,10 +181,8 @@ func (r *Renderer) Warn(s string) { fmt.Fprintln(r.Err, r.paint(ansiYellow, s)) // Error prints a plain (non-format) error line to stderr. func (r *Renderer) Error(s string) { fmt.Fprintln(r.Err, r.paint(ansiRed, s)) } -// Errorf prints a formatted error line to stderr. -func (r *Renderer) Errorf(format string, a ...any) { - fmt.Fprintln(r.Err, r.paint(ansiRed, fmt.Sprintf(format, a...))) -} +// Success prints a confirmation line to stderr (green). +func (r *Renderer) Success(s string) { fmt.Fprintln(r.Err, r.paint(ansiGreen, s)) } // Prompt returns the interactive REPL prompt string, colored when enabled. func (r *Renderer) Prompt(label string) string { return r.paint(ansiBold+ansiCyan, label) } diff --git a/internal/selfupdate/apply.go b/internal/selfupdate/apply.go deleted file mode 100644 index 8fd880b..0000000 --- a/internal/selfupdate/apply.go +++ /dev/null @@ -1,243 +0,0 @@ -package selfupdate - -import ( - "archive/tar" - "bytes" - "compress/gzip" - "context" - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "io" - "net/http" - "os" - "path" - "path/filepath" - "runtime" - "strings" - "time" -) - -// maxAssetBytes caps how much we will read for a release asset, as a guard -// against a pathological response. The binary is a few MB. -const maxAssetBytes = 200 << 20 // 200 MiB - -// downloadTimeout bounds a single asset download end-to-end. -const downloadTimeout = 2 * time.Minute - -// downloadClient fetches release assets. Its Timeout is a backstop; callers also -// pass a context deadline. -var downloadClient = &http.Client{Timeout: downloadTimeout} - -// ErrUnmanagedInstall is returned when the running binary lives somewhere we -// must not overwrite — a package-manager prefix (Homebrew, Nix) or a directory -// we cannot write to. The caller should tell the user to use their installer. -var ErrUnmanagedInstall = errors.New("binary is not in a self-updatable location") - -// Apply downloads the release archive for tag, verifies it against the -// published sha256sums.txt, and atomically replaces the running executable. -// Self-upgrade is Unix-only; on Windows, install manually or use WSL. -func Apply(ctx context.Context, tag string) error { - if runtime.GOOS == "windows" { - return errors.New("self-upgrade is not supported on Windows — download the latest release manually or use WSL") - } - - exe, err := resolveExecutable() - if err != nil { - return err - } - if err := checkWritable(exe); err != nil { - return err - } - - asset := assetName() - base := "https://github.com/" + repoSlug + "/releases/download/" + tag + "/" - - archive, err := download(ctx, base+asset) - if err != nil { - return fmt.Errorf("download %s: %w", asset, err) - } - - sums, err := download(ctx, base+"sha256sums.txt") - if err != nil { - return fmt.Errorf("download checksums: %w", err) - } - if err := verifyChecksum(archive, sums, asset); err != nil { - return err - } - - binary, err := extractBinary(archive) - if err != nil { - return err - } - - return replaceBinary(exe, binary) -} - -// resolveExecutable returns the real on-disk path of the running binary, with -// symlinks resolved so that package-manager installs (which symlink into a bin -// dir from a versioned cellar) are detected by their true location. -func resolveExecutable() (string, error) { - exe, err := os.Executable() - if err != nil { - return "", err - } - if resolved, err := filepath.EvalSymlinks(exe); err == nil { - exe = resolved - } - return exe, nil -} - -// checkWritable refuses installs we must not touch: package-manager prefixes -// (their upgrades are owned by the manager) and directories we cannot write to. -func checkWritable(exe string) error { - lower := strings.ToLower(exe) - for _, marker := range []string{"/cellar/", "/opt/homebrew/", "/nix/store/", "/var/lib/flatpak/"} { - if strings.Contains(lower, marker) { - return fmt.Errorf("%w: %s looks like a package-manager install; upgrade with your package manager instead", ErrUnmanagedInstall, exe) - } - } - - dir := filepath.Dir(exe) - probe, err := os.CreateTemp(dir, ".abstr-update-*") - if err != nil { - return fmt.Errorf("%w: cannot write to %s: %v", ErrUnmanagedInstall, dir, err) - } - name := probe.Name() - probe.Close() - os.Remove(name) - return nil -} - -// replaceBinary installs newBinary at target atomically: it writes to a temp -// file in the same directory (so os.Rename stays on one filesystem and is -// atomic), preserves the current file's permissions, then renames over the -// target. The old binary is never removed until the rename succeeds, so a -// failure leaves the existing install untouched — no rollback needed. Replacing -// a running binary is safe on Unix: the process keeps its open inode. -func replaceBinary(target string, newBinary []byte) error { - perm := os.FileMode(0o755) - if info, err := os.Stat(target); err == nil { - perm = info.Mode().Perm() - } - - dir := filepath.Dir(target) - tmp, err := os.CreateTemp(dir, ".abstr-new-*") - if err != nil { - return fmt.Errorf("create temp binary: %w", err) - } - tmpName := tmp.Name() - // Remove the temp file if we don't successfully rename it into place. - defer os.Remove(tmpName) - - if _, err := tmp.Write(newBinary); err != nil { - tmp.Close() - return fmt.Errorf("write new binary: %w", err) - } - if err := tmp.Chmod(perm); err != nil { - tmp.Close() - return fmt.Errorf("chmod new binary: %w", err) - } - if err := tmp.Close(); err != nil { - return fmt.Errorf("close new binary: %w", err) - } - - if err := os.Rename(tmpName, target); err != nil { - return fmt.Errorf("replace %s: %w", target, err) - } - return nil -} - -// assetName is the release archive for the current platform (see -// .goreleaser.yaml). Every supported platform ships as tar.gz. -func assetName() string { - return fmt.Sprintf("%s_%s_%s.tar.gz", binaryName, runtime.GOOS, runtime.GOARCH) -} - -// download fetches a URL fully into memory, erroring if the response exceeds -// maxAssetBytes rather than silently truncating. -func download(ctx context.Context, url string) ([]byte, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, err - } - resp, err := downloadClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status %s", resp.Status) - } - - // Read one byte past the cap so we can distinguish "exactly at cap" from - // "over cap" and fail loudly on an oversized (or unbounded) response. - body, err := io.ReadAll(io.LimitReader(resp.Body, maxAssetBytes+1)) - if err != nil { - return nil, err - } - if len(body) > maxAssetBytes { - return nil, fmt.Errorf("response exceeds %d bytes", maxAssetBytes) - } - return body, nil -} - -// verifyChecksum confirms archive's sha256 matches the entry for asset in a -// sha256sums.txt manifest. Lines are " "; a leading '*' on the -// filename (sha256sum's binary-mode output) is tolerated. -func verifyChecksum(archive, sums []byte, asset string) error { - var want string - for _, line := range strings.Split(string(sums), "\n") { - fields := strings.Fields(line) - if len(fields) == 2 && strings.TrimPrefix(fields[1], "*") == asset { - want = fields[0] - break - } - } - if want == "" { - return fmt.Errorf("no checksum entry for %s", asset) - } - - sum := sha256.Sum256(archive) - got := hex.EncodeToString(sum[:]) - if !strings.EqualFold(got, want) { - return fmt.Errorf("checksum mismatch for %s: expected %s, got %s", asset, want, got) - } - return nil -} - -// extractBinary pulls the abstr binary out of the release tarball. It sits at -// the archive root. -func extractBinary(archive []byte) ([]byte, error) { - gz, err := gzip.NewReader(bytes.NewReader(archive)) - if err != nil { - return nil, err - } - defer gz.Close() - - tr := tar.NewReader(gz) - for { - hdr, err := tr.Next() - if errors.Is(err, io.EOF) { - break - } - if err != nil { - return nil, err - } - if path.Base(hdr.Name) == binaryName && hdr.Typeflag == tar.TypeReg { - // Read one byte past the cap to detect an oversized (or maliciously - // inflated) entry rather than silently truncating it — the archive - // checksum only covers the compressed bytes, not this payload. - bin, err := io.ReadAll(io.LimitReader(tr, maxAssetBytes+1)) - if err != nil { - return nil, err - } - if len(bin) > maxAssetBytes { - return nil, fmt.Errorf("extracted binary exceeds %d bytes", maxAssetBytes) - } - return bin, nil - } - } - return nil, fmt.Errorf("archive did not contain a %q binary", binaryName) -} diff --git a/internal/selfupdate/selfupdate.go b/internal/selfupdate/selfupdate.go deleted file mode 100644 index 623f989..0000000 --- a/internal/selfupdate/selfupdate.go +++ /dev/null @@ -1,91 +0,0 @@ -// Package selfupdate resolves the newest published release and replaces the -// running binary in place. Releases are published to GitHub Releases by -// GoReleaser (see .goreleaser.yaml); each release carries per-os/arch tarballs -// named abstr_{os}_{arch}.tar.gz alongside a sha256sums.txt manifest. -package selfupdate - -import ( - "context" - "fmt" - "net/http" - "strings" - "time" - - "golang.org/x/mod/semver" -) - -// repoSlug is the GitHub owner/name that publishes releases. -const repoSlug = "abstraction-dev/cli" - -// binaryName is the released binary/basename (matches .goreleaser.yaml). -const binaryName = "abstr" - -// resolveTimeout is a backstop on the latest-version lookup so a stalled -// connection can't hang the command; callers also pass a context deadline. -const resolveTimeout = 10 * time.Second - -// LatestVersion resolves the newest published tag (e.g. "v1.3.0") without hitting -// the GitHub API: a HEAD on releases/latest 302-redirects to the tagged release, -// and the tag is the final path segment of the Location header. This avoids the -// API's unauthenticated rate limit and returns no JSON to parse. -func LatestVersion(ctx context.Context) (string, error) { - // Do not follow the redirect — we want to read its Location, not the page. - client := &http.Client{ - Timeout: resolveTimeout, - CheckRedirect: func(*http.Request, []*http.Request) error { - return http.ErrUseLastResponse - }, - } - - url := "https://github.com/" + repoSlug + "/releases/latest" - req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil) - if err != nil { - return "", err - } - - resp, err := client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - - if resp.StatusCode < 300 || resp.StatusCode >= 400 { - return "", fmt.Errorf("unexpected status resolving latest release: %s", resp.Status) - } - - loc := resp.Header.Get("Location") - if loc == "" { - return "", fmt.Errorf("no Location header on releases/latest redirect") - } - - tag := loc[strings.LastIndex(loc, "/")+1:] - if !semver.IsValid(tag) { - return "", fmt.Errorf("resolved latest tag %q is not valid semver", tag) - } - return tag, nil -} - -// IsNewer reports whether latest is a strictly higher semver than current. -// A "dev" (or otherwise non-semver) current version is never considered -// upgradeable — local builds should not self-update. -func IsNewer(current, latest string) bool { - cur := normalize(current) - if !semver.IsValid(cur) || !semver.IsValid(latest) { - return false - } - return semver.Compare(latest, cur) > 0 -} - -// normalize turns a bare or "abstr "-prefixed version into a semver-comparable -// string with a leading "v". -func normalize(v string) string { - v = strings.TrimSpace(strings.TrimPrefix(v, binaryName)) - v = strings.TrimSpace(v) - if v == "" { - return v - } - if !strings.HasPrefix(v, "v") { - v = "v" + v - } - return v -} diff --git a/internal/selfupdate/selfupdate_test.go b/internal/selfupdate/selfupdate_test.go deleted file mode 100644 index 4a4abd6..0000000 --- a/internal/selfupdate/selfupdate_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package selfupdate - -import ( - "archive/tar" - "bytes" - "compress/gzip" - "crypto/sha256" - "encoding/hex" - "testing" -) - -func TestIsNewer(t *testing.T) { - cases := []struct { - current, latest string - want bool - }{ - {"v1.2.3", "v1.2.4", true}, - {"1.2.3", "v1.2.4", true}, // bare current is normalized - {"abstr 1.2.3", "v2.0.0", true}, // "abstr "-prefixed current - {"v1.2.3", "v1.2.3", false}, - {"v1.2.4", "v1.2.3", false}, // downgrade - {"dev", "v1.2.3", false}, // local build never upgrades - {"v1.2.3", "garbage", false}, - } - for _, c := range cases { - if got := IsNewer(c.current, c.latest); got != c.want { - t.Errorf("IsNewer(%q, %q) = %v, want %v", c.current, c.latest, got, c.want) - } - } -} - -func TestVerifyChecksum(t *testing.T) { - archive := []byte("pretend this is a tarball") - sum := sha256.Sum256(archive) - asset := "abstr_linux_amd64.tar.gz" - manifest := []byte( - "deadbeef abstr_darwin_arm64.tar.gz\n" + - hex.EncodeToString(sum[:]) + " " + asset + "\n", - ) - - if err := verifyChecksum(archive, manifest, asset); err != nil { - t.Fatalf("verifyChecksum matching: %v", err) - } - - // sha256sum's binary-mode output prefixes the filename with '*'. - binModeManifest := []byte(hex.EncodeToString(sum[:]) + " *" + asset + "\n") - if err := verifyChecksum(archive, binModeManifest, asset); err != nil { - t.Fatalf("verifyChecksum binary-mode (*filename): %v", err) - } - - if err := verifyChecksum([]byte("tampered"), manifest, asset); err == nil { - t.Error("verifyChecksum should fail on mismatch") - } - - if err := verifyChecksum(archive, manifest, "abstr_windows_amd64.zip"); err == nil { - t.Error("verifyChecksum should fail when asset absent from manifest") - } -} - -func TestExtractFromTarGz(t *testing.T) { - want := []byte("#!/binary payload") - archive := makeTarGz(t, map[string][]byte{ - "README.md": []byte("docs"), - "abstr": want, - }) - - got, err := extractBinary(archive) - if err != nil { - t.Fatalf("extractBinary: %v", err) - } - if !bytes.Equal(got, want) { - t.Errorf("extracted %q, want %q", got, want) - } -} - -func TestExtractFromTarGzMissing(t *testing.T) { - archive := makeTarGz(t, map[string][]byte{"README.md": []byte("docs")}) - if _, err := extractBinary(archive); err == nil { - t.Error("extractBinary should fail when binary absent") - } -} - -func makeTarGz(t *testing.T, files map[string][]byte) []byte { - t.Helper() - var buf bytes.Buffer - gz := gzip.NewWriter(&buf) - tw := tar.NewWriter(gz) - for name, data := range files { - hdr := &tar.Header{Name: name, Mode: 0o755, Size: int64(len(data)), Typeflag: tar.TypeReg} - if err := tw.WriteHeader(hdr); err != nil { - t.Fatal(err) - } - if _, err := tw.Write(data); err != nil { - t.Fatal(err) - } - } - if err := tw.Close(); err != nil { - t.Fatal(err) - } - if err := gz.Close(); err != nil { - t.Fatal(err) - } - return buf.Bytes() -} diff --git a/internal/apiclient/client.go b/internal/transport/client.go similarity index 98% rename from internal/apiclient/client.go rename to internal/transport/client.go index 33a7468..d7fa560 100644 --- a/internal/apiclient/client.go +++ b/internal/transport/client.go @@ -1,6 +1,6 @@ -// Package apiclient is the HTTP + SSE client for the Abstraction backend's CLI +// Package transport is the HTTP + SSE client for the Abstraction backend's CLI // endpoints. -package apiclient +package transport import ( "bytes" diff --git a/internal/apiclient/client_test.go b/internal/transport/client_test.go similarity index 99% rename from internal/apiclient/client_test.go rename to internal/transport/client_test.go index 41bfca0..3a06070 100644 --- a/internal/apiclient/client_test.go +++ b/internal/transport/client_test.go @@ -1,4 +1,4 @@ -package apiclient +package transport import ( "context" diff --git a/internal/apiclient/errors.go b/internal/transport/errors.go similarity index 97% rename from internal/apiclient/errors.go rename to internal/transport/errors.go index 88f49da..72168d3 100644 --- a/internal/apiclient/errors.go +++ b/internal/transport/errors.go @@ -1,4 +1,4 @@ -package apiclient +package transport import ( "fmt" diff --git a/internal/transport/settings.go b/internal/transport/settings.go new file mode 100644 index 0000000..4686653 --- /dev/null +++ b/internal/transport/settings.go @@ -0,0 +1,20 @@ +package transport + +import ( + "strings" + + "github.com/abstraction-dev/cli/internal/browser" + "github.com/abstraction-dev/cli/internal/config" +) + +// SettingsURL is the page where a user creates an API key. +func SettingsURL(baseURL string) string { + return strings.TrimRight(baseURL, "/") + "/settings" +} + +// OpenSettings launches the settings page in the user's browser, honouring a +// configured browser command. Callers fall back to printing the URL on error, +// since a headless session has no launcher. +func OpenSettings(cfg *config.Config, baseURL string) error { + return browser.OpenWithConfig(cfg, SettingsURL(baseURL)) +} diff --git a/internal/apiclient/sse.go b/internal/transport/sse.go similarity index 99% rename from internal/apiclient/sse.go rename to internal/transport/sse.go index 544d368..df46f08 100644 --- a/internal/apiclient/sse.go +++ b/internal/transport/sse.go @@ -1,4 +1,4 @@ -package apiclient +package transport // sseReader is a lightweight Server-Sent Events reader. It is a copy of // llmkit/internal/sse/reader.go (that package is internal to the llmkit module diff --git a/internal/apiclient/sse_test.go b/internal/transport/sse_test.go similarity index 99% rename from internal/apiclient/sse_test.go rename to internal/transport/sse_test.go index e64a412..a3b77d5 100644 --- a/internal/apiclient/sse_test.go +++ b/internal/transport/sse_test.go @@ -1,4 +1,4 @@ -package apiclient +package transport import ( "errors" diff --git a/internal/apiclient/types.go b/internal/transport/types.go similarity index 99% rename from internal/apiclient/types.go rename to internal/transport/types.go index e00be82..323aeee 100644 --- a/internal/apiclient/types.go +++ b/internal/transport/types.go @@ -1,4 +1,4 @@ -package apiclient +package transport import ( "encoding/json"