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
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ scay

First time, scay walks you through setup right there:

1. **`/auth`** — pick a provider and paste your API key (skip this for Ollama;
keys are stored with `0600` permissions under `~/.config/scay`).
1. **`/auth`** — pick a provider and authenticate. OpenAI accepts an API key or
an experimental ChatGPT subscription login; Ollama needs neither. Credentials
are stored with `0600` permissions under `~/.config/scay`.
2. **`/model`** — pick the provider, model, and thinking effort. With Ollama,
the menu lists whatever models you actually have installed.
3. **Type what you want.** Anything that isn't a `/command` goes to the model.
Expand All @@ -67,7 +68,7 @@ Slash commands, inside the session:
| Command | What it does |
|---|---|
| `/model` | Pick provider, model, and thinking effort (applies live) |
| `/auth [provider]` | Store an API key, or the host for Ollama |
| `/auth [provider]` | Authenticate; OpenAI offers API key or experimental ChatGPT login |
| `/mcp` | Manage MCP servers — `add`, `list`, `remove`, `get`, `tools` |
| `/compact` | Squash a long history into a summary, freeing context |
| `/clear` | Start the conversation over |
Expand All @@ -86,17 +87,18 @@ scay update # self-update to the latest release
scay version # print the version
```

## Providers & API keys
## Providers & authentication

| Provider | Key via `/auth` or env var |
| Provider | Authentication |
|---|---|
| Anthropic | `ANTHROPIC_API_KEY` |
| OpenAI | `OPENAI_API_KEY` |
| OpenAI | `OPENAI_API_KEY`, or experimental ChatGPT subscription login via `/auth openai` |
| Google Gemini | `GEMINI_API_KEY` or `GOOGLE_API_KEY` |
| Ollama (local) | no key — just `ollama serve` (`OLLAMA_HOST` to point elsewhere) |

Environment variables override the config file, so CI and one-off shells work
without touching your saved setup. Responses stream on all four backends, and
without touching your saved setup. ChatGPT login relies on the Codex protocol
and is explicitly experimental. Responses stream on all four backends, and
transient API failures retry automatically with backoff.

## MCP servers (optional)
Expand Down
16 changes: 13 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# scay roadmap

> **Current state: post-v0.1.0 debloat refactor (2026-07-04), unreleased.** The separate
> **Current state: OpenAI Responses migration + experimental ChatGPT login (2026-07-14), unreleased.** The separate
> TUI (`cmd/scay-tui` nested Charm module) is **gone** — one module, one binary, nothing
> else to compile; the octopus mascot survives in the CLI banner. The command surface
> collapsed into the interactive session: `scay` opens it (even unconfigured — it guides
Expand All @@ -10,7 +10,9 @@
> new `scay update` (self-update from GitHub releases); `scay -p` one-shot mode was
> removed. gofmt/vet/build/test clean; REPL flows (onboarding, /help, /model via local
> Ollama, /mcp list, live turn with tool use) verified by driving the binary. **Next:**
> tag v0.2.0. **Still deferred (needs real keys):** live checks vs
> tag v0.2.0. OpenAI now uses Responses API; `/auth openai` offers either a Platform
> API key or an experimental ChatGPT/Codex browser login with renewable credentials.
> **Still deferred (needs real credentials):** live checks vs
> Anthropic/OpenAI/Gemini; `scay update` needs a **public** published release to be
> exercised end-to-end (the repo is currently private, so the unauthenticated GitHub
> API 404s — verified the error path handles this cleanly).
Expand All @@ -37,7 +39,7 @@ Legend: `[x]` done · `[~]` in progress · `[ ]` todo
- [ ] **Live check (deferred post-v0.1.0, needs a real key):** `export ANTHROPIC_API_KEY=…`, `scay model` → Claude, then `scay -p "…"` on a real task

## M2 — Multi-provider
- [x] OpenAI adapter (`/v1/chat/completions` — chosen over `/v1/responses` for simplicity)
- [x] Initial OpenAI Chat Completions adapter (superseded by the M8 Responses API migration)
- [x] Gemini adapter (`:generateContent`)
- [x] Ollama adapter (`/api/chat`, `stream:false`)
- [x] id-less tool-call matching (Gemini/Ollama, by name) + verbatim reasoning replay — mock-tested
Expand Down Expand Up @@ -69,6 +71,14 @@ Legend: `[x]` done · `[~]` in progress · `[ ]` todo
## Post-v0.1.0
- [x] `scay model` fetches installed models live from Ollama (`GET /api/tags`, 3s timeout, resolved host incl. `OLLAMA_HOST`); curated list is now only the unreachable-server fallback. Mock-tested + verified against local Ollama and a dead port.

## M8 — OpenAI Responses + experimental ChatGPT login (2026-07-14)
- [x] OpenAI adapter migrated from Chat Completions to Responses API; messages, function calls, tool results, reasoning items, streaming completion, and usage are normalized through the existing provider types
- [x] `/auth openai` offers Platform API key or experimental ChatGPT subscription login; OAuth uses Authorization Code + PKCE with a localhost callback, tokens stay in the existing `0600` config file, and `OPENAI_API_KEY` still overrides saved credentials
- [x] Renew access tokens before expiry and retry one request after a 401; the ChatGPT/Codex backend gets its account ID and required `store:false`; changing OpenAI sign-in mode clears history because opaque reasoning replay is backend-specific
- [x] Mock tests: Responses request/response and streaming, Codex account header, one-refresh 401 recovery, credential resolution, JWT-derived account/expiry
- [x] **Live check (ChatGPT Plus/Pro):** browser login, a multi-turn text exchange, and `write_file` → `read_file` tool use through the Codex backend. Required `store:false`, omission of `max_output_tokens`, stream-item function-call handling, and assistant-message replay were discovered and covered by tests.
- [ ] **Live checks (needs a real account):** API-key tool-use task through Responses; ChatGPT Plus/Pro token refresh and a new session after token rotation. The ChatGPT route depends on the Codex protocol and remains experimental until OpenAI publishes a third-party OAuth contract.

## M7 — Debloat: one binary, everything in-session (2026-07-04)
- [x] Removed `cmd/scay-tui` (the nested Charm Go module) and all references (CI, release script, gitignore, docs); the block-art octopus moved into the CLI banner
- [x] Collapsed the command surface: `auth`/`model`/`mcp` subcommands and `-p` one-shot removed; everything runs inside the interactive session as `/auth`, `/model`, `/mcp`, `/compact`, `/clear`, `/help`, `/exit`
Expand Down
35 changes: 21 additions & 14 deletions docs/OVERVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@ Every dependency and abstraction must earn its weight. Concretely:
1. **Providers → hand-rolled clients.** One thin `net/http` + `encoding/json` client
per provider (~150–400 LOC each). Zero deps, full control; we own the JSON and
track API drift ourselves.
2. **Login → API key now, OAuth later.** API-key auth for v1, but credentials sit
behind a `Credential` interface so OAuth can be added per-provider without a
config schema break.
2. **Login → API key plus experimental ChatGPT login.** OpenAI accepts a Platform
API key or an experimental browser OAuth flow compatible with Codex. Credentials
sit behind a renewable `Credential` interface; the ChatGPT route is isolated
because OpenAI does not yet publish third-party OAuth client registration.
3. **Model selection → curated list + free-form.** A short built-in list of current
models per provider, plus "type any model id". Effort from a unified ladder.
4. **Key storage → `0600` file + env.** Stored under `os.UserConfigDir()`
Expand Down Expand Up @@ -57,16 +58,17 @@ loop never sees provider quirks; each adapter re-places them for its own API.

| Provider | Endpoint | System prompt | Stream | Tool-call id | Effort knob |
|-----------|---------------------------------------|------------------------|-----------|---------------------|--------------------------------------|
| OpenAI | `POST /v1/chat/completions` | a message | SSE (delta) | `tool_call_id` | `reasoning_effort` |
| OpenAI | `POST /v1/responses` (API key) or Codex backend (ChatGPT) | `instructions` | SSE (response events) | `call_id` | `reasoning.effort` |
| Anthropic | `POST /v1/messages` | top-level `system` | SSE (block) | `toolu_…` | `output_config.effort` + `thinking` |
| Gemini | `…/models/{m}:generateContent` | top-level `systemInstruction` | SSE (chunk) | **none — match by name** | `thinkingConfig` |
| Ollama | `POST /api/chat` | a `system` message | NDJSON | **none — match by name** | `think: false\|"low".."max"` |

> **OpenAI endpoint choice:** scay targets **Chat Completions**, not the Responses
> API. It's the simplest surface to hand-roll — stable tool-call ids, a plain
> top-level `reasoning_effort`, no reasoning-item round-trip — which fits the
> debloat goal. The same client can drive Gemini's and Ollama's OpenAI-compatible
> endpoints via a `BaseURL` override if ever needed.
> **OpenAI endpoint choice:** scay targets the **Responses API**. API-key requests
> go to the public Platform endpoint; experimental ChatGPT sign-in goes to the
> Codex backend with an access token and `ChatGPT-Account-ID`. Responses has more
> round-trip plumbing than Chat Completions, but its opaque reasoning and function
> call items map cleanly onto `Block.Reasoning` and keep both auth methods behind
> one adapter.

Five divergences the **adapters** absorb (never the loop):

Expand Down Expand Up @@ -97,9 +99,10 @@ replays next request — is identical on both paths. That matters most for:
reassembled into a `{type, thinking, signature}` block for verbatim replay;
`input_json_delta` fragments concatenate into tool-call args; `redacted_thinking`
arrives whole in `content_block_start` and is kept raw.
- **OpenAI** (`stream: true` + `stream_options.include_usage`, SSE, `[DONE]`
terminator): tool-call fragments are keyed by `index` and concatenated; the final
usage chunk has no choices.
- **OpenAI** (`stream: true`, Responses SSE): `response.output_text.delta` and
reasoning deltas render immediately; `response.completed` carries the complete
output-item list and usage. Function calls and reasoning items are replayed
verbatim on the next request.
- **Gemini** (`:streamGenerateContent?alt=sse`): each frame is a chunk of parts.
Parts carrying a `thoughtSignature` (and all thought/functionCall parts) are kept
raw for replay; unsigned plain-text fragments are merged into one block.
Expand Down Expand Up @@ -162,8 +165,12 @@ matches), printed unified diffs, an approval gate on write/edit/bash (bypassed b
## Config & auth

`config.json` under `os.UserConfigDir()/scay`, written `0600`. Env vars override the
file. Credentials sit behind a `Credential` interface (`apiKeyCredential` today) so
OAuth is an additive change later.
file. OpenAI supports `api_key` and experimental `chatgpt` credentials; the latter
stores access token, rotating refresh token, account ID, and expiry in that protected
file. `Credential` resolves a current token at request time, refreshes once after a
401, and persists the rotated token atomically. The browser login uses Authorization
Code + PKCE on a localhost callback; it has no third-party client-registration
guarantee from OpenAI and is deliberately marked experimental.

## MCP

Expand Down
55 changes: 52 additions & 3 deletions internal/cli/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,22 @@ package cli

import (
"bufio"
"context"
"fmt"
"os/exec"
"runtime"
"slices"
"strings"
"time"

"github.com/robitec97/scay/internal/config"
"github.com/robitec97/scay/internal/provider"
)

// authCommand handles /auth: pick a provider (or name it: `/auth openai`),
// then store its API key (or host for Ollama). Key input hides the echo when
// the terminal allows; keys are stored 0600. OAuth can slot in later behind
// the same config schema.
// then store its API key (or host for Ollama). OpenAI additionally offers the
// experimental ChatGPT/Codex browser login. Key input hides the echo when the
// terminal allows; credentials are stored 0600.
func authCommand(in *bufio.Reader, args []string) error {
cfg, err := config.Load()
if err != nil {
Expand Down Expand Up @@ -47,6 +51,32 @@ func authCommand(in *bufio.Reader, args []string) error {
host = "http://localhost:11434"
}
pc.Host = host
} else if pid == "openai" {
fmt.Println("OpenAI sign-in method:")
fmt.Println(" 1) API key (OpenAI Platform billing)")
fmt.Println(" 2) ChatGPT subscription (experimental)")
idx := readIndex(in, "> ", 2)
if idx < 0 {
return fmt.Errorf("cancelled")
}
if idx == 1 {
fmt.Println("Opening ChatGPT sign-in in your browser…")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
if err := config.LoginOpenAIChatGPT(ctx, openBrowser); err != nil {
return err
}
path, _ := config.Path()
fmt.Printf("Saved OpenAI ChatGPT credentials to %s.\n", path)
return nil
}
key := readSecret(in, "Paste your openai API key: ")
if key == "" {
return fmt.Errorf("no key entered")
}
pc.AuthType = "api_key"
pc.APIKey = key
pc.OAuth = nil
} else {
key := readSecret(in, fmt.Sprintf("Paste your %s API key: ", pid))
if key == "" {
Expand All @@ -66,3 +96,22 @@ func authCommand(in *bufio.Reader, args []string) error {
fmt.Printf("Saved %s credentials to %s.\n", pid, path)
return nil
}

// openBrowser starts the platform's URL launcher. A printed URL still lets the
// user proceed when a minimal terminal environment has no desktop integration.
func openBrowser(rawURL string) error {
fmt.Printf("If the browser does not open, visit:\n%s\n", rawURL)
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("open", rawURL)
case "windows":
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL)
default:
cmd = exec.Command("xdg-open", rawURL)
}
if err := cmd.Start(); err != nil {
fmt.Printf("Could not open a browser automatically: %v\n", err)
}
return nil
}
5 changes: 3 additions & 2 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,9 @@ Usage:
Everything else happens inside the session — type /help there:
/model /auth /mcp /compact /clear /exit

API keys are stored via /auth (0600 under your OS config dir, e.g.
~/.config/scay) or come from environment variables (ANTHROPIC_API_KEY,
API keys and the experimental OpenAI ChatGPT login are stored via /auth (0600
under your OS config dir, e.g. ~/.config/scay). Environment variables
(ANTHROPIC_API_KEY,
OPENAI_API_KEY, GEMINI_API_KEY/GOOGLE_API_KEY, OLLAMA_HOST), which override
the config file.
`)
Expand Down
55 changes: 41 additions & 14 deletions internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,12 @@ var errNotConfigured = errors.New("not configured")
// repl is one interactive session: the reader, the (re)buildable agent, and
// the conversation. The agent is nil until a model is configured.
type repl struct {
in *bufio.Reader
yolo bool
ag *agent.Agent
sess *agent.Session
cleanup func() // closes the current agent's MCP sessions
in *bufio.Reader
yolo bool
ag *agent.Agent
sess *agent.Session
cleanup func() // closes the current agent's MCP sessions
identity string // provider plus auth mode; reasoning replay is backend-specific
}

// runREPL starts an interactive session. With yolo, every tool action is
Expand Down Expand Up @@ -131,7 +132,7 @@ func (r *repl) command(line string) bool {
func printHelp() {
fmt.Print(`Commands:
/model pick provider, model, and thinking effort
/auth [provider] log in: store an API key (anthropic|openai|google|ollama)
/auth [provider] log in (OpenAI: API key or experimental ChatGPT subscription)
/mcp manage MCP servers (run /mcp for subcommands)
/compact replace history with a model-written summary (frees context)
/clear clear history
Expand All @@ -158,10 +159,9 @@ func (r *repl) reload() {
// but is cleared when the provider changes: replayed reasoning blocks are
// provider-specific opaque blobs another backend would reject.
func (r *repl) rebuild() error {
oldProvider := ""
oldIdentity := r.identity
var oldMsgs []provider.Message
if r.ag != nil {
oldProvider = r.ag.Provider.Name()
oldMsgs = r.sess.Messages
}

Expand All @@ -176,18 +176,34 @@ func (r *repl) rebuild() error {
}
r.cleanup()
r.ag, r.sess, r.cleanup = ag, sess, cleanup
r.identity = configuredProviderIdentity()

if oldProvider == ag.Provider.Name() {
if oldIdentity == r.identity {
r.sess.Messages = oldMsgs
} else if len(oldMsgs) > 0 {
fmt.Println("Provider changed — history cleared.")
fmt.Println("Provider or OpenAI sign-in method changed — history cleared.")
}
return nil
}

func (r *repl) printStatus() {
wd, _ := os.Getwd()
fmt.Printf(" %s / %s (effort %s) in %s\n", r.ag.Provider.Name(), r.ag.Model, r.ag.Effort, wd)
name := r.ag.Provider.Name()
if r.identity == "openai:chatgpt" {
name += " / ChatGPT experimental"
}
fmt.Printf(" %s / %s (effort %s) in %s\n", name, r.ag.Model, r.ag.Effort, wd)
}

func configuredProviderIdentity() string {
cfg, err := config.Load()
if err != nil || cfg.Provider == "" {
return ""
}
if cfg.Provider == "openai" {
return "openai:" + cfg.Resolve("openai").Cred.Kind()
}
return cfg.Provider
}

// buildAgent constructs an Agent + Session from saved config for the current
Expand All @@ -203,11 +219,22 @@ func buildAgent(progress agent.Progress, approve tools.Approver) (*agent.Agent,
return nil, nil, nop, errNotConfigured
}
res := cfg.Resolve(cfg.Provider)
token, tokErr := res.Cred.Token()
token, tokErr := res.Cred.Token(context.Background(), false)
if tokErr != nil && cfg.Provider != "ollama" {
return nil, nil, nop, fmt.Errorf("no API key for %s — use /auth %s or set the env var", cfg.Provider, cfg.Provider)
return nil, nil, nop, fmt.Errorf("no credentials for %s — use /auth %s or set the env var", cfg.Provider, cfg.Provider)
}
opts := provider.Options{APIKey: token, BaseURL: res.Host}
if cfg.Provider == "openai" {
opts.ChatGPT = res.Cred.Kind() == "chatgpt"
opts.OpenAIAuth = func(ctx context.Context, retry bool) (provider.OpenAIAuth, error) {
token, err := res.Cred.Token(ctx, retry)
if err != nil {
return provider.OpenAIAuth{}, err
}
return provider.OpenAIAuth{Token: token, AccountID: res.Cred.AccountID()}, nil
}
}
p, err := provider.New(cfg.Provider, provider.Options{APIKey: token, BaseURL: res.Host})
p, err := provider.New(cfg.Provider, opts)
if err != nil {
return nil, nil, nop, err
}
Expand Down
Loading
Loading