Skip to content
Merged
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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*.dll
*.so
*.dylib
bin/

# Test binary, built with `go test -c`
*.test
Expand All @@ -33,4 +34,6 @@ go.work.sum

# agents
.claude/
docs/
docs/
# build output
bin/
11 changes: 8 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
.PHONY: build test lint run
.PHONY: build test lint run run-plain

RUN_FLAGS = --provider=ollama --model=qwen3.5:4b --tools=native --think --num-ctx 8192

build:
go build -o bin/pico ./cmd/pico
Expand All @@ -11,5 +13,8 @@ lint:
go vet ./...
golangci-lint run

run:
go run ./cmd/pico --provider=ollama --model=qwen3.5:4b --tui --tools=native --think --num-ctx 8192
run: build
./bin/pico $(RUN_FLAGS) --tui

run-plain: build
./bin/pico $(RUN_FLAGS)
107 changes: 65 additions & 42 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
# pico code

A small CLI AI agent written in Go. One agent loop, two LLM backends —
[Anthropic](https://docs.anthropic.com/) (cloud) and [Ollama](https://ollama.com/)
(local) — and a handful of sandboxed tools (read a file, list a directory, run
an allowlisted command, write a file behind a flag).
A small CLI AI agent written in Go. One agent loop, three LLM backends —
[Anthropic](https://docs.anthropic.com/) (cloud), [Ollama](https://ollama.com/)
(local), and any OpenAI-compatible `/chat/completions` endpoint — plus
sandboxed filesystem tools (read, list, search, glob always on; write and
edit behind a flag), an allowlisted `run_command`, and optional MCP servers
for anything beyond that.

## Install

Expand All @@ -17,6 +19,15 @@ into `/usr/local/bin` (or `~/.local/bin` if that isn't writable) — no Go
required. Windows isn't supported yet: command timeouts rely on POSIX
process groups.

Many systems already have a `pico` — usually nano's Pine-compatibility alias
at `/usr/bin/pico`. Set `BIN_NAME` to install under a different command name
and keep both, and `INSTALL_DIR` to choose where it lands:

```bash
curl -fsSL https://raw.githubusercontent.com/reno/pico-code/main/install.sh \
| BIN_NAME=pico-code INSTALL_DIR=~/.local/bin sh
```

Or via Homebrew (macOS/Linux):

```bash
Expand Down Expand Up @@ -45,14 +56,17 @@ pico --provider=ollama --model=qwen3:8b

# Cloud — needs ANTHROPIC_API_KEY set first.
pico --provider=anthropic --model=claude-sonnet-4-5

# Any OpenAI-compatible endpoint (OpenAI itself, vLLM, LM Studio, ...).
pico --provider=openai --model=gpt-4o
```

Either command drops you into a `> ` prompt. Ctrl+D (EOF) exits cleanly;
Ctrl+C interrupts the process the way it would any CLI tool — one to stop,
a second if the first doesn't. `--tui` behaves differently on purpose: see
[While chatting](#while-chatting).

## The two backends
## The three backends

### Anthropic

Expand All @@ -68,34 +82,34 @@ or an unreachable network surfaces as a clear error rather than a hang.

Every request marks two `cache_control` breakpoints: one on the system
prompt, one on the last block of the last message. Because history only
ever grows by appending, each new turn's request shares an identical
prefix with the one before it up to that second breakpoint — the provider
serves everything up to there from cache and only charges full price for
the newly appended tail. A `/compact` (or automatic compaction, 8.2)
rewrites the oldest turns into a fresh synthetic summary, which changes
the cached prefix's bytes; the next request simply writes a new cache
entry there instead of ever risking a stale one.

The savings are real but numbers vary a lot by conversation shape, so
here's the arithmetic rather than a single "X% faster" claim, using
`claude-sonnet-4-5`'s published per-token rates ($3.00 input / $3.75 cache
write / $0.30 cache read, per million tokens — see `internal/config/pricing.go`
for the same "these are estimates" caveat `/usage` carries). For a 2,000
token stable prefix (a typical system prompt plus tool definitions) reused
across 10 requests within the cache's TTL:

| | cost |
|---|---|
| without caching (10 × full-price reads of the 2,000 tokens) | 10 × 2000 × $3.00 / 1e6 = **$0.0600** |
| with caching (1 write + 9 reads of the 2,000 tokens) | (2000 × $3.75 / 1e6) + 9 × (2000 × $0.30 / 1e6) = $0.0075 + $0.0054 = **$0.0129** |

That's a ~78% reduction on the *stable-prefix portion* of input cost over
10 turns, and it keeps improving the longer the conversation runs — the
one-time write premium amortizes away while every later turn keeps paying
the ~10×-cheaper read rate. It only covers the part of each request that's
actually stable; the growing tail (each turn's own new messages) is never
cached and always costs full price, so a whole conversation's total
savings will be smaller than this in practice.
ever grows by appending, each new turn shares an identical prefix with the
last one up to that second breakpoint, so the provider serves it from
cache and only charges full price for the newly appended tail. A
`/compact` (or automatic compaction, 8.2) rewrites the oldest turns into a
fresh summary, which simply starts a new cache entry rather than risking a
stale one.

Using `claude-sonnet-4-5`'s published rates ($3.00 input / $3.75 cache
write / $0.30 cache read per million tokens — see `internal/config/pricing.go`),
a 2,000-token stable prefix reused across 10 requests costs $0.0600
without caching vs. $0.0129 with it: a ~78% reduction on the
*stable-prefix portion* of input cost, improving further the longer the
conversation runs. It doesn't touch the growing tail of new messages each
turn adds, which is never cached and always full price.

### OpenAI-compatible

```bash
export OPENAI_API_KEY=sk-... # optional — some endpoints need no auth
export OPENAI_BASE_URL=http://localhost:8000/v1 # optional, default api.openai.com/v1
./bin/pico --provider=openai --model=<model ID>
```

One adapter for anything that speaks the same flat `/chat/completions` +
`tool_calls` shape as OpenAI: OpenAI itself, vLLM, LM Studio, or a local
Ollama's own `/v1` endpoint. `OPENAI_BASE_URL` is what points it at any of
those; an unset `OPENAI_API_KEY` is not an error, since many
OpenAI-compatible servers run with no auth at all.

### Ollama

Expand All @@ -117,7 +131,8 @@ Not every local model supports native tool calling, and some that advertise
support still narrate a tool call in prose instead of using it. So
`--provider=ollama` defaults `--tools` to `prompted` unless you pass
`--tools` explicitly; pass `--tools=native` yourself if your model handles
it reliably. (`--provider=anthropic` keeps defaulting to `native`.)
it reliably. (`--provider=anthropic` and `--provider=openai` keep
defaulting to `native`.)
`--tools=prompted` injects tool schemas into the system prompt and parses a
fenced JSON block back out of the reply instead of relying on the model's
native tool-calling — it can't stream, so it also forces `--stream=false`
Expand All @@ -129,7 +144,7 @@ you'll need to add `--tools=native` explicitly.

| Flag | Default | Meaning |
| ----------------- | ------------ | -------------------------------------------------------------- |
| `--provider` | `anthropic` | `anthropic` or `ollama` |
| `--provider` | `anthropic` | `anthropic`, `ollama`, or `openai` |
| `--model` | (none) | model ID for the selected provider |
| `--max-turns` | `25` | agent loop turns before it stops on its own |
| `--token-budget` | `100000` | cumulative tokens before it stops on its own |
Expand All @@ -139,17 +154,19 @@ you'll need to add `--tools=native` explicitly.
| `--stream` | `true` | stream a reply as it arrives, plain mode only (the TUI always streams) |
| `--tui` | `false` | bubbletea TUI instead of the plain read-eval-print loop |
| `--log-level` | `info` | `debug`, `info`, `warn`, or `error` |
| `--num-ctx` | `4096` | Ollama's context window (`num_ctx`); ignored by Anthropic |
| `--allow-write` | `false` | register the `write_file` tool |
| `--num-ctx` | `4096` | Ollama's context window (`num_ctx`); ignored by other providers |
| `--allow-write` | `false` | register the `write_file` and `edit_file` tools |
| `--session` | (none) | name a session to resume or start; saved after every turn |
| `--allow-commands`| (none) | comma-separated binary allowlist; registers `run_command` only if non-empty |
| `--context-window`| `200000` | context window compaction measures usage against; ignored by Ollama, which uses `--num-ctx` |
| `--think` | `false` | ask the model for a reasoning trace ahead of its reply; currently Ollama only |
| `--mcp-config` | (none) | path to a JSON file listing MCP servers (`{"mcpServers": {name: {command, args, env}}}`) |

`PICO_CODE_PROVIDER` is an environment fallback for `--provider`, checked
only when the flag isn't explicitly set. `ANTHROPIC_API_KEY` and
`OLLAMA_HOST` are read from the environment only — there's no flag for
either, so a credential never ends up in shell history.
only when the flag isn't explicitly set. `ANTHROPIC_API_KEY`, `OLLAMA_HOST`,
`OPENAI_API_KEY`, and `OPENAI_BASE_URL` are read from the environment
only — there's no flag for any of them, so a credential never ends up in
shell history.

## While chatting

Expand All @@ -161,6 +178,8 @@ Both the plain REPL and the TUI understand the same commands:
- `/new` — start a fresh, unsaved conversation
- `/save [name]` — save the current conversation as a session
- `/load <name>` — replace the current conversation with a saved session
- `/mcp [reconnect <name>]` — list configured MCP servers and their status,
or reconnect one (only meaningful with `--mcp-config` set)

`--session <name>` does the same save/resume automatically, after every
turn, in both — kill the process mid-conversation and `--session <name>`
Expand Down Expand Up @@ -204,11 +223,15 @@ design.
binary) executes only that allowlisted binary, directly (`exec.Command`,
never a shell — no `|`, `&&`, `$()`, or similar can do anything), with a
timeout and truncated output.
- `write_file` and `run_command` both need approval unless `--yes` is
passed; the plain REPL prompts on the terminal, the TUI shows a modal.
- `write_file`, `edit_file`, `run_command`, and any tool from an MCP server
all need approval unless `--yes` is passed; the plain REPL prompts on the
terminal, the TUI shows a modal.
- Tool output is truncated (head + tail, with an elision marker) before it
ever reaches the model's context, so one large file can't blow the
context window.
- A provider or model-resolution failure (an unreachable Ollama daemon, a
typo'd `--model`) is rewritten into a one-line message naming the actual
problem instead of the raw transport error (`cmd/pico/errors.go`).

## How the agent loop works

Expand Down
66 changes: 33 additions & 33 deletions cmd/pico/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,44 +69,44 @@ func configureChat(cmd *cobra.Command, getenv func(string) string) {
}

cmd.RunE = func(cmd *cobra.Command, _ []string) error {
provider := flags.provider
if !cmd.Flags().Changed("provider") {
if v := getenv("PICO_CODE_PROVIDER"); v != "" {
provider = v
}
}

toolsMode := flags.tools
if !cmd.Flags().Changed("tools") && provider == string(config.ProviderOllama) {
toolsMode = string(config.ToolsPrompted)
provider := flags.provider
if !cmd.Flags().Changed("provider") {
if v := getenv("PICO_CODE_PROVIDER"); v != "" {
provider = v
}
}

cfg, err := config.Load(config.Flags{
Provider: provider,
Model: flags.model,
MaxTurns: flags.maxTurns,
TokenBudget: flags.tokenBudget,
Workspace: flags.workspace,
Yes: flags.yes,
Tools: toolsMode,
Stream: flags.stream,
TUI: flags.tui,
LogLevel: flags.logLevel,
NumCtx: flags.numCtx,
AllowWrite: flags.allowWrite,
Session: flags.session,
AllowCommands: flags.allowCommands,
ContextWindow: flags.contextWindow,
MCPConfig: flags.mcpConfig,
Think: flags.think,
}, getenv)
if err != nil {
return fmt.Errorf("resolving config: %w", err)
}
toolsMode := flags.tools
if !cmd.Flags().Changed("tools") && provider == string(config.ProviderOllama) {
toolsMode = string(config.ToolsPrompted)
}

return runChat(cmd, cfg)
cfg, err := config.Load(config.Flags{
Provider: provider,
Model: flags.model,
MaxTurns: flags.maxTurns,
TokenBudget: flags.tokenBudget,
Workspace: flags.workspace,
Yes: flags.yes,
Tools: toolsMode,
Stream: flags.stream,
TUI: flags.tui,
LogLevel: flags.logLevel,
NumCtx: flags.numCtx,
AllowWrite: flags.allowWrite,
Session: flags.session,
AllowCommands: flags.allowCommands,
ContextWindow: flags.contextWindow,
MCPConfig: flags.mcpConfig,
Think: flags.think,
}, getenv)
if err != nil {
return fmt.Errorf("resolving config: %w", err)
}

return runChat(cmd, cfg)
}

f := cmd.Flags()
f.StringVar(&flags.provider, "provider", "anthropic", "LLM backend to use (anthropic|ollama|openai)")
f.StringVar(&flags.model, "model", "", "model identifier for the selected provider")
Expand Down
26 changes: 21 additions & 5 deletions cmd/pico/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ func TestChatProviderEnvFallback(t *testing.T) {
root := newRootCmd(getenv)
buf := &bytes.Buffer{}
root.SetOut(buf)
root.SetArgs(nil)
root.SetArgs([]string{"--model=test-model"})

if err := root.Execute(); err != nil {
t.Fatalf("chat returned error: %v", err)
Expand All @@ -78,7 +78,7 @@ func TestChatFlagOverridesEnv(t *testing.T) {
root := newRootCmd(getenv)
buf := &bytes.Buffer{}
root.SetOut(buf)
root.SetArgs([]string{"--provider=anthropic"})
root.SetArgs([]string{"--provider=anthropic", "--model=test-model"})

if err := root.Execute(); err != nil {
t.Fatalf("chat returned error: %v", err)
Expand All @@ -93,7 +93,7 @@ func TestChatToolsDefaultsToPromptedForOllama(t *testing.T) {
root := newRootCmd(func(string) string { return "" })
buf := &bytes.Buffer{}
root.SetOut(buf)
root.SetArgs([]string{"--provider=ollama"})
root.SetArgs([]string{"--provider=ollama", "--model=test-model"})

if err := root.Execute(); err != nil {
t.Fatalf("chat returned error: %v", err)
Expand All @@ -108,7 +108,7 @@ func TestChatToolsDefaultsToNativeForAnthropic(t *testing.T) {
root := newRootCmd(func(string) string { return "" })
buf := &bytes.Buffer{}
root.SetOut(buf)
root.SetArgs([]string{"--provider=anthropic"})
root.SetArgs([]string{"--provider=anthropic", "--model=test-model"})

if err := root.Execute(); err != nil {
t.Fatalf("chat returned error: %v", err)
Expand All @@ -123,7 +123,7 @@ func TestChatExplicitToolsOverridesOllamaDefault(t *testing.T) {
root := newRootCmd(func(string) string { return "" })
buf := &bytes.Buffer{}
root.SetOut(buf)
root.SetArgs([]string{"--provider=ollama", "--tools=native"})
root.SetArgs([]string{"--provider=ollama", "--tools=native", "--model=test-model"})

if err := root.Execute(); err != nil {
t.Fatalf("chat returned error: %v", err)
Expand All @@ -148,3 +148,19 @@ func TestChatUnknownProviderIsClearError(t *testing.T) {
t.Errorf("expected error to name the offending value, got: %v", err)
}
}

func TestChatMissingModelIsClearError(t *testing.T) {
root := newRootCmd(func(string) string { return "" })
buf := &bytes.Buffer{}
root.SetOut(buf)
root.SetErr(buf)
root.SetArgs(nil)

err := root.Execute()
if err == nil {
t.Fatal("expected an error when --model is not passed")
}
if !strings.Contains(err.Error(), "--model") {
t.Errorf("expected error to mention --model, got: %v", err)
}
}
29 changes: 0 additions & 29 deletions docs/tui-check.md

This file was deleted.

Loading
Loading