commit 526663526f299d054361948c7ec8c958c159a206 from: mtmn date: Mon Aug 31 15:57:55 2026 UTC add usage and interactive tools commit - 58a690fcd50ec0816b9b0f9d23b57441197e9acb commit + 526663526f299d054361948c7ec8c958c159a206 blob - ea8c4bf7f35f6f77f75d92ad8ce8349f6e81ddba blob + b4050dedcd52a2cc5d85c657d451b8aa1113ce97 --- .gitignore +++ .gitignore @@ -1 +1,3 @@ /target +/alpacu/alpacu +/alpaci/alpaci blob - e60ff715483a6f4bc139c951e3c2ffae2da3581b blob + dfc9af52e35f72826cacc6fe6afeb5f425b0ac86 --- README.md +++ README.md @@ -50,7 +50,7 @@ export OLLAMA_API_KEY=your-api-key ``` By default requests are sent to `https://ollama.com`. To target a different -host — for example a local Ollama server — set `OLLAMA_API_ENDPOINT`: +host, for example a local Ollama server, set `OLLAMA_API_ENDPOINT`: ```sh export OLLAMA_API_ENDPOINT=http://localhost:11434 @@ -61,6 +61,85 @@ Pick a model with `-m/--model` (default `gpt-oss:120b` --- +## Extra tools + +### alpacu + +`alpacu` is a small Go program in the `alpacu/` directory. It calls the Ollama +Cloud usage endpoint and prints your session quota (5 hour window) and weekly +quota (7 day window) as colored bars, with a countdown to the next reset and a +per-model request table with request counts. + +It shares the same `OLLAMA_API_KEY` setup as `alpaca`. The key can also be +passed with `-k`. Build and install with make (Go 1.26, no dependencies): + +```sh +$ cd alpacu +$ make # build ./alpacu +$ make vet # vet +$ sudo make install # installs alpacu(1) and alpacu.1 to /usr/local +``` + +Options: + +- `-k key`: API key. Falls back to `OLLAMA_API_KEY` +- `-b url`: base URL. Defaults to `$OLLAMA_API_ENDPOINT`, then `https://ollama.com` +- `-j`: print the raw JSON response instead of the rendered output +- `-h`: show help + +See `alpacu.1` for the full man page. + +### alpaci + +`alpaci` edits prompt in `$VISUAL`, `$EDITOR`, or `vim`, sends saved text +to Ollama `POST /api/chat`, then writes reply to stdout. Piped stdin +prefills prompt. Editor uses `/dev/tty`, keeping editor I/O out of pipeline. + +Uses `OLLAMA_API_KEY` or `-k`. Build needs Go 1.26: + +```sh +$ cd alpaci +$ make # build ./alpaci +$ make vet # vet +$ sudo make install # installs alpaci(1) and alpaci.1 to /usr/local +``` + +```sh +# Write a prompt in the editor, send it, see the reply +$ alpaci + +# Pick a model and reasoning effort +$ alpaci -m gpt-oss:120b -e high + +# Set a system prompt +$ alpaci -s "Answer in one sentence" + +# Edit piped input in the editor before sending +$ git diff --staged | alpaci -s "Write a conventional commit message" + +# Sit in the middle of a pipeline: pipe in, edit, pipe out +$ curl -s "wttr.in/?1" | alpaci -s "Summarize this weather in 1 sentence" | say +``` + +Options: + +- `-m model`: model to use. Defaults to `$ALPACA_MODEL`, then `glm-5.3-flash` +- `-e effort`: reasoning effort, one of `low`, `medium`, `high`, `none`. Defaults to `$ALPACA_EFFORT`, then `low`. With `none`, the request omits the think field +- `-s system`: system prompt +- `-T secs`: request timeout in seconds. Defaults to 300 +- `-k key`: API key. Falls back to `OLLAMA_API_KEY` +- `-b url`: base URL. Defaults to `$OLLAMA_API_ENDPOINT`, then `https://ollama.com` +- `-j`: print the raw JSON response instead of the reply +- `-h`: show help + +`$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. + +See `alpaci.1`. + +--- + ## Basic Usage See `alpaca --help` for documentation @@ -131,41 +210,6 @@ $ git diff --staged \ | git commit -F - ``` -### `alpaca_shell` - Example Interactive Shell as a Shell Script - -As an example scripting with `alpaca` a "chat" interface is provided at `bin/alpaca_shell`. - -It is a simple (~90 LOC) but a fun and illustrative toy: - -```sh -$ ./bin/alpaca_shell - -alpaca> what markdown files here? -+ ls *.md -README.md - -alpaca> open it in text edit -+ open -a TextEdit README.md - -alpaca> find all rust files in this directory. Open in text edit -+ open -a TextEdit $(find . -type f -iname '*.rs') - -alpaca> pause music -+ osascript -e 'tell application "Music" to pause' - -alpaca> in 3 secs, show a notif saying "Hey". Also say it -+ sleep 3 && osascript -e 'display notification "Hey"' && say "Hey" - -alpaca> what safari tabs are open -+ osascript -e 'tell application "Safari" to get the name of every tab of every window & the URL of every tab of every window' -leoshimo/cogni: Unix native interface to LLMs, https://github.com/leoshimo/cogni - -alpaca> look at readme, say a quick summary of it -+ cat README.md -[.. snip ..] -The README.md file is for a project named 'alpaca', which is a Unix native interface for interacting with large language models (LLMs)... [.. snip ..] -``` - ### In Emacs Emacs can use `shell-command-on-region` to pipe buffer regions to `alpaca`. @@ -185,8 +229,8 @@ For example, the following defines a command that plum This binding is useful across a wide range of tasks, for example: -- Normalizing non-uniform text - e.g. unstructured logs to structured JSON events. -- Editing or organizing text semantically - e.g. rewording or grouping by category. +- Normalizing non-uniform text, such as unstructured logs to structured JSON events. +- Editing or organizing text semantically, such as rewording or grouping by category. - Generating summary for an Org Agenda doc. ### In Vim @@ -194,7 +238,7 @@ This binding is useful across a wide range of tasks, f Vim can run external shell commands on entire buffer or visual selection to power similar workflows possible from Emacs. See `h :!` in vim. -For example, given a bulleted list of fruits, it an be sorted by color by: +For example, a bulleted list of fruits can be sorted by color by: 1. Selecting the list of fruits in visual mode 2. Type `:!alpaca -s "Sort this list by color"` blob - 19b526d58a07f3bf3e83fa5e27abd80228d1087e (mode 755) blob + /dev/null --- bin/alpaca_shell +++ /dev/null @@ -1,100 +0,0 @@ -#!/usr/bin/env bash -# alpaca_shell - lightweight interactive shell on top of alpaca -# - -set -euo pipefail -trap 'printf "\n"; exit 130' INT - -messages=( - "--system" "You help convert natural language into safe macOS shell commands. Reply with exactly one directive per turn. Emit shell commands without any prefix. Narrate or ask questions using '!print ' lines only; never send plain text without '!print'. Do not ask the user to provide commands—propose the next step yourself. Only ask clarifying questions when essential, prefacing them with '!print ?'. When the task is complete, reply with '!stop'. After every command I run, I send you another user message that begins with 'Command output:'. Use that context before choosing the next step." - "--user" "show the current working directory" - "--assistant" "pwd" - "--user" 'Command output:\n/Users/example' - "--assistant" "!stop" - "--user" "print hello world to the terminal" - "--assistant" 'echo "Hello, world!"' - "--user" 'Command output:\nHello, world!' - "--assistant" "!stop" -) - -while true; do - if ! read -erp 'alpaca> ' request; then - printf "\n" - break - fi - [[ "$request" =~ ^[[:space:]]*$ ]] && continue - [[ "$request" == ":quit" || "$request" == ":exit" ]] && break - - messages+=(--user "$request") - - while true; do - response=$(alpaca "${messages[@]}") - response=${response//$'\r'/} - directive=${response%%$'\n'*} - - if [[ -z "$directive" ]]; then - printf 'No response from model.\n' >&2 - break - fi - - messages+=(--assistant "$directive") - - if [[ "$directive" == "!stop" ]]; then - break - fi - - if [[ "$directive" == "!print"* ]]; then - text=${directive#!print} - text=${text# } - [[ -n "$text" ]] && printf '%s\n' "$text" - messages+=(--user "Narration displayed.") - continue - fi - - if [[ "$directive" == \!* ]]; then - printf '%s\n' "${directive:1}" - messages+=(--user "Message shown to user.") - continue - fi - - treat_as_command=0 - if [[ "$directive" =~ ^[^[:space:]]+= ]]; then - treat_as_command=1 - else - first_word=${directive%%[[:space:]]*} - if [[ -z "$first_word" ]]; then - treat_as_command=0 - elif command -v "$first_word" >/dev/null 2>&1; then - treat_as_command=1 - else - treat_as_command=0 - fi - fi - - if [[ $treat_as_command -eq 0 ]]; then - printf '%s\n' "$directive" - messages+=(--user "Narration displayed.") - continue - fi - - printf '+ %s\n' "$directive" - if output=$(bash -lc "$directive" 2>&1); then - [[ -n "$output" ]] && printf '%s\n' "$output" - if [[ -n "$output" ]]; then - messages+=("--user" $'Command output:\n'"$output") - else - messages+=("--user" "Command output: (no output)") - fi - else - exit_status=$? - [[ -n "$output" ]] && printf '%s\n' "$output" - printf 'Command failed (exit %d)\n' "$exit_status" >&2 - if [[ -n "$output" ]]; then - printf -v failure_output 'Command output (exit %d):\n%s' "$exit_status" "$output" - else - printf -v failure_output 'Command output (exit %d): (no output)' "$exit_status" - fi - messages+=("--user" "$failure_output") - fi - done -done blob - /dev/null blob + 59fb7790b87ce34b7771a9df76a9ce689f29d3e6 (mode 644) --- /dev/null +++ alpaci/Makefile @@ -0,0 +1,35 @@ +.POSIX: + +SHELL = /bin/sh +GO ?= go +GOCACHE ?= $(HOME)/.cache/go-build +BIN ?= alpaci +PREFIX ?= /usr/local +BINDIR ?= $(PREFIX)/bin +MANDIR ?= $(PREFIX)/share/man +DESTDIR ?= +DOAS ?= sudo +GOFLAGS ?= + +.PHONY: all build clean install uninstall vet + +all: build + +build: + GOFLAGS='$(GOFLAGS)' CGO_ENABLED=0 GOCACHE=$(GOCACHE) $(GO) build -trimpath -buildvcs=false -o $(BIN) . + +vet: + CGO_ENABLED=0 GOCACHE=$(GOCACHE) $(GO) vet ./... + +install: build + $(DOAS) mkdir -p $(DESTDIR)$(BINDIR) $(DESTDIR)$(MANDIR)/man1 + $(DOAS) cp $(BIN) $(DESTDIR)$(BINDIR)/$(BIN) + $(DOAS) chmod 0755 $(DESTDIR)$(BINDIR)/$(BIN) + $(DOAS) cp $(BIN).1 $(DESTDIR)$(MANDIR)/man1/$(BIN).1 + $(DOAS) chmod 0644 $(DESTDIR)$(MANDIR)/man1/$(BIN).1 + +uninstall: + $(DOAS) rm -f $(DESTDIR)$(BINDIR)/$(BIN) $(DESTDIR)$(MANDIR)/man1/$(BIN).1 + +clean: + rm -f $(BIN) blob - /dev/null blob + d7ab9b8ae63ba72911d32cb13c147cdbe53f5407 (mode 644) --- /dev/null +++ alpaci/alpaci.1 @@ -0,0 +1,142 @@ +.\" alpaci . edit a prompt, then send it to the Ollama chat API +.Dd August 31, 2026 +.Dt ALPACI 1 +.Os +.Sh NAME +.Nm alpaci +.Nd edit a prompt in the editor, then send it to the Ollama chat API +.Sh SYNOPSIS +.Nm +.Op Fl m Ar model +.Op Fl e Ar effort +.Op Fl s Ar system +.Op Fl T Ar secs +.Op Fl k Ar key +.Op Fl b Ar url +.Op Fl j | Ns Fl h +.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. +Editor always uses +.Pa /dev/tty . +Editor input and output never enter pipeline. +.Nm +fails when no controlling terminal exists. +.Pp +Temporary file is removed on exit. +Request is non-streaming +.Pq Cm stream=false . +.Sh OPTIONS +.Bl -tag -width Ds +.It Fl m Ar model +Model to use. +Defaults to +.Ev ALPACA_MODEL , +then +.Cm glm-5.3-flash . +.It Fl e Ar effort +Reasoning effort, one of +.Cm low , +.Cm medium , +.Cm high , +or +.Cm none . +Defaults to +.Ev ALPACA_EFFORT , +then +.Cm low . +With +.Cm none , +the request omits the +.Sy think +field. +.It Fl s Ar system +System prompt. +.It Fl T Ar secs +Request timeout in seconds. +Defaults to 300. +.It Fl k Ar key +API key. +Falls back to +.Ev OLLAMA_API_KEY . +.It Fl b Ar url +Base URL. +Defaults to +.Ev OLLAMA_API_ENDPOINT , +then +.Cm https://ollama.com . +Useful for local or staging instances. +.It Fl j +Print the raw JSON response instead of the reply. +.It Fl h +Show help and exit. +.El +.Sh ENVIRONMENT +.Bl -tag -width Ds +.It Ev OLLAMA_API_KEY +API key used when +.Fl k +is not given. +.It Ev OLLAMA_API_ENDPOINT +Base URL used when +.Fl b +is not given. +.It Ev ALPACA_MODEL +Default model when +.Fl m +is not given. +.It Ev ALPACA_EFFORT +Default reasoning effort when +.Fl e +is not given. +.It Ev VISUAL , Ev EDITOR +Editor command for prompt buffer. +Values split on whitespace; quotes and escapes are not parsed. +Use wrapper script for complex commands. +Defaults to +.Xr vim 1 . +.El +.Sh EXIT STATUS +.Ex -std +.Nm +exits 2 for invalid options, missing API key, non-positive timeout, or +operands. +It exits with editor exit status when editor fails. +.Sh EXAMPLES +Write a prompt in the editor, send it, see the reply: +.Bd -literal -offset indent +$ alpaci +.Ed +.Pp +Pick a model and reasoning effort: +.Bd -literal -offset indent +$ alpaci -m gpt-oss:120b -e high +.Ed +.Pp +Set a system prompt: +.Bd -literal -offset indent +$ git diff --staged | alpaci -s \&"Write a conventional commit message\&" +.Ed +.Pp +Sit in the middle of a pipeline: +.Bd -literal -offset indent +$ curl -s \&"wttr.in/?1\&" | alpaci -s \&"Summarize this weather in 1 sentence\&" | say +.Ed +.Pp +Print the raw JSON response: +.Bd -literal -offset indent +$ alpaci -j +.Ed +.Sh SEE ALSO +.Xr alpaca 1 , +.Xr alpacu 1 , +.Xr vim 1 +.Pp +.Lk https://docs.ollama.com/api/chat Ollama Chat API +.Lk https://ollama.com/library Ollama model library +.Sh AUTHORS +.An m +.Aq Mt miro@local \ No newline at end of file blob - /dev/null blob + a34b92ad53263ea92f653975b5d0e8cd701b97fc (mode 644) --- /dev/null +++ alpaci/go.mod @@ -0,0 +1,3 @@ +module alpaci + +go 1.26 blob - /dev/null blob + 2cb8d4558586bf7a8cade65ba3ab56e5b4324645 (mode 644) --- /dev/null +++ alpaci/main.go @@ -0,0 +1,277 @@ +// alpaci - edit a prompt in the editor, then send it to the Ollama chat API +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "strings" + "time" +) + +const usage = `usage: alpaci [-m model] [-e effort] [-s system] [-T secs] [-k key] [-b url] [-j] [-h] + + -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) + -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 + +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. +` + +const ( + defaultBase = "https://ollama.com" + maxErrorBody = 64 << 10 + maxTimeoutSec = int64(1<<63-1) / int64(time.Second) +) + +var client = &http.Client{} + +type message struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type chatReq struct { + Model string `json:"model"` + Messages []message `json:"messages"` + Stream bool `json:"stream"` + Think string `json:"think,omitempty"` +} + +// validEffort reports whether an -e value is accepted. "none" means the +// request omits the think field. +func validEffort(e string) bool { + switch e { + case "low", "medium", "high", "none": + return true + } + return false +} + +// 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 { + var m []message + if system != "" { + m = append(m, message{Role: "system", Content: system}) + } + m = append(m, message{Role: "user", Content: prompt}) + think := effort + if effort == "none" { + think = "" + } + return chatReq{Model: model, Messages: m, Stream: false, Think: think} +} + +// parseChatRes extracts the reply text from the chat response body. +func parseChatRes(data []byte) (string, error) { + var r struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + DoneReason string `json:"done_reason"` + } + if err := json.Unmarshal(data, &r); err != nil { + return "", err + } + return r.Message.Content, nil +} + +func errBody(data []byte) (string, error) { + var e struct { + Error string `json:"error"` + } + if err := json.Unmarshal(data, &e); err != nil { + return "", err + } + return e.Error, nil +} + +// send posts the request to base/api/chat, mirroring what alpaca does. +func send(ctx context.Context, base, key string, r chatReq) ([]byte, error) { + body, err := json.Marshal(r) + if err != nil { + return nil, err + } + url := strings.TrimRight(base, "/") + "/api/chat" + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+key) + req.Header.Set("Content-Type", "application/json") + res, err := client.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + data, err := io.ReadAll(io.LimitReader(res.Body, maxErrorBody)) + if err != nil { + return nil, err + } + msg, err := errBody(data) + if err != nil { + msg = strings.TrimSpace(string(data)) + } + return nil, fmt.Errorf("status %d: %s", res.StatusCode, msg) + } + return io.ReadAll(res.Body) +} + +func envOr(name, def string) string { + if v := os.Getenv(name); v != "" { + return v + } + return def +} + +// editorCmd splits $VISUAL or $EDITOR on whitespace. VISUAL wins. +// Quotes and escapes are not parsed; use a wrapper script for them. +func editorCmd() []string { + for _, name := range []string{"VISUAL", "EDITOR"} { + if v := os.Getenv(name); v != "" { + if fields := strings.Fields(v); len(fields) > 0 { + return fields + } + } + } + return []string{"vim"} +} + +// runEditor runs the editor on controlling terminal. This keeps editor +// input and screen output out of stdin/stdout pipelines. +func runEditor(file string) error { + ed := editorCmd() + if len(ed) == 0 { + return errors.New("empty editor command") + } + tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0) + if err != nil { + return fmt.Errorf("open controlling terminal: %w", err) + } + defer tty.Close() + cmd := exec.Command(ed[0], append(ed[1:], file)...) + cmd.Stdin, cmd.Stdout, cmd.Stderr = tty, tty, tty + return cmd.Run() +} + +// 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 { + tmp, err := os.CreateTemp("", "alpaci-") + must(err) + name := tmp.Name() + defer os.Remove(name) + if initial != "" { + if _, err := tmp.WriteString(initial); err != nil { + tmp.Close() + must(err) + } + } + tmp.Close() + + err = runEditor(name) + var ee *exec.ExitError + if errors.As(err, &ee) { + os.Exit(ee.ExitCode()) + } + must(err) + + data, err := os.ReadFile(name) + must(err) + return string(data) +} + +func must(err error) { + if err != nil { + fmt.Fprintf(os.Stderr, "alpaci: %v\n", err) + os.Exit(1) + } +} + +func usageError(format string, args ...any) { + fmt.Fprintf(os.Stderr, "alpaci: "+format+"\n", args...) + fmt.Fprint(os.Stderr, usage) + os.Exit(2) +} + +func main() { + model := flag.String("m", envOr("ALPACA_MODEL", "glm-5.3-flash"), "model") + effort := flag.String("e", envOr("ALPACA_EFFORT", "low"), "reasoning effort") + system := flag.String("s", "", "system prompt") + timeout := flag.Int("T", 300, "timeout seconds") + key := flag.String("k", os.Getenv("OLLAMA_API_KEY"), "API key") + base := flag.String("b", envOr("OLLAMA_API_ENDPOINT", defaultBase), "base URL") + jsn := flag.Bool("j", false, "raw JSON") + 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) + } + 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. + 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) + 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)) + 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) +} blob - /dev/null blob + 1023dee492803d6cdb3c8bbef6f5114b3a9f7f85 (mode 644) --- /dev/null +++ alpacu/Makefile @@ -0,0 +1,35 @@ +.POSIX: + +SHELL = /bin/sh +GO ?= go +GOCACHE ?= $(HOME)/.cache/go-build +BIN ?= alpacu +PREFIX ?= /usr/local +BINDIR ?= $(PREFIX)/bin +MANDIR ?= $(PREFIX)/share/man +DESTDIR ?= +DOAS ?= sudo +GOFLAGS ?= + +.PHONY: all build clean install uninstall vet + +all: build + +build: + GOFLAGS='$(GOFLAGS)' CGO_ENABLED=0 GOCACHE=$(GOCACHE) $(GO) build -trimpath -buildvcs=false -o $(BIN) . + +vet: + CGO_ENABLED=0 GOCACHE=$(GOCACHE) $(GO) vet ./... + +install: build + $(DOAS) mkdir -p $(DESTDIR)$(BINDIR) $(DESTDIR)$(MANDIR)/man1 + $(DOAS) cp $(BIN) $(DESTDIR)$(BINDIR)/$(BIN) + $(DOAS) chmod 0755 $(DESTDIR)$(BINDIR)/$(BIN) + $(DOAS) cp $(BIN).1 $(DESTDIR)$(MANDIR)/man1/$(BIN).1 + $(DOAS) chmod 0644 $(DESTDIR)$(MANDIR)/man1/$(BIN).1 + +uninstall: + $(DOAS) rm -f $(DESTDIR)$(BINDIR)/$(BIN) $(DESTDIR)$(MANDIR)/man1/$(BIN).1 + +clean: + rm -f $(BIN) blob - /dev/null blob + 0af891cd07617f550c4dc94f4997139702d6160f (mode 644) --- /dev/null +++ alpacu/alpacu.1 @@ -0,0 +1,87 @@ +.\" alpacu . show Ollama Cloud account usage +.Dd August 31, 2026 +.Dt ALPACU 1 +.Os +.Sh NAME +.Nm alpacu +.Nd show Ollama Cloud session and weekly usage in the terminal +.Sh SYNOPSIS +.Nm +.Op Fl k Ar key +.Op Fl b Ar url +.Op Fl j +.Op Fl h +.Sh DESCRIPTION +.Nm +calls the Ollama Cloud usage endpoint with your API key. +It then prints your session quota (5 hour window) and weekly quota +(7 day window) as ANSI bars showing the used share, a live countdown +to the next reset, and a per-model request table. +.Pp +API key comes from +.Fl k +or +.Ev OLLAMA_API_KEY . +Base URL comes from +.Fl b +or +.Ev OLLAMA_API_ENDPOINT . +.Pp +The reset countdown is computed locally from the current UTC time. +Session resets align to multiples of 5 hours from the Unix epoch. +Weekly resets are offset by 4 days from the epoch so all accounts share +the same boundary. +This matches the formula published in the +.Lk https://github.com/ollama/ollama/issues/12532 ollama/ollama issue 12532 . +.Sh OPTIONS +.Bl -tag -width Ds +.It Fl k Ar key +API key. +Falls back to +.Ev OLLAMA_API_KEY . +.It Fl b Ar url +Base URL. +Defaults to +.Ev OLLAMA_API_ENDPOINT , +then +.Cm https://ollama.com . +Useful for local or staging instances. +.It Fl j +Print the raw JSON response instead of the rendered output. +.It Fl h +Show help and exit. +.El +.Sh ENVIRONMENT +.Bl -tag -width Ds +.It Ev OLLAMA_API_KEY +API key used when +.Fl k +is not given. +.It Ev OLLAMA_API_ENDPOINT +Base URL used when +.Fl b +is not given. +.El +.Sh EXIT STATUS +.Ex -std +.Nm +exits 2 for invalid options, missing API key, or operands. +.Sh EXAMPLES +Print the current usage: +.Bd -literal -offset indent +alpacu -k '$OLLAMA_API_KEY' +.Ed +.Pp +Print the raw JSON: +.Bd -literal -offset indent +alpacu -j +.Ed +.Sh SEE ALSO +.Xr curl 1 +.Pp +.Lk https://docs.ollama.com/api/introduction Ollama API introduction +.Lk https://docs.ollama.com/api/usage Ollama Cloud usage endpoint +.Lk https://ollama.com/pricing Ollama Cloud pricing and reset windows +.Sh AUTHORS +.An m +.Aq Mt miro@local \ No newline at end of file blob - /dev/null blob + 483cb7744fb59ddbc2fea3e42d1eaaabff3c5ff1 (mode 644) --- /dev/null +++ alpacu/go.mod @@ -0,0 +1,3 @@ +module alpacu + +go 1.26 blob - /dev/null blob + 5cef3c11a7e246561d6ddc5c081a845f23925afc (mode 644) --- /dev/null +++ alpacu/main.go @@ -0,0 +1,243 @@ +package main + +import ( + "cmp" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "os" + "slices" + "strings" + "time" +) + +const ( + maxErrorBody = 64 << 10 + usage = `usage: alpacu [-k key] [-b url] [-j] [-h] + + -k key API key (else $OLLAMA_API_KEY) + -b url base URL (default https://ollama.com) + -j print raw JSON + -h help +` +) + +var client = &http.Client{Timeout: 15 * time.Second} + +type Mcount struct { + Name string `json:"name"` + N int `json:"request_count"` +} + +type Limit struct { + Usage float64 `json:"usage"` + Models []Mcount `json:"models"` +} + +type Resp struct { + Activity struct { + Cost string `json:"cost"` + Period struct { + Type string `json:"type"` + From string `json:"starting_at"` + To string `json:"ending_at"` + } `json:"period"` + } `json:"activity"` + Limits struct { + Session Limit `json:"session"` + Weekly Limit `json:"weekly"` + } `json:"limits"` +} + +func main() { + key := flag.String("k", os.Getenv("OLLAMA_API_KEY"), "API key") + base := flag.String("b", envOr("OLLAMA_API_ENDPOINT", "https://ollama.com"), "base URL") + jsn := flag.Bool("j", false, "raw JSON") + help := flag.Bool("h", false, "help") + flag.Usage = func() { fmt.Fprint(os.Stderr, usage) } + flag.Parse() + if *help { + fmt.Print(usage) + return + } + if flag.NArg() != 0 { + usageError("unexpected argument %q", flag.Arg(0)) + } + if *key == "" { + usageError("no API key") + } + + r, err := fetch(*base, *key) + must(err) + + if *jsn { + b, err := json.MarshalIndent(r, "", " ") + must(err) + fmt.Println(string(b)) + return + } + + render(r) +} + +func fetch(base, key string) (*Resp, error) { + req, err := http.NewRequest("GET", strings.TrimRight(base, "/")+"/api/usage", nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", key) + req.Header.Set("Accept", "application/json") + res, err := client.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + b, err := io.ReadAll(io.LimitReader(res.Body, maxErrorBody)) + if err != nil { + return nil, err + } + return nil, fmt.Errorf("status %d: %s", res.StatusCode, strings.TrimSpace(string(b))) + } + var r Resp + if err := json.NewDecoder(res.Body).Decode(&r); err != nil { + return nil, err + } + return &r, nil +} + +func envOr(name, def string) string { + if v := os.Getenv(name); v != "" { + return v + } + return def +} + +func must(err error) { + if err != nil { + fmt.Fprintf(os.Stderr, "alpacu: %v\n", err) + os.Exit(1) + } +} + +func usageError(format string, args ...any) { + fmt.Fprintf(os.Stderr, "alpacu: "+format+"\n", args...) + fmt.Fprint(os.Stderr, usage) + os.Exit(2) +} + +// ANSI TUI + +const ( + clr = "\x1b[0m" + dim = "\x1b[2m" + bold = "\x1b[1m" + red = "\x1b[31m" + ylw = "\x1b[33m" + grn = "\x1b[32m" + cyn = "\x1b[36m" + clrW = "\x1b[38;5;255m" + bkW = "\x1b[48;5;237m" + bkD = "\x1b[48;5;235m" +) + +// clrPct colors a used fraction: green when light, red when nearly spent. +func clrPct(f float64) string { + switch { + case f >= 1.0: + return red + bold + case f >= 0.9: + return ylw + bold + case f >= 0.5: + return cyn + default: + return grn + } +} + +// countdown returns "resets in Xh Ym" for the next reset of a window +// whose length is period seconds. Session resets align to UTC multiples +// of 18000s (5h) from epoch. Weekly resets are offset by 4 days from +// epoch (so all accounts share the same boundary). Matches the formula +// in ollama/ollama issue #12532. +func countdown(period int64, offsetSec int64) string { + now := time.Now().UTC().Unix() + left := period - ((now - offsetSec) % period) + if left <= 0 { + left = period + } + return fmt.Sprintf("resets in %dh %02dm", left/3600, (left%3600)/60) +} + +func bar(f float64, w int) string { + fill := int(float64(w) * f) + if fill < 0 { + fill = 0 + } + if fill > w { + fill = w + } + return strings.Repeat("█", fill) + strings.Repeat("░", w-fill) +} + +func sorted(m []Mcount) []Mcount { + s := slices.Clone(m) + slices.SortFunc(s, func(a, b Mcount) int { return cmp.Compare(b.N, a.N) }) + return s +} + +// row prints one quota bar showing the used share of the window. +func row(label string, used float64, w int, note string) string { + c := clrPct(used) + pct := fmt.Sprintf("%5.1f%%", used*100) + b := bar(used, w) + return fmt.Sprintf(" %s%-7s%s %s%s%s %s%s%s %s\n", + dim, label, clr, c, b, clr, c, pct, clr, note) +} + +func render(r *Resp) { + // header + per := r.Activity.Period + hdr := fmt.Sprintf("%sollama cloud usage%s %s%s . %s to %s%s", + bold+cyn, clr, dim, per.Type, per.From, per.To, clr) + if r.Activity.Cost != "" { + hdr += fmt.Sprintf(" %sextra %s USD%s", dim, r.Activity.Cost, clr) + } + + fmt.Println(hdr) + fmt.Println() + fmt.Println(row("session", r.Limits.Session.Usage, 40, dim+countdown(5*3600, 0)+clr)) + fmt.Println(row("weekly ", r.Limits.Weekly.Usage, 40, dim+countdown(7*86400, 4*86400)+clr)) + + // models side-by-side + sm := sorted(r.Limits.Session.Models) + wm := sorted(r.Limits.Weekly.Models) + h := len(sm) + if len(wm) > h { + h = len(wm) + } + if h == 0 { + return + } + fmt.Println() + fmt.Printf(" %s%s%-20s %6s %-20s %6s%s\n", + bold, dim, "session models", "reqs", "weekly models", "reqs", clr) + for i := 0; i < h; i++ { + sl, sr := "", "" + wl, wr := "", "" + if i < len(sm) { + sl, sr = sm[i].Name, fmt.Sprint(sm[i].N) + } + if i < len(wm) { + wl, wr = wm[i].Name, fmt.Sprint(wm[i].N) + } + fmt.Printf(" %s%-20s%s %s%6s%s %s%-20s%s %s%6s%s\n", + clrW+bkW, sl, clr, + bold, sr, clr, + clrW+bkD, wl, clr, + bold, wr, clr, + ) + } +}