commit - 47bf7343aead260e3f34dd28bdd5ad2414605a38
commit + b5fc4794c6055d415e4584d6b75b0e5be51daf36
blob - dfc9af52e35f72826cacc6fe6afeb5f425b0ac86
blob + 32b13b2ec6d040b6dc591434a8ac38a3323d4b84
--- README.md
+++ README.md
`$VISUAL` and `$EDITOR` split on whitespace; no quote or escape parsing.
Use wrapper script for complex editor command. `-s` sends system message before
-user prompt. Temporary file is removed. No operands. `-T` must be positive.
+user prompt. Temporary file is removed. `-T` must be positive.
+Each successful request is saved as a readable, length-prefixed transcript in
+`$XDG_DATA_HOME/alpaca/` (or `~/.local/share/alpaca/`). Run `alpaci c` to
+continue the newest transcript: its final assistant reply opens in the editor,
+and each saved follow-up is sent with the entire conversation as context. The
+saved model, effort and system prompt are reused unless explicitly overridden.
+Save an empty editor buffer to leave the continuation loop.
+
See `alpaci.1`.
---
blob - 59fb7790b87ce34b7771a9df76a9ce689f29d3e6
blob + ebbb6f56f1f80870e7cad47707cc8d77033dbd15
--- alpaci/Makefile
+++ alpaci/Makefile
DOAS ?= sudo
GOFLAGS ?=
-.PHONY: all build clean install uninstall vet
+.PHONY: all build clean install uninstall vet test
all: build
vet:
CGO_ENABLED=0 GOCACHE=$(GOCACHE) $(GO) vet ./...
+test:
+ CGO_ENABLED=0 GOCACHE=$(GOCACHE) $(GO) test ./...
+
install: build
$(DOAS) mkdir -p $(DESTDIR)$(BINDIR) $(DESTDIR)$(MANDIR)/man1
$(DOAS) cp $(BIN) $(DESTDIR)$(BINDIR)/$(BIN)
blob - /dev/null
blob + a4767e340e731b6243f69e1f64ca0489bf00e828 (mode 644)
--- /dev/null
+++ alpaci/.gitignore
+/alpaci
+*.log
blob - d7ab9b8ae63ba72911d32cb13c147cdbe53f5407
blob + 511661c65cce7b14f0f4c58f6fb801f19d5c3939
--- alpaci/alpaci.1
+++ alpaci/alpaci.1
.\" alpaci . edit a prompt, then send it to the Ollama chat API
-.Dd August 31, 2026
+.Dd September 2, 2025
.Dt ALPACI 1
.Os
.Sh NAME
.Op Fl T Ar secs
.Op Fl k Ar key
.Op Fl b Ar url
-.Op Fl j | Ns Fl h
+.Op Fl j
+.Op Fl h
+.Op Cm c
.Sh DESCRIPTION
.Nm
edits a temporary prompt file, sends saved text as user prompt to
.Pa {base}/api/chat ,
and writes reply to standard output.
Piped standard input prefills prompt file.
+Each successful request is saved as a length-prefixed transcript in
+.Pa $XDG_DATA_HOME/alpaca
+or, when that variable is unset,
+.Pa ~/.local/share/alpaca .
+.Pp
+With operand
+.Cm c ,
+.Nm
+loads the newest transcript, opens its final assistant reply in the editor,
+and continues prompting until the edited buffer is empty. Each continuation
+request includes the recorded conversation as context. The saved model, effort,
+and system prompt are reused unless their options are explicitly supplied.
Editor always uses
.Pa /dev/tty .
Editor input and output never enter pipeline.
API key.
Falls back to
.Ev OLLAMA_API_KEY .
+May be omitted for unauthenticated local endpoints.
.It Fl b Ar url
Base URL.
Defaults to
Useful for local or staging instances.
.It Fl j
Print the raw JSON response instead of the reply.
+.It Cm c
+Continue the newest saved conversation.
.It Fl h
Show help and exit.
.El
Default reasoning effort when
.Fl e
is not given.
+.It Ev XDG_DATA_HOME
+Base directory for saved transcripts. When unset, transcripts use
+.Pa ~/.local/share/alpaca .
.It Ev VISUAL , Ev EDITOR
Editor command for prompt buffer.
Values split on whitespace; quotes and escapes are not parsed.
.Bd -literal -offset indent
$ alpaci -j
.Ed
+.Pp
+Continue the newest saved conversation:
+.Bd -literal -offset indent
+$ alpaci c
+.Ed
.Sh SEE ALSO
.Xr alpaca 1 ,
.Xr alpacu 1 ,
blob - a34b92ad53263ea92f653975b5d0e8cd701b97fc
blob + 68e9a41d837a763b332460756a1d591590912e66
--- alpaci/go.mod
+++ alpaci/go.mod
module alpaci
-go 1.26
+go 1.22
blob - 2cb8d4558586bf7a8cade65ba3ab56e5b4324645
blob + 2fdc1645a62c645ec01cc6a1def7cb9fc5e5b823
--- alpaci/main.go
+++ alpaci/main.go
package main
import (
+ "bufio"
"bytes"
"context"
"encoding/json"
"net/http"
"os"
"os/exec"
+ "path/filepath"
+ "sort"
+ "strconv"
"strings"
"time"
)
-const usage = `usage: alpaci [-m model] [-e effort] [-s system] [-T secs] [-k key] [-b url] [-j] [-h]
+const usage = `usage: alpaci [-m model] [-e effort] [-s system] [-T secs] [-k key] [-b url] [-j] [-h] [c]
-m model model to use (else $ALPACA_MODEL, else glm-5.3-flash)
-e effort reasoning effort: low, medium, high or none
(else $ALPACA_EFFORT, else low)
-s system system prompt
-T secs request timeout in seconds (default 300)
- -k key API key (else $OLLAMA_API_KEY)
+ -k key API key (else $OLLAMA_API_KEY; omit for unauthenticated endpoints)
-b url base URL (else $OLLAMA_API_ENDPOINT, else https://ollama.com)
-j print the raw JSON response instead of the reply
-h show help
+ c continue the newest saved conversation
Edit a prompt in the editor, then send it to the Ollama chat API and
print the reply. Piped stdin prefills the editor buffer first, so alpaci
can sit in the middle of a pipeline: pipe data in, edit it, pipe the
reply onward.
+
+With c, alpaci opens the previous assistant reply in the editor and keeps
+prompting. Conversations are saved under $XDG_DATA_HOME/alpaca (or
+~/.local/share/alpaca) and sent as context on each continuation turn.
`
const (
- defaultBase = "https://ollama.com"
- maxErrorBody = 64 << 10
- maxTimeoutSec = int64(1<<63-1) / int64(time.Second)
+ defaultBase = "https://ollama.com"
+ maxErrorBody = 64 << 10
+ maxTimeoutSec = int64(1<<63-1) / int64(time.Second)
+ transcriptMagic = "alpaci-transcript-v1"
)
-var client = &http.Client{}
+type httpClient interface {
+ Do(*http.Request) (*http.Response, error)
+}
+// client is the default HTTP client. Each request carries its own context
+// timeout via -T, so no global Timeout is set. We do set a timeout for
+// reading response headers so a stalled connection fails quickly.
+var client httpClient = &http.Client{
+ Transport: &http.Transport{
+ ResponseHeaderTimeout: 30 * time.Second,
+ },
+}
+
type message struct {
Role string `json:"role"`
Content string `json:"content"`
Think string `json:"think,omitempty"`
}
+type transcript struct {
+ Model string
+ Effort string
+ System string
+ Messages []message
+}
+
// validEffort reports whether an -e value is accepted. "none" means the
// request omits the think field.
func validEffort(e string) bool {
// buildReq assembles the chat request. A non-empty system prompt becomes
// a system message before the user prompt; "none" drops the think field.
func buildReq(model, effort, system, prompt string) chatReq {
+ return buildReqMessages(model, effort, system, []message{{Role: "user", Content: prompt}})
+}
+
+func buildReqMessages(model, effort, system string, messages []message) chatReq {
var m []message
if system != "" {
m = append(m, message{Role: "system", Content: system})
}
- m = append(m, message{Role: "user", Content: prompt})
+ m = append(m, messages...)
think := effort
if effort == "none" {
think = ""
return chatReq{Model: model, Messages: m, Stream: false, Think: think}
}
+func dataDir() (string, error) {
+ if dir := os.Getenv("XDG_DATA_HOME"); dir != "" {
+ return filepath.Join(dir, "alpaca"), nil
+ }
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("find home directory: %w", err)
+ }
+ return filepath.Join(home, ".local", "share", "alpaca"), nil
+}
+
+// writeField writes a length-prefixed field so arbitrary message content is safe.
+func writeField(w io.Writer, name, value string) error {
+ _, err := fmt.Fprintf(w, "%s %d\n%s\n", name, len(value), value)
+ return err
+}
+
+func writeTranscript(path string, t transcript) error {
+ var b bytes.Buffer
+ b.WriteString(transcriptMagic + "\n")
+ for _, f := range []struct{ name, value string }{{"model", t.Model}, {"effort", t.Effort}, {"system", t.System}} {
+ if err := writeField(&b, f.name, f.value); err != nil {
+ return err
+ }
+ }
+ for _, m := range t.Messages {
+ if m.Role != "user" && m.Role != "assistant" {
+ return fmt.Errorf("invalid transcript role %q", m.Role)
+ }
+ if err := writeField(&b, "message-"+m.Role, m.Content); err != nil {
+ return err
+ }
+ }
+ return os.WriteFile(path, b.Bytes(), 0600)
+}
+
+func readField(r *bufio.Reader) (string, string, error) {
+ line, err := r.ReadString('\n')
+ if err != nil {
+ return "", "", err
+ }
+ parts := strings.Split(strings.TrimSuffix(line, "\n"), " ")
+ if len(parts) != 2 || parts[0] == "" {
+ return "", "", errors.New("invalid field header")
+ }
+ n, err := strconv.Atoi(parts[1])
+ if err != nil || n < 0 {
+ return "", "", errors.New("invalid field length")
+ }
+ data := make([]byte, n)
+ if _, err := io.ReadFull(r, data); err != nil {
+ return "", "", err
+ }
+ if end, err := r.ReadByte(); err != nil || end != '\n' {
+ return "", "", errors.New("missing field terminator")
+ }
+ return parts[0], string(data), nil
+}
+
+func readTranscript(path string) (transcript, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return transcript{}, err
+ }
+ defer f.Close()
+ r := bufio.NewReader(f)
+ magic, err := r.ReadString('\n')
+ if err != nil || strings.TrimSuffix(magic, "\n") != transcriptMagic {
+ return transcript{}, errors.New("not an alpaci transcript")
+ }
+ var t transcript
+ for _, expected := range []string{"model", "effort", "system"} {
+ name, value, err := readField(r)
+ if err != nil || name != expected {
+ return transcript{}, fmt.Errorf("invalid %s field", expected)
+ }
+ switch name {
+ case "model":
+ t.Model = value
+ case "effort":
+ t.Effort = value
+ case "system":
+ t.System = value
+ }
+ }
+ for {
+ name, value, err := readField(r)
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ if err != nil || (name != "message-user" && name != "message-assistant") {
+ return transcript{}, errors.New("invalid message field")
+ }
+ t.Messages = append(t.Messages, message{Role: strings.TrimPrefix(name, "message-"), Content: value})
+ }
+ if len(t.Messages) == 0 || len(t.Messages)%2 != 0 {
+ return transcript{}, errors.New("transcript must end with an assistant reply")
+ }
+ for i, m := range t.Messages {
+ want := "user"
+ if i%2 == 1 {
+ want = "assistant"
+ }
+ if m.Role != want {
+ return transcript{}, errors.New("transcript messages are out of order")
+ }
+ }
+ return t, nil
+}
+
+func newTranscript(t transcript) (string, error) {
+ dir, err := dataDir()
+ if err != nil {
+ return "", err
+ }
+ if err := os.MkdirAll(dir, 0700); err != nil {
+ return "", err
+ }
+ f, err := os.CreateTemp(dir, strconv.FormatInt(time.Now().UnixNano(), 10)+"-*.log")
+ if err != nil {
+ return "", err
+ }
+ f.Close()
+ path := f.Name()
+ return path, writeTranscript(path, t)
+}
+
+func newestTranscript() (string, transcript, error) {
+ dir, err := dataDir()
+ if err != nil {
+ return "", transcript{}, err
+ }
+ entries, err := os.ReadDir(dir)
+ if errors.Is(err, os.ErrNotExist) {
+ return "", transcript{}, errors.New("no saved conversations")
+ }
+ if err != nil {
+ return "", transcript{}, err
+ }
+ sort.Slice(entries, func(i, j int) bool {
+ a, _ := entries[i].Info()
+ b, _ := entries[j].Info()
+ return a.ModTime().After(b.ModTime())
+ })
+ var lastErr error
+ for _, entry := range entries {
+ if entry.IsDir() || filepath.Ext(entry.Name()) != ".log" {
+ continue
+ }
+ path := filepath.Join(dir, entry.Name())
+ t, err := readTranscript(path)
+ if err != nil {
+ lastErr = fmt.Errorf("read conversation %s: %w", entry.Name(), err)
+ continue
+ }
+ return path, t, nil
+ }
+ if lastErr != nil {
+ return "", transcript{}, fmt.Errorf("no usable conversation: %w", lastErr)
+ }
+ return "", transcript{}, errors.New("no saved conversations")
+}
+
// parseChatRes extracts the reply text from the chat response body.
func parseChatRes(data []byte) (string, error) {
var r struct {
- Message struct {
+ Message *struct {
Content string `json:"content"`
} `json:"message"`
DoneReason string `json:"done_reason"`
if err := json.Unmarshal(data, &r); err != nil {
return "", err
}
+ if r.Message == nil {
+ return "", errors.New("response missing message field")
+ }
return r.Message.Content, nil
}
return e.Error, nil
}
-// send posts the request to base/api/chat, mirroring what alpaca does.
+// send posts the request to base/api/chat. Authorization is sent only when
+// key is non-empty, so local endpoints without authentication still work.
func send(ctx context.Context, base, key string, r chatReq) ([]byte, error) {
body, err := json.Marshal(r)
if err != nil {
if err != nil {
return nil, err
}
- req.Header.Set("Authorization", "Bearer "+key)
+ if key != "" {
+ req.Header.Set("Authorization", "Bearer "+key)
+ }
req.Header.Set("Content-Type", "application/json")
res, err := client.Do(req)
if err != nil {
// editPrompt writes the initial text (possibly empty) into a temp file,
// opens the editor on it, and returns the saved contents. The file is
-// always removed. The process exits with the editor's exit code when the
-// editor fails.
-func editPrompt(initial string) string {
+// always removed. An editor exit error is returned as *exec.ExitError so
+// the caller can exit with the same status without bypassing cleanup.
+func editPrompt(initial string) (string, error) {
tmp, err := os.CreateTemp("", "alpaci-")
- must(err)
+ if err != nil {
+ return "", err
+ }
name := tmp.Name()
defer os.Remove(name)
if initial != "" {
if _, err := tmp.WriteString(initial); err != nil {
tmp.Close()
- must(err)
+ return "", err
}
}
tmp.Close()
err = runEditor(name)
var ee *exec.ExitError
if errors.As(err, &ee) {
- os.Exit(ee.ExitCode())
+ return "", ee
}
- must(err)
+ if err != nil {
+ return "", err
+ }
data, err := os.ReadFile(name)
- must(err)
- return string(data)
+ if err != nil {
+ return "", err
+ }
+ return string(data), nil
}
func must(err error) {
os.Exit(2)
}
+func flagWasSet(name string) bool {
+ set := false
+ flag.Visit(func(f *flag.Flag) {
+ if f.Name == name {
+ set = true
+ }
+ })
+ return set
+}
+
+func printResponse(body []byte, raw bool) {
+ if raw {
+ if !json.Valid(body) {
+ must(fmt.Errorf("response is not valid JSON"))
+ }
+ var out bytes.Buffer
+ must(json.Indent(&out, body, "", " "))
+ out.WriteByte('\n')
+ _, _ = os.Stdout.Write(out.Bytes())
+ return
+ }
+ reply, err := parseChatRes(body)
+ must(err)
+ fmt.Print(reply)
+}
+
+func sendTurn(model, effort, system, base, key string, timeout int, t *transcript, prompt string) (string, []byte) {
+ t.Messages = append(t.Messages, message{Role: "user", Content: prompt})
+ ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
+ defer cancel()
+ body, err := send(ctx, base, key, buildReqMessages(model, effort, system, t.Messages))
+ must(err)
+ reply, err := parseChatRes(body)
+ must(err)
+ t.Messages = append(t.Messages, message{Role: "assistant", Content: reply})
+ return reply, body
+}
+
func main() {
model := flag.String("m", envOr("ALPACA_MODEL", "glm-5.3-flash"), "model")
effort := flag.String("e", envOr("ALPACA_EFFORT", "low"), "reasoning effort")
help := flag.Bool("h", false, "help")
flag.Usage = func() { fmt.Fprint(os.Stderr, usage) }
flag.Parse()
-
if *help {
fmt.Print(usage)
return
}
- if !validEffort(*effort) {
- usageError("invalid effort %q", *effort)
+ continueMode := flag.NArg() == 1 && flag.Arg(0) == "c"
+ if flag.NArg() != 0 && !continueMode {
+ usageError("unexpected argument %q", flag.Arg(0))
}
if *timeout <= 0 || int64(*timeout) > maxTimeoutSec {
usageError("timeout must be between 1 and %d seconds", maxTimeoutSec)
}
- if flag.NArg() != 0 {
- usageError("unexpected argument %q", flag.Arg(0))
- }
- if *key == "" {
- usageError("no API key")
- }
- // With a tty on stdin start from an empty buffer; otherwise prefill
- // it with the piped input first.
+ if continueMode {
+ path, t, err := newestTranscript()
+ must(err)
+ if !flagWasSet("m") {
+ *model = t.Model
+ }
+ if !flagWasSet("e") {
+ *effort = t.Effort
+ }
+ if !flagWasSet("s") {
+ *system = t.System
+ }
+ if !validEffort(*effort) {
+ usageError("invalid effort %q", *effort)
+ }
+ initial := t.Messages[len(t.Messages)-1].Content
+ for {
+ prompt, err := editPrompt(initial)
+ var ee *exec.ExitError
+ if errors.As(err, &ee) {
+ os.Exit(ee.ExitCode())
+ }
+ must(err)
+ if prompt == "" {
+ return
+ }
+ reply, body := sendTurn(*model, *effort, *system, *base, *key, *timeout, &t, prompt)
+ must(writeTranscript(path, t))
+ printResponse(body, *jsn)
+ initial = reply
+ }
+ }
+ if !validEffort(*effort) {
+ usageError("invalid effort %q", *effort)
+ }
initial := ""
if st, statErr := os.Stdin.Stat(); statErr == nil && st.Mode()&os.ModeCharDevice == 0 {
b, err := io.ReadAll(os.Stdin)
must(err)
initial = string(b)
}
-
- prompt := editPrompt(initial)
+ prompt, err := editPrompt(initial)
+ var ee *exec.ExitError
+ if errors.As(err, &ee) {
+ os.Exit(ee.ExitCode())
+ }
+ must(err)
if prompt == "" {
return
}
-
- ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*timeout)*time.Second)
- defer cancel()
- body, err := send(ctx, *base, *key, buildReq(*model, *effort, *system, prompt))
+ t := transcript{Model: *model, Effort: *effort, System: *system}
+ _, body := sendTurn(*model, *effort, *system, *base, *key, *timeout, &t, prompt)
+ _, err = newTranscript(t)
must(err)
-
- if *jsn {
- var out bytes.Buffer
- if err := json.Indent(&out, body, "", " "); err != nil {
- must(err)
- }
- out.WriteByte('\n')
- os.Stdout.Write(out.Bytes())
- return
- }
- reply, err := parseChatRes(body)
- must(err)
- fmt.Print(reply)
+ printResponse(body, *jsn)
}
blob - /dev/null
blob + f5b85699b33d174f53d9eaeb3e76e5a815d41a63 (mode 644)
--- /dev/null
+++ alpaci/main_test.go
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestTranscriptRoundTrip(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "session.log")
+ want := transcript{
+ Model: "model", Effort: "none", System: "system\ntext",
+ Messages: []message{{Role: "user", Content: "hello\nmessage-user 4\nevil"}, {Role: "assistant", Content: "reply"}},
+ }
+ if err := writeTranscript(path, want); err != nil {
+ t.Fatal(err)
+ }
+ got, err := readTranscript(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("got %#v, want %#v", got, want)
+ }
+}
+
+func TestNewestTranscript(t *testing.T) {
+ t.Setenv("XDG_DATA_HOME", t.TempDir())
+ dir, err := dataDir()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(dir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ old := filepath.Join(dir, "old.log")
+ newer := filepath.Join(dir, "new.log")
+ if err := writeTranscript(old, transcript{Model: "old", Effort: "low", Messages: []message{{Role: "user", Content: "old question"}, {Role: "assistant", Content: "old"}}}); err != nil {
+ t.Fatal(err)
+ }
+ if err := writeTranscript(newer, transcript{Model: "new", Effort: "high", Messages: []message{{Role: "user", Content: "new question"}, {Role: "assistant", Content: "new"}}}); err != nil {
+ t.Fatal(err)
+ }
+ oldTime := time.Now().Add(-time.Second)
+ if err := os.Chtimes(old, oldTime, oldTime); err != nil {
+ t.Fatal(err)
+ }
+ path, got, err := newestTranscript()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if path != newer || got.Model != "new" {
+ t.Fatalf("got %q %#v", path, got)
+ }
+}
+
+func TestContinuationRequestIncludesHistory(t *testing.T) {
+ req := buildReqMessages("m", "low", "s", []message{{Role: "user", Content: "first"}, {Role: "assistant", Content: "answer"}, {Role: "user", Content: "next"}})
+ want := []message{{Role: "system", Content: "s"}, {Role: "user", Content: "first"}, {Role: "assistant", Content: "answer"}, {Role: "user", Content: "next"}}
+ if !reflect.DeepEqual(req.Messages, want) {
+ t.Fatalf("got %#v, want %#v", req.Messages, want)
+ }
+}
+
+func TestNewestTranscriptWithoutHistory(t *testing.T) {
+ t.Setenv("XDG_DATA_HOME", t.TempDir())
+ if _, _, err := newestTranscript(); err == nil {
+ t.Fatal("expected no-history error")
+ }
+}
+
+func TestParseChatRes(t *testing.T) {
+ cases := []struct {
+ name string
+ body string
+ want string
+ wantErr bool
+ }{
+ {"reply", `{"message":{"content":"hello"},"done_reason":"stop"}`, "hello", false},
+ {"empty reply", `{"message":{"content":""},"done_reason":"stop"}`, "", false},
+ {"missing message", `{"done_reason":"stop"}`, "", true},
+ {"not json", `not json`, "", true},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got, err := parseChatRes([]byte(tc.body))
+ if (err != nil) != tc.wantErr {
+ t.Fatalf("err=%v, wantErr=%v", err, tc.wantErr)
+ }
+ if got != tc.want {
+ t.Fatalf("got %q, want %q", got, tc.want)
+ }
+ })
+ }
+}
+
+func TestErrBody(t *testing.T) {
+ msg, err := errBody([]byte(`{"error":"bad request"}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if msg != "bad request" {
+ t.Fatalf("got %q, want %q", msg, "bad request")
+ }
+ if _, err := errBody([]byte(`not json`)); err == nil {
+ t.Fatal("expected error for non-JSON")
+ }
+}
+
+type fakeClient struct {
+ req *http.Request
+ res *http.Response
+ err error
+}
+
+func (c *fakeClient) Do(req *http.Request) (*http.Response, error) {
+ c.req = req
+ if c.err != nil {
+ return nil, c.err
+ }
+ return c.res, nil
+}
+
+func TestSendSuccess(t *testing.T) {
+ body := []byte(`{"message":{"content":"hi"},"done_reason":"stop"}`)
+ old := client
+ defer func() { client = old }()
+ fc := &fakeClient{res: &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(bytes.NewReader(body)),
+ Header: http.Header{"Content-Type": []string{"application/json"}},
+ }}
+ client = fc
+
+ got, err := send(context.Background(), "https://example.com", "secret", buildReq("m", "low", "s", "p"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(got, body) {
+ t.Fatalf("got %s, want %s", got, body)
+ }
+ if got := fc.req.Header.Get("Authorization"); got != "Bearer secret" {
+ t.Fatalf("Authorization=%q, want Bearer secret", got)
+ }
+ if !strings.HasSuffix(fc.req.URL.Path, "/api/chat") {
+ t.Fatalf("URL path=%q", fc.req.URL.Path)
+ }
+}
+
+func TestSendOmitsAuthWithoutKey(t *testing.T) {
+ body := []byte(`{"message":{"content":"hi"},"done_reason":"stop"}`)
+ old := client
+ defer func() { client = old }()
+ fc := &fakeClient{res: &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(bytes.NewReader(body)),
+ }}
+ client = fc
+
+ if _, err := send(context.Background(), "http://localhost", "", buildReq("m", "low", "", "p")); err != nil {
+ t.Fatal(err)
+ }
+ if fc.req.Header.Get("Authorization") != "" {
+ t.Fatalf("Authorization header sent unexpectedly")
+ }
+}
+
+func TestSendErrorStatus(t *testing.T) {
+ old := client
+ defer func() { client = old }()
+ fc := &fakeClient{res: &http.Response{
+ StatusCode: http.StatusBadRequest,
+ Body: io.NopCloser(bytes.NewReader([]byte(`{"error":"nope"}`))),
+ }}
+ client = fc
+
+ _, err := send(context.Background(), "https://example.com", "secret", buildReq("m", "low", "", "p"))
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ if !strings.Contains(err.Error(), "nope") {
+ t.Fatalf("error=%q, want it to contain nope", err)
+ }
+}
+
+func TestInvalidTranscript(t *testing.T) {
+ cases := []struct {
+ name string
+ write func(string) error
+ }{
+ {"bad magic", func(p string) error { return os.WriteFile(p, []byte("wrong\n"), 0600) }},
+ {"odd message count", func(p string) error {
+ return writeTranscript(p, transcript{Model: "m", Effort: "low", Messages: []message{{Role: "user", Content: "x"}}})
+ }},
+ {"out of order", func(p string) error {
+ return writeTranscript(p, transcript{Model: "m", Effort: "low", Messages: []message{{Role: "assistant", Content: "x"}, {Role: "user", Content: "y"}}})
+ }},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ p := filepath.Join(t.TempDir(), "bad.log")
+ if err := tc.write(p); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := readTranscript(p); err == nil {
+ t.Fatal("expected error")
+ }
+ })
+ }
+}
+
+func TestNewestTranscriptSkipsCorrupt(t *testing.T) {
+ t.Setenv("XDG_DATA_HOME", t.TempDir())
+ dir, err := dataDir()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(dir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ corrupt := filepath.Join(dir, "corrupt.log")
+ good := filepath.Join(dir, "good.log")
+ if err := os.WriteFile(corrupt, []byte("bad\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := writeTranscript(good, transcript{Model: "good", Effort: "low", Messages: []message{{Role: "user", Content: "q"}, {Role: "assistant", Content: "a"}}}); err != nil {
+ t.Fatal(err)
+ }
+ goodTime := time.Now().Add(-time.Second)
+ if err := os.Chtimes(good, goodTime, goodTime); err != nil {
+ t.Fatal(err)
+ }
+ path, got, err := newestTranscript()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if path != good || got.Model != "good" {
+ t.Fatalf("got %q %#v", path, got)
+ }
+}
+
+func TestRawJSONRequiresValidJSON(t *testing.T) {
+ // Capture must's os.Exit by running in a subprocess would be heavy; instead
+ // exercise parseChatRes path separately and trust raw mode validates first.
+ if json.Valid([]byte(`not json`)) {
+ t.Fatal("test setup wrong")
+ }
+ // The printResponse raw branch uses json.Valid before Indent; parsing the
+ // same body via parseChatRes also fails, which is the non-raw path.
+ if _, err := parseChatRes([]byte(`not json`)); err == nil {
+ t.Fatal("expected parse error for non-JSON")
+ }
+}
+
+func TestBuildReqOmitsThinkForNone(t *testing.T) {
+ req := buildReq("m", "none", "", "p")
+ if req.Think != "" {
+ t.Fatalf("Think=%q, want empty", req.Think)
+ }
+ req = buildReq("m", "high", "", "p")
+ if req.Think != "high" {
+ t.Fatalf("Think=%q, want high", req.Think)
+ }
+}
+
+func TestNewTranscriptUniquePaths(t *testing.T) {
+ t.Setenv("XDG_DATA_HOME", t.TempDir())
+ p1, err := newTranscript(transcript{Model: "m", Effort: "low", Messages: []message{{Role: "user", Content: "x"}, {Role: "assistant", Content: "y"}}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ p2, err := newTranscript(transcript{Model: "m", Effort: "low", Messages: []message{{Role: "user", Content: "x"}, {Role: "assistant", Content: "y"}}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if p1 == p2 {
+ t.Fatal("newTranscript produced duplicate paths")
+ }
+}