Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
bec0745
Add extend login / logout via OAuth browser sign-in
jordanalexmeyer Aug 18, 2026
e0e501c
Brand the loopback landing pages for extend login
jordanalexmeyer Aug 18, 2026
9662f3b
Personalize the extend login success line via GET /me
jordanalexmeyer Aug 18, 2026
fb165d5
Remove EXTEND_OAUTH_CLIENT_ID override (D94)
jordanalexmeyer Aug 18, 2026
1e2c960
Ignore state-mismatched loopback callbacks and harden the server
jordanalexmeyer Aug 18, 2026
6b918e9
Pin discovered OAuth endpoints to the API base host
jordanalexmeyer Aug 18, 2026
cf43a4c
Persist rotated refresh tokens before use; narrow reauth to grant rej…
jordanalexmeyer Aug 18, 2026
adde736
Serialize token refresh across CLI processes
jordanalexmeyer Aug 18, 2026
15f26db
Sanitize /me fields before printing the login success line
jordanalexmeyer Aug 18, 2026
2a00531
Low-severity hardening across the oauth flow
jordanalexmeyer Aug 18, 2026
7a1b3a2
Refuse OAuth redirects off the pinned API origin
jordanalexmeyer Aug 19, 2026
e625728
Fail closed when the cross-process refresh lock is unavailable
jordanalexmeyer Aug 19, 2026
01e0174
Refuse API client redirects off the pinned base origin
jordanalexmeyer Aug 19, 2026
fa3ec0a
Reject cleartext http API bases for non-loopback hosts
jordanalexmeyer Aug 19, 2026
2d93227
Sanitize server-controlled error text before it reaches the terminal
jordanalexmeyer Aug 19, 2026
7cee39b
Address UX and polish findings from the login review
jordanalexmeyer Aug 21, 2026
ef34290
Remove accidentally committed local build binary
jordanalexmeyer Aug 21, 2026
f197bc8
Scope the agent skill to extend-cli and refresh auth docs
jordanalexmeyer Aug 21, 2026
6f9c8d0
Split README setup from authentication and trim detail
jordanalexmeyer Aug 21, 2026
3fbffe0
Let the setup wizard choose between browser login and an API key
jordanalexmeyer Aug 21, 2026
2f6ab1e
Remove em dashes from README
jordanalexmeyer Aug 21, 2026
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,6 @@ evals/workspace/
# Editor/IDE
# .idea/
# .vscode/

# local build output
/extend
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ under `evals/runner`; see `evals/README.md`.
- `internal/cli` — every command, group, and help topic; the typed
CommandDoc tree is the source of truth (see `internal/cli/AGENTS.md`).
- `internal/extendx` — SDK client construction, run polling/wait, file I/O.
- `internal/oauth`: browser login (PKCE flow, loopback redirect, token
storage in the keychain with file fallback, silent refresh).
- `internal/output`, `internal/iostreams` — rendering and stream handling.
- `evals/` — skill-eval harness (see `evals/AGENTS.md`).

Expand Down
64 changes: 42 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,41 +33,61 @@ already on your `PATH`. See its options with:

curl -fsSL https://extend.ai/install.sh | sh -s -- --help

## Use with coding agents
## Setup

The CLI ships a [`SKILL.md`](https://agentskills.io) that teaches
agent harnesses (Claude Code, Codex, OpenCode, Cursor, Goose, etc.)
to use `extend` correctly without you having to spell out every command.
extend setup

Install to the cross-client default path:
The wizard asks how you want to sign in, through your browser (no API
key needed) or with an API key, then picks your region, walks you
through the rest, and installs the agent skill. See
[Authentication](#authentication) for how the two credential types
differ.

extend skill install
## Authentication

This writes `~/.agents/skills/extend/SKILL.md`, the path Codex,
OpenCode, Cursor, and most other harnesses look at. Claude Code reads
from `~/.claude/skills/` instead, so point `--target` at it:
**Browser login** (`extend setup`, or `extend login` directly) is best
on your own machine: nothing to create or copy, tokens are stored
securely and refresh on their own, and the session is scoped to the
workspace and environment you approve. `extend logout` revokes it;
`extend whoami` shows who you're signed in as.

extend skill install --target ~/.claude/skills/extend/SKILL.md
**API keys** are long-lived and headless, the right choice for
scripts, CI, and agents. Save one with `extend setup`, or set
environment variables directly:

Make sure to re-run
`extend skill install` after upgrading to pick up new commands and
flag changes.
export EXTEND_API_KEY=sk_xxx
export EXTEND_REGION=us # us | us2 | eu (default: us)
export EXTEND_WORKSPACE_ID=ws_xxx # for org-scoped keys

## Authenticate
When several sources are configured, credentials are used in this
order:

Run the interactive wizard; it picks your region, validates the API key,
and saves it to `~/.config/extend/config.json`:
1. `EXTEND_API_KEY` (or `EXTEND_<LABEL>_API_KEY` under `--env <label>`)
2. The API key saved by `extend setup`
3. The stored `extend login` session

extend setup
Check what is in effect with `extend whoami` or `extend config`. For
token storage details and troubleshooting, run `extend help auth`.

Or set environment variables (these take precedence over the saved config):
## Use with coding agents

export EXTEND_API_KEY=sk_xxx
The CLI ships a [`SKILL.md`](https://agentskills.io) that teaches
agent harnesses (Claude Code, Codex, OpenCode, Cursor, Goose, etc.)
to use `extend` correctly without you having to spell out every command.

Optional:
Install to the cross-client default path:

export EXTEND_REGION=us # us | us2 | eu (default: us)
export EXTEND_WORKSPACE_ID=ws_xxx # for org-scoped keys
extend skill install

This writes `~/.agents/skills/extend-cli/SKILL.md`, the path Codex,
OpenCode, Cursor, and most other harnesses read, and symlinks it into
`~/.claude/skills/extend-cli` for Claude Code. To scope the skill to
one project instead of your whole machine:

extend skill install --target ./.agents/skills/extend-cli/SKILL.md

Re-run `extend skill install` after upgrading to pick up new commands
and flag changes.

## Examples

Expand Down
24 changes: 23 additions & 1 deletion evals/runner/grade/judge.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
Expand Down Expand Up @@ -164,7 +165,28 @@ func callJudge(cfg JudgeConfig, prompt string) (*JudgeVerdict, error) {
}
client := cfg.Client
if client == nil {
client = &http.Client{Timeout: timeout}
// x-api-key is not one of the headers Go's client strips on a
// cross-host redirect (only Authorization/Cookie are), so a
// redirected Messages call would replay the Anthropic key to
// whatever host the Location header names. Pin every hop to
// the configured judge API origin.
base, baseErr := url.Parse(baseURL)
client = &http.Client{
Timeout: timeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return errors.New("stopped after 10 redirects")
}
if baseErr != nil {
return fmt.Errorf("refusing redirect: parse judge base url %q: %w", baseURL, baseErr)
}
if req.URL.Scheme != base.Scheme || req.URL.Host != base.Host {
return fmt.Errorf("refusing redirect to %s://%s: not the judge API origin %s://%s",
req.URL.Scheme, req.URL.Host, base.Scheme, base.Host)
}
return nil
},
}
}

outputConfig := map[string]any{
Expand Down
6 changes: 3 additions & 3 deletions evals/runner/harness/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ func (c *Claude) Run(ctx context.Context, opts RunOptions) (*Result, error) {
// per-harness path under HomeDir. Claude Code reads from
// $HOME/.claude/skills/<name>/SKILL.md.
func installSkillForClaude(homeDir string) error {
dst := filepath.Join(homeDir, ".claude", "skills", "extend", "SKILL.md")
dst := filepath.Join(homeDir, ".claude", "skills", "extend-cli", "SKILL.md")
return generateSkillTo(dst)
}

Expand Down Expand Up @@ -203,7 +203,7 @@ func claudeTokens(events []map[string]any) int {

// anyClaudeSkillUsage looks for evidence that the skill was activated:
// either a Skill tool-use event, or a Read tool-use targeting a path
// that contains "skills/extend/SKILL.md".
// that contains "skills/extend-cli/SKILL.md".
func anyClaudeSkillUsage(events []map[string]any) bool {
for _, ev := range events {
t, _ := ev["type"].(string)
Expand Down Expand Up @@ -233,7 +233,7 @@ func anyClaudeSkillUsage(events []map[string]any) bool {
if name == "Read" {
input, _ := b["input"].(map[string]any)
p, _ := input["file_path"].(string)
if strings.Contains(p, "skills/extend/SKILL.md") {
if strings.Contains(p, "skills/extend-cli/SKILL.md") {
return true
}
}
Expand Down
8 changes: 4 additions & 4 deletions evals/runner/harness/codex.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ func (c *Codex) Run(ctx context.Context, opts RunOptions) (*Result, error) {
// Since we override HOME for skill isolation, we must explicitly
// point CODEX_HOME at the host's real auth dir or every codex run
// fails 401. Skill discovery is still isolated via HOME (codex
// reads `$HOME/.agents/skills/extend/SKILL.md`).
// reads `$HOME/.agents/skills/extend-cli/SKILL.md`).
cmd.Env = append(cmd.Env, "CODEX_HOME="+hostCodexHome())

stream, err := openStream(opts.EventsPath)
Expand Down Expand Up @@ -168,7 +168,7 @@ func hasChatGPTLogin() bool {
// $HOME/.agents/skills/<name>/SKILL.md (the agentskills.io standard
// path; see https://developers.openai.com/codex/skills/).
func installSkillForCodex(homeDir string) error {
dst := filepath.Join(homeDir, ".agents", "skills", "extend", "SKILL.md")
dst := filepath.Join(homeDir, ".agents", "skills", "extend-cli", "SKILL.md")
return generateSkillTo(dst)
}

Expand Down Expand Up @@ -233,13 +233,13 @@ func anyCodexSkillUsage(events []map[string]any) bool {
continue
}
cmd, _ := item["command"].(string)
if strings.Contains(cmd, "skills/extend/SKILL.md") {
if strings.Contains(cmd, "skills/extend-cli/SKILL.md") {
return true
}
// Codex may also emit a tool item type for skill loads;
// treat any text mentioning the skill path as activation.
text, _ := item["text"].(string)
if strings.Contains(text, "skills/extend/SKILL.md") {
if strings.Contains(text, "skills/extend-cli/SKILL.md") {
return true
}
case "skill.loaded", "skill.activated":
Expand Down
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ require (
github.com/mattn/go-isatty v0.0.21
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.9
github.com/zalando/go-keyring v0.2.8
gopkg.in/yaml.v3 v3.0.1
)

Expand All @@ -32,7 +33,9 @@ require (
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
Expand Down
12 changes: 10 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,16 @@ github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/extend-hq/extend-go-sdk v0.4.0 h1:t4mK/FNPejM4dwE0cM7oX267HResdbVxlQBwNq4jkYY=
github.com/extend-hq/extend-go-sdk v0.4.0/go.mod h1:OGwNG071bADLZ/BicWu/TLYn+QGELr/r051eECkfcn0=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
Expand Down Expand Up @@ -96,14 +100,18 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
Expand Down
7 changes: 5 additions & 2 deletions internal/cli/configcmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,13 @@ func runConfig(app *App) error {
s := resolveSettings(app.Env, app.Region, app.Workspace, os.Getenv, config.Load)
pal := paletteFor(app.IO)

// Only API keys exist today; this line is where OAuth status surfaces.
authMethod := "(none)"
authNote := ""
if s.key.val != "" {
authMethod = "API key"
} else if src, _ := resolveOAuthSource(app.Env, s, app.IO.ErrOut); src != nil {
authMethod = "OAuth login"
authNote = "from 'extend login'; 'extend logout' to clear"
}

// An unset region/baseURL means extendx falls back to its default (US
Expand Down Expand Up @@ -105,7 +108,7 @@ func runConfig(app *App) error {

type row struct{ label, val, note string }
rows := []row{
{"Auth method", authMethod, ""},
{"Auth method", authMethod, authNote},
{"API key", maskKey(s.key.val), s.key.src},
{"Region", region, regionSrc},
{"Base URL", baseURL, baseSrc},
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ const defaultAPIVersion = "2026-02-09"
// stable and matches the priority a user is most likely to care about
// (auth first, then routing, then transport, then per-feature secrets).
var envVars = []envVarSpec{
{Name: envAPIKey, Required: true, Description: "API key (sk_...). Required for any command that calls the API."},
{Name: envAPIKey, Required: true, Description: "API key (sk_...). Required for API commands unless signed in via 'extend login'."},
{Name: envBaseURL, Description: "Override base URL. Wins over EXTEND_REGION."},
{Name: envRegion, Description: "Region: us|eu. Selects the regional API endpoint."},
{Name: envWorkspaceID, Description: "Workspace ID for org-scoped API keys (sent as X-Extend-Workspace-Id)."},
Expand Down
Loading