Skip to content
Open
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
27 changes: 14 additions & 13 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,22 +53,20 @@ Interactive commands: `/pr <url|number>`, `/pr clear`, `/workspace [slug]`,
| `abstr workspace use <slug\|name>` | Switch the current workspace. |
| `abstr workspace` | Interactive workspace picker. |
| `abstr config path` / `abstr config show` | Inspect configuration. |
| `abstr config set auto_upgrade <true\|false>` | Toggle automatic upgrades. |
| `abstr upgrade` / `abstr upgrade --check` | Update to the latest release (or just check). |
| `abstr config set <key> <value>` | 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

Expand All@@ -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.
12 changes: 12 additions & 0 deletions internal/browser/open.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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 `<command> <url>`.
func OpenWithConfig(cfg *config.Config, url string) error {
if cmd := cfg.BrowserCommandResolved(); cmd != "" {
return exec.Command(cmd, url).Start()
}
return Open(url)
}
32 changes: 23 additions & 9 deletions internal/cli/ask.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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)
}
Expand DownExpand Up@@ -110,14 +106,15 @@ 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)
if err != nil {
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.
Expand All@@ -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)
}
16 changes: 10 additions & 6 deletions internal/cli/configcmd.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:
Expand All@@ -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
Expand Down
23 changes: 11 additions & 12 deletions internal/cli/env.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
)
Expand All@@ -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
}
Expand All@@ -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
}
Expand All@@ -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 != "" {
Expand All@@ -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)")
}

Expand All@@ -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
Expand All@@ -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
Expand DownExpand Up@@ -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
}
Expand Down
102 changes: 102 additions & 0 deletions internal/cli/history.go
Original file line numberDiff line numberDiff line change
@@ -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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Data lossrunHistory

Trailing positional arguments are ignored, so a malformed history clear ... invocation still deletes history instead of returning a usage error.

Explain · Fix in Claude Code · Fix in Codex

Copy for agent
Fix this issue found by Abstraction's review of pull request #16 ("Replace self-update with local history; rename apiclient to transport") in abstraction-dev/cli.
Branch: feature/transport-and-history
Function: runHistory
File: internal/cli/history.go (lines 18-64)
Category: Data loss
Trailing positional arguments are ignored, so a malformed `history clear ...` invocation still deletes history instead of returning a usage error.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Data lossrunHistory

Arguments after the action are silently ignored, so history clear --config X clears the default store rather than the requested file, risking unintended history deletion.

Explain · Fix in Claude Code · Fix in Codex

Copy for agent
Fix this issue found by Abstraction's review of pull request #16 ("Replace self-update with local history; rename apiclient to transport") in abstraction-dev/cli.
Branch: feature/transport-and-history
Function: runHistory
File: internal/cli/history.go (lines 18-64)
Category: Data loss
Arguments after the action are silently ignored, so `history clear --config X` clears the default store rather than the requested file, risking unintended history deletion.

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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic errorlistHistory

The fixed 32-column prefix allowance undercounts non-UTC RFC3339 timestamps, causing long history rows to exceed the terminal width.

Explain · Fix in Claude Code · Fix in Codex

Copy for agent
Fix this issue found by Abstraction's review of pull request #16 ("Replace self-update with local history; rename apiclient to transport") in abstraction-dev/cli.
Branch: feature/transport-and-history
Function: listHistory
File: internal/cli/history.go (lines 68-88)
Category: Logic error
The fixed 32-column prefix allowance undercounts non-UTC RFC3339 timestamps, causing long history rows to exceed the terminal width.

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]
}
6 changes: 3 additions & 3 deletions internal/cli/login.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand All@@ -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
Expand Down
Loading