dial: one dial for every coding agent's model

Wails v3 tray panel and window, TUI and CLI. Switches model, effort and
provider for Claude Code, Codex, Gemini CLI, OpenCode, Pi, Goose, Cursor,
Copilot CLI and Crush with format-preserving config edits. Providers page
and registry, stored keys, live vendor model lists, profiles.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
yetoneandClaude committed 2026-09-22 21:47:29 +08:00
commit 0a234ce41d
59 files changed
+6943

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
/dial
/dial.exe
/dial.app/
/dist/
+60
View File
@@ -0,0 +1,60 @@
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
LDFLAGS = -s -w -X main.version=$(VERSION)
TAGS = production
TARGETS = darwin/arm64 darwin/amd64 linux/amd64 linux/arm64 windows/amd64 windows/arm64
# The GUI links the platform webview through cgo, so it is built natively.
# `nogui` builds the terminal-only dial, which cross-compiles anywhere.
ifeq ($(shell uname -s),Darwin)
export CGO_CFLAGS = -mmacosx-version-min=11.0
export CGO_LDFLAGS = -mmacosx-version-min=11.0
endif
.PHONY: build cli install test app icons release release-cli clean
build:
go build -tags $(TAGS) -trimpath -ldflags="$(LDFLAGS)" -o dial .
cli:
CGO_ENABLED=0 go build -tags nogui -trimpath -ldflags="$(LDFLAGS)" -o dial .
install:
go install -tags $(TAGS) -trimpath -ldflags="$(LDFLAGS)" .
test:
go vet ./... && go test ./...
# macOS bundle: menu bar app with no Dock icon (LSUIElement).
app: build
@rm -rf dial.app
@mkdir -p dial.app/Contents/MacOS dial.app/Contents/Resources
@cp dial dial.app/Contents/MacOS/dial
@cp build/darwin/dial.icns dial.app/Contents/Resources/dial.icns
@sed 's/@VERSION@/$(VERSION)/' build/darwin/Info.plist > dial.app/Contents/Info.plist
@echo " dial.app"
icons:
@go run build/icon/gen.go tray internal/gui/tray.png
@go run build/icon/gen.go app 64 internal/gui/icon.png
@rm -rf build/darwin/dial.iconset && mkdir -p build/darwin/dial.iconset
@for s in 16 32 128 256 512; do \
go run build/icon/gen.go app $$s build/darwin/dial.iconset/icon_$${s}x$${s}.png; \
go run build/icon/gen.go app $$((s*2)) build/darwin/dial.iconset/icon_$${s}x$${s}@2x.png; \
done
@iconutil -c icns build/darwin/dial.iconset -o build/darwin/dial.icns && rm -rf build/darwin/dial.iconset
release: clean build
@mkdir -p dist
@cp dial dist/dial-$(shell go env GOOS)-$(shell go env GOARCH)
@$(MAKE) --no-print-directory release-cli
release-cli:
@mkdir -p dist
@for t in $(TARGETS); do \
os=$${t%/*}; arch=$${t#*/}; ext=""; [ $$os = windows ] && ext=.exe; \
echo " $$os/$$arch (cli)"; \
CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch go build -tags nogui -trimpath -ldflags="$(LDFLAGS)" -o dist/dial-cli-$$os-$$arch$$ext . ; \
done
clean:
rm -rf dial dial.exe dial.app dist
+184
View File
@@ -0,0 +1,184 @@
# dial
One small dial for every coding agent's model.
`dial` is a single screen that lists each AI coding agent on your machine and
the model it is set to. Click a value, pick a model. That is the whole app.
It lives in the menu bar: click the icon and a panel drops down; the same
screen also opens as a normal window (`dial`, or *Open dial* in the tray menu),
and there is a terminal version (`dial tui`) and a plain CLI.
```
◉ dial
▸ Claude Code claude-fable-5-1[1m] ~/.claude/settings.json
Codex gpt-6-astra effort medium
Gemini CLI gemini-3.1-pro
OpenCode anthropic/claude-sonnet-5 small anthropic/claude-haiku-4-5
Pi openrouter/z-ai/glm-5.2:batch
Goose anthropic/claude-sonnet-5
Cursor auto
Copilot CLI claude-fable-5
↑↓ agent · ←→ field · ↵ change · s save profile · p profiles · q quit
```
- **One small binary.** About 11 MB with the desktop app (it uses the system
webview through [Wails](https://wails.io), nothing bundled), 7 MB for the
terminal-only build. macOS, Linux and Windows.
- **Edits config files surgically.** Only the one key you change is touched;
comments, ordering and indentation in your `settings.json`, `config.toml`,
`opencode.jsonc` or `config.yaml` survive intact. Writes are atomic.
- **Real model lists.** When you switch to a provider, dial asks that vendor
which models it serves (`GET /models` with your key) and offers exactly
those; the [models.dev](https://models.dev) catalog, Codex's own model cache
and a built-in list fill in when a vendor has no list. Anything can also be
typed in.
- **Providers.** DeepSeek, Kimi, GLM, MiniMax, Qwen, OpenRouter and any
Anthropic- or Responses-compatible gateway you add, each with its key, a
connection test and one-click switching of every agent that can use it.
- **Profiles.** Snapshot every agent's settings under a name and switch all of
them back in one move.
- **Real logos, no framework.** Plain HTML over the system webview; brand
icons from [lobehub/icons](https://github.com/lobehub/lobe-icons).
## Agents
| Agent | File | Fields |
| ------------ | --------------------------------- | --------------- |
| Claude Code | `~/.claude/settings.json` | provider, model |
| Codex | `~/.codex/config.toml` | provider, model, effort |
| Gemini CLI | `~/.gemini/settings.json`, `~/.gemini/.env` | auth, model |
| OpenCode | `~/.config/opencode/opencode.json(c)` | model, small |
| Pi | `~/.pi/agent/settings.json` | model |
| Goose | `~/.config/goose/config.yaml` | model |
| Cursor CLI | `~/.cursor/cli-config.json` | model |
| Copilot CLI | `~/.copilot/settings.json` | model |
| Crush | `~/.config/crush/crush.json` | large, small |
Provider-scoped agents (OpenCode, Pi, Goose, Crush) take `provider/model`.
Only agents that are installed or configured are shown.
## Providers
The *Providers* page (or `dial providers`) lists every vendor dial knows:
its endpoints, whether a key is present, how many models it serves and
which agents use it. Built-ins: DeepSeek, Kimi, Zhipu/Z.ai GLM, MiniMax,
Qwen and OpenRouter, each with a global and a China endpoint where the vendor
has one. Any Anthropic-Messages or OpenAI-Responses compatible endpoint can
be added; built-ins can be edited, hidden or reset.
```sh
dial providers # who is configured, who has a key, who uses what
dial provider deepseek # endpoints, key, the models it serves
dial provider add "My Gateway" anthropic=https://gw.example.com/anthropic env=GW_API_KEY catalog=anthropic
dial provider edit kimi models=kimi-k3-preview
dial provider test deepseek # one tiny request per endpoint, with latency
dial provider models deepseek # re-fetch the vendor's model list
dial provider rm kimi # hide a built-in (reset brings it back)
dial key DEEPSEEK_API_KEY sk-… # keys for apps that see no shell env
```
Keys come from your shell environment first, then from
`~/.config/dial/keys` (mode 0600). The desktop app asks for a key the first
time you pick a provider that has none.
**Claude Code** — `dial claude provider deepseek` sets `ANTHROPIC_BASE_URL`,
`ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL` and the small-model variable in the
`env` block of `settings.json`; `provider anthropic` removes them and
restores whatever was there. Claude Code re-reads its settings, so a running
session picks the change up.
**Codex** — `dial codex provider deepseek` adds a `[model_providers.deepseek]`
table to `config.toml`, points `model_catalog_json` at
`~/.codex/dial-models.json` (a catalog dial writes from the vendor's live
model list) and keeps `model`/`effort` valid. `provider openai` removes all
of that; your ChatGPT sign-in is never touched. Any `[model_providers.*]`
table you wrote yourself shows up as a provider too. Codex builds its model
list once at start-up, so the Codex app (and open `codex` sessions) must be
restarted to see the new list; dial says so after the switch.
**Gemini CLI** — `dial gemini auth api-key|google|vertex` sets
`security.auth.selectedType`; the API key goes to `~/.gemini/.env`.
Only DeepSeek has been verified end to end against a real account so far;
the other built-ins follow the vendors' documented endpoints.
## Install
```sh
go install github.com/yetone/dial@latest
```
or build locally:
```sh
make build # ./dial with the desktop app (needs cgo + the platform webview)
make app # macOS: dial.app, a menu bar app with no Dock icon
make cli # terminal-only build, no cgo, cross-compiles anywhere
make release # dist/: native app build + cli builds for every platform
```
Linux needs `libgtk-3-dev` and `libwebkit2gtk-4.1-dev` for the app build;
Windows uses the WebView2 runtime that ships with the OS.
## Use
```sh
dial # open the app: a window plus the menu bar icon
dial tray # menu bar icon only (use this in your login items)
dial tui # the same dial, in the terminal
dial ls # list every agent and its current settings
dial claude opus # set a model (agent names accept prefixes: cc, oc, gem …)
dial codex gpt-5.6-sol
dial codex effort high # other fields
dial codex xhigh # bare effort levels are recognised too
dial codex provider deepseek # run Codex against DeepSeek (needs DEEPSEEK_API_KEY)
dial claude provider kimi # Claude Code through Kimi's Anthropic-compatible endpoint
dial gemini auth api-key
dial opencode anthropic/claude-sonnet-5
dial oc small anthropic/claude-haiku-4-5
dial save work # snapshot everything as a profile
dial use work # switch back
dial profiles
dial rm work
dial sync # refresh the models.dev catalog and every live model list
```
In the app, click any value to open a filtered list; type to search or to
enter something that is not listed; `esc` closes the panel. Profiles are the
chips at the bottom: click to apply, `×` to delete, *+ save current* to add.
The *Providers* tab of the window opens each provider inline: endpoints, key,
catalog, extra models, *Test connection* and a button per agent that can use
it.
Keys in the terminal dial:
| Key | Action |
| ---------- | ------------------------------------- |
| `↑` `↓` | choose agent |
| `←` `→` | choose field (model, effort, small …) |
| `↵` | open the picker |
| type | filter; enter accepts custom values |
| `s` | save current setup as a profile |
| `p` | apply or delete (`ctrl+d`) a profile |
| `S` | sync the model catalog |
| `q` | quit |
Agents read their config at startup, so a running session keeps its model
until you start a new one.
## Files
- `~/.config/dial/profiles.json` — saved profiles
- `~/.config/dial/providers.json` — your providers and edits to built-ins
- `~/.config/dial/keys` — stored API keys (0600)
- `~/.config/dial/stash.json` — values dial replaced, restored on switch-back
- `~/.cache/dial/models.json` — models.dev catalog (OpenCode's cache at
`~/.cache/opencode/models.json` is used when present)
- `~/.cache/dial/models/<provider>.json` — model lists fetched from vendors
`XDG_CONFIG_HOME` and `XDG_CACHE_HOME` are respected.
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key><string>dial</string>
<key>CFBundleDisplayName</key><string>dial</string>
<key>CFBundleIdentifier</key><string>com.yetone.dial</string>
<key>CFBundleExecutable</key><string>dial</string>
<key>CFBundleIconFile</key><string>dial</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>@VERSION@</string>
<key>CFBundleVersion</key><string>@VERSION@</string>
<key>LSMinimumSystemVersion</key><string>11.0</string>
<key>LSUIElement</key><true/>
<key>NSHighResolutionCapable</key><true/>
<key>NSHumanReadableCopyright</key><string>yetone</string>
</dict>
</plist>
Binary file not shown.
+151
View File
@@ -0,0 +1,151 @@
//go:build ignore
// Icon generator. Everything dial shows is drawn from one glyph: a ring with a
// gap at the bottom and a pointer, like a knob.
//
// go run build/icon/gen.go tray internal/gui/tray.png # 44px black template icon (macOS menu bar)
// go run build/icon/gen.go app 64 internal/gui/icon.png # coloured app icon at a given size
// make icons # regenerates all of them plus dial.icns
package main
import (
"fmt"
"image"
"image/color"
"image/png"
"math"
"os"
"strconv"
)
func main() {
var img image.Image
switch {
case len(os.Args) == 3 && os.Args[1] == "tray":
img = tray()
case len(os.Args) == 4 && os.Args[1] == "app":
n, _ := strconv.Atoi(os.Args[2])
img = app(n)
default:
fmt.Fprintln(os.Stderr, "usage: gen.go tray <out.png> | gen.go app <size> <out.png>")
os.Exit(2)
}
f, err := os.Create(os.Args[len(os.Args)-1])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
defer f.Close()
if err := png.Encode(f, img); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
// tray draws the glyph as a macOS template image (black + alpha).
func tray() image.Image {
const S = 44
img := image.NewNRGBA(image.Rect(0, 0, S, S))
cx, cy := float64(S)/2, float64(S)/2
rOuter, rInner := 17.5, 14.0
px, py := cx+8.0*math.Cos(-math.Pi/4), cy+8.0*math.Sin(-math.Pi/4) // pointer tip, upper right
for y := 0; y < S; y++ {
for x := 0; x < S; x++ {
a := 0.0
// supersample 4x4
for sy := 0; sy < 4; sy++ {
for sx := 0; sx < 4; sx++ {
fx := float64(x) + (float64(sx)+0.5)/4
fy := float64(y) + (float64(sy)+0.5)/4
dx, dy := fx-cx, fy-cy
d := math.Hypot(dx, dy)
ang := math.Atan2(dy, dx) // -pi..pi, y down
// ring with a gap at the bottom (like a knob's travel)
inRing := d <= rOuter && d >= rInner && !(ang > math.Pi/2-0.55 && ang < math.Pi/2+0.55)
// pointer: thick segment from centre to tip
inPtr := segDist(fx, fy, cx, cy, px, py) <= 2.2
// hub
inHub := d <= 3.4
if inRing || inPtr || inHub {
a++
}
}
}
img.SetNRGBA(x, y, color.NRGBA{0, 0, 0, uint8(a / 16 * 255)})
}
}
return img
}
// app draws a rounded indigo square with the glyph in white.
func app(n int) image.Image {
S := float64(n)
img := image.NewNRGBA(image.Rect(0, 0, n, n))
// macOS icon grid: the squircle occupies ~80% of the canvas
inset := S * 0.1
side := S - 2*inset
radius := side * 0.225
cx, cy := S/2, S/2
rOuter, rInner := side*0.30, side*0.235
ptrLen, ptrW := side*0.135, side*0.04
px, py := cx+ptrLen*math.Cos(-math.Pi/4), cy+ptrLen*math.Sin(-math.Pi/4)
hub := side * 0.062
bg1 := [3]float64{0x5b, 0x55, 0xf0} // top: lighter indigo
bg2 := [3]float64{0x3f, 0x37, 0xc9} // bottom: deeper
ss := 4
if n <= 64 {
ss = 8
}
for y := 0; y < n; y++ {
for x := 0; x < n; x++ {
var aBg, aFg float64
for sy := 0; sy < ss; sy++ {
for sx := 0; sx < ss; sx++ {
fx := float64(x) + (float64(sx)+0.5)/float64(ss)
fy := float64(y) + (float64(sy)+0.5)/float64(ss)
if roundRect(fx, fy, inset, inset, side, side, radius) {
aBg++
dx, dy := fx-cx, fy-cy
d := math.Hypot(dx, dy)
ang := math.Atan2(dy, dx)
inRing := d <= rOuter && d >= rInner && !(ang > math.Pi/2-0.55 && ang < math.Pi/2+0.55)
inPtr := segDist(fx, fy, cx, cy, px, py) <= ptrW
if inRing || inPtr || d <= hub {
aFg++
}
}
}
}
tot := float64(ss * ss)
t := float64(y) / S
r := bg1[0]*(1-t) + bg2[0]*t
g := bg1[1]*(1-t) + bg2[1]*t
b := bg1[2]*(1-t) + bg2[2]*t
fg := aFg / tot
if aBg > 0 {
fg /= aBg / tot
}
img.SetNRGBA(x, y, color.NRGBA{
uint8(r*(1-fg) + 255*fg), uint8(g*(1-fg) + 255*fg), uint8(b*(1-fg) + 255*fg),
uint8(aBg / tot * 255),
})
}
}
return img
}
func roundRect(px, py, x, y, w, h, r float64) bool {
if px < x || py < y || px > x+w || py > y+h {
return false
}
qx := math.Max(math.Abs(px-(x+w/2))-(w/2-r), 0)
qy := math.Max(math.Abs(py-(y+h/2))-(h/2-r), 0)
return math.Hypot(qx, qy) <= r
}
func segDist(px, py, ax, ay, bx, by float64) float64 {
vx, vy := bx-ax, by-ay
wx, wy := px-ax, py-ay
t := math.Max(0, math.Min(1, (wx*vx+wy*vy)/(vx*vx+vy*vy)))
return math.Hypot(px-(ax+t*vx), py-(ay+t*vy))
}
+43
View File
@@ -0,0 +1,43 @@
module github.com/yetone/dial
go 1.26.3
require (
github.com/charmbracelet/bubbles v0.21.1
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/tidwall/gjson v1.19.0
github.com/tidwall/jsonc v0.3.3
github.com/wailsapp/wails/v3 v3.0.0-beta.24
)
require (
github.com/adrg/xdg v0.5.3 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.5 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.39.0 // indirect
)
+87
View File
@@ -0,0 +1,87 @@
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v0.21.1 h1:nj0decPiixaZeL9diI4uzzQTkkz1kYY8+jgzCZXSmW0=
github.com/charmbracelet/bubbles v0.21.1/go.mod h1:HHvIYRCpbkCJw2yo0vNX1O5loCwSr9/mWS8GYSg50Sk=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/x/ansi v0.11.5 h1:NBWeBpj/lJPE3Q5l+Lusa4+mH6v7487OP8K0r1IhRg4=
github.com/charmbracelet/x/ansi v0.11.5/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
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/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
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/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
github.com/tidwall/jsonc v0.3.3 h1:RVQqL3xFfDkKKXIDsrBiVQiEpBtxoKbmMXONb2H/y2w=
github.com/tidwall/jsonc v0.3.3/go.mod h1:dw+3CIxqHi+t8eFSpzzMlcVYxKp08UP5CD8/uSFCyJE=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/wailsapp/wails/v3 v3.0.0-beta.24 h1:YmD11T9FYtuKIBgyC7JbkwPzLqNZ2IysQ7rZ3pDG1tI=
github.com/wailsapp/wails/v3 v3.0.0-beta.24/go.mod h1:/6QR46/nhGCSADHbS++XtDb9dkTnenTHlGskTPRo9S0=
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=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+9
View File
@@ -0,0 +1,9 @@
//go:build nogui
package main
import "errors"
const hasGUI = false
func runGUI(bool) error { return errors.New("this build has no GUI; run `dial tui`") }
+9
View File
@@ -0,0 +1,9 @@
//go:build !nogui
package main
import "github.com/yetone/dial/internal/gui"
const hasGUI = true
func runGUI(showMain bool) error { return gui.Run(version, showMain) }
+144
View File
@@ -0,0 +1,144 @@
// Package agent describes every coding agent dial can drive: where its config
// lives, which fields matter (model, effort, …) and which values to offer.
package agent
import (
"fmt"
"os"
"os/exec"
"runtime"
"sort"
"strings"
)
// Option is one value the picker offers for a field.
type Option struct {
Value string `json:"value"`
Label string `json:"label,omitempty"` // display name, when the value is an id
Note string `json:"note"`
Key string `json:"key,omitempty"` // env var the option needs, if any
NeedKey bool `json:"needKey,omitempty"` // that key is not set anywhere yet
Icon string `json:"icon,omitempty"` // bundled icon name
}
// Field is one tunable setting of an agent.
type Field struct {
Key string
Label string
Get func() string
Set func(string) error
Options func(cur map[string]string) []Option
}
// Agent is one supported coding agent.
type Agent struct {
ID string
Name string
Icon string // bundled icon name (internal/gui/assets/icons)
Aliases []string
Bin string // executable name, used for detection
Dir string // config directory, used for detection
Path string // config file dial edits
Fields []Field
// Notice, if set, is advice worth showing after a change: agents that
// read their config once at start-up need a restart to see it.
Notice func() string
}
// Running reports whether a process whose command line matches any pattern
// (an extended regexp, as for pgrep -f) is alive. Unknown on Windows.
func Running(patterns ...string) bool {
if runtime.GOOS == "windows" {
return false
}
for _, pat := range patterns {
if err := exec.Command("pgrep", "-f", pat).Run(); err == nil {
return true
}
}
return false
}
// Detected reports whether the agent seems to be installed or configured.
func (a *Agent) Detected() bool {
if _, err := os.Stat(a.Path); err == nil {
return true
}
if a.Dir != "" {
if _, err := os.Stat(a.Dir); err == nil {
return true
}
}
if a.Bin != "" {
if _, err := exec.LookPath(a.Bin); err == nil {
return true
}
}
return false
}
// Field looks a field up by key.
func (a *Agent) Field(key string) *Field {
for i := range a.Fields {
if a.Fields[i].Key == key {
return &a.Fields[i]
}
}
return nil
}
// Values reads every field.
func (a *Agent) Values() map[string]string {
m := make(map[string]string, len(a.Fields))
for _, f := range a.Fields {
m[f.Key] = f.Get()
}
return m
}
// Detected returns the agents present on this machine, in display order.
func Detected() []*Agent {
var out []*Agent
for _, a := range All() {
if a.Detected() {
out = append(out, a)
}
}
return out
}
// Find resolves a user-typed name (id, alias, or unique prefix).
func Find(q string) (*Agent, error) {
q = strings.ToLower(strings.TrimSpace(q))
all := All()
var prefix []*Agent
for _, a := range all {
if a.ID == q {
return a, nil
}
for _, al := range a.Aliases {
if al == q {
return a, nil
}
}
if strings.HasPrefix(a.ID, q) || strings.HasPrefix(strings.ToLower(a.Name), q) {
prefix = append(prefix, a)
}
}
switch len(prefix) {
case 1:
return prefix[0], nil
case 0:
return nil, fmt.Errorf("unknown agent %q (try: %s)", q, strings.Join(ids(all), ", "))
}
return nil, fmt.Errorf("%q is ambiguous: %s", q, strings.Join(ids(prefix), ", "))
}
func ids(as []*Agent) []string {
s := make([]string, len(as))
for i, a := range as {
s[i] = a.ID
}
sort.Strings(s)
return s
}
+255
View File
@@ -0,0 +1,255 @@
package agent
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"github.com/yetone/dial/internal/catalog"
"github.com/yetone/dial/internal/edit"
)
// All returns every agent dial knows about, detected or not.
func All() []*Agent {
home, _ := os.UserHomeDir()
cfg := os.Getenv("XDG_CONFIG_HOME")
if cfg == "" {
cfg = filepath.Join(home, ".config")
}
return []*Agent{
claude(home),
codex(home),
gemini(home),
opencode(home, cfg),
pi(home),
goose(home, cfg),
cursor(home),
copilot(home),
crush(home, cfg),
}
}
// ---- accessors -------------------------------------------------------------
func jsonGet(path, key string) func() string {
return func() string { v, _ := edit.GetJSON(path, key); return v }
}
func jsonSet(path, key string) func(string) error {
return func(v string) error { return edit.SetJSON(path, edit.KV{Path: key, Value: v}) }
}
// pair joins a provider field and a model field into one "provider/model"
// value, which is how OpenCode already spells it and how people think of it.
func pairGet(get func(string) (string, bool), pKey, mKey string) func() string {
return func() string {
p, _ := get(pKey)
m, _ := get(mKey)
switch {
case m == "":
return ""
case p == "":
return m
}
return p + "/" + m
}
}
func pairSet(set func(...edit.KV) error, pKey, mKey string) func(string) error {
return func(v string) error {
p, m, ok := strings.Cut(v, "/")
if !ok || p == "" || m == "" {
return fmt.Errorf("expected provider/model, got %q", v)
}
return set(edit.KV{Path: pKey, Value: p}, edit.KV{Path: mKey, Value: m})
}
}
// ---- option builders -------------------------------------------------------
func options(models []catalog.Model, prefix string) []Option {
out := make([]Option, 0, len(models))
for _, m := range models {
out = append(out, Option{Value: prefix + m.ID, Note: m.Name})
}
return out
}
func static(vals ...string) []Option {
out := make([]Option, len(vals))
for i, v := range vals {
out[i] = Option{Value: v}
}
return out
}
// providerOptions lists provider/model pairs for every provider the user has
// credentials for: keys of an auth file, API-key env vars, and whatever the
// current value already uses.
func providerOptions(authFile string, cur string, extra ...string) []Option {
set := map[string]bool{}
for _, p := range extra {
set[p] = true
}
if p, _, ok := strings.Cut(cur, "/"); ok {
set[p] = true
}
if b, err := os.ReadFile(authFile); err == nil {
var m map[string]json.RawMessage
if json.Unmarshal(b, &m) == nil {
for k := range m {
set[k] = true
}
}
}
for _, p := range catalog.Providers() {
if catalog.ProviderAvailable(p) {
set[p] = true
}
}
providers := make([]string, 0, len(set))
for p := range set {
providers = append(providers, p)
}
sort.Strings(providers)
var out []Option
for _, p := range providers {
out = append(out, options(catalog.Provider(p), p+"/")...)
}
return out
}
// ---- agents ----------------------------------------------------------------
func opencode(home, cfg string) *Agent {
dir := filepath.Join(cfg, "opencode")
path := filepath.Join(dir, "opencode.json")
if _, err := os.Stat(filepath.Join(dir, "opencode.jsonc")); err == nil {
path = filepath.Join(dir, "opencode.jsonc")
}
auth := filepath.Join(home, ".local", "share", "opencode", "auth.json")
opts := func(key string) func(map[string]string) []Option {
return func(cur map[string]string) []Option { return providerOptions(auth, cur[key]) }
}
return &Agent{
ID: "opencode", Name: "OpenCode", Icon: "opencode", Aliases: []string{"oc"},
Bin: "opencode", Dir: dir, Path: path,
Fields: []Field{
{Key: "model", Label: "model", Get: jsonGet(path, "model"), Set: jsonSet(path, "model"), Options: opts("model")},
{Key: "small", Label: "small", Get: jsonGet(path, "small_model"), Set: jsonSet(path, "small_model"), Options: opts("small")},
},
}
}
func pi(home string) *Agent {
dir := filepath.Join(home, ".pi", "agent")
path := filepath.Join(dir, "settings.json")
auth := filepath.Join(dir, "auth.json")
get := func(k string) (string, bool) { return edit.GetJSON(path, k) }
set := func(kvs ...edit.KV) error { return edit.SetJSON(path, kvs...) }
return &Agent{
ID: "pi", Name: "Pi", Icon: "pi", Bin: "pi", Dir: dir, Path: path,
Fields: []Field{{
Key: "model", Label: "model",
Get: pairGet(get, "defaultProvider", "defaultModel"),
Set: pairSet(set, "defaultProvider", "defaultModel"),
Options: func(cur map[string]string) []Option { return providerOptions(auth, cur["model"]) },
}},
}
}
func goose(home, cfg string) *Agent {
path := filepath.Join(cfg, "goose", "config.yaml")
if runtime.GOOS == "windows" {
if app := os.Getenv("APPDATA"); app != "" {
path = filepath.Join(app, "Block", "goose", "config", "config.yaml")
}
}
get := func(k string) (string, bool) { return edit.GetYAMLTop(path, k) }
set := func(kvs ...edit.KV) error { return edit.SetYAMLTop(path, kvs...) }
return &Agent{
ID: "goose", Name: "Goose", Icon: "goose", Bin: "goose", Dir: filepath.Dir(path), Path: path,
Fields: []Field{{
Key: "model", Label: "model",
Get: pairGet(get, "GOOSE_PROVIDER", "GOOSE_MODEL"),
Set: pairSet(set, "GOOSE_PROVIDER", "GOOSE_MODEL"),
Options: func(cur map[string]string) []Option { return providerOptions("", cur["model"]) },
}},
}
}
func cursor(home string) *Agent {
path := filepath.Join(home, ".cursor", "cli-config.json")
return &Agent{
ID: "cursor", Name: "Cursor", Icon: "cursor", Aliases: []string{"cursor-agent"},
Bin: "cursor-agent", Dir: filepath.Dir(path), Path: path,
Fields: []Field{{
Key: "model", Label: "model",
Get: jsonGet(path, "model.modelId"),
Set: func(v string) error {
return edit.SetJSON(path,
edit.KV{Path: "model.modelId", Value: v},
edit.KV{Path: "model.displayModelId", Value: v},
edit.KV{Path: "model.displayName", Value: v},
edit.KV{Path: "hasChangedDefaultModel", Value: true},
)
},
Options: func(map[string]string) []Option {
return []Option{{Value: "auto", Note: "let Cursor pick"}}
},
}},
}
}
func copilot(home string) *Agent {
dir := filepath.Join(home, ".copilot")
path := filepath.Join(dir, "settings.json")
return &Agent{
ID: "copilot", Name: "Copilot CLI", Icon: "githubcopilot", Aliases: []string{"gh-copilot"},
Bin: "copilot", Dir: dir, Path: path,
Fields: []Field{{
Key: "model", Label: "model",
Get: jsonGet(path, "model"),
Set: jsonSet(path, "model"),
Options: func(map[string]string) []Option { return options(catalog.Builtin("copilot"), "") },
}},
}
}
func crush(home, cfg string) *Agent {
path := filepath.Join(cfg, "crush", "crush.json")
if runtime.GOOS == "windows" {
if app := os.Getenv("LOCALAPPDATA"); app != "" {
path = filepath.Join(app, "crush", "crush.json")
}
}
get := func(k string) (string, bool) { return edit.GetJSON(path, k) }
set := func(kvs ...edit.KV) error { return edit.SetJSON(path, kvs...) }
opts := func(key string) func(map[string]string) []Option {
return func(cur map[string]string) []Option {
var extra []string
if b, err := os.ReadFile(path); err == nil {
var c struct {
Providers map[string]json.RawMessage `json:"providers"`
}
if json.Unmarshal(b, &c) == nil {
for p := range c.Providers {
extra = append(extra, p)
}
}
}
return providerOptions("", cur[key], extra...)
}
}
return &Agent{
ID: "crush", Name: "Crush", Icon: "crush", Bin: "crush", Dir: filepath.Dir(path), Path: path,
Fields: []Field{
{Key: "model", Label: "large", Get: pairGet(get, "models.large.provider", "models.large.model"), Set: pairSet(set, "models.large.provider", "models.large.model"), Options: opts("model")},
{Key: "small", Label: "small", Get: pairGet(get, "models.small.provider", "models.small.model"), Set: pairSet(set, "models.small.provider", "models.small.model"), Options: opts("small")},
},
}
}
+172
View File
@@ -0,0 +1,172 @@
package agent
import (
"fmt"
"path/filepath"
"strings"
"github.com/yetone/dial/internal/catalog"
"github.com/yetone/dial/internal/edit"
)
// Claude Code reads its endpoint from the `env` block of settings.json:
// ANTHROPIC_BASE_URL plus ANTHROPIC_AUTH_TOKEN point it at any vendor that
// speaks the Anthropic Messages API, and the ANTHROPIC_DEFAULT_*_MODEL
// variables tell it what "opus", "sonnet" and "haiku" mean over there.
var claudeAliases = []Option{
{Value: "opus", Note: "alias · latest Opus"},
{Value: "sonnet", Note: "alias · latest Sonnet"},
{Value: "haiku", Note: "alias · latest Haiku"},
{Value: "opusplan", Note: "alias · Opus for planning, Sonnet for work"},
{Value: "sonnet[1m]", Note: "alias · Sonnet with 1M context"},
}
// env vars dial owns on a third-party provider; all go away on the way back.
var claudeEnv = []string{
"ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL", "ANTHROPIC_SMALL_FAST_MODEL",
}
func claude(home string) *Agent {
path := filepath.Join(home, ".claude", "settings.json")
env := func(k string) string { v, _ := edit.GetJSON(path, "env."+k); return v }
model := jsonGet(path, "model")
// current provider: "anthropic" (no base URL), a preset id, or "custom".
current := func() (string, *Provider) {
base := env("ANTHROPIC_BASE_URL")
if base == "" {
return "anthropic", nil
}
if p := providerByURL(base, func(p Provider) string { return p.Anthropic }); p != nil {
return p.ID, p
}
return "custom", nil
}
// write the model everywhere Claude Code looks for it on a third party.
setModel := func(p *Provider, m string) error {
kvs := []edit.KV{{Path: "model", Value: m}}
if p != nil {
small := p.Small
if small == "" {
small = m
}
kvs = append(kvs,
edit.KV{Path: "env.ANTHROPIC_MODEL", Value: m},
edit.KV{Path: "env.ANTHROPIC_DEFAULT_OPUS_MODEL", Value: m},
edit.KV{Path: "env.ANTHROPIC_DEFAULT_SONNET_MODEL", Value: m},
edit.KV{Path: "env.ANTHROPIC_DEFAULT_HAIKU_MODEL", Value: small},
edit.KV{Path: "env.ANTHROPIC_SMALL_FAST_MODEL", Value: small},
)
}
return edit.SetJSON(path, kvs...)
}
use := func(id string) error {
cur, _ := current()
switch id {
case "custom":
if cur != "custom" {
return fmt.Errorf("custom means whatever ANTHROPIC_BASE_URL is already in %s; set it there", path)
}
return nil
case "anthropic":
keys := make([]string, len(claudeEnv))
for i, k := range claudeEnv {
keys[i] = "env." + k
}
if err := edit.DelJSON(path, keys...); err != nil {
return err
}
// a vendor's model id means nothing to Anthropic: restore what was
// set before leaving, else Claude Code's own default.
if m := model(); m != "" && !strings.HasPrefix(m, "claude") && !isAlias(m) {
if prev := unstash("claude.model"); prev != "" {
return edit.SetJSON(path, edit.KV{Path: "model", Value: prev})
}
return edit.DelJSON(path, "model")
}
return nil
}
p := provider(id)
if p == nil || p.Anthropic == "" {
return fmt.Errorf("unknown provider %q", id)
}
key := Key(p.EnvKey)
if key == "" {
return needKey(*p)
}
if cur == "anthropic" {
stash(map[string]string{"claude.model": model()})
}
p.refreshQuietly()
if err := edit.SetJSON(path,
edit.KV{Path: "env.ANTHROPIC_BASE_URL", Value: p.Anthropic},
edit.KV{Path: "env.ANTHROPIC_AUTH_TOKEN", Value: key},
); err != nil {
return err
}
ms := p.Models()
m := model()
if !hasModel(ms, m) && len(ms) > 0 {
m = ms[0].ID
}
return setModel(p, m)
}
return &Agent{
ID: "claude", Name: "Claude Code", Icon: "claudecode-color", Aliases: []string{"cc", "claude-code"},
Bin: "claude", Dir: filepath.Dir(path), Path: path,
Fields: []Field{
{
Key: "provider", Label: "provider",
Get: func() string { id, _ := current(); return id },
Set: use,
Options: func(map[string]string) []Option {
out := []Option{{Value: "anthropic", Label: "Anthropic", Icon: "claude-color", Note: "Claude sign-in / $ANTHROPIC_API_KEY"}}
var ps []Option
for _, p := range Providers() {
if p.Anthropic != "" {
ps = append(ps, p.option(p.Anthropic))
}
}
out = append(out, sortReady(ps)...)
if id, _ := current(); id == "custom" {
out = append(out, Option{Value: "custom", Note: hostOf(env("ANTHROPIC_BASE_URL")) + " (from settings.json)"})
}
return out
},
},
{
Key: "model", Label: "model",
Get: model,
Set: func(v string) error { _, p := current(); return setModel(p, v) },
Options: func(map[string]string) []Option {
if _, p := current(); p != nil {
return options(p.Models(), "")
}
return append(append([]Option{}, claudeAliases...), options(catalog.Provider("anthropic"), "")...)
},
},
},
}
}
func isAlias(m string) bool {
for _, a := range claudeAliases {
if a.Value == m {
return true
}
}
return false
}
func hasModel(ms []catalog.Model, id string) bool {
for _, m := range ms {
if m.ID == id {
return true
}
}
return false
}
+328
View File
@@ -0,0 +1,328 @@
package agent
import (
_ "embed"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/yetone/dial/internal/catalog"
"github.com/yetone/dial/internal/edit"
)
// Codex talks the OpenAI Responses API to whichever provider config.toml
// names. Switching provider means three things: a [model_providers.<id>]
// table, `model_provider = "<id>"`, and a model catalog file so the models
// show up inside Codex's own /model picker.
// codexPrompt is Codex's generic system prompt (Apache-2.0, openai/codex,
// core/gpt-5.2-codex_prompt.md). Third-party models need one because the
// bundled catalog only carries prompts for OpenAI models.
//
//go:embed codex_prompt.md
var codexPrompt string
// CodexProvider is an endpoint Codex can be pointed at.
type CodexProvider struct {
ID string
Name string
BaseURL string // must speak the Responses API at <BaseURL>/responses
EnvKey string // environment variable holding the API key
Catalog string // models.dev provider id for the model list
Icon string
dial *Provider // the dial provider behind a preset
custom bool // found in config.toml, not a dial preset
}
// codexPresets are "openai" (Codex's own default, expressed by the absence
// of model_provider) plus every provider with a Responses endpoint.
func codexPresets() []CodexProvider {
out := []CodexProvider{{ID: "openai", Name: "OpenAI", Icon: "openai"}}
for _, p := range Providers() {
if p.Responses != "" {
out = append(out, CodexProvider{ID: p.ID, Name: p.Name, BaseURL: p.Responses, EnvKey: p.EnvKey, Catalog: p.Catalog, Icon: p.Icon, dial: &p})
}
}
return out
}
func codex(home string) *Agent {
dir := filepath.Join(home, ".codex")
path := filepath.Join(dir, "config.toml")
catalogPath := filepath.Join(dir, "dial-models.json")
get := func(k string) string { v, _ := edit.GetTOMLTop(path, k); return v }
providers := func() []CodexProvider {
out := codexPresets()
for _, t := range edit.TOMLTables(path) {
id, ok := strings.CutPrefix(t, "model_providers.")
if !ok || strings.Contains(id, ".") {
continue
}
if p := findProvider(out, id); p != nil {
continue
}
kv := edit.GetTOMLTable(path, t)
out = append(out, CodexProvider{ID: id, Name: kv["name"], BaseURL: kv["base_url"], EnvKey: kv["env_key"], custom: true})
}
return out
}
current := func() CodexProvider {
id := get("model_provider")
if id == "" {
id = "openai"
}
if p := findProvider(providers(), id); p != nil {
return *p
}
return CodexProvider{ID: id, Name: id, custom: true}
}
models := func(p CodexProvider) []catalog.Model {
switch {
case p.ID == "openai":
return catalog.Codex()
case p.dial != nil:
return p.dial.Models()
}
return nil
}
// keep model and effort valid for the provider; a fresh provider gets
// its first model and that model's default effort.
settle := func(p CodexProvider) error {
ms := models(p)
if len(ms) == 0 {
return nil
}
model, effort := get("model"), get("model_reasoning_effort")
found := false
for _, m := range ms {
if m.ID == model {
found = true
}
}
if !found {
model = ms[0].ID
}
var kvs []edit.KV
if model != get("model") {
kvs = append(kvs, edit.KV{Path: "model", Value: model})
}
if e := catalog.Efforts(ms, model); len(e) > 0 && !contains(e, effort) {
kvs = append(kvs, edit.KV{Path: "model_reasoning_effort", Value: defaultEffort(e)})
}
if len(kvs) == 0 {
return nil
}
return edit.SetTOMLTop(path, kvs...)
}
use := func(id string) error {
p := findProvider(providers(), id)
if p == nil {
return fmt.Errorf("unknown provider %q", id)
}
if p.ID == "openai" {
if err := edit.DelTOMLTop(path, "model_provider", "model_catalog_json"); err != nil {
return err
}
os.Remove(catalogPath)
if m := unstash("codex.model"); m != "" && !hasModel(models(*p), get("model")) {
var kvs []edit.KV
kvs = append(kvs, edit.KV{Path: "model", Value: m})
if e := unstash("codex.effort"); e != "" {
kvs = append(kvs, edit.KV{Path: "model_reasoning_effort", Value: e})
}
if err := edit.SetTOMLTop(path, kvs...); err != nil {
return err
}
}
return settle(*p)
}
if current().ID == "openai" {
stash(map[string]string{"codex.model": get("model"), "codex.effort": get("model_reasoning_effort")})
}
if !p.custom {
key := Key(p.EnvKey)
if key == "" {
return needKey(Provider{Name: p.Name, EnvKey: p.EnvKey})
}
p.dial.refreshQuietly()
kvs := []edit.KV{
{Path: "name", Value: p.Name},
{Path: "base_url", Value: p.BaseURL},
{Path: "env_key", Value: p.EnvKey},
{Path: "wire_api", Value: "responses"},
}
// Codex reads env_key from its own environment; a key that only
// dial holds (desktop app, no shell) rides along as a bearer token.
if os.Getenv(p.EnvKey) == "" {
kvs = append(kvs, edit.KV{Path: "experimental_bearer_token", Value: key})
}
if err := edit.SetTOMLTable(path, "model_providers."+p.ID, kvs...); err != nil {
return err
}
}
kvs := []edit.KV{{Path: "model_provider", Value: p.ID}}
if ms := models(*p); len(ms) > 0 {
if err := edit.WriteAtomic(catalogPath, codexCatalog(ms, *p)); err != nil {
return err
}
kvs = append(kvs, edit.KV{Path: "model_catalog_json", Value: catalogPath})
} else if err := edit.DelTOMLTop(path, "model_catalog_json"); err != nil {
return err
}
if err := edit.SetTOMLTop(path, kvs...); err != nil {
return err
}
return settle(*p)
}
return &Agent{
ID: "codex", Name: "Codex", Icon: "codex-color", Bin: "codex", Dir: dir, Path: path,
// the app-server behind the Codex app (and every codex TUI) builds
// its model list once, at start-up.
Notice: func() string {
if Running(`(^|/)codex( |$)`) {
return "Codex builds its model list at start-up — restart the Codex app (and open codex sessions) to see this."
}
return ""
},
Fields: []Field{
{
Key: "provider", Label: "provider",
Get: func() string { return current().ID },
Set: use,
Options: func(map[string]string) []Option {
var out []Option
for _, p := range providers() {
o := Option{Value: p.ID, Label: p.Name, Note: p.note(), Key: p.EnvKey, Icon: p.Icon}
o.NeedKey = p.EnvKey != "" && Key(p.EnvKey) == ""
out = append(out, o)
}
return append(out[:1], sortReady(out[1:])...)
},
},
{
Key: "model", Label: "model",
Get: func() string { return get("model") },
Set: func(v string) error {
if err := edit.SetTOMLTop(path, edit.KV{Path: "model", Value: v}); err != nil {
return err
}
return settle(current())
},
Options: func(map[string]string) []Option { return options(models(current()), "") },
},
{
Key: "effort", Label: "effort",
Get: func() string { return get("model_reasoning_effort") },
Set: func(v string) error {
return edit.SetTOMLTop(path, edit.KV{Path: "model_reasoning_effort", Value: v})
},
Options: func(cur map[string]string) []Option {
if e := catalog.Efforts(models(current()), cur["model"]); len(e) > 0 {
return static(e...)
}
return static("low", "medium", "high", "xhigh")
},
},
},
}
}
func (p CodexProvider) note() string {
switch {
case p.ID == "openai":
return "ChatGPT sign-in or OPENAI_API_KEY"
case p.EnvKey != "" && Key(p.EnvKey) == "":
return p.host() + " · $" + p.EnvKey + " not set"
case p.EnvKey != "":
return p.host() + " · $" + p.EnvKey
}
return p.host()
}
func (p CodexProvider) host() string { return hostOf(p.BaseURL) }
func findProvider(ps []CodexProvider, id string) *CodexProvider {
for i := range ps {
if ps[i].ID == id {
return &ps[i]
}
}
return nil
}
func contains(xs []string, x string) bool {
for _, v := range xs {
if v == x {
return true
}
}
return false
}
// defaultEffort picks the middle of the road: "medium" or "high" when
// offered, else whatever the list starts with.
func defaultEffort(e []string) string {
for _, want := range []string{"medium", "high"} {
if contains(e, want) {
return want
}
}
return e[0]
}
// codexCatalog renders models in the shape of Codex's models.json so
// `model_catalog_json` can point at it. Only fields Codex requires or that
// change behaviour are set; the rest take Codex's defaults.
func codexCatalog(ms []catalog.Model, p CodexProvider) []byte {
type level struct {
Effort string `json:"effort"`
Description string `json:"description"`
}
type model struct {
Slug string `json:"slug"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
Instructions string `json:"base_instructions"`
DefaultEffort *string `json:"default_reasoning_level"`
Efforts []level `json:"supported_reasoning_levels"`
Shell string `json:"shell_type"`
Visibility string `json:"visibility"`
InAPI bool `json:"supported_in_api"`
Priority int `json:"priority"`
Verbosity bool `json:"support_verbosity"`
DefVerbosity *string `json:"default_verbosity"`
ApplyPatch string `json:"apply_patch_tool_type"`
Truncation struct {
Mode string `json:"mode"`
Limit int `json:"limit"`
} `json:"truncation_policy"`
Tools []string `json:"experimental_supported_tools"`
Modalities []string `json:"input_modalities"`
}
var out struct {
Models []model `json:"models"`
}
for i, m := range ms {
e := model{
Slug: m.ID, DisplayName: m.Name, Description: m.Name + " via " + p.host(),
Instructions: codexPrompt, Efforts: []level{},
Shell: "unified_exec", Visibility: "list", InAPI: true, Priority: i + 1,
ApplyPatch: "freeform", Tools: []string{}, Modalities: []string{"text"},
}
e.Truncation.Mode, e.Truncation.Limit = "tokens", 10000
for _, ef := range m.Efforts {
e.Efforts = append(e.Efforts, level{Effort: ef})
}
if len(m.Efforts) > 0 {
d := defaultEffort(m.Efforts)
e.DefaultEffort = &d
}
out.Models = append(out.Models, e)
}
b, _ := json.MarshalIndent(out, "", " ")
return b
}
+80
View File
@@ -0,0 +1,80 @@
You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI on a user's computer.
## General
- When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.)
## Editing constraints
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
- Add succinct code comments that explain what is going on if code is not self-explanatory. You should not add comments like "Assigns the value to the variable", but a brief comment might be useful ahead of a complex code block that the user would otherwise have to spend time parsing out. Usage of these comments should be rare.
- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).
- You may be in a dirty git worktree.
* NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user.
* If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes.
* If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them.
* If the changes are in unrelated files, just ignore them and don't revert them.
- Do not amend a commit unless explicitly requested to do so.
- While you are working, you might notice unexpected changes that you didn't make. If this happens, STOP IMMEDIATELY and ask the user how they would like to proceed.
- **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user.
## Plan tool
When using the planning tool:
- Skip using the planning tool for straightforward tasks (roughly the easiest 25%).
- Do not make single-step plans.
- When you made a plan, update it after having performed one of the sub-tasks that you shared on the plan.
## Special user requests
- If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so.
- If the user asks for a "review", default to a code review mindset: prioritise identifying bugs, risks, behavioural regressions, and missing tests. Findings must be the primary focus of the response - keep summaries or overviews brief and only after enumerating the issues. Present findings first (ordered by severity with file/line references), follow with open questions or assumptions, and offer a change-summary only as a secondary detail. If no findings are discovered, state that explicitly and mention any residual risks or testing gaps.
## Frontend tasks
When doing frontend design tasks, avoid collapsing into "AI slop" or safe, average-looking layouts.
Aim for interfaces that feel intentional, bold, and a bit surprising.
- Typography: Use expressive, purposeful fonts and avoid default stacks (Inter, Roboto, Arial, system).
- Color & Look: Choose a clear visual direction; define CSS variables; avoid purple-on-white defaults. No purple bias or dark mode bias.
- Motion: Use a few meaningful animations (page-load, staggered reveals) instead of generic micro-motions.
- Background: Don't rely on flat, single-color backgrounds; use gradients, shapes, or subtle patterns to build atmosphere.
- Overall: Avoid boilerplate layouts and interchangeable UI patterns. Vary themes, type families, and visual languages across outputs.
- Ensure the page loads properly on both desktop and mobile
Exception: If working within an existing website or design system, preserve the established patterns, structure, and visual language.
## Presenting your work and final message
You are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value.
- Default: be very concise; friendly coding teammate tone.
- Ask only when needed; suggest ideas; mirror the user's style.
- For substantial work, summarize clearly; follow final‑answer formatting.
- Skip heavy formatting for simple confirmations.
- Don't dump large files you've written; reference paths only.
- No "save/copy this file" - User is on the same machine.
- Offer logical next steps (tests, commits, build) briefly; add verify steps if you couldn't do something.
- For code changes:
* Lead with a quick explanation of the change, and then give more details on the context covering where and why a change was made. Do not start this explanation with "summary", just jump right in.
* If there are natural next steps the user may want to take, suggest them at the end of your response. Do not make suggestions if there are no natural next steps.
* When suggesting multiple options, use numeric lists for the suggestions so the user can quickly respond with a single number.
- The user does not command execution outputs. When asked to show the output of a command (e.g. `git show`), relay the important details in your answer or summarize the key lines so the user understands the result.
### Final answer structure and style guidelines
- Plain text; CLI handles styling. Use structure only when it helps scanability.
- Headers: optional; short Title Case (1-3 words) wrapped in **…**; no blank line before the first bullet; add only if they truly help.
- Bullets: use - ; merge related points; keep to one line when possible; 4–6 per list ordered by importance; keep phrasing consistent.
- Monospace: backticks for commands/paths/env vars/code ids and inline examples; use for literal keyword bullets; never combine with **.
- Code samples or multi-line snippets should be wrapped in fenced code blocks; include an info string as often as possible.
- Structure: group related bullets; order sections general → specific → supporting; for subsections, start with a bolded keyword bullet, then items; match complexity to the task.
- Tone: collaborative, concise, factual; present tense, active voice; self‑contained; no "above/below"; parallel wording.
- Don'ts: no nested bullets/hierarchies; no ANSI codes; don't cram unrelated keywords; keep keyword lists short—wrap/reformat if long; avoid naming formatting styles in answers.
- Adaptation: code explanations → precise, structured with code refs; simple tasks → lead with outcome; big changes → logical walkthrough + rationale + next actions; casual one-offs → plain sentences, no headers/bullets.
- File References: When referencing files in your response follow the below rules:
* Use inline code to make file paths clickable.
* Each reference should have a stand alone path. Even if it's the same file.
* Accepted: absolute, workspace‑relative, a/ or b/ diff prefixes, or bare filename/suffix.
* Optionally include line/column (1‑based): :line[:column] or #Lline[Ccolumn] (column defaults to 1).
* Do not use URIs like file://, vscode://, or https://.
* Do not provide range of lines
* Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\repo\project\main.rs:12:5
+119
View File
@@ -0,0 +1,119 @@
package agent
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/yetone/dial/internal/catalog"
"github.com/yetone/dial/internal/edit"
)
// Gemini CLI only speaks Google's own API, so "provider" here is how it
// authenticates: Google sign-in, a Gemini API key, or Vertex AI. The choice
// lives in settings.json (security.auth.selectedType); a key dial holds is
// handed over through ~/.gemini/.env, which the CLI loads at start-up.
func gemini(home string) *Agent {
dir := filepath.Join(home, ".gemini")
path := filepath.Join(dir, "settings.json")
envPath := filepath.Join(dir, ".env")
auth := jsonGet(path, "security.auth.selectedType")
base := func() string { v, _ := edit.GetEnvFile(envPath, "GOOGLE_GEMINI_BASE_URL"); return v }
current := func() string {
if base() != "" {
return "custom"
}
switch auth() {
case "gemini-api-key":
return "api-key"
case "vertex-ai":
return "vertex"
}
return "google"
}
use := func(id string) error {
switch id {
case "custom":
if current() != "custom" {
return fmt.Errorf("custom means whatever GOOGLE_GEMINI_BASE_URL is already in %s; set it there", envPath)
}
return nil
case "google":
if err := edit.SetJSON(path, edit.KV{Path: "security.auth.selectedType", Value: "oauth-personal"}); err != nil {
return err
}
case "vertex":
if err := edit.SetJSON(path, edit.KV{Path: "security.auth.selectedType", Value: "vertex-ai"}); err != nil {
return err
}
case "api-key":
key := Key("GEMINI_API_KEY")
if key == "" {
return needKey(Provider{Name: "Gemini API", EnvKey: "GEMINI_API_KEY"})
}
if err := edit.SetJSON(path, edit.KV{Path: "security.auth.selectedType", Value: "gemini-api-key"}); err != nil {
return err
}
if os.Getenv("GEMINI_API_KEY") == "" {
if err := edit.SetEnvFile(envPath, edit.KV{Path: "GEMINI_API_KEY", Value: key}); err != nil {
return err
}
os.Chmod(envPath, 0o600)
}
default:
return fmt.Errorf("unknown provider %q", id)
}
return edit.DelEnvFile(envPath, "GOOGLE_GEMINI_BASE_URL")
}
return &Agent{
ID: "gemini", Name: "Gemini CLI", Icon: "geminicli-color", Aliases: []string{"gemini-cli"},
Bin: "gemini", Dir: dir, Path: path,
Notice: func() string {
if Running(`(^|/)gemini( |$)`) {
return "Gemini CLI reads its settings at start-up — restart open gemini sessions to see this."
}
return ""
},
Fields: []Field{
{
Key: "provider", Label: "auth",
Get: current,
Set: use,
Options: func(map[string]string) []Option {
key := Option{Value: "api-key", Label: "API key", Key: "GEMINI_API_KEY", Icon: "gemini-color", Note: "Gemini API · $GEMINI_API_KEY"}
if Key("GEMINI_API_KEY") == "" {
key.Note += " not set"
key.NeedKey = true
}
out := []Option{
{Value: "google", Label: "Google account", Icon: "gemini-color", Note: "OAuth sign-in"},
key,
{Value: "vertex", Label: "Vertex AI", Icon: "googlecloud-color", Note: "Vertex AI · $GOOGLE_CLOUD_PROJECT"},
}
if current() == "custom" {
out = append(out, Option{Value: "custom", Note: hostOf(base()) + " (from .env)"})
}
return out
},
},
{
Key: "model", Label: "model",
Get: jsonGet(path, "model.name"),
Set: jsonSet(path, "model.name"),
Options: func(map[string]string) []Option {
var ms []catalog.Model
for _, m := range catalog.Provider("google") {
if strings.HasPrefix(m.ID, "gemini-") {
ms = append(ms, m)
}
}
return options(ms, "")
},
},
},
}
}
+655
View File
@@ -0,0 +1,655 @@
package agent
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"github.com/yetone/dial/internal/catalog"
)
// Provider is a model vendor an agent can be pointed at. Each agent speaks
// one wire protocol, so a provider is offered to an agent only when it has
// a base URL for that protocol.
//
// dial ships a list of vendors whose endpoints speak the protocols natively;
// the user can add their own, or edit and hide the built-in ones. Everything
// but the built-in defaults lives in ~/.config/dial/providers.json.
type Provider struct {
ID string `json:"id"`
Name string `json:"name"`
EnvKey string `json:"envKey"` // environment variable that carries the API key
Catalog string `json:"catalog,omitempty"` // models.dev provider id for the model list
Anthropic string `json:"anthropic,omitempty"` // base URL of an Anthropic Messages API (Claude Code)
Responses string `json:"responses,omitempty"` // base URL of an OpenAI Responses API (Codex)
Small string `json:"small,omitempty"` // model for the small/fast slot, when the vendor has one
Extra []string `json:"models,omitempty"` // the user's own model ids, on top of the catalog
Website string `json:"website,omitempty"` // the vendor's console
KeysURL string `json:"keysUrl,omitempty"` // where to create an API key
Icon string `json:"icon,omitempty"` // bundled icon name; empty means "derive one"
Builtin bool `json:"builtin"` // shipped with dial (may still be edited)
}
// builtins are the vendors dial knows out of the box. Only endpoints that
// speak the protocol natively are listed; nothing here needs a proxy.
var builtins = []Provider{
{ID: "deepseek", Name: "DeepSeek", EnvKey: "DEEPSEEK_API_KEY", Catalog: "deepseek", Icon: "deepseek-color",
Anthropic: "https://api.deepseek.com/anthropic", Responses: "https://api.deepseek.com/v1", Small: "deepseek-flash",
Website: "https://platform.deepseek.com", KeysURL: "https://platform.deepseek.com/api_keys"},
{ID: "kimi", Name: "Kimi", EnvKey: "MOONSHOT_API_KEY", Catalog: "moonshotai", Icon: "kimi",
Anthropic: "https://api.moonshot.ai/anthropic",
Website: "https://platform.kimi.ai", KeysURL: "https://platform.kimi.ai/console/api-keys"},
{ID: "kimi-cn", Name: "Kimi (China)", EnvKey: "MOONSHOT_API_KEY", Catalog: "moonshotai", Icon: "kimi",
Anthropic: "https://api.moonshot.cn/anthropic",
Website: "https://platform.kimi.com", KeysURL: "https://platform.kimi.com/console/api-keys"},
{ID: "glm", Name: "Zhipu GLM", EnvKey: "ZHIPU_API_KEY", Catalog: "zhipuai", Icon: "zhipu-color",
Anthropic: "https://open.bigmodel.cn/api/anthropic",
Website: "https://open.bigmodel.cn", KeysURL: "https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys"},
{ID: "zai", Name: "Z.ai GLM", EnvKey: "ZAI_API_KEY", Catalog: "zhipuai", Icon: "zai",
Anthropic: "https://api.z.ai/api/anthropic",
Website: "https://z.ai", KeysURL: "https://z.ai/manage-apikey/apikey-list"},
{ID: "minimax", Name: "MiniMax", EnvKey: "MINIMAX_API_KEY", Catalog: "minimax", Icon: "minimax-color",
Anthropic: "https://api.minimax.io/anthropic",
Website: "https://platform.minimax.io", KeysURL: "https://platform.minimax.io/user-center/basic-information/interface-key"},
{ID: "minimax-cn", Name: "MiniMax (China)", EnvKey: "MINIMAX_API_KEY", Catalog: "minimax", Icon: "minimax-color",
Anthropic: "https://api.minimax.cn/anthropic",
Website: "https://platform.minimax.cn", KeysURL: "https://platform.minimax.cn/user-center/basic-information/interface-key"},
{ID: "qwen", Name: "Qwen", EnvKey: "DASHSCOPE_API_KEY", Catalog: "alibaba", Icon: "qwen-color",
Anthropic: "https://dashscope-intl.aliyuncs.com/apps/anthropic",
Website: "https://modelstudio.console.alibabacloud.com", KeysURL: "https://modelstudio.console.alibabacloud.com/?tab=playground#/api-key"},
{ID: "qwen-cn", Name: "Qwen (China)", EnvKey: "DASHSCOPE_API_KEY", Catalog: "alibaba", Icon: "qwen-color",
Anthropic: "https://dashscope.aliyuncs.com/apps/anthropic",
Website: "https://bailian.console.aliyun.com", KeysURL: "https://bailian.console.aliyun.com/?tab=model#/api-key"},
{ID: "openrouter", Name: "OpenRouter", EnvKey: "OPENROUTER_API_KEY", Catalog: "openrouter", Icon: "openrouter",
Anthropic: "https://openrouter.ai/api",
Website: "https://openrouter.ai", KeysURL: "https://openrouter.ai/keys"},
}
// ---- registry ---------------------------------------------------------------
type registry struct {
Providers []Provider `json:"providers"` // user-defined, and edited copies of built-ins
Hidden []string `json:"hidden,omitempty"`
}
// ProvidersPath is the file that holds the user's providers.
func ProvidersPath() string { return filepath.Join(filepath.Dir(KeysPath()), "providers.json") }
func loadRegistry() registry {
var r registry
if b, err := os.ReadFile(ProvidersPath()); err == nil {
json.Unmarshal(b, &r)
}
return r
}
func saveRegistry(r registry) error {
p := ProvidersPath()
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return err
}
b, err := json.MarshalIndent(r, "", " ")
if err != nil {
return err
}
return os.WriteFile(p, append(b, '\n'), 0o644)
}
// Providers is every provider in display order: the built-ins (with the
// user's edits, minus the hidden ones), then the user's own.
func Providers() []Provider {
r := loadRegistry()
edited := map[string]Provider{}
for _, p := range r.Providers {
edited[p.ID] = p
}
var out []Provider
for _, b := range builtins {
if contains(r.Hidden, b.ID) {
continue
}
if e, ok := edited[b.ID]; ok {
e.Builtin = true
if e.Icon == "" {
e.Icon = b.Icon
}
out = append(out, e)
continue
}
b.Builtin = true
out = append(out, b)
}
for _, p := range r.Providers {
if builtin(p.ID) == nil {
p.Builtin = false
out = append(out, p)
}
}
return out
}
func builtin(id string) *Provider {
for i := range builtins {
if builtins[i].ID == id {
return &builtins[i]
}
}
return nil
}
func provider(id string) *Provider {
for _, p := range Providers() {
if p.ID == id {
return &p
}
}
return nil
}
// FindProvider looks a provider up by id.
func FindProvider(id string) (*Provider, error) {
if p := provider(strings.ToLower(strings.TrimSpace(id))); p != nil {
return p, nil
}
return nil, fmt.Errorf("unknown provider %q", id)
}
var idRe = regexp.MustCompile(`[^a-z0-9]+`)
// ProviderID derives an id from a display name: "My Proxy" → "my-proxy".
func ProviderID(name string) string {
return strings.Trim(idRe.ReplaceAllString(strings.ToLower(name), "-"), "-")
}
// SaveProvider adds or updates a provider. A built-in gets an edited copy;
// the defaults stay available through ResetProvider.
func SaveProvider(p Provider) error {
p.ID = strings.TrimSpace(p.ID)
if p.ID == "" {
p.ID = ProviderID(p.Name)
}
if p.ID == "" || p.ID != ProviderID(p.ID) {
return fmt.Errorf("provider id must be lowercase letters, digits and dashes, not %q", p.ID)
}
if p.Name == "" {
p.Name = p.ID
}
p.EnvKey = strings.TrimSpace(p.EnvKey)
if p.EnvKey == "" {
p.EnvKey = strings.ToUpper(strings.ReplaceAll(p.ID, "-", "_")) + "_API_KEY"
}
if strings.ContainsAny(p.EnvKey, "= \n") {
return fmt.Errorf("bad variable name %q", p.EnvKey)
}
for _, u := range []*string{&p.Anthropic, &p.Responses, &p.Website, &p.KeysURL} {
*u = strings.TrimRight(strings.TrimSpace(*u), "/")
if *u != "" && !strings.HasPrefix(*u, "http://") && !strings.HasPrefix(*u, "https://") {
*u = "https://" + *u
}
}
if p.Anthropic == "" && p.Responses == "" {
return errors.New("a provider needs at least one endpoint: an Anthropic Messages URL (for Claude Code) or a Responses URL (for Codex)")
}
p.Extra = cleanModels(p.Extra)
p.Builtin = false
r := loadRegistry()
r.Hidden = without(r.Hidden, p.ID)
replaced := false
for i := range r.Providers {
if r.Providers[i].ID == p.ID {
r.Providers[i] = p
replaced = true
}
}
if !replaced {
if b := builtin(p.ID); b != nil && sameProvider(*b, p) {
return saveRegistry(r) // edited back to the defaults: nothing to keep
}
r.Providers = append(r.Providers, p)
}
return saveRegistry(r)
}
// DeleteProvider removes a user provider, or hides a built-in one.
func DeleteProvider(id string) error {
r := loadRegistry()
var keep []Provider
found := false
for _, p := range r.Providers {
if p.ID == id {
found = true
continue
}
keep = append(keep, p)
}
r.Providers = keep
if builtin(id) != nil {
found = true
if !contains(r.Hidden, id) {
r.Hidden = append(r.Hidden, id)
}
}
if !found {
return fmt.Errorf("unknown provider %q", id)
}
return saveRegistry(r)
}
// ResetProvider drops the user's edits to a built-in (and un-hides it).
func ResetProvider(id string) error {
if builtin(id) == nil {
return fmt.Errorf("%q is not a built-in provider", id)
}
r := loadRegistry()
var keep []Provider
for _, p := range r.Providers {
if p.ID != id {
keep = append(keep, p)
}
}
r.Providers = keep
r.Hidden = without(r.Hidden, id)
return saveRegistry(r)
}
// HiddenProviders lists built-ins the user removed, so they can come back.
func HiddenProviders() []Provider {
var out []Provider
for _, id := range loadRegistry().Hidden {
if b := builtin(id); b != nil {
b.Builtin = true
out = append(out, *b)
}
}
return out
}
func sameProvider(a, b Provider) bool {
a.Builtin, b.Builtin = false, false
x, _ := json.Marshal(a)
y, _ := json.Marshal(b)
return bytes.Equal(x, y)
}
func cleanModels(ms []string) []string {
var out []string
for _, m := range ms {
if m = strings.TrimSpace(m); m != "" && !contains(out, m) {
out = append(out, m)
}
}
return out
}
func without(xs []string, x string) []string {
var out []string
for _, v := range xs {
if v != x {
out = append(out, v)
}
}
return out
}
// providerByURL finds the provider whose base URL for the given protocol
// matches u (ignoring scheme and trailing slashes).
func providerByURL(u string, url func(Provider) string) *Provider {
n := normURL(u)
if n == "" {
return nil
}
for _, p := range Providers() {
if normURL(url(p)) == n {
return &p
}
}
return nil
}
func normURL(u string) string {
u = strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(u), "https://"), "http://")
return strings.ToLower(strings.TrimRight(u, "/"))
}
func hostOf(u string) string {
h := normURL(u)
if i := strings.Index(h, "/"); i > 0 {
h = h[:i]
}
return h
}
// Host is the provider's API host, for display.
func (p Provider) Host() string {
if p.Anthropic != "" {
return hostOf(p.Anthropic)
}
return hostOf(p.Responses)
}
// Models lists the provider's models: the user's own first, then what the
// vendor itself reported (see RefreshModels), else the catalog's, newest
// first, minus experiments.
func (p Provider) Models() []catalog.Model {
var out []catalog.Model
for _, id := range p.Extra {
out = append(out, catalog.Model{ID: id, Name: id})
}
known := catalog.Provider(p.Catalog)
if live, _, ok := catalog.Live(p.ID); ok {
for _, m := range catalog.Decorate(live, known) {
if !contains(p.Extra, m.ID) {
out = append(out, m)
}
}
return out
}
for _, m := range known {
if !contains(p.Extra, m.ID) && !strings.Contains(m.ID, "-exp") && !strings.Contains(m.ID, "preview") {
out = append(out, m)
}
}
return out
}
// LiveModels reports when the vendor's own list was last fetched.
func (p Provider) LiveModels() (n int, fetched time.Time, ok bool) {
ms, t, ok := catalog.Live(p.ID)
return len(ms), t, ok
}
// RefreshModels asks the vendor which models it serves and remembers the
// answer, so pickers stop offering names the vendor retired.
func (p Provider) RefreshModels(ctx context.Context) ([]catalog.Model, error) {
key := Key(p.EnvKey)
if key == "" {
return nil, needKey(p)
}
var lastErr error
for _, base := range []string{p.Responses, p.Anthropic} {
if base == "" {
continue
}
ms, err := catalog.Fetch(ctx, base, key)
if err == nil {
return ms, catalog.SaveLive(p.ID, base, ms)
}
lastErr = err
}
return nil, lastErr
}
// refreshQuietly is RefreshModels for the switch path: quick, and a failure
// just leaves the catalog in charge.
func (p Provider) refreshQuietly() {
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
defer cancel()
p.RefreshModels(ctx)
}
// option renders the provider as a picker entry for the given base URL.
func (p Provider) option(base string) Option {
o := Option{Value: p.ID, Label: p.Name, Key: p.EnvKey, Icon: p.Icon, Note: hostOf(base) + " · $" + p.EnvKey}
if Key(p.EnvKey) == "" {
o.Note += " not set"
o.NeedKey = true
}
return o
}
// sortReady puts providers whose key is present first, keeping order otherwise.
func sortReady(opts []Option) []Option {
sort.SliceStable(opts, func(i, j int) bool { return !opts[i].NeedKey && opts[j].NeedKey })
return opts
}
// ---- connectivity -----------------------------------------------------------
// TestResult is what a probe of one endpoint came back with.
type TestResult struct {
Protocol string `json:"protocol"` // "anthropic" or "responses"
OK bool `json:"ok"`
Status int `json:"status,omitempty"`
Millis int64 `json:"ms"`
Model string `json:"model,omitempty"`
Error string `json:"error,omitempty"`
}
// Test sends the smallest possible request to each of the provider's
// endpoints with the key dial has, and reports what came back.
func (p Provider) Test(ctx context.Context) []TestResult {
key := Key(p.EnvKey)
if key != "" {
p.RefreshModels(ctx)
}
model := ""
if ms := p.Models(); len(ms) > 0 {
model = ms[0].ID
}
var out []TestResult
if p.Anthropic != "" {
body := fmt.Sprintf(`{"model":%q,"max_tokens":1,"messages":[{"role":"user","content":"hi"}]}`, model)
out = append(out, probe(ctx, "anthropic", p.Anthropic+"/v1/messages", body, model, map[string]string{
"x-api-key": key, "Authorization": "Bearer " + key, "anthropic-version": "2023-06-01",
}))
}
if p.Responses != "" {
body := fmt.Sprintf(`{"model":%q,"input":"hi","max_output_tokens":16}`, model)
out = append(out, probe(ctx, "responses", p.Responses+"/responses", body, model, map[string]string{
"Authorization": "Bearer " + key,
}))
}
return out
}
func probe(ctx context.Context, proto, url, body, model string, headers map[string]string) TestResult {
r := TestResult{Protocol: proto, Model: model}
if model == "" {
r.Error = "no model to try: add one to the provider, or sync the catalog"
return r
}
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(body))
if err != nil {
r.Error = err.Error()
return r
}
req.Header.Set("Content-Type", "application/json")
for k, v := range headers {
req.Header.Set(k, v)
}
start := time.Now()
res, err := http.DefaultClient.Do(req)
r.Millis = time.Since(start).Milliseconds()
if err != nil {
r.Error = strings.TrimPrefix(err.Error(), "Post \""+url+"\": ")
return r
}
defer res.Body.Close()
r.Status = res.StatusCode
if res.StatusCode >= 200 && res.StatusCode < 300 {
r.OK = true
return r
}
b, _ := io.ReadAll(io.LimitReader(res.Body, 4096))
r.Error = apiError(b, res.Status)
return r
}
// apiError pulls the human message out of an error body when there is one.
func apiError(b []byte, fallback string) string {
var v struct {
Error struct {
Message string `json:"message"`
} `json:"error"`
Message string `json:"message"`
}
if json.Unmarshal(b, &v) == nil {
if v.Error.Message != "" {
return v.Error.Message
}
if v.Message != "" {
return v.Message
}
}
if s := strings.TrimSpace(string(b)); s != "" && len(s) < 200 && !strings.HasPrefix(s, "<") {
return fallback + ": " + s
}
return fallback
}
// ---- API keys --------------------------------------------------------------
//
// Keys come from the environment first. For apps that do not inherit a
// shell (a desktop app, a GUI launcher) dial can hold a key itself, in a
// 0600 file next to its profiles, and writes it into the agent's config on
// switch.
// KeysPath is the file dial keeps API keys in.
func KeysPath() string {
if x := os.Getenv("XDG_CONFIG_HOME"); x != "" {
return filepath.Join(x, "dial", "keys")
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "dial", "keys")
}
// Key returns the API key for an env var: the environment, else dial's store.
func Key(env string) string {
if env == "" {
return ""
}
if v := os.Getenv(env); v != "" {
return v
}
return StoredKeys()[env]
}
// KeyState says where a key comes from: "env", "stored" or "" (nowhere).
func KeyState(env string) (state, masked string) {
if env == "" {
return "", ""
}
if v := os.Getenv(env); v != "" {
return "env", Mask(v)
}
if v := StoredKeys()[env]; v != "" {
return "stored", Mask(v)
}
return "", ""
}
// StoredKeys reads dial's key store.
func StoredKeys() map[string]string {
out := map[string]string{}
f, err := os.Open(KeysPath())
if err != nil {
return out
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
if k, v, ok := strings.Cut(sc.Text(), "="); ok && k != "" {
out[k] = v
}
}
return out
}
// SetKey stores (or, with an empty value, forgets) a key.
func SetKey(env, value string) error {
env = strings.TrimSpace(env)
if env == "" || strings.ContainsAny(env, "= \n") {
return fmt.Errorf("bad variable name %q", env)
}
keys := StoredKeys()
if value == "" {
delete(keys, env)
} else {
keys[env] = strings.TrimSpace(value)
}
names := make([]string, 0, len(keys))
for k := range keys {
names = append(names, k)
}
sort.Strings(names)
var b strings.Builder
for _, k := range names {
b.WriteString(k + "=" + keys[k] + "\n")
}
p := KeysPath()
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return err
}
if err := os.WriteFile(p, []byte(b.String()), 0o600); err != nil {
return err
}
return os.Chmod(p, 0o600)
}
// needKey is the error for a provider whose key is nowhere to be found.
func needKey(p Provider) error {
return fmt.Errorf("%s needs $%s: export it, or store it with `dial key %s <value>`", p.Name, p.EnvKey, p.EnvKey)
}
// Mask hides all but the ends of a secret.
func Mask(s string) string {
if len(s) <= 8 {
return strings.Repeat("•", len(s))
}
return s[:4] + "…" + s[len(s)-4:]
}
// ---- stash -----------------------------------------------------------------
//
// Leaving a vendor's home provider replaces the model with one the new
// vendor knows. The old choice is kept here so the way back restores it.
func stashPath() string { return filepath.Join(filepath.Dir(KeysPath()), "stash.json") }
func stashLoad() map[string]string {
out := map[string]string{}
if b, err := os.ReadFile(stashPath()); err == nil {
json.Unmarshal(b, &out)
}
return out
}
// stash remembers values under keys; empty values are dropped.
func stash(kv map[string]string) {
m := stashLoad()
for k, v := range kv {
if v == "" {
delete(m, k)
} else {
m[k] = v
}
}
b, _ := json.MarshalIndent(m, "", " ")
os.MkdirAll(filepath.Dir(stashPath()), 0o755)
os.WriteFile(stashPath(), b, 0o600)
}
// unstash returns a remembered value and forgets it.
func unstash(key string) string {
m := stashLoad()
v := m[key]
if v != "" {
delete(m, key)
b, _ := json.MarshalIndent(m, "", " ")
os.WriteFile(stashPath(), b, 0o600)
}
return v
}
+326
View File
@@ -0,0 +1,326 @@
// Package catalog knows which models exist. It reads the models.dev catalog
// (from dial's own cache or OpenCode's), Codex's model cache, and falls back
// to a small built-in list so the picker is never empty.
package catalog
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// Model is one entry the picker can offer.
type Model struct {
ID string // e.g. "claude-sonnet-5"
Name string // display name
Provider string // models.dev provider id
Released string // YYYY-MM-DD, used for ordering
Efforts []string
}
type mdProvider struct {
ID string `json:"id"`
Name string `json:"name"`
Env []string `json:"env"`
Models map[string]mdModel `json:"models"`
}
type mdModel struct {
ID string `json:"id"`
Name string `json:"name"`
ReleaseDate string `json:"release_date"`
Reasoning []struct {
Type string `json:"type"`
Values []string `json:"values"`
} `json:"reasoning_options"`
Modalities struct {
Output []string `json:"output"`
} `json:"modalities"`
}
const modelsDevURL = "https://models.dev/api.json"
var (
once sync.Once
mdev map[string]mdProvider
)
// CachePath is where `dial sync` stores the models.dev catalog.
func CachePath() string {
if x := os.Getenv("XDG_CACHE_HOME"); x != "" {
return filepath.Join(x, "dial", "models.json")
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".cache", "dial", "models.json")
}
func opencodeCache() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".cache", "opencode", "models.json")
}
// Source reports which catalog file is in use ("" when only built-ins are).
func Source() string {
for _, p := range []string{CachePath(), opencodeCache()} {
if _, err := os.Stat(p); err == nil {
return p
}
}
return ""
}
func load() map[string]mdProvider {
once.Do(func() {
for _, p := range []string{CachePath(), opencodeCache()} {
b, err := os.ReadFile(p)
if err != nil {
continue
}
var m map[string]mdProvider
if json.Unmarshal(b, &m) == nil && len(m) > 0 {
mdev = m
return
}
}
})
return mdev
}
// Reset forgets the loaded catalog so the next call re-reads the cache.
func Reset() {
once = sync.Once{}
mdev = nil
}
// Sync downloads the models.dev catalog into CachePath.
func Sync(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, modelsDevURL, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "dial")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("models.dev: %s", resp.Status)
}
b, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20))
if err != nil {
return err
}
var probe map[string]mdProvider
if err := json.Unmarshal(b, &probe); err != nil || len(probe) == 0 {
return fmt.Errorf("models.dev: unexpected payload")
}
p := CachePath()
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return err
}
if err := os.WriteFile(p, b, 0o644); err != nil {
return err
}
Reset()
return nil
}
// Stale reports whether no catalog exists or the cache is older than a week.
func Stale() bool {
src := Source()
if src == "" {
return true
}
st, err := os.Stat(src)
return err != nil || time.Since(st.ModTime()) > 7*24*time.Hour
}
// ProviderEnv lists the env vars that unlock a models.dev provider.
func ProviderEnv(provider string) []string {
if p, ok := load()[provider]; ok {
return p.Env
}
return nil
}
// ProviderAvailable is true when any of the provider's API-key env vars is set.
func ProviderAvailable(provider string) bool {
for _, e := range ProviderEnv(provider) {
if os.Getenv(e) != "" {
return true
}
}
return false
}
// Provider returns the text models of one models.dev provider, newest first.
// Built-in models are merged in so the list is usable without any cache.
func Provider(id string) []Model {
seen := map[string]bool{}
var out []Model
if p, ok := load()[id]; ok {
for _, m := range p.Models {
if !textModel(m) {
continue
}
mm := Model{ID: m.ID, Name: m.Name, Provider: id, Released: m.ReleaseDate}
for _, r := range m.Reasoning {
if r.Type == "effort" {
mm.Efforts = r.Values
}
}
seen[m.ID] = true
out = append(out, mm)
}
}
for _, m := range builtin[id] {
if !seen[m.ID] {
m.Provider = id
out = append(out, m)
}
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Released != out[j].Released {
return out[i].Released > out[j].Released
}
return out[i].ID < out[j].ID
})
return out
}
func textModel(m mdModel) bool {
if len(m.Modalities.Output) > 0 {
text := false
for _, o := range m.Modalities.Output {
if o == "text" {
text = true
}
}
if !text {
return false
}
}
id := m.ID
for _, bad := range []string{"embed", "-tts", "image", "audio", "-live", "robotics", "computer-use", "deep-research", "transcribe", "realtime", "moderation", "whisper", "dall-e", "sora"} {
if strings.Contains(id, bad) {
return false
}
}
return true
}
// Providers returns models.dev provider ids known to the catalog.
func Providers() []string {
var ids []string
for id := range load() {
ids = append(ids, id)
}
sort.Strings(ids)
return ids
}
// Codex returns the models Codex itself lists (from its local cache).
func Codex() []Model {
home, _ := os.UserHomeDir()
b, err := os.ReadFile(filepath.Join(home, ".codex", "models_cache.json"))
if err != nil {
return builtin["codex"]
}
var cache struct {
Models []struct {
Slug string `json:"slug"`
DisplayName string `json:"display_name"`
Visibility string `json:"visibility"`
Priority int `json:"priority"`
Levels []struct {
Effort string `json:"effort"`
} `json:"supported_reasoning_levels"`
} `json:"models"`
}
if json.Unmarshal(b, &cache) != nil || len(cache.Models) == 0 {
return builtin["codex"]
}
sort.SliceStable(cache.Models, func(i, j int) bool { return cache.Models[i].Priority < cache.Models[j].Priority })
var out []Model
for _, m := range cache.Models {
if m.Visibility == "hide" {
continue
}
mm := Model{ID: m.Slug, Name: m.DisplayName, Provider: "openai"}
for _, l := range m.Levels {
mm.Efforts = append(mm.Efforts, l.Effort)
}
out = append(out, mm)
}
return out
}
// Efforts returns the reasoning levels a model supports, if known.
func Efforts(models []Model, id string) []string {
for _, m := range models {
if m.ID == id && len(m.Efforts) > 0 {
return m.Efforts
}
}
return nil
}
// builtin keeps the picker useful with no catalog on disk.
var builtin = map[string][]Model{
"anthropic": {
{ID: "claude-fable-5-1", Name: "Claude Fable 5.1", Released: "2026-09-01"},
{ID: "claude-opus-5", Name: "Claude Opus 5", Released: "2026-07-01"},
{ID: "claude-sonnet-5", Name: "Claude Sonnet 5", Released: "2026-07-01"},
{ID: "claude-haiku-4-5-20251001", Name: "Claude Haiku 4.5", Released: "2025-10-01"},
},
"openai": {
{ID: "gpt-5.6-sol", Name: "GPT-5.6 Sol"},
{ID: "gpt-5.5", Name: "GPT-5.5"},
{ID: "gpt-5.3-codex", Name: "GPT-5.3 Codex"},
},
"codex": {
{ID: "gpt-6-astra", Name: "GPT-6-Astra", Efforts: []string{"low", "medium", "high", "xhigh", "max", "ultra"}},
{ID: "gpt-5.6-sol", Name: "GPT-5.6-Sol", Efforts: []string{"low", "medium", "high", "xhigh"}},
{ID: "gpt-5.6-terra", Name: "GPT-5.6-Terra", Efforts: []string{"low", "medium", "high", "xhigh"}},
{ID: "gpt-5.6-luna", Name: "GPT-5.6-Luna", Efforts: []string{"low", "medium", "high", "xhigh"}},
{ID: "gpt-5.5", Name: "GPT-5.5", Efforts: []string{"low", "medium", "high", "xhigh"}},
},
"deepseek": {
{ID: "deepseek-flash", Name: "DeepSeek V4.1 Flash", Released: "2026-09-10", Efforts: []string{"low", "high", "max"}},
{ID: "deepseek-v4-pro", Name: "DeepSeek V4 Pro", Released: "2026-08-12", Efforts: []string{"low", "high", "max"}},
},
"google": {
{ID: "gemini-3.1-pro", Name: "Gemini 3.1 Pro"},
{ID: "gemini-3.5-flash", Name: "Gemini 3.5 Flash"},
{ID: "gemini-2.5-pro", Name: "Gemini 2.5 Pro"},
{ID: "gemini-2.5-flash", Name: "Gemini 2.5 Flash"},
},
"copilot": {
{ID: "auto", Name: "Let Copilot pick"},
{ID: "claude-fable-5", Name: "Claude Fable 5"},
{ID: "claude-opus-4.8", Name: "Claude Opus 4.8"},
{ID: "claude-sonnet-4.5", Name: "Claude Sonnet 4.5"},
{ID: "claude-haiku-4.5", Name: "Claude Haiku 4.5"},
{ID: "gpt-5.6-sol", Name: "GPT-5.6 Sol"},
{ID: "gpt-5.6-terra", Name: "GPT-5.6 Terra"},
{ID: "gpt-5.6-luna", Name: "GPT-5.6 Luna"},
{ID: "gpt-5.5", Name: "GPT-5.5"},
{ID: "gpt-5.4", Name: "GPT-5.4"},
{ID: "gpt-5.4-mini", Name: "GPT-5.4 mini"},
{ID: "gpt-5.3-codex", Name: "GPT-5.3 Codex"},
{ID: "gemini-3.6-flash", Name: "Gemini 3.6 Flash"},
{ID: "gemini-3.5-flash", Name: "Gemini 3.5 Flash"},
},
}
// Builtin exposes a built-in list by name (e.g. "copilot").
func Builtin(name string) []Model { return builtin[name] }
+185
View File
@@ -0,0 +1,185 @@
package catalog
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
// A vendor's own /models endpoint is the truth about what it serves today;
// models.dev lags and keeps legacy names around. dial asks the vendor when
// it has a key, remembers the answer next to the models.dev cache, and lets
// the catalog fill in display names and reasoning levels.
// LivePath is where the fetched model list of one provider is kept.
func LivePath(provider string) string {
return filepath.Join(filepath.Dir(CachePath()), "models", provider+".json")
}
type liveFile struct {
Fetched time.Time `json:"fetched"`
Base string `json:"base"`
Models []Model `json:"models"`
}
// Live returns the model list last fetched from the provider, if any.
func Live(provider string) (models []Model, fetched time.Time, ok bool) {
b, err := os.ReadFile(LivePath(provider))
if err != nil {
return nil, time.Time{}, false
}
var f liveFile
if json.Unmarshal(b, &f) != nil || len(f.Models) == 0 {
return nil, time.Time{}, false
}
return f.Models, f.Fetched, true
}
// SaveLive stores a fetched list; an empty list forgets it.
func SaveLive(provider, base string, models []Model) error {
p := LivePath(provider)
if len(models) == 0 {
err := os.Remove(p)
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return err
}
b, _ := json.MarshalIndent(liveFile{Fetched: time.Now(), Base: base, Models: models}, "", " ")
return os.WriteFile(p, b, 0o644)
}
// Fetch asks an endpoint for its models. base is an API base URL of any
// flavour (…/v1, …/anthropic, …/api); the usual list paths are tried
// around it. The result keeps the server's order.
func Fetch(ctx context.Context, base, key string) ([]Model, error) {
base = strings.TrimRight(strings.TrimSpace(base), "/")
if base == "" {
return nil, errors.New("no base URL")
}
var urls []string
add := func(u string) {
for _, x := range urls {
if x == u {
return
}
}
urls = append(urls, u)
}
add(base + "/models")
add(base + "/v1/models")
root := base
for _, suffix := range []string{"/anthropic", "/apps/anthropic", "/api/anthropic", "/v1", "/api", "/api/v1"} {
if strings.HasSuffix(root, suffix) {
root = strings.TrimSuffix(root, suffix)
}
}
add(root + "/v1/models")
add(root + "/models")
var lastErr error
for _, u := range urls {
ms, err := fetchOne(ctx, u, key)
if err == nil && len(ms) > 0 {
return ms, nil
}
if err != nil {
lastErr = err
}
if ctx.Err() != nil {
break
}
}
if lastErr == nil {
lastErr = errors.New("no model list at " + base)
}
return nil, lastErr
}
func fetchOne(ctx context.Context, url, key string) ([]Model, error) {
ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "dial")
if key != "" {
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("x-api-key", key)
req.Header.Set("anthropic-version", "2023-06-01")
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
b, _ := io.ReadAll(io.LimitReader(res.Body, 8<<20))
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s: %s", url, res.Status)
}
var v struct {
Data []liveModel `json:"data"`
Models []liveModel `json:"models"`
}
if err := json.Unmarshal(b, &v); err != nil {
return nil, fmt.Errorf("%s: not a model list", url)
}
rows := v.Data
if len(rows) == 0 {
rows = v.Models
}
var out []Model
for _, r := range rows {
id := r.ID
if id == "" {
id = r.Name
}
if id == "" || !textModel(mdModel{ID: id}) {
continue
}
name := r.DisplayName
if name == "" {
name = id
}
out = append(out, Model{ID: id, Name: name})
}
return out, nil
}
type liveModel struct {
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
}
// Decorate fills in names and reasoning levels for live models from the
// catalog's entry for the same id, keeping the live order.
func Decorate(live []Model, known []Model) []Model {
byID := make(map[string]Model, len(known))
for _, m := range known {
byID[m.ID] = m
}
out := make([]Model, 0, len(live))
for _, m := range live {
if k, ok := byID[m.ID]; ok {
if m.Name == "" || m.Name == m.ID {
m.Name = k.Name
}
m.Efforts, m.Released, m.Provider = k.Efforts, k.Released, k.Provider
}
out = append(out, m)
}
return out
}
+82
View File
@@ -0,0 +1,82 @@
package edit
import "testing"
func TestDelJSONMiddleKeepsLayout(t *testing.T) {
in := "{\n \"env\": {\n \"A\": \"1\",\n \"B\": \"2\",\n \"C\": \"3\"\n },\n \"model\": \"m\"\n}\n"
p := tmpFile(t, "s.json", in)
if err := DelJSON(p, "env.B"); err != nil {
t.Fatal(err)
}
want := "{\n \"env\": {\n \"A\": \"1\",\n \"C\": \"3\"\n },\n \"model\": \"m\"\n}\n"
if got := read(t, p); got != want {
t.Fatalf("got\n%s\nwant\n%s", got, want)
}
// last member: preceding comma goes
if err := DelJSON(p, "env.C", "nope.x"); err != nil {
t.Fatal(err)
}
want = "{\n \"env\": {\n \"A\": \"1\"\n },\n \"model\": \"m\"\n}\n"
if got := read(t, p); got != want {
t.Fatalf("got\n%s\nwant\n%s", got, want)
}
// only member: parent collapses to {}
if err := DelJSON(p, "env.A"); err != nil {
t.Fatal(err)
}
want = "{\n \"env\": {},\n \"model\": \"m\"\n}\n"
if got := read(t, p); got != want {
t.Fatalf("got\n%s\nwant\n%s", got, want)
}
// top-level last member
if err := DelJSON(p, "model"); err != nil {
t.Fatal(err)
}
want = "{\n \"env\": {}\n}\n"
if got := read(t, p); got != want {
t.Fatalf("got\n%s\nwant\n%s", got, want)
}
}
func TestDelJSONCompactAndComments(t *testing.T) {
p := tmpFile(t, "c.jsonc", "{\"a\":1,\"b\":{\"x\":true},\"c\":3}")
if err := DelJSON(p, "a", "b.x"); err != nil {
t.Fatal(err)
}
if got := read(t, p); got != "{\"b\":{},\"c\":3}" {
t.Fatalf("got %q", got)
}
p = tmpFile(t, "d.jsonc", "{\n // keep me\n \"a\": 1, // trailing\n \"b\": 2\n}\n")
if err := DelJSON(p, "b"); err != nil {
t.Fatal(err)
}
if got := read(t, p); got != "{\n // keep me\n \"a\": 1 // trailing\n}\n" {
t.Fatalf("got %q", got)
}
}
func TestEnvFile(t *testing.T) {
p := tmpFile(t, ".env", "# comment\nFOO=bar\nexport BAZ=\"q x\"\n")
if v, ok := GetEnvFile(p, "BAZ"); !ok || v != "q x" {
t.Fatalf("got %q %v", v, ok)
}
if err := SetEnvFile(p, KV{"FOO", "new"}, KV{"KEY", "sk-1"}); err != nil {
t.Fatal(err)
}
if got := read(t, p); got != "# comment\nFOO=new\nexport BAZ=\"q x\"\nKEY=sk-1\n" {
t.Fatalf("got %q", got)
}
if err := DelEnvFile(p, "FOO", "KEY"); err != nil {
t.Fatal(err)
}
if got := read(t, p); got != "# comment\nexport BAZ=\"q x\"\n" {
t.Fatalf("got %q", got)
}
np := tmpFile(t, "new.env", "")
if err := SetEnvFile(np, KV{"A", "1"}); err != nil {
t.Fatal(err)
}
if got := read(t, np); got != "A=1\n" {
t.Fatalf("got %q", got)
}
}
+190
View File
@@ -0,0 +1,190 @@
package edit
import (
"os"
"path/filepath"
"testing"
)
func tmpFile(t *testing.T, name, content string) string {
t.Helper()
p := filepath.Join(t.TempDir(), name)
if content != "" || name == "empty.json" {
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}
return p
}
func read(t *testing.T, p string) string {
t.Helper()
b, err := os.ReadFile(p)
if err != nil {
t.Fatal(err)
}
return string(b)
}
func TestJSONReplaceKeepsEverything(t *testing.T) {
in := "{\n \"env\": {\n \"X\": \"1\"\n },\n \"model\": \"old\",\n \"hooks\": []\n}\n"
p := tmpFile(t, "settings.json", in)
if err := SetJSON(p, KV{Path: "model", Value: "new"}); err != nil {
t.Fatal(err)
}
want := "{\n \"env\": {\n \"X\": \"1\"\n },\n \"model\": \"new\",\n \"hooks\": []\n}\n"
if got := read(t, p); got != want {
t.Fatalf("got:\n%s\nwant:\n%s", got, want)
}
if v, ok := GetJSON(p, "model"); !ok || v != "new" {
t.Fatalf("GetJSON = %q, %v", v, ok)
}
}
func TestJSONInsertTopLevel(t *testing.T) {
in := "{\n \"a\": 1\n}\n"
p := tmpFile(t, "settings.json", in)
if err := SetJSON(p, KV{Path: "model", Value: "m"}); err != nil {
t.Fatal(err)
}
want := "{\n \"model\": \"m\",\n \"a\": 1\n}\n"
if got := read(t, p); got != want {
t.Fatalf("got:\n%s\nwant:\n%s", got, want)
}
}
func TestJSONInsertNestedIntoExistingObject(t *testing.T) {
in := "{\n \"model\": {\n \"other\": true\n }\n}\n"
p := tmpFile(t, "settings.json", in)
if err := SetJSON(p, KV{Path: "model.name", Value: "gemini-2.5-pro"}); err != nil {
t.Fatal(err)
}
want := "{\n \"model\": {\n \"name\": \"gemini-2.5-pro\",\n \"other\": true\n }\n}\n"
if got := read(t, p); got != want {
t.Fatalf("got:\n%s\nwant:\n%s", got, want)
}
}
func TestJSONInsertNestedCreatesParents(t *testing.T) {
in := "{\n \"ui\": {}\n}\n"
p := tmpFile(t, "settings.json", in)
if err := SetJSON(p, KV{Path: "models.large.model", Value: "x"}); err != nil {
t.Fatal(err)
}
want := "{\n \"models\": {\n \"large\": {\n \"model\": \"x\"\n }\n },\n \"ui\": {}\n}\n"
if got := read(t, p); got != want {
t.Fatalf("got:\n%s\nwant:\n%s", got, want)
}
}
func TestJSONScalarParentBecomesObject(t *testing.T) {
in := "{\n \"model\": \"gemini-2.5-pro\"\n}\n"
p := tmpFile(t, "settings.json", in)
if err := SetJSON(p, KV{Path: "model.name", Value: "gemini-3-pro"}); err != nil {
t.Fatal(err)
}
want := "{\n \"model\": {\n \"name\": \"gemini-3-pro\"\n }\n}\n"
if got := read(t, p); got != want {
t.Fatalf("got:\n%s\nwant:\n%s", got, want)
}
}
func TestJSONCKeepsComments(t *testing.T) {
in := "{\n // schema\n \"$schema\": \"https://opencode.ai/config.json\", /* x */\n \"model\": \"a/b\"\n}\n"
p := tmpFile(t, "opencode.jsonc", in)
if err := SetJSON(p, KV{Path: "model", Value: "anthropic/claude-sonnet-5"}, KV{Path: "small_model", Value: "anthropic/claude-haiku-4-5"}); err != nil {
t.Fatal(err)
}
want := "{\n \"small_model\": \"anthropic/claude-haiku-4-5\",\n // schema\n \"$schema\": \"https://opencode.ai/config.json\", /* x */\n \"model\": \"anthropic/claude-sonnet-5\"\n}\n"
if got := read(t, p); got != want {
t.Fatalf("got:\n%s\nwant:\n%s", got, want)
}
if v, _ := GetJSON(p, "small_model"); v != "anthropic/claude-haiku-4-5" {
t.Fatalf("GetJSON = %q", v)
}
}
func TestJSONEmptyAndMissingFile(t *testing.T) {
p := tmpFile(t, "empty.json", "")
if err := SetJSON(p, KV{Path: "model", Value: "m"}, KV{Path: "flag", Value: true}); err != nil {
t.Fatal(err)
}
if got, want := read(t, p), "{\n \"flag\": true,\n \"model\": \"m\"\n}\n"; got != want {
t.Fatalf("got:\n%s\nwant:\n%s", got, want)
}
p2 := filepath.Join(t.TempDir(), "sub", "new.json")
if err := SetJSON(p2, KV{Path: "model", Value: "m"}); err != nil {
t.Fatal(err)
}
if v, _ := GetJSON(p2, "model"); v != "m" {
t.Fatalf("missing file: got %q", v)
}
}
func TestJSONCompact(t *testing.T) {
p := tmpFile(t, "c.json", `{"a":1}`)
if err := SetJSON(p, KV{Path: "model", Value: "m"}); err != nil {
t.Fatal(err)
}
if got, want := read(t, p), `{"model":"m","a":1}`; got != want {
t.Fatalf("got %s want %s", got, want)
}
}
func TestJSONBracesInStrings(t *testing.T) {
in := "{\n \"cmd\": \"echo {\\\"x\\\": 1}\"\n}\n"
p := tmpFile(t, "s.json", in)
if err := SetJSON(p, KV{Path: "model", Value: "m"}); err != nil {
t.Fatal(err)
}
want := "{\n \"model\": \"m\",\n \"cmd\": \"echo {\\\"x\\\": 1}\"\n}\n"
if got := read(t, p); got != want {
t.Fatalf("got:\n%s\nwant:\n%s", got, want)
}
}
func TestTOMLTop(t *testing.T) {
in := "model_reasoning_effort = \"medium\"\nmodel = \"old\" # comment\n\n[projects.\"/x\"]\nmodel = \"not-top\"\n"
p := tmpFile(t, "config.toml", in)
if v, ok := GetTOMLTop(p, "model"); !ok || v != "old" {
t.Fatalf("get = %q %v", v, ok)
}
if err := SetTOMLTop(p, KV{Path: "model", Value: "gpt-5.5"}, KV{Path: "model_provider", Value: "openai"}); err != nil {
t.Fatal(err)
}
want := "model_reasoning_effort = \"medium\"\nmodel = \"gpt-5.5\"\nmodel_provider = \"openai\"\n\n[projects.\"/x\"]\nmodel = \"not-top\"\n"
if got := read(t, p); got != want {
t.Fatalf("got:\n%s\nwant:\n%s", got, want)
}
if v, _ := GetTOMLTop(p, "model"); v != "gpt-5.5" {
t.Fatalf("get after set = %q", v)
}
}
func TestTOMLMissingFile(t *testing.T) {
p := filepath.Join(t.TempDir(), "config.toml")
if err := SetTOMLTop(p, KV{Path: "model", Value: "m"}); err != nil {
t.Fatal(err)
}
if got, want := read(t, p), "model = \"m\"\n"; got != want {
t.Fatalf("got %q want %q", got, want)
}
}
func TestYAMLTop(t *testing.T) {
in := "GOOSE_PROVIDER: anthropic\nGOOSE_MODEL: claude-3-7 # old\nextensions:\n developer:\n enabled: true\n"
p := tmpFile(t, "config.yaml", in)
if v, _ := GetYAMLTop(p, "GOOSE_MODEL"); v != "claude-3-7" {
t.Fatalf("get = %q", v)
}
if err := SetYAMLTop(p, KV{Path: "GOOSE_PROVIDER", Value: "openrouter"}, KV{Path: "GOOSE_MODEL", Value: "z-ai/glm-5.2:batch"}); err != nil {
t.Fatal(err)
}
want := "GOOSE_PROVIDER: openrouter\nGOOSE_MODEL: \"z-ai/glm-5.2:batch\"\nextensions:\n developer:\n enabled: true\n"
if got := read(t, p); got != want {
t.Fatalf("got:\n%s\nwant:\n%s", got, want)
}
if v, _ := GetYAMLTop(p, "GOOSE_MODEL"); v != "z-ai/glm-5.2:batch" {
t.Fatalf("get after set = %q", v)
}
}
+75
View File
@@ -0,0 +1,75 @@
package edit
import (
"regexp"
"strings"
)
// dotenv files (KEY=value per line) as Gemini CLI reads from ~/.gemini/.env.
var envLine = regexp.MustCompile(`^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$`)
// GetEnvFile reads one key from a dotenv file.
func GetEnvFile(path, key string) (string, bool) {
raw, err := Read(path)
if err != nil {
return "", false
}
for _, l := range splitLines(string(raw)) {
if m := envLine.FindStringSubmatch(l); m != nil && m[1] == key {
if v, ok := unquote(m[2]); ok {
return v, true
}
return m[2], true
}
}
return "", false
}
// SetEnvFile sets keys in a dotenv file, replacing lines in place and
// appending new ones. Other lines are kept byte for byte.
func SetEnvFile(path string, kvs ...KV) error {
raw, err := Read(path)
if err != nil {
return err
}
lines := splitLines(string(raw))
for _, kv := range kvs {
v := toString(kv.Value)
if strings.ContainsAny(v, " #\"'$") {
v = `"` + strings.ReplaceAll(v, `"`, `\"`) + `"`
}
lines = setLine(lines, kv.Path, kv.Path+"="+v, nil, func(l string) (string, bool) {
if m := envLine.FindStringSubmatch(l); m != nil {
return m[1], true
}
return "", false
})
}
return WriteAtomic(path, []byte(joinLines(lines)))
}
// DelEnvFile removes keys from a dotenv file.
func DelEnvFile(path string, keys ...string) error {
raw, err := Read(path)
if err != nil || len(raw) == 0 {
return err
}
drop := map[string]bool{}
for _, k := range keys {
drop[k] = true
}
var out []string
changed := false
for _, l := range splitLines(string(raw)) {
if m := envLine.FindStringSubmatch(l); m != nil && drop[m[1]] {
changed = true
continue
}
out = append(out, l)
}
if !changed {
return nil
}
return WriteAtomic(path, []byte(joinLines(out)))
}
+58
View File
@@ -0,0 +1,58 @@
// Package edit provides format-preserving editors for the config files that
// coding agents keep: JSON/JSONC, TOML and YAML. Only the requested key
// changes; comments, ordering and indentation are left as they are.
package edit
import (
"errors"
"io/fs"
"os"
"path/filepath"
)
// Read returns the file contents, or (nil, nil) when the file does not exist.
func Read(path string) ([]byte, error) {
b, err := os.ReadFile(path)
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
return b, err
}
// WriteAtomic writes data to path via a temp file + rename so a crash can
// never leave a half-written config behind. File mode is preserved.
func WriteAtomic(path string, data []byte) error {
mode := fs.FileMode(0o644)
if st, err := os.Stat(path); err == nil {
mode = st.Mode().Perm()
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp")
if err != nil {
return err
}
tmpName := tmp.Name()
cleanup := func() { _ = os.Remove(tmpName) }
if _, err := tmp.Write(data); err != nil {
tmp.Close()
cleanup()
return err
}
if err := tmp.Chmod(mode); err != nil {
tmp.Close()
cleanup()
return err
}
if err := tmp.Close(); err != nil {
cleanup()
return err
}
if err := os.Rename(tmpName, path); err != nil {
cleanup()
return err
}
return nil
}
+287
View File
@@ -0,0 +1,287 @@
package edit
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"github.com/tidwall/gjson"
"github.com/tidwall/jsonc"
)
// KV is one key path (dot-separated, e.g. "model.name") and its new value.
type KV struct {
Path string
Value any
}
// GetJSON reads a dot-separated key path from a JSON or JSONC file.
func GetJSON(path, keyPath string) (string, bool) {
raw, err := Read(path)
if err != nil || len(raw) == 0 {
return "", false
}
r := gjson.GetBytes(jsonc.ToJSONInPlace(raw), keyPath)
if !r.Exists() {
return "", false
}
return r.String(), true
}
// SetJSON sets one or more key paths in a JSON/JSONC file, preserving
// comments, key order and indentation. Missing files and parents are created.
func SetJSON(path string, kvs ...KV) error {
raw, err := Read(path)
if err != nil {
return err
}
if len(bytes.TrimSpace(raw)) == 0 {
raw = []byte("{}\n")
}
for _, kv := range kvs {
raw, err = setJSONBytes(raw, kv.Path, kv.Value)
if err != nil {
return fmt.Errorf("%s: %w", path, err)
}
}
return WriteAtomic(path, raw)
}
func setJSONBytes(raw []byte, keyPath string, value any) ([]byte, error) {
// Comments are blanked out in a copy of identical length so gjson offsets
// map 1:1 back onto the original bytes.
stripped := jsonc.ToJSONInPlace(append([]byte(nil), raw...))
root := gjson.ParseBytes(stripped)
if !root.IsObject() {
return nil, fmt.Errorf("top level is not a JSON object")
}
parts := strings.Split(keyPath, ".")
for i := len(parts); i >= 1; i-- {
r := gjson.GetBytes(stripped, strings.Join(parts[:i], "."))
if !r.Exists() {
continue
}
if i == len(parts) || !r.IsObject() {
// Exact key exists (replace value), or an ancestor exists but is a
// scalar (replace it with the nested object we need).
return splice(raw, r.Index, len(r.Raw), marshalAt(raw, r.Index, nest(parts[i:], value)))
}
return insertKey(raw, stripped, r.Index, parts[i], nest(parts[i+1:], value))
}
return insertKey(raw, stripped, 0, parts[0], nest(parts[1:], value))
}
func nest(parts []string, value any) any {
for i := len(parts) - 1; i >= 0; i-- {
value = map[string]any{parts[i]: value}
}
return value
}
func splice(raw []byte, at, n int, with []byte) ([]byte, error) {
out := make([]byte, 0, len(raw)-n+len(with))
out = append(out, raw[:at]...)
out = append(out, with...)
out = append(out, raw[at+n:]...)
return out, nil
}
// insertKey adds key: value as the first member of the object that starts
// at or after position from.
func insertKey(raw, stripped []byte, from int, key string, value any) ([]byte, error) {
open := from
for open < len(raw) && raw[open] != '{' {
open++
}
close := matchBrace(stripped, open)
if open >= len(raw) || close < 0 {
return nil, fmt.Errorf("cannot locate object for %q", key)
}
k, _ := json.Marshal(key)
pretty := bytes.IndexByte(raw, '\n') >= 0
empty := len(bytes.TrimSpace(raw[open+1:close])) == 0
if !pretty {
v, _ := json.Marshal(value)
ins := string(k) + ":" + string(v)
if !empty {
ins += ","
}
return splice(raw, open+1, 0, []byte(ins))
}
base := lineIndent(raw, open)
unit := indentUnit(raw, open, base)
child := base + unit
v, _ := json.MarshalIndent(value, child, unit)
kv := child + string(k) + ": " + string(v)
if empty {
return splice(raw, open, close-open+1, []byte("{\n"+kv+"\n"+base+"}"))
}
return splice(raw, open+1, 0, []byte("\n"+kv+","))
}
// marshalAt renders value with indentation matching the line that holds pos.
func marshalAt(raw []byte, pos int, value any) []byte {
if bytes.IndexByte(raw, '\n') < 0 {
b, _ := json.Marshal(value)
return b
}
base := lineIndent(raw, pos)
b, _ := json.MarshalIndent(value, base, indentUnit(raw, pos, base))
return b
}
func lineIndent(raw []byte, pos int) string {
start := bytes.LastIndexByte(raw[:pos], '\n') + 1
i := start
for i < len(raw) && (raw[i] == ' ' || raw[i] == '\t') {
i++
}
return string(raw[start:i])
}
// indentUnit guesses the indentation step from the line following pos,
// falling back to two spaces (or a tab when the file uses tabs).
func indentUnit(raw []byte, pos int, base string) string {
if nl := bytes.IndexByte(raw[pos:], '\n'); nl >= 0 {
next := lineIndent(raw, pos+nl+1)
if len(next) > len(base) && strings.HasPrefix(next, base) {
return next[len(base):]
}
}
if strings.Contains(base, "\t") || bytes.Contains(raw, []byte("\n\t")) {
return "\t"
}
return " "
}
// matchBrace returns the index of the '}' closing the '{' at open, skipping
// over string literals. It expects comment-free input.
func matchBrace(b []byte, open int) int {
depth := 0
inStr := false
for i := open; i < len(b); i++ {
c := b[i]
switch {
case inStr:
if c == '\\' {
i++
} else if c == '"' {
inStr = false
}
case c == '"':
inStr = true
case c == '{':
depth++
case c == '}':
depth--
if depth == 0 {
return i
}
}
}
return -1
}
// DelJSON removes key paths from a JSON/JSONC file, preserving everything
// else. Missing keys are ignored; an object left empty collapses to {}.
func DelJSON(path string, keyPaths ...string) error {
raw, err := Read(path)
if err != nil || len(bytes.TrimSpace(raw)) == 0 {
return err
}
changed := false
for _, kp := range keyPaths {
out, ok, err := delJSONBytes(raw, kp)
if err != nil {
return fmt.Errorf("%s: %w", path, err)
}
if ok {
raw, changed = out, true
}
}
if !changed {
return nil
}
return WriteAtomic(path, raw)
}
func delJSONBytes(raw []byte, keyPath string) ([]byte, bool, error) {
stripped := jsonc.ToJSONInPlace(append([]byte(nil), raw...))
r := gjson.GetBytes(stripped, keyPath)
if !r.Exists() || r.Index == 0 {
return raw, false, nil
}
// value span → back over ':' and whitespace to the key string
a := r.Index
for a > 0 && (stripped[a-1] == ' ' || stripped[a-1] == '\t' || stripped[a-1] == '\n' || stripped[a-1] == '\r' || stripped[a-1] == ':') {
a--
}
if a == 0 || stripped[a-1] != '"' {
return nil, false, fmt.Errorf("cannot locate key %q", keyPath)
}
a-- // closing quote of the key
for a > 0 && stripped[a-1] != '"' {
a--
}
a-- // opening quote
b := r.Index + len(r.Raw)
ws := func(c byte) bool { return c == ' ' || c == '\t' || c == '\n' || c == '\r' }
// the member's own span: whole line when it sits alone on one
from, to := a, b
ls := bytes.LastIndexByte(raw[:a], '\n') + 1
alone := len(bytes.TrimSpace(raw[ls:a])) == 0
j := b
for j < len(stripped) && ws(stripped[j]) {
j++
}
var out []byte
if j < len(stripped) && stripped[j] == ',' {
// not the last member: drop through the trailing comma
to = j + 1
if alone {
from = ls
if to < len(raw) && raw[to] == '\n' {
to++
}
}
out, _ = splice(raw, from, to-from, nil)
} else {
// last member: drop the line, then the comma that preceded it
if alone {
from = ls
if to < len(raw) && raw[to] == '\n' {
to++
}
}
out, _ = splice(raw, from, to-from, nil)
k := a
for k > 0 && ws(stripped[k-1]) {
k--
}
if k > 0 && stripped[k-1] == ',' {
out, _ = splice(out, k-1, 1, nil)
}
}
// collapse a now-empty parent object to {}
s2 := jsonc.ToJSONInPlace(append([]byte(nil), out...))
parent := gjson.ParseBytes(s2)
if i := strings.LastIndex(keyPath, "."); i >= 0 {
parent = gjson.GetBytes(s2, keyPath[:i])
}
if parent.IsObject() && len(parent.Map()) == 0 {
open := parent.Index
for open < len(s2) && s2[open] != '{' {
open++
}
close := matchBrace(s2, open)
if close > open && len(bytes.TrimSpace(out[open+1:close])) == 0 {
out, _ = splice(out, open, close-open+1, []byte("{}"))
}
}
return out, true, nil
}
+192
View File
@@ -0,0 +1,192 @@
package edit
import (
"fmt"
"regexp"
"strconv"
"strings"
)
// Top-level TOML and YAML keys are edited line by line. That keeps every
// comment and every other line byte-for-byte intact, which a round trip
// through a parser would not.
var (
tomlTable = regexp.MustCompile(`^\s*\[`)
tomlKV = regexp.MustCompile(`^\s*([A-Za-z0-9_.-]+|"[^"]*")\s*=\s*(.*?)\s*$`)
yamlKV = regexp.MustCompile(`^([A-Za-z0-9_.-]+)\s*:\s*(.*?)\s*$`)
yamlPlain = regexp.MustCompile(`^[A-Za-z0-9._/-]+$`)
)
// GetTOMLTop reads a top-level (pre-table) key from a TOML file.
func GetTOMLTop(path, key string) (string, bool) {
raw, err := Read(path)
if err != nil || raw == nil {
return "", false
}
for _, line := range splitLines(string(raw)) {
if tomlTable.MatchString(line) {
break
}
if m := tomlKV.FindStringSubmatch(line); m != nil && strings.Trim(m[1], `"`) == key {
return tomlValue(m[2]), true
}
}
return "", false
}
// SetTOMLTop sets top-level keys in a TOML file. Existing lines are replaced
// in place; new keys go right after the last existing top-level key.
func SetTOMLTop(path string, kvs ...KV) error {
raw, err := Read(path)
if err != nil {
return err
}
lines := splitLines(string(raw))
for _, kv := range kvs {
lines = setLine(lines, kv.Path, kv.Path+" = "+strconv.Quote(toString(kv.Value)), tomlTable, func(line string) (string, bool) {
m := tomlKV.FindStringSubmatch(line)
if m == nil {
return "", false
}
return strings.Trim(m[1], `"`), true
})
}
return WriteAtomic(path, []byte(joinLines(lines)))
}
// GetYAMLTop reads a top-level scalar key from a YAML file.
func GetYAMLTop(path, key string) (string, bool) {
raw, err := Read(path)
if err != nil || raw == nil {
return "", false
}
for _, line := range splitLines(string(raw)) {
if m := yamlKV.FindStringSubmatch(line); m != nil && m[1] == key {
return yamlValue(m[2]), true
}
}
return "", false
}
// SetYAMLTop sets top-level scalar keys in a YAML file.
func SetYAMLTop(path string, kvs ...KV) error {
raw, err := Read(path)
if err != nil {
return err
}
lines := splitLines(string(raw))
for _, kv := range kvs {
v := toString(kv.Value)
if !yamlPlain.MatchString(v) {
v = strconv.Quote(v)
}
lines = setLine(lines, kv.Path, kv.Path+": "+v, nil, func(line string) (string, bool) {
m := yamlKV.FindStringSubmatch(line)
if m == nil {
return "", false
}
return m[1], true
})
}
return WriteAtomic(path, []byte(joinLines(lines)))
}
// setLine replaces the line whose key matches, or inserts newLine after the
// last key line in the header section (before the first line matching stop).
func setLine(lines []string, key, newLine string, stop *regexp.Regexp, keyOf func(string) (string, bool)) []string {
lastKey := -1
for i, line := range lines {
if stop != nil && stop.MatchString(line) {
break
}
k, ok := keyOf(line)
if !ok {
continue
}
if k == key {
lines[i] = newLine
return lines
}
lastKey = i
}
at := lastKey + 1
out := make([]string, 0, len(lines)+1)
out = append(out, lines[:at]...)
out = append(out, newLine)
out = append(out, lines[at:]...)
return out
}
// unquote strips a leading quoted scalar (and anything after it, such as an
// inline comment); ok is false when v does not start with a quote.
func unquote(v string) (string, bool) {
if v == "" || (v[0] != '"' && v[0] != '\'') {
return "", false
}
q := v[0]
for i := 1; i < len(v); i++ {
if q == '"' && v[i] == '\\' {
i++
continue
}
if v[i] == q {
if q == '"' {
if s, err := strconv.Unquote(v[:i+1]); err == nil {
return s, true
}
}
return v[1:i], true
}
}
return v[1:], true
}
func tomlValue(v string) string {
if s, ok := unquote(v); ok {
return s
}
if i := strings.Index(v, "#"); i >= 0 {
v = strings.TrimSpace(v[:i])
}
return v
}
func yamlValue(v string) string {
if s, ok := unquote(v); ok {
return s
}
if i := strings.Index(v, " #"); i >= 0 {
v = strings.TrimSpace(v[:i])
}
return v
}
func toString(v any) string {
switch x := v.(type) {
case string:
return x
case bool:
return strconv.FormatBool(x)
case int:
return strconv.Itoa(x)
}
return fmt.Sprint(v)
}
// splitLines splits on '\n' but keeps a trailing newline as an empty final
// element so joinLines can restore the file exactly.
func splitLines(s string) []string {
if s == "" {
return []string{""}
}
return strings.Split(s, "\n")
}
func joinLines(lines []string) string {
s := strings.Join(lines, "\n")
if !strings.HasSuffix(s, "\n") {
s += "\n"
}
return s
}
+167
View File
@@ -0,0 +1,167 @@
package edit
import (
"regexp"
"strconv"
"strings"
)
// Whole TOML tables are treated as units: dial owns the tables it writes
// (for example a model provider) and replaces them verbatim, while every
// other line of the file stays untouched.
var tomlHeader = regexp.MustCompile(`^\s*\[\s*([^\]]+?)\s*\]`)
// TOMLTables lists the table headers of a TOML file in order.
func TOMLTables(path string) []string {
raw, err := Read(path)
if err != nil || raw == nil {
return nil
}
var out []string
for _, line := range splitLines(string(raw)) {
if m := tomlHeader.FindStringSubmatch(line); m != nil && !strings.HasPrefix(m[1], "[") {
out = append(out, m[1])
}
}
return out
}
// GetTOMLTable returns the scalar keys of one table.
func GetTOMLTable(path, name string) map[string]string {
raw, err := Read(path)
if err != nil || raw == nil {
return nil
}
lines := splitLines(string(raw))
from, to := tableSpan(lines, name)
if from < 0 {
return nil
}
out := map[string]string{}
for _, line := range lines[from+1 : to] {
if m := tomlKV.FindStringSubmatch(line); m != nil {
out[strings.Trim(m[1], `"`)] = tomlValue(m[2])
}
}
return out
}
// SetTOMLTable replaces table `name` with the given keys, or appends it.
func SetTOMLTable(path, name string, kvs ...KV) error {
raw, err := Read(path)
if err != nil {
return err
}
block := []string{"[" + name + "]"}
for _, kv := range kvs {
block = append(block, kv.Path+" = "+tomlLiteral(kv.Value))
}
lines := splitLines(string(raw))
from, to := tableSpan(lines, name)
var out []string
if from < 0 {
out = trimBlank(lines)
if len(out) > 0 {
out = append(out, "")
}
out = append(out, block...)
out = append(out, "")
} else {
out = append(out, lines[:from]...)
out = append(out, block...)
if to < len(lines) && strings.TrimSpace(lines[to]) != "" {
out = append(out, "")
}
out = append(out, lines[to:]...)
}
return WriteAtomic(path, []byte(joinLines(out)))
}
// DelTOMLTable removes table `name` (header and body) if present.
func DelTOMLTable(path, name string) error {
raw, err := Read(path)
if err != nil || raw == nil {
return err
}
lines := splitLines(string(raw))
from, to := tableSpan(lines, name)
if from < 0 {
return nil
}
// Take the blank lines before the header with it so no gap is left.
for from > 0 && strings.TrimSpace(lines[from-1]) == "" {
from--
}
out := append([]string{}, lines[:from]...)
if to < len(lines) && len(out) > 0 && strings.TrimSpace(lines[to]) != "" {
out = append(out, "")
}
out = append(out, lines[to:]...)
return WriteAtomic(path, []byte(joinLines(out)))
}
// DelTOMLTop removes top-level (pre-table) keys.
func DelTOMLTop(path string, keys ...string) error {
raw, err := Read(path)
if err != nil || raw == nil {
return err
}
drop := map[string]bool{}
for _, k := range keys {
drop[k] = true
}
var out []string
header := true
for _, line := range splitLines(string(raw)) {
if header && tomlTable.MatchString(line) {
header = false
}
if header {
if m := tomlKV.FindStringSubmatch(line); m != nil && drop[strings.Trim(m[1], `"`)] {
continue
}
}
out = append(out, line)
}
return WriteAtomic(path, []byte(joinLines(out)))
}
// tableSpan returns the line range [from, to) of a table: its header line
// through the line before the next header. from is -1 when absent.
func tableSpan(lines []string, name string) (int, int) {
from := -1
for i, line := range lines {
m := tomlHeader.FindStringSubmatch(line)
if m == nil || strings.HasPrefix(m[1], "[") {
continue
}
if from >= 0 {
return from, i
}
if m[1] == name {
from = i
}
}
if from < 0 {
return -1, -1
}
return from, len(lines)
}
func trimBlank(lines []string) []string {
for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" {
lines = lines[:len(lines)-1]
}
return lines
}
func tomlLiteral(v any) string {
switch x := v.(type) {
case bool:
return strconv.FormatBool(x)
case int:
return strconv.Itoa(x)
}
return strconv.Quote(toString(v))
}
+86
View File
@@ -0,0 +1,86 @@
package edit
import (
"os"
"path/filepath"
"strings"
"testing"
)
const tomlDoc = `model = "gpt-6-astra" # keep
approval_policy = "never"
[notice]
hide = true
[projects."/x"]
trust_level = "trusted"
`
func tmpToml(t *testing.T) string {
t.Helper()
p := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(p, []byte(tomlDoc), 0o644); err != nil {
t.Fatal(err)
}
return p
}
func TestTOMLTables(t *testing.T) {
p := tmpToml(t)
got := strings.Join(TOMLTables(p), ",")
if got != `notice,projects."/x"` {
t.Fatalf("tables = %q", got)
}
}
func TestSetAndDelTOMLTable(t *testing.T) {
p := tmpToml(t)
if err := SetTOMLTable(p, "model_providers.deepseek", KV{Path: "name", Value: "DeepSeek"}, KV{Path: "wire_api", Value: "responses"}); err != nil {
t.Fatal(err)
}
kv := GetTOMLTable(p, "model_providers.deepseek")
if kv["name"] != "DeepSeek" || kv["wire_api"] != "responses" {
t.Fatalf("table = %v", kv)
}
// replace in place, other tables intact
if err := SetTOMLTable(p, "model_providers.deepseek", KV{Path: "name", Value: "DS"}); err != nil {
t.Fatal(err)
}
raw, _ := os.ReadFile(p)
s := string(raw)
if strings.Count(s, "[model_providers.deepseek]") != 1 || strings.Contains(s, "wire_api") || !strings.Contains(s, "trust_level") || !strings.Contains(s, "# keep") {
t.Fatalf("after replace:\n%s", s)
}
if err := DelTOMLTable(p, "model_providers.deepseek"); err != nil {
t.Fatal(err)
}
raw, _ = os.ReadFile(p)
if string(raw) != tomlDoc {
t.Fatalf("delete did not restore:\n%s", raw)
}
}
func TestSetTOMLTableMiddle(t *testing.T) {
p := tmpToml(t)
if err := SetTOMLTable(p, "notice", KV{Path: "hide", Value: false}, KV{Path: "n", Value: 3}); err != nil {
t.Fatal(err)
}
raw, _ := os.ReadFile(p)
want := "[notice]\nhide = false\nn = 3\n\n[projects.\"/x\"]"
if !strings.Contains(string(raw), want) {
t.Fatalf("got:\n%s", raw)
}
}
func TestDelTOMLTop(t *testing.T) {
p := tmpToml(t)
if err := DelTOMLTop(p, "model", "hide"); err != nil {
t.Fatal(err)
}
raw, _ := os.ReadFile(p)
s := string(raw)
if strings.Contains(s, "gpt-6-astra") || !strings.Contains(s, "hide = true") || !strings.HasPrefix(s, "approval_policy") {
t.Fatalf("got:\n%s", s)
}
}
+203
View File
@@ -0,0 +1,203 @@
// Package gui hosts the desktop app: a tray panel and a regular window that
// share one small web UI. The UI talks to Go over a tiny JSON API served by
// the same handler that serves the static assets, so no binding generator or
// bundler is involved.
package gui
import (
"context"
"embed"
"encoding/json"
"io/fs"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/yetone/dial/internal/agent"
"github.com/yetone/dial/internal/catalog"
"github.com/yetone/dial/internal/profile"
)
//go:embed assets
var assets embed.FS
// Windows is what the API needs from the host application.
type Windows interface {
HidePanel()
ShowMain()
Quit()
// OpenURL hands a link to the system browser.
OpenURL(url string)
// FitPanel asks for the panel to be tall enough for its content.
FitPanel(height int)
}
type fieldJSON struct {
Key string `json:"key"`
Label string `json:"label"`
Value string `json:"value"`
Options []agent.Option `json:"options"`
}
type agentJSON struct {
ID string `json:"id"`
Name string `json:"name"`
Icon string `json:"icon"`
Path string `json:"path"`
Fields []fieldJSON `json:"fields"`
}
type profileJSON struct {
Name string `json:"name"`
Summary string `json:"summary"`
}
type stateJSON struct {
Agents []agentJSON `json:"agents"`
Profiles []profileJSON `json:"profiles"`
Catalog string `json:"catalog"`
Notice string `json:"notice,omitempty"` // advice after a change, e.g. "restart Codex"
}
// Handler serves the embedded UI and the JSON API.
func Handler(w Windows) http.Handler {
static, _ := fs.Sub(assets, "assets")
mux := http.NewServeMux()
mux.Handle("/", http.FileServer(http.FS(static)))
mux.HandleFunc("GET /api/state", func(rw http.ResponseWriter, r *http.Request) {
writeJSON(rw, state())
})
mux.HandleFunc("POST /api/set", func(rw http.ResponseWriter, r *http.Request) {
var in struct {
Agent, Field, Value string
Key struct{ Env, Value string } // API key to store first, if the option asked for one
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
fail(rw, err)
return
}
if in.Key.Env != "" && in.Key.Value != "" {
if err := agent.SetKey(in.Key.Env, in.Key.Value); err != nil {
fail(rw, err)
return
}
}
a, err := agent.Find(in.Agent)
if err != nil {
fail(rw, err)
return
}
f := a.Field(in.Field)
if f == nil {
http.Error(rw, "unknown field", http.StatusBadRequest)
return
}
if err := f.Set(strings.TrimSpace(in.Value)); err != nil {
fail(rw, err)
return
}
s := state()
if a.Notice != nil {
s.Notice = a.Notice()
}
writeJSON(rw, s)
})
mux.HandleFunc("POST /api/profile/{action}", func(rw http.ResponseWriter, r *http.Request) {
var in struct{ Name string }
_ = json.NewDecoder(r.Body).Decode(&in)
in.Name = strings.TrimSpace(in.Name)
var err error
changed := 0
switch r.PathValue("action") {
case "save":
err = profile.Save(in.Name, profile.Snapshot())
case "use":
var ps map[string]profile.Profile
if ps, err = profile.Load(); err == nil {
changed, err = profile.Apply(ps[in.Name])
}
case "delete":
err = profile.Delete(in.Name)
default:
http.NotFound(rw, r)
return
}
if err != nil {
fail(rw, err)
return
}
s := state()
writeJSON(rw, struct {
stateJSON
Changed int `json:"changed"`
}{s, changed})
})
mux.HandleFunc("POST /api/sync", func(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
if err := catalog.Sync(ctx); err != nil {
fail(rw, err)
return
}
writeJSON(rw, state())
})
providerRoutes(mux, w)
mux.HandleFunc("POST /api/window/{action}", func(rw http.ResponseWriter, r *http.Request) {
switch r.PathValue("action") {
case "hide":
w.HidePanel()
case "main":
w.ShowMain()
case "quit":
w.Quit()
case "fit":
if h, err := strconv.Atoi(r.URL.Query().Get("h")); err == nil {
w.FitPanel(h)
}
}
rw.WriteHeader(http.StatusNoContent)
})
return mux
}
func state() stateJSON {
s := stateJSON{Agents: []agentJSON{}, Profiles: []profileJSON{}, Catalog: catalog.Source()}
for _, a := range agent.Detected() {
vals := a.Values()
aj := agentJSON{ID: a.ID, Name: a.Name, Icon: a.Icon, Path: tilde(a.Path)}
for _, f := range a.Fields {
opts := f.Options(vals)
if opts == nil {
opts = []agent.Option{}
}
aj.Fields = append(aj.Fields, fieldJSON{Key: f.Key, Label: f.Label, Value: vals[f.Key], Options: opts})
}
s.Agents = append(s.Agents, aj)
}
if ps, err := profile.Load(); err == nil {
for _, n := range profile.Names(ps) {
s.Profiles = append(s.Profiles, profileJSON{Name: n, Summary: profile.Summary(ps[n])})
}
}
return s
}
func writeJSON(rw http.ResponseWriter, v any) {
rw.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(rw).Encode(v)
}
func fail(rw http.ResponseWriter, err error) {
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(rw).Encode(map[string]string{"error": err.Error()})
}
func tilde(p string) string {
if home, err := os.UserHomeDir(); err == nil && strings.HasPrefix(p, home) {
return "~" + p[len(home):]
}
return p
}
+131
View File
@@ -0,0 +1,131 @@
package gui
import (
_ "embed"
"os"
"runtime"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
)
//go:embed tray.png
var trayIcon []byte // black glyph, tinted by the macOS menu bar
//go:embed icon.png
var appIcon []byte // coloured, for other trays and the about box
type host struct {
app *application.App
panel *application.WebviewWindow
main *application.WebviewWindow
tray *application.SystemTray
panelHeight int
}
func (h *host) HidePanel() { h.panel.Hide() }
func (h *host) ShowMain() {
h.panel.Hide()
h.main.Show()
h.main.Focus()
}
func (h *host) Quit() { h.app.Quit() }
func (h *host) OpenURL(url string) { _ = h.app.Browser.OpenURL(url) }
const panelWidth, panelMin, panelMax = 440, 220, 720
// FitPanel grows or shrinks the panel to its content and keeps it anchored
// under the tray icon.
func (h *host) FitPanel(height int) {
height = max(panelMin, min(panelMax, height))
if h.panelHeight == height {
return
}
h.panelHeight = height
h.panel.SetSize(panelWidth, height)
if h.panel.IsVisible() {
_ = h.tray.PositionWindow(h.panel, 6)
}
}
// Run starts the desktop app: a menu bar icon whose click drops down a compact
// panel, plus a regular window for when you want it to stay around.
// showMain opens the window immediately; otherwise only the tray icon appears.
func Run(version string, showMain bool) error {
// DIAL_THEME=light|dark forces the palette; handy for screenshots.
theme := ""
if t := os.Getenv("DIAL_THEME"); t != "" {
theme = "&theme=" + t
}
h := &host{}
h.app = application.New(application.Options{
Name: "dial",
Description: "one dial for every coding agent's model",
Icon: appIcon,
Assets: application.AssetOptions{Handler: Handler(h)},
Mac: application.MacOptions{ActivationPolicy: application.ActivationPolicyAccessory},
Windows: application.WindowsOptions{DisableQuitOnLastWindowClosed: true},
})
h.panel = h.app.Window.NewWithOptions(application.WebviewWindowOptions{
Name: "panel",
Title: "dial",
URL: "/?mode=panel" + theme,
Width: panelWidth,
Height: 520,
Hidden: true,
Frameless: true,
AlwaysOnTop: true,
DisableResize: true,
HideOnEscape: true,
HideOnFocusLost: true,
BackgroundType: application.BackgroundTypeTranslucent,
Mac: application.MacWindow{
Backdrop: application.MacBackdropTranslucent,
CornerRadius: 12,
},
Windows: application.WindowsWindow{HiddenOnTaskbar: true},
})
h.main = h.app.Window.NewWithOptions(application.WebviewWindowOptions{
Name: "main",
Title: "dial",
URL: "/?" + theme,
Width: 660,
Height: 600,
MinWidth: 560,
MinHeight: 420,
Hidden: true,
Mac: application.MacWindow{
TitleBar: application.MacTitleBarHiddenInset,
InvisibleTitleBarHeight: 44,
},
})
// Closing the window keeps the tray alive; quitting is a menu action.
h.main.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
h.main.Hide()
e.Cancel()
})
menu := h.app.NewMenu()
menu.Add("Open dial").OnClick(func(*application.Context) { h.ShowMain() })
menu.AddSeparator()
menu.Add("Version " + version).SetEnabled(false)
menu.Add("Quit dial").OnClick(func(*application.Context) { h.app.Quit() })
h.tray = h.app.SystemTray.New()
h.tray.SetTooltip("dial")
if runtime.GOOS == "darwin" {
h.tray.SetTemplateIcon(trayIcon)
} else {
h.tray.SetIcon(appIcon)
}
h.tray.SetMenu(menu)
h.tray.AttachWindow(h.panel).WindowOffset(6)
if showMain {
h.app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) { h.ShowMain() })
}
return h.app.Run()
}
+303
View File
@@ -0,0 +1,303 @@
/* dial — one quiet surface, real logos, controls that read as pop-up buttons. */
:root {
--bg: #f4f4f6;
--card: #ffffff;
--card-2: #f9f9fb; /* expanded editor */
--pill: #f1f1f4;
--pill-hover: #e9e9ee;
--line: #e3e3e8;
--line-2: #ececf0;
--fg: #1c1c21;
--fg-2: #4b4b53;
--muted: #85858d;
--faint: #b9b9c2;
--accent: #4f46e5;
--accent-soft: #eeedfd;
--accent-fg: #ffffff;
--sel: #ecebfb;
--green: #189a58;
--green-soft: #e4f6ec;
--red: #d64545;
--red-soft: #fbe9e9;
--amber: #b45309;
--amber-soft: #fbf0df;
--pop-bg: rgba(255, 255, 255, .94);
--shadow: 0 16px 44px rgba(20, 22, 30, .16), 0 2px 8px rgba(20, 22, 30, .06), 0 0 0 .5px rgba(20, 22, 30, .1);
--mono-l: 40%; --mono-a: .13;
--font: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif;
--code: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
--logo-invert: 0;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #1a1a1e;
--card: #232327;
--card-2: #1f1f23;
--pill: #2d2d33;
--pill-hover: #36363d;
--line: #333339;
--line-2: #2c2c32;
--fg: #ededf1;
--fg-2: #b9b9c1;
--muted: #8b8b94;
--faint: #56565f;
--accent: #a5b4fc;
--accent-soft: #2b2c47;
--accent-fg: #14142a;
--sel: #33345a;
--green: #6fd99b;
--green-soft: #1e3a2b;
--red: #f28b82;
--red-soft: #45272a;
--amber: #f2b544;
--amber-soft: #3f3220;
--pop-bg: rgba(38, 38, 43, .96);
--shadow: 0 16px 44px rgba(0, 0, 0, .55), 0 2px 8px rgba(0, 0, 0, .3), 0 0 0 .5px rgba(255, 255, 255, .1);
--mono-l: 74%; --mono-a: .18;
--logo-invert: 1;
}
}
:root[data-theme="dark"] {
--bg: #1a1a1e; --card: #232327; --card-2: #1f1f23; --pill: #2d2d33; --pill-hover: #36363d;
--line: #333339; --line-2: #2c2c32; --fg: #ededf1; --fg-2: #b9b9c1; --muted: #8b8b94; --faint: #56565f;
--accent: #a5b4fc; --accent-soft: #2b2c47; --accent-fg: #14142a; --sel: #33345a;
--green: #6fd99b; --green-soft: #1e3a2b; --red: #f28b82; --red-soft: #45272a; --amber: #f2b544; --amber-soft: #3f3220;
--pop-bg: rgba(38, 38, 43, .96);
--shadow: 0 16px 44px rgba(0, 0, 0, .55), 0 2px 8px rgba(0, 0, 0, .3), 0 0 0 .5px rgba(255, 255, 255, .1);
--mono-l: 74%; --mono-a: .18; --logo-invert: 1;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
font: 13px/1.4 var(--font);
color: var(--fg);
background: var(--bg);
display: flex;
flex-direction: column;
overflow: hidden;
-webkit-font-smoothing: antialiased;
user-select: none;
-webkit-user-select: none;
cursor: default;
}
/* The tray panel sits on a frosted NSVisualEffectView, so its own paint is thin. */
body.panel { background: color-mix(in srgb, var(--bg) 80%, transparent); border-radius: 12px; }
button { font: inherit; color: inherit; background: none; border: 0; cursor: default; }
button:focus-visible, input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
input { font: inherit; color: var(--fg); }
.grow { flex: 1; }
[hidden] { display: none !important; }
code { font: 11.5px var(--code); }
/* ---- brand icons: colour ones are images, mono ones take the text colour --- */
.ic { display: inline-flex; flex: none; align-items: center; justify-content: center; }
.ic img { width: 100%; height: 100%; object-fit: contain; display: block; }
.ic .mask { width: 100%; height: 100%; background: currentColor; -webkit-mask: var(--i) center / contain no-repeat; mask: var(--i) center / contain no-repeat; }
.ic.monogram {
border-radius: 30%;
font-weight: 700; letter-spacing: .02em;
color: hsl(var(--h) 55% var(--mono-l));
background: hsl(var(--h) 65% 55% / var(--mono-a));
}
/* ---- header --------------------------------------------------------------- */
.top { display: flex; align-items: center; gap: 8px; padding: 0 10px 0 14px; height: 46px; flex: none; position: relative; }
body.window .top { padding-left: 88px; }
.brand { display: flex; align-items: center; gap: 6px; font-weight: 600; letter-spacing: .01em; }
.logo { color: var(--accent); display: inline-flex; }
.seg {
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
display: inline-flex; padding: 2px; border-radius: 8px;
background: var(--pill);
}
body.panel .seg { display: none; }
.seg button { height: 24px; padding: 0 12px; border-radius: 6px; font-size: 12.5px; color: var(--muted); transition: background .15s, color .15s, box-shadow .15s; }
.seg button.on { background: var(--card); color: var(--fg); box-shadow: 0 1px 2px rgba(0, 0, 0, .12), 0 0 0 .5px rgba(0, 0, 0, .06); }
.actions { margin-left: auto; display: flex; gap: 2px; }
.icon {
width: 26px; height: 26px; border-radius: 7px;
display: inline-flex; align-items: center; justify-content: center;
color: var(--muted);
transition: background .12s, color .12s;
}
.icon:hover { background: var(--pill); color: var(--fg); }
.icon.spin svg { animation: spin .9s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ---- lists ---------------------------------------------------------------- */
.view { flex: 1; min-height: 0; overflow-y: auto; padding: 0 12px 4px; display: flex; flex-direction: column; }
body.window .view { padding: 2px 16px 8px; }
.view::-webkit-scrollbar { width: 0; }
.list { background: var(--card); border: 1px solid var(--line); border-radius: 12px; overflow: hidden; flex: none; }
.row { display: flex; align-items: center; gap: 10px; min-height: 42px; padding: 6px 10px 6px 12px; }
body.window .row { min-height: 48px; padding: 7px 12px 7px 14px; gap: 12px; }
.row + .row, .editor + .row, .row + .editor { border-top: 1px solid var(--line-2); }
.row .ic { width: 20px; height: 20px; font-size: 8.5px; }
body.window .row .ic { width: 22px; height: 22px; font-size: 9px; }
.row .name { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
.row .who { min-width: 0; flex: 1 1 auto; }
body.panel .agent .who { flex: 0 0 auto; max-width: 150px; }
.row .sub { font-size: 11.5px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; }
.empty-state { padding: 36px 20px; text-align: center; color: var(--muted); }
.empty-state b { display: block; color: var(--fg); font-weight: 500; margin-bottom: 4px; }
/* an agent row: logo, name, then one pop-up button per setting */
.agent .who { flex: 0 1 auto; max-width: 42%; }
.fields { display: flex; flex-wrap: wrap; gap: 4px 5px; justify-content: flex-end; min-width: 0; margin-left: auto; }
.field {
display: inline-flex; align-items: center; gap: 5px;
max-width: 100%; min-width: 0;
height: 25px; padding: 0 6px 0 8px;
border-radius: 7px;
background: var(--pill);
box-shadow: inset 0 0 0 1px var(--line-2);
font-size: 12.5px;
transition: background .12s, box-shadow .12s;
}
body.panel .field { height: 24px; font-size: 12px; }
.field:hover, .field.open { background: var(--pill-hover); box-shadow: inset 0 0 0 1px var(--line); }
.field.open { box-shadow: inset 0 0 0 1px var(--accent); }
.field .ic { width: 14px; height: 14px; font-size: 6.5px; }
.field .k { color: var(--muted); font-size: 11.5px; }
.field .v { min-width: 0; flex: 0 1 auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-variant-numeric: tabular-nums; }
.field .v.empty { color: var(--muted); }
.field .chev { color: var(--faint); display: inline-flex; margin-left: -1px; }
.field:hover .chev, .field.open .chev { color: var(--muted); }
.field.flash { animation: flash 1.2s ease-out; }
@keyframes flash { 0%, 35% { box-shadow: inset 0 0 0 1px var(--green); background: var(--green-soft); } 100% {} }
/* ---- profiles ------------------------------------------------------------- */
.profiles { padding: 8px 2px 0; flex: none; }
.row-head { display: flex; align-items: center; height: 26px; }
.label { font-size: 11px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase; color: var(--muted); }
.text { font-size: 12px; color: var(--muted); padding: 3px 7px; border-radius: 6px; transition: background .12s, color .12s; white-space: nowrap; }
.text:hover { color: var(--fg); background: var(--pill); }
.text.primary { color: var(--accent-fg); background: var(--accent); font-weight: 500; }
.text.primary:hover { filter: brightness(1.08); }
.text.danger:hover { color: var(--red); background: var(--red-soft); }
.chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 4px 0 6px; min-height: 12px; }
.chip {
display: inline-flex; align-items: center; gap: 2px;
height: 25px; padding: 0 5px 0 10px;
border-radius: 999px;
border: 1px solid var(--line);
background: var(--card);
font-size: 12px;
transition: border-color .12s, background .12s, color .12s;
}
.chip:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
.chip .x { width: 16px; height: 16px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-size: 12px; line-height: 1; opacity: 0; transition: opacity .12s; }
.chip:hover .x { opacity: .6; }
.chip .x:hover { opacity: 1; background: rgba(127, 127, 127, .2); }
.chip-input {
height: 25px; padding: 0 10px; border-radius: 999px; border: 1px solid var(--accent);
background: var(--card); outline: none; width: 140px; font-size: 12px;
}
.chips .hint { font-size: 12px; color: var(--faint); line-height: 25px; }
/* ---- footer --------------------------------------------------------------- */
.foot { display: flex; align-items: center; gap: 2px; padding: 0 8px 6px 14px; min-height: 28px; flex: none; }
body.window .foot { padding-left: 18px; }
.status { font-size: 11.5px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: opacity .3s; }
.status.ok { color: var(--green); }
.status.err { color: var(--red); }
.status.warn { color: var(--amber); }
/* ---- picker popover ------------------------------------------------------- */
.pop {
position: fixed; z-index: 10;
width: 320px; max-height: 340px;
display: flex; flex-direction: column;
background: var(--pop-bg);
-webkit-backdrop-filter: blur(24px); backdrop-filter: blur(24px);
border-radius: 11px;
box-shadow: var(--shadow);
overflow: hidden;
transform-origin: top left;
animation: pop .14s cubic-bezier(.2, .8, .3, 1);
}
.pop.up { transform-origin: bottom left; }
@keyframes pop { from { opacity: 0; transform: scale(.97) translateY(-3px); } }
.pop .search { display: flex; align-items: center; gap: 8px; padding: 0 12px; border-bottom: 1px solid var(--line-2); color: var(--faint); }
.pop input { flex: 1; background: none; border: 0; outline: none; height: 36px; font-size: 13px; }
.pop input::placeholder { color: var(--faint); }
.pop ul { list-style: none; overflow-y: auto; padding: 5px; }
.pop li {
display: flex; align-items: center; gap: 9px;
height: 30px; padding: 0 8px; border-radius: 7px;
white-space: nowrap; font-size: 12.5px;
}
.pop li .ic { width: 16px; height: 16px; font-size: 7px; }
.pop li .v { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; font-variant-numeric: tabular-nums; }
.pop li .n { flex: 1 1 0; min-width: 0; font-size: 11.5px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; }
.pop li .n.nokey { color: var(--faint); }
.pop li.sel { background: var(--sel); }
.pop li .check { color: var(--accent); display: none; margin-left: auto; flex: none; }
.pop li.cur .check { display: inline-flex; }
.pop li.custom .v { color: var(--fg); }
.pop li.custom .n { color: var(--muted); }
.pop .none { padding: 10px 12px; color: var(--muted); font-size: 12px; white-space: normal; line-height: 1.45; }
.pop.keying .search { border-bottom-color: var(--accent); color: var(--accent); }
.pop .none b { color: var(--fg); font-weight: 500; }
/* ---- providers page ------------------------------------------------------- */
.provider .who { flex: 1 1 auto; }
.provider .name .badge { margin-left: 6px; }
.badge { font-size: 10px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; padding: 1px 5px; border-radius: 4px; background: var(--pill); color: var(--muted); vertical-align: 1px; }
.provider.selected { background: var(--card-2); }
.uses { display: flex; align-items: center; gap: 3px; margin-left: auto; }
.use {
width: 24px; height: 24px; border-radius: 7px;
display: inline-flex; align-items: center; justify-content: center;
opacity: .32; filter: grayscale(1);
transition: opacity .12s, background .12s, filter .12s;
}
.use .ic { width: 16px; height: 16px; font-size: 7px; }
.use:hover { opacity: 1; filter: none; background: var(--pill); }
.use.on { opacity: 1; filter: none; background: var(--accent-soft); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent); }
.key { display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; color: var(--muted); white-space: nowrap; min-width: 150px; justify-content: flex-end; }
.key.none { color: var(--amber); }
.key::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--amber); opacity: .7; }
.key.on { color: var(--fg-2); }
.key.on::before { background: var(--green); }
.provider .chev { color: var(--faint); display: inline-flex; transition: transform .15s; }
.provider.selected .chev { transform: rotate(90deg); }
.after-list { display: flex; align-items: center; gap: 8px; padding: 8px 4px 0; }
.hidden-line { font-size: 11.5px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.hidden-line button { color: var(--muted); text-decoration: underline; text-decoration-color: var(--faint); text-underline-offset: 2px; }
.hidden-line button:hover { color: var(--fg); }
/* the inline editor under a provider row */
.editor { background: var(--card-2); padding: 12px 14px 12px 48px; display: grid; grid-template-columns: 96px 1fr; gap: 8px 12px; align-items: center; }
body.panel .editor { padding-left: 14px; }
.editor label { font-size: 12px; color: var(--muted); text-align: right; }
.editor .hint { font-size: 11px; color: var(--faint); margin-top: -2px; }
.editor input[type="text"], .editor input[type="password"], .editor input[type="url"] {
height: 27px; padding: 0 9px; border-radius: 7px;
border: 1px solid var(--line); background: var(--card);
font-size: 12.5px; outline: none; width: 100%; min-width: 0;
transition: border-color .12s, box-shadow .12s;
}
.editor input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); }
.editor input:disabled { color: var(--muted); background: var(--pill); }
.editor input::placeholder { color: var(--faint); }
.editor .pair { display: flex; gap: 8px; align-items: center; }
.editor .pair input:first-child { flex: 1; }
.editor .pair .env { flex: 0 0 150px; font-family: var(--code); font-size: 11.5px; }
.editor .side { display: flex; gap: 2px; align-items: center; flex: none; }
.editor .side .text { padding: 3px 6px; }
.editor .links { display: flex; gap: 12px; font-size: 12px; }
.editor .links button { color: var(--accent); }
.editor .links button:hover { text-decoration: underline; text-underline-offset: 2px; }
.editor .results { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; min-height: 20px; font-size: 12px; }
.res { display: inline-flex; align-items: center; gap: 5px; height: 22px; padding: 0 8px; border-radius: 6px; font-size: 11.5px; background: var(--pill); color: var(--muted); }
.res.ok { background: var(--green-soft); color: var(--green); }
.res.bad { background: var(--red-soft); color: var(--red); }
.res .ms { opacity: .75; font-variant-numeric: tabular-nums; }
.editor .bar { grid-column: 1 / -1; display: flex; align-items: center; gap: 4px; padding-top: 4px; border-top: 1px solid var(--line-2); margin-top: 4px; }
.editor .bar .grow { flex: 1; }
.editor .models { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--fg-2); }
.editor .models .muted { color: var(--muted); }
.editor .models .text { padding: 2px 6px; }
.busy { opacity: .55; pointer-events: none; }
+631
View File
@@ -0,0 +1,631 @@
// dial — one state object per view, rendered into a list. No framework.
const $ = (s) => document.querySelector(s);
const params = new URLSearchParams(location.search);
const mode = params.get("mode") || "window";
document.body.classList.add(mode);
if (params.get("theme")) document.documentElement.dataset.theme = params.get("theme");
let state = { agents: [], profiles: [], catalog: "" };
let providers = null; // { providers, hidden, catalogs }
let view = "agents";
let pick = null; // { agent, field, options, items, cursor, anchor, key?, keyFor?, onCommit? }
let editing = null; // provider id being edited, "" for a new one
let draft = null; // the editor's working copy
async function api(path, body) {
const res = await fetch("/api/" + path, {
method: body === undefined ? "GET" : "POST",
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
if (res.status === 204) return null;
const data = await res.json();
if (!res.ok) throw new Error(data.error || res.statusText);
return data;
}
function el(tag, cls, text) {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (text !== undefined) e.textContent = text;
return e;
}
function svg(d, size = 12, stroke = 1.6) {
const s = document.createElementNS("http://www.w3.org/2000/svg", "svg");
s.setAttribute("viewBox", "0 0 16 16");
s.setAttribute("width", size);
s.setAttribute("height", size);
s.innerHTML = `<path d="${d}" fill="none" stroke="currentColor" stroke-width="${stroke}" stroke-linecap="round" stroke-linejoin="round"/>`;
return s;
}
const CHEV = "m5.5 6.5 2.5 2.5 2.5-2.5";
const CHEV_R = "m6.5 4.5 3 3.5-3 3.5";
const CHECK = "m3.5 8.5 3 3 6-7";
// A brand icon: colour logos are images, mono logos take the text colour,
// anything unknown gets a two-letter monogram tinted by its name.
function icon(name, label) {
const e = el("span", "ic");
if (name) {
if (name.endsWith("-color") || name === "crush") {
const img = el("img");
img.src = `icons/${name}.${name === "crush" ? "png" : "svg"}`;
img.alt = "";
img.draggable = false;
e.append(img);
} else {
const m = el("span", "mask");
m.style.setProperty("--i", `url(icons/${name}.svg)`);
e.append(m);
}
return e;
}
const w = (label || "?").split(/\s+/);
e.classList.add("monogram");
e.textContent = (w.length > 1 ? w[0][0] + w[1][0] : (label || "?").slice(0, 2)).toUpperCase();
let h = 0;
for (const ch of label || "") h = (h * 31 + ch.charCodeAt(0)) % 360;
e.style.setProperty("--h", h);
return e;
}
function status(msg, kind = "", ms = 3500) {
const s = $("#status");
s.textContent = msg;
s.title = msg;
s.className = "status " + kind;
clearTimeout(status.t);
if (msg) status.t = setTimeout(() => { s.textContent = ""; s.className = "status"; }, ms);
}
// ---------- agents view ----------
function optionFor(field, value) {
return field.options.find((o) => o.value === value);
}
function renderAgents() {
const list = $("#agents");
list.replaceChildren();
if (!state.agents.length) {
const e = el("div", "empty-state");
e.append(el("b", "", "No coding agents found"), el("span", "", "Install Claude Code, Codex, Gemini CLI, OpenCode… and dial will list them here."));
list.append(e);
}
for (const a of state.agents) {
const row = el("div", "row agent");
row.dataset.id = a.id;
row.title = a.path;
const who = el("div", "who");
who.append(el("div", "name", a.name));
const fields = el("div", "fields");
for (const f of a.fields) {
const b = el("button", "field");
const opt = optionFor(f, f.value);
b.title = `${f.label}: ${f.value || "agent default"}`;
if (opt?.icon || (f.key === "provider" && f.value)) b.append(icon(opt?.icon, opt?.label || f.value));
else if (f.label !== "model" || !f.value) b.append(el("span", "k", f.label));
b.append(el("span", "v" + (f.value ? "" : " empty"), opt?.label || f.value || "default"));
const c = el("span", "chev");
c.append(svg(CHEV, 11, 1.7));
b.append(c);
b.onclick = (ev) => openPicker(a, f, b, ev);
fields.append(b);
}
row.append(icon(a.icon, a.name), who, fields);
list.append(row);
}
const chips = $("#profiles");
chips.replaceChildren();
if (!state.profiles.length) chips.append(el("span", "hint", "Save the current setup to switch everything back in one click."));
for (const p of state.profiles) {
const c = el("button", "chip");
c.title = p.summary;
c.append(el("span", "", p.name));
const x = el("span", "x", "×");
x.title = "Delete profile";
x.onclick = (ev) => { ev.stopPropagation(); profileAction("delete", p.name); };
c.append(x);
c.onclick = () => profileAction("use", p.name);
chips.append(c);
}
fit();
}
// The tray panel has no scrollbars to speak of, so it grows to fit instead.
function fit() {
if (mode !== "panel") return;
const h = $(".top").offsetHeight + $("#agents").offsetHeight + $(".profiles").offsetHeight + $(".foot").offsetHeight + 4;
if (h !== fit.last) { fit.last = h; api("window/fit?h=" + h, {}); }
}
async function load() {
try {
state = await api("state");
renderAgents();
if (view === "providers") await loadProviders();
} catch (e) {
status(e.message, "err");
}
}
// ---------- picker ----------
function score(q, o) {
if (!q) return 1;
const lv = o.value.toLowerCase(), ll = (o.label || "").toLowerCase();
if (lv === q || ll === q) return 100;
if (lv.startsWith(q) || ll.startsWith(q)) return 60;
if (lv.includes(q) || ll.includes(q)) return 40;
let i = 0;
for (const ch of lv) if (ch === q[i]) i++;
if (i === q.length) return 20;
if (o.note && o.note.toLowerCase().includes(q)) return 10;
return 0;
}
function placePop(anchor, w, h) {
const pop = $("#pop");
const r = anchor.getBoundingClientRect(), pad = 8;
pop.style.width = w + "px";
let x = Math.min(r.left, innerWidth - w - pad);
let y = r.bottom + 5;
pop.classList.remove("up");
if (y + h > innerHeight - pad && r.top - 5 - h >= pad) { y = r.top - 5 - h; pop.classList.add("up"); }
else if (y + h > innerHeight - pad) y = Math.max(pad, innerHeight - pad - h);
pop.style.left = Math.max(pad, x) + "px";
pop.style.top = y + "px";
}
function openPicker(agent, field, anchor, ev) {
ev.stopPropagation();
closePicker();
const cur = field.value;
// current value first, then the rest in catalog order
const options = [...field.options];
const i = options.findIndex((o) => o.value === cur);
if (i > 0) options.unshift(...options.splice(i, 1));
else if (i < 0 && cur) options.unshift({ value: cur, note: "current value" });
pick = { agent, field, options, anchor, cursor: 0 };
anchor.classList.add("open");
const pop = $("#pop");
pop.hidden = false;
placePop(anchor, options.some((o) => o.note && o.note !== o.value) ? 360 : 300, 340);
const q = $("#q");
q.value = "";
q.placeholder = field.label === "model" ? "Filter, or type any model id…" : `Filter ${field.label}…`;
filter();
q.focus();
}
function filter() {
if (!pick) return;
const q = $("#q").value.trim().toLowerCase();
const scored = pick.options.map((o) => ({ o, s: score(q, o) })).filter((x) => x.s > 0).sort((a, b) => b.s - a.s);
pick.items = scored.map((x) => x.o);
const typed = $("#q").value.trim();
if (typed && pick.field.label !== "provider" && pick.field.label !== "auth" && !pick.items.some((o) => o.value === typed)) {
pick.items.push({ value: typed, note: "use as typed", custom: true });
}
pick.cursor = 0;
renderList();
}
function renderList() {
const list = $("#list");
list.replaceChildren();
if (!pick.items.length) { list.append(el("div", "none", "No matches.")); return; }
const hasIcons = pick.items.some((o) => o.icon);
pick.items.forEach((o, idx) => {
const li = el("li", (idx === pick.cursor ? "sel" : "") + (o.value === pick.field.value ? " cur" : "") + (o.custom ? " custom" : ""));
if (hasIcons) li.append(icon(o.icon, o.label || o.value));
li.append(el("span", "v", o.label || o.value));
const note = o.note && o.note !== (o.label || o.value) ? o.note : "";
if (note) li.append(el("span", "n" + (o.needKey ? " nokey" : ""), note));
const ck = el("span", "check");
ck.append(svg(CHECK, 12, 1.8));
li.append(ck);
li.onmousemove = () => { if (pick.cursor !== idx) { pick.cursor = idx; renderList(); } };
li.onclick = () => commit(o.value);
list.append(li);
});
list.children[pick.cursor]?.scrollIntoView({ block: "nearest" });
}
function move(d) {
if (!pick || !pick.items.length) return;
pick.cursor = (pick.cursor + d + pick.items.length) % pick.items.length;
renderList();
}
async function commit(value) {
if (!pick || !value) return;
const { agent, field } = pick;
const opt = pick.options.find((o) => o.value === value);
if (opt?.needKey && !pick.key) return askKey(opt);
const key = pick.key;
closePicker();
if (value === field.value && !key) return;
try {
state = await api("set", { agent: agent.id, field: field.key, value, key });
renderAgents();
const b = document.querySelector(`.agent[data-id="${agent.id}"] .field:nth-child(${agent.fields.indexOf(field) + 1})`);
b?.classList.add("flash");
const shown = opt?.label || value;
if (state.notice) status(`${agent.name} → ${shown}. ${state.notice}`, "warn", 9000);
else status(`${agent.name} ${field.label} → ${shown}`, "ok");
if (providers) loadProviders();
} catch (e) {
status(e.message, "err");
}
}
// A provider whose key is nowhere yet: the popover turns into a key prompt.
// The key goes to dial's store, then the switch proceeds as normal.
function askKey(opt) {
const pop = $("#pop");
pop.classList.add("keying");
const q = $("#q");
q.type = "password";
q.value = "";
q.placeholder = `Paste your ${opt.key}, then ↩`;
pick.items = [];
pick.keyFor = opt;
const list = $("#list");
list.replaceChildren();
const li = el("div", "none");
const b = el("b", "", opt.label || opt.value);
li.append(b, ` needs an API key. dial keeps it in ~/.config/dial/keys (mode 0600) and writes it into the agent's config when you switch.`);
list.append(li);
q.focus();
}
function closePicker() {
if (!pick) return;
pick.anchor.classList.remove("open");
const pop = $("#pop");
pop.hidden = true;
pop.classList.remove("keying");
$("#q").type = "text";
pick = null;
}
$("#q").addEventListener("input", () => { if (!pick?.keyFor) filter(); });
$("#q").addEventListener("keydown", (e) => {
if (pick?.keyFor) {
if (e.key === "Enter") {
e.preventDefault();
const v = $("#q").value.trim();
if (!v) return;
pick.key = { env: pick.keyFor.key, value: v };
commit(pick.keyFor.value);
} else if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); closePicker(); }
return;
}
if (e.key === "ArrowDown" || (e.ctrlKey && e.key === "n")) { e.preventDefault(); move(1); }
else if (e.key === "ArrowUp" || (e.ctrlKey && e.key === "p")) { e.preventDefault(); move(-1); }
else if (e.key === "Enter") { e.preventDefault(); commit(pick?.items[pick.cursor]?.value); }
else if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); closePicker(); }
});
document.addEventListener("mousedown", (e) => { if (pick && !$("#pop").contains(e.target)) closePicker(); });
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !pick && mode === "panel") api("window/hide", {});
});
// ---------- profiles ----------
async function profileAction(action, name) {
try {
const data = await api("profile/" + action, { name });
state = data;
renderAgents();
if (action === "use") status(`${name} applied · ${data.changed} setting${data.changed === 1 ? "" : "s"} changed`, "ok");
else if (action === "save") status(`Saved ${name}`, "ok");
else status(`Deleted ${name}`);
} catch (e) {
status(e.message, "err");
}
}
$("#save").onclick = () => {
const chips = $("#profiles");
if (chips.querySelector(".chip-input")) return;
const input = el("input", "chip-input");
input.placeholder = "Profile name";
input.onkeydown = (e) => {
if (e.key === "Enter" && input.value.trim()) profileAction("save", input.value.trim());
else if (e.key === "Escape") input.remove();
e.stopPropagation();
};
input.onblur = () => setTimeout(() => input.remove(), 100);
chips.prepend(input);
input.focus();
};
// ---------- providers view ----------
async function loadProviders() {
providers = await api("providers");
renderProviders();
}
function keyLabel(p) {
if (p.key.state === "env") return `$${p.envKey}`;
if (p.key.state === "stored") return "key saved";
return "no key";
}
function renderProviders() {
const list = $("#providers");
list.replaceChildren();
for (const p of providers.providers) {
const row = el("div", "row provider" + (editing === p.id ? " selected" : ""));
row.dataset.id = p.id;
const who = el("div", "who");
const name = el("div", "name", p.name);
if (!p.builtin) name.append(el("span", "badge", "custom"));
who.append(name, el("div", "sub", p.host + (p.models.count ? ` · ${p.models.count} models` : "")));
const uses = el("div", "uses");
for (const a of p.agents) {
const b = el("button", "use" + (a.current ? " on" : ""));
b.title = a.current ? `${a.name} uses ${p.name}` : `Switch ${a.name} to ${p.name}`;
b.append(icon(a.icon, a.name));
b.onclick = (ev) => { ev.stopPropagation(); switchAgent(a, p, b); };
uses.append(b);
}
const key = el("span", "key " + (p.key.state ? "on" : "none"), keyLabel(p));
key.title = p.key.state ? `${p.envKey} ${p.key.masked}` : `${p.envKey} is not set`;
const chev = el("span", "chev");
chev.append(svg(CHEV_R, 11, 1.7));
row.append(icon(p.icon, p.name), who, uses, key, chev);
row.onclick = () => { editing = editing === p.id ? null : p.id; draft = null; renderProviders(); };
list.append(row);
if (editing === p.id) list.append(renderEditor(p));
}
if (editing === "") list.append(renderEditor());
list.querySelector(".editor")?.scrollIntoView({ block: "nearest" });
const hl = $("#hiddenLine");
hl.replaceChildren();
if (providers.hidden.length) {
hl.append("Hidden: ");
providers.hidden.forEach((h, i) => {
const b = el("button", "", h.name);
b.title = `Show ${h.name} again`;
b.onclick = () => providerAction("reset", { id: h.id }, `${h.name} is back`);
if (i) hl.append(", ");
hl.append(b);
});
}
}
// Switch an agent to a provider straight from the providers page. A missing
// key opens the editor with the key field ready.
async function switchAgent(a, p, btn) {
if (a.current) return;
if (!p.key.state) {
editing = p.id; draft = null;
renderProviders();
status(`${p.name} needs $${p.envKey} first`, "warn");
document.querySelector(".editor input[type=password]")?.focus();
return;
}
btn.classList.add("busy");
try {
state = await api("set", { agent: a.id, field: "provider", value: p.id });
renderAgents();
await loadProviders();
if (state.notice) status(`${a.name} → ${p.name}. ${state.notice}`, "warn", 9000);
else status(`${a.name} → ${p.name}`, "ok");
} catch (e) {
btn.classList.remove("busy");
status(e.message, "err");
}
}
async function providerAction(action, body, okMsg) {
try {
providers = await api("provider/" + action, body);
if (action !== "save" || editing === "") editing = null;
draft = null;
renderProviders();
state = await api("state");
renderAgents();
if (okMsg) status(okMsg, "ok");
} catch (e) {
status(e.message, "err");
}
}
function field(label, control, hint) {
const l = el("label", "", label);
const wrap = el("div");
wrap.append(control);
if (hint) wrap.append(el("div", "hint", hint));
return [l, wrap];
}
function input(value, placeholder, type = "text") {
const i = el("input");
i.type = type;
i.value = value || "";
i.placeholder = placeholder || "";
i.spellcheck = false;
i.autocomplete = "off";
i.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Escape") { editing = null; draft = null; renderProviders(); } };
return i;
}
function renderEditor(p) {
const isNew = !p;
draft = draft || (p ? { ...p, extra: [...p.extra] } : { id: "", name: "", envKey: "", anthropic: "", responses: "", catalog: "", website: "", keysUrl: "", builtin: false, extra: [] });
const ed = el("div", "editor");
ed.onclick = (e) => e.stopPropagation();
const name = input(draft.name, "e.g. My Gateway");
name.oninput = () => { draft.name = name.value; if (isNew) { draft.id = slug(name.value); id.value = draft.id; } };
const id = input(draft.id, "auto");
id.disabled = !isNew;
id.oninput = () => { draft.id = id.value; };
const idWrap = el("div", "pair");
idWrap.append(name, id);
id.style.flex = "0 0 130px";
id.title = "Identifier used in agent configs";
ed.append(...field("Name", idWrap, isNew ? "The id on the right is what agent configs will reference." : ""));
const anth = input(draft.anthropic, "https://…/anthropic", "url");
anth.oninput = () => { draft.anthropic = anth.value; };
ed.append(...field("Anthropic API", anth, "Base URL of a Messages-compatible endpoint. Used by Claude Code."));
const resp = input(draft.responses, "https://…/v1", "url");
resp.oninput = () => { draft.responses = resp.value; };
ed.append(...field("Responses API", resp, "Base URL of an OpenAI Responses-compatible endpoint. Used by Codex."));
const env = input(draft.envKey, "MY_API_KEY");
env.className = "env";
env.oninput = () => { draft.envKey = env.value; };
const key = input("", p?.key?.state === "env" ? `set in your shell · ${p.key.masked}` : p?.key?.state === "stored" ? `saved · ${p.key.masked}` : "paste an API key", "password");
key.oninput = () => { draft.key = key.value; };
const side = el("div", "side");
const eye = el("button", "text", "Show");
eye.onclick = () => { key.type = key.type === "password" ? "text" : "password"; eye.textContent = key.type === "password" ? "Show" : "Hide"; };
side.append(eye);
if (p?.key?.state === "stored") {
const forget = el("button", "text danger", "Forget");
forget.onclick = async () => {
try { providers = await api("key", { env: p.envKey, value: "" }); status(`Forgot $${p.envKey}`); renderProviders(); } catch (e) { status(e.message, "err"); }
};
side.append(forget);
}
const keyWrap = el("div", "pair");
keyWrap.append(key, env, side);
ed.append(...field("API key", keyWrap, "Kept in ~/.config/dial/keys (0600). A variable already in your shell wins."));
const cat = input(draft.catalog, "models.dev provider id, e.g. deepseek");
cat.setAttribute("list", "catalogs");
cat.oninput = () => { draft.catalog = cat.value; };
const dl = el("datalist");
dl.id = "catalogs";
for (const c of providers.catalogs) dl.append(new Option(c));
const catWrap = el("div");
catWrap.append(cat, dl);
ed.append(...field("Catalog", catWrap, "Model names and reasoning levels, used until the vendor's own list is fetched."));
const extra = input(draft.extra.join(", "), "extra model ids, comma separated");
extra.oninput = () => { draft.extra = extra.value.split(/[,\s]+/).filter(Boolean); };
ed.append(...field("Own models", extra));
if (p) {
const m = el("div", "models");
if (p.models.live) m.append(el("span", "", `${p.models.count} models from ${p.host}`), el("span", "muted", `· ${p.models.fetched}`));
else m.append(el("span", "", `${p.models.count} models from the catalog`));
const refresh = el("button", "text", "Refresh");
refresh.title = "Ask the vendor which models it serves";
refresh.onclick = async () => {
refresh.classList.add("busy");
try {
const r = await api("provider/models", { id: p.id });
status(`${p.name}: ${r.models.length} models`, "ok");
await loadProviders();
} catch (e) { status(e.message, "err"); refresh.classList.remove("busy"); }
};
m.append(refresh);
ed.append(...field("Models", m));
}
const links = el("div", "links");
const site = draft.website || (draft.anthropic || draft.responses ? "https://" + hostOf(draft.anthropic || draft.responses) : "");
if (site) { const b = el("button", "", "Website ↗"); b.onclick = () => api("open", { url: site }); links.append(b); }
if (draft.keysUrl) { const b = el("button", "", "Get an API key ↗"); b.onclick = () => api("open", { url: draft.keysUrl }); links.append(b); }
if (links.children.length) ed.append(...field("", links));
const bar = el("div", "bar");
const results = el("div", "results");
if (p) {
const test = el("button", "text", "Test connection");
test.onclick = async () => {
test.classList.add("busy");
results.replaceChildren(el("span", "res", "testing…"));
try {
const r = await api("provider/test", { id: p.id });
results.replaceChildren();
for (const t of r.results) {
const b = el("span", "res " + (t.ok ? "ok" : "bad"));
b.append(el("span", "", `${t.protocol} ${t.ok ? "✓" : "✗"}`));
if (t.ok) b.append(el("span", "ms", `${t.ms} ms`));
else b.append(el("span", "", t.status ? `${t.status} · ${t.error}` : t.error));
b.title = t.model ? `model ${t.model}` : "";
results.append(b);
}
} catch (e) { results.replaceChildren(); status(e.message, "err"); }
test.classList.remove("busy");
};
bar.append(test, results);
}
bar.append(el("span", "grow"));
if (p && p.builtin) {
const reset = el("button", "text", "Reset");
reset.title = "Back to dial's defaults";
reset.onclick = () => providerAction("reset", { id: p.id }, `${p.name} reset`);
const hide = el("button", "text danger", "Hide");
hide.title = "Hide this built-in provider";
hide.onclick = () => providerAction("delete", { id: p.id }, `${p.name} hidden`);
bar.append(reset, hide);
} else if (p) {
const del = el("button", "text danger", "Delete");
del.onclick = () => providerAction("delete", { id: p.id }, `${p.name} deleted`);
bar.append(del);
}
const cancel = el("button", "text", "Cancel");
cancel.onclick = () => { editing = null; draft = null; renderProviders(); };
const save = el("button", "text primary", isNew ? "Add provider" : "Save");
save.onclick = () => {
const body = { id: draft.id, name: draft.name, envKey: draft.envKey, catalog: draft.catalog, anthropic: draft.anthropic, responses: draft.responses, small: draft.small, models: draft.extra, website: draft.website, keysUrl: draft.keysUrl, icon: draft.icon, key: draft.key || "" };
providerAction("save", body, `${draft.name || draft.id} saved`);
};
bar.append(cancel, save);
ed.append(bar);
setTimeout(() => (isNew ? name : null)?.focus(), 0);
return ed;
}
function slug(s) { return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); }
function hostOf(u) { try { return new URL(u.includes("://") ? u : "https://" + u).host; } catch { return ""; } }
$("#addProvider").onclick = () => { editing = ""; draft = null; renderProviders(); };
// ---------- header / footer ----------
function show(v) {
view = v;
for (const b of $("#nav").children) b.classList.toggle("on", b.dataset.view === v);
$("#view-agents").hidden = v !== "agents";
$("#view-providers").hidden = v !== "providers";
closePicker();
if (v === "providers") loadProviders().catch((e) => status(e.message, "err"));
}
for (const b of $("#nav").children) b.onclick = () => { show(b.dataset.view); b.blur(); };
$("#sync").onclick = async () => {
const b = $("#sync");
b.classList.add("spin");
try {
state = await api("sync", {});
renderAgents();
if (providers) await loadProviders();
status("Model catalog refreshed", "ok");
} catch (e) {
status("Sync failed: " + e.message, "err");
} finally {
b.classList.remove("spin");
}
};
$("#open").onclick = () => api("window/main", {});
$("#openMain").onclick = () => api("window/main", {});
$("#quit").onclick = () => api("window/quit", {});
if (mode === "window") { $("#open").remove(); $("#openMain").remove(); $("#quit").remove(); }
else { $("#nav").remove(); }
// Config files may change underneath us (another dial, an editor); reload when
// the panel comes back into view.
document.addEventListener("visibilitychange", () => { if (!document.hidden) load(); });
window.addEventListener("focus", load);
load();
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude Code</title><path clip-rule="evenodd" d="M20.998 10.949H24v3.102h-3v3.028h-1.487V20H18v-2.921h-1.487V20H15v-2.921H9V20H7.488v-2.921H6V20H4.487v-2.921H3V14.05H0V10.95h3V5h17.998v5.949zM6 10.949h1.488V8.102H6v2.847zm10.51 0H18V8.102h-1.49v2.847z" fill="#D97757" fill-rule="evenodd"></path></svg>

After

Width:  |  Height:  |  Size: 424 B

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Codex</title><path d="M19.503 0H4.496A4.496 4.496 0 000 4.496v15.007A4.496 4.496 0 004.496 24h15.007A4.496 4.496 0 0024 19.503V4.496A4.496 4.496 0 0019.503 0z" fill="#fff"></path><path d="M9.064 3.344a4.578 4.578 0 012.285-.312c1 .115 1.891.54 2.673 1.275.01.01.024.017.037.021a.09.09 0 00.043 0 4.55 4.55 0 013.046.275l.047.022.116.057a4.581 4.581 0 012.188 2.399c.209.51.313 1.041.315 1.595a4.24 4.24 0 01-.134 1.223.123.123 0 00.03.115c.594.607.988 1.33 1.183 2.17.289 1.425-.007 2.71-.887 3.854l-.136.166a4.548 4.548 0 01-2.201 1.388.123.123 0 00-.081.076c-.191.551-.383 1.023-.74 1.494-.9 1.187-2.222 1.846-3.711 1.838-1.187-.006-2.239-.44-3.157-1.302a.107.107 0 00-.105-.024c-.388.125-.78.143-1.204.138a4.441 4.441 0 01-1.945-.466 4.544 4.544 0 01-1.61-1.335c-.152-.202-.303-.392-.414-.617a5.81 5.81 0 01-.37-.961 4.582 4.582 0 01-.014-2.298.124.124 0 00.006-.056.085.085 0 00-.027-.048 4.467 4.467 0 01-1.034-1.651 3.896 3.896 0 01-.251-1.192 5.189 5.189 0 01.141-1.6c.337-1.112.982-1.985 1.933-2.618.212-.141.413-.251.601-.33.215-.089.43-.164.646-.227a.098.098 0 00.065-.066 4.51 4.51 0 01.829-1.615 4.535 4.535 0 011.837-1.388zm3.482 10.565a.637.637 0 000 1.272h3.636a.637.637 0 100-1.272h-3.636zM8.462 9.23a.637.637 0 00-1.106.631l1.272 2.224-1.266 2.136a.636.636 0 101.095.649l1.454-2.455a.636.636 0 00.005-.64L8.462 9.23z" fill="url(#lobe-icons-codex-_R_0_)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-codex-_R_0_" x1="12" x2="12" y1="3" y2="21"><stop stop-color="#B1A7FF"></stop><stop offset=".5" stop-color="#7A9DFF"></stop><stop offset="1" stop-color="#3941FF"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cursor</title><path d="M22.106 5.68L12.5.135a.998.998 0 00-.998 0L1.893 5.68a.84.84 0 00-.419.726v11.186c0 .3.16.577.42.727l9.607 5.547a.999.999 0 00.998 0l9.608-5.547a.84.84 0 00.42-.727V6.407a.84.84 0 00-.42-.726zm-.603 1.176L12.228 22.92c-.063.108-.228.064-.228-.061V12.34a.59.59 0 00-.295-.51l-9.11-5.26c-.107-.062-.063-.228.062-.228h18.55c.264 0 .428.286.296.514z"></path></svg>

After

Width:  |  Height:  |  Size: 547 B

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="#3186FF"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-0-_R_0_)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-1-_R_0_)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-2-_R_0_)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-0-_R_0_" x1="7" x2="11" y1="15.5" y2="12"><stop stop-color="#08B962"></stop><stop offset="1" stop-color="#08B962" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-1-_R_0_" x1="8" x2="11.5" y1="5.5" y2="11"><stop stop-color="#F94543"></stop><stop offset="1" stop-color="#F94543" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-2-_R_0_" x1="3.5" x2="17.5" y1="13.5" y2="12"><stop stop-color="#FABC12"></stop><stop offset=".46" stop-color="#FABC12" stop-opacity="0"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini CLI</title><path d="M0 4.391A4.391 4.391 0 014.391 0h15.217A4.391 4.391 0 0124 4.391v15.217A4.391 4.391 0 0119.608 24H4.391A4.391 4.391 0 010 19.608V4.391z" fill="url(#lobe-icons-gemini-cli-_R_0_)"></path><path clip-rule="evenodd" d="M19.74 1.444a2.816 2.816 0 012.816 2.816v15.48a2.816 2.816 0 01-2.816 2.816H4.26a2.816 2.816 0 01-2.816-2.816V4.26A2.816 2.816 0 014.26 1.444h15.48zM7.236 8.564l7.752 3.728-7.752 3.727v2.802l9.557-4.596v-3.866L7.236 5.763v2.801z" fill="#1E1E2E" fill-rule="evenodd"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-cli-_R_0_" x1="24" x2="0" y1="6.587" y2="16.494"><stop stop-color="#EE4D5D"></stop><stop offset=".328" stop-color="#B381DD"></stop><stop offset=".476" stop-color="#207CFE"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 921 B

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>GithubCopilot</title><path d="M19.245 5.364c1.322 1.36 1.877 3.216 2.11 5.817.622 0 1.2.135 1.592.654l.73.964c.21.278.323.61.323.955v2.62c0 .339-.173.669-.453.868C20.239 19.602 16.157 21.5 12 21.5c-4.6 0-9.205-2.583-11.547-4.258-.28-.2-.452-.53-.453-.868v-2.62c0-.345.113-.679.321-.956l.73-.963c.392-.517.974-.654 1.593-.654l.029-.297c.25-2.446.81-4.213 2.082-5.52 2.461-2.54 5.71-2.851 7.146-2.864h.198c1.436.013 4.685.323 7.146 2.864zm-7.244 4.328c-.284 0-.613.016-.962.05-.123.447-.305.85-.57 1.108-1.05 1.023-2.316 1.18-2.994 1.18-.638 0-1.306-.13-1.851-.464-.516.165-1.012.403-1.044.996a65.882 65.882 0 00-.063 2.884l-.002.48c-.002.563-.005 1.126-.013 1.69.002.326.204.63.51.765 2.482 1.102 4.83 1.657 6.99 1.657 2.156 0 4.504-.555 6.985-1.657a.854.854 0 00.51-.766c.03-1.682.006-3.372-.076-5.053-.031-.596-.528-.83-1.046-.996-.546.333-1.212.464-1.85.464-.677 0-1.942-.157-2.993-1.18-.266-.258-.447-.661-.57-1.108-.32-.032-.64-.049-.96-.05zm-2.525 4.013c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zm5 0c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zM7.635 5.087c-1.05.102-1.935.438-2.385.906-.975 1.037-.765 3.668-.21 4.224.405.394 1.17.657 1.995.657h.09c.649-.013 1.785-.176 2.73-1.11.435-.41.705-1.433.675-2.47-.03-.834-.27-1.52-.63-1.813-.39-.336-1.275-.482-2.265-.394zm6.465.394c-.36.292-.6.98-.63 1.813-.03 1.037.24 2.06.675 2.47.968.957 2.136 1.104 2.776 1.11h.044c.825 0 1.59-.263 1.995-.657.555-.556.765-3.187-.21-4.224-.45-.468-1.335-.804-2.385-.906-.99-.088-1.875.058-2.265.394zM12 7.615c-.24 0-.525.015-.84.044.03.16.045.336.06.526l-.001.159a2.94 2.94 0 01-.014.25c.225-.022.425-.027.612-.028h.366c.187 0 .387.006.612.028-.015-.146-.015-.277-.015-.409.015-.19.03-.365.06-.526a9.29 9.29 0 00-.84-.044z"></path></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>GoogleCloud</title><path d="M15.961 7.327l2.086-2.086.14-.879C14.384.905 8.34 1.297 4.913 5.18A9.643 9.643 0 002.88 8.991l.747-.105 4.172-.688.322-.33c1.856-2.038 4.994-2.312 7.137-.578l.703.037z" fill="#EA4335"></path><path d="M21.02 8.93a9.399 9.399 0 00-2.834-4.568L15.258 7.29a5.204 5.204 0 011.91 4.129v.52a2.606 2.606 0 012.607 2.605c0 1.44-1.167 2.577-2.606 2.577h-5.22l-.512.556v3.126l.513.49h5.219c3.743.03 6.802-2.952 6.83-6.695a6.778 6.778 0 00-2.98-5.668z" fill="#4285F4"></path><path d="M6.738 21.293h5.212v-4.172H6.738c-.371 0-.731-.08-1.069-.234l-.74.227-2.1 2.086-.183.71a6.763 6.763 0 004.092 1.383z" fill="#34A853"></path><path d="M6.738 7.759A6.778 6.778 0 002.646 19.91l3.023-3.023a2.606 2.606 0 113.448-3.448l3.023-3.023a6.771 6.771 0 00-5.402-2.657z" fill="#FBBC05"></path></svg>

After

Width:  |  Height:  |  Size: 925 B

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Goose</title><path d="M21.595 23.61c1.167-.254 2.405-.944 2.405-.944l-2.167-1.784a12.124 12.124 0 01-2.695-3.131 12.127 12.127 0 00-3.97-4.049l-.794-.462a1.115 1.115 0 01-.488-.815.844.844 0 01.154-.575c.413-.582 2.548-3.115 2.94-3.44.503-.416 1.065-.762 1.586-1.159.074-.056.148-.112.221-.17.003-.002.007-.004.009-.007.167-.131.325-.272.45-.438.453-.524.563-.988.59-1.193-.061-.197-.244-.639-.753-1.148.319.02.705.272 1.056.569.235-.376.481-.773.727-1.171.165-.266-.08-.465-.086-.471h-.001V3.22c-.007-.007-.206-.25-.471-.086-.567.35-1.134.702-1.639 1.021 0 0-.597-.012-1.305.599a2.464 2.464 0 00-.438.45l-.007.009c-.058.072-.114.147-.17.221-.397.521-.743 1.083-1.16 1.587-.323.391-2.857 2.526-3.44 2.94a.842.842 0 01-.574.153 1.115 1.115 0 01-.815-.488l-.462-.794a12.123 12.123 0 00-4.049-3.97 12.133 12.133 0 01-3.13-2.695L1.332 0S.643 1.238.39 2.405c.352.428 1.27 1.49 2.34 2.302C1.58 4.167.73 3.75.06 3.4c-.103.765-.063 1.92.043 2.816.726.317 1.961.806 3.219 1.066-1.006.236-2.11.278-2.961.262.15.554.358 1.119.64 1.688.119.263.25.52.39.77.452.125 2.222.383 3.164.171l-2.51.897a27.776 27.776 0 002.544 2.726c2.031-1.092 2.494-1.241 4.018-2.238-2.467 2.008-3.108 2.828-3.8 3.67l-.483.678c-.25.351-.469.725-.65 1.117-.61 1.31-1.47 4.1-1.47 4.1-.154.486.202.842.674.674 0 0 2.79-.861 4.1-1.47.392-.182.766-.4 1.118-.65l.677-.483c.227-.187.453-.37.701-.586 0 0 1.705 2.02 3.458 3.349l.896-2.511c-.211.942.046 2.712.17 3.163.252.142.509.272.772.392.569.28 1.134.49 1.688.64-.016-.853.026-1.956.261-2.962.26 1.258.75 2.493 1.067 3.219.895.106 2.051.146 2.816.043a73.87 73.87 0 01-1.308-2.67c.811 1.07 1.874 1.988 2.302 2.34h-.001z"></path></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><path d="M21.846 0a1.923 1.923 0 110 3.846H20.15a.226.226 0 01-.227-.226V1.923C19.923.861 20.784 0 21.846 0z"></path><path d="M11.065 11.199l7.257-7.2c.137-.136.06-.41-.116-.41H14.3a.164.164 0 00-.117.051l-7.82 7.756c-.122.12-.302.013-.302-.179V3.82c0-.127-.083-.23-.185-.23H3.186c-.103 0-.186.103-.186.23V19.77c0 .128.083.23.186.23h2.69c.103 0 .186-.102.186-.23v-3.25c0-.069.025-.135.069-.178l2.424-2.406a.158.158 0 01.205-.023l6.484 4.772a7.677 7.677 0 003.453 1.283c.108.012.2-.095.2-.23v-3.06c0-.117-.07-.212-.164-.227a5.028 5.028 0 01-2.027-.807l-5.613-4.064c-.117-.078-.132-.279-.028-.381z"></path></svg>

After

Width:  |  Height:  |  Size: 786 B

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Minimax</title><defs><linearGradient id="lobe-icons-minimax-_R_0_" x1="0%" x2="100.182%" y1="50.057%" y2="50.057%"><stop offset="0%" stop-color="#E2167E"></stop><stop offset="100%" stop-color="#FE603C"></stop></linearGradient></defs><path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z" fill="url(#lobe-icons-minimax-_R_0_)" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>opencode</title><path d="M16 6H8v12h8V6zm4 16H4V2h16v20z"></path></svg>

After

Width:  |  Height:  |  Size: 235 B

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenRouter</title><path d="M18.654 3.87a5.087 5.087 0 110 10.174L23.7 19.09c.64.641.187 1.737-.72 1.737H8.48a8.479 8.479 0 010-16.958h10.175zM8.479 7.26a5.087 5.087 0 100 10.176 5.087 5.087 0 000-10.175z"></path></svg>

After

Width:  |  Height:  |  Size: 382 B

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Pi</title><path clip-rule="evenodd" d="M1 1h16.5v11H12v5.5H6.5V23H1V1zm5.5 5.5V12H12V6.5H6.5z"></path><path d="M17.5 12H23v11h-5.5V12z"></path></svg>

After

Width:  |  Height:  |  Size: 313 B

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Qwen</title><path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z" fill="url(#lobe-icons-qwen-_R_0_)" fill-rule="nonzero"></path><defs><linearGradient id="lobe-icons-qwen-_R_0_" x1="0%" x2="100%" y1="0%" y2="0%"><stop offset="0%" stop-color="#6336E7" stop-opacity=".84"></stop><stop offset="100%" stop-color="#6F69F7" stop-opacity=".84"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Z.ai</title><path d="M12.105 2L9.927 4.953H.653L2.83 2h9.276zM23.254 19.048L21.078 22h-9.242l2.174-2.952h9.244zM24 2L9.264 22H0L14.736 2H24z"></path></svg>

After

Width:  |  Height:  |  Size: 319 B

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Zhipu</title><path d="M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z" fill="#3859FF" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

+67
View File
@@ -0,0 +1,67 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>dial</title>
<link rel="stylesheet" href="app.css">
</head>
<body>
<header class="top" style="--wails-draggable:drag">
<div class="brand">
<span class="logo" aria-hidden="true"><svg viewBox="0 0 44 44" width="15" height="15"><path d="M22 4.5a17.5 17.5 0 1 1-9.4 32.3M22 4.5A17.5 17.5 0 0 0 12.6 36.8" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round"/><path d="M22 22l5.7-5.7" stroke="currentColor" stroke-width="4.4" stroke-linecap="round"/><circle cx="22" cy="22" r="3.4" fill="currentColor"/></svg></span>
<span>dial</span>
</div>
<nav class="seg" id="nav" style="--wails-draggable:no-drag">
<button data-view="agents" class="on">Agents</button>
<button data-view="providers">Providers</button>
</nav>
<div class="actions" style="--wails-draggable:no-drag">
<button class="icon" id="sync" title="Refresh the model catalog (models.dev)">
<svg viewBox="0 0 16 16" width="14" height="14"><path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9M13.5 2v3h-3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<button class="icon" id="open" title="Open as a window">
<svg viewBox="0 0 16 16" width="14" height="14"><path d="M6 3H3.5A1.5 1.5 0 0 0 2 4.5v8A1.5 1.5 0 0 0 3.5 14h8a1.5 1.5 0 0 0 1.5-1.5V10M9 2h5v5M14 2 8 8" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
</header>
<main class="view" id="view-agents">
<div class="list" id="agents"></div>
<section class="profiles">
<div class="row-head">
<span class="label">Profiles</span>
<span class="grow"></span>
<button class="text" id="save">+ Save current</button>
</div>
<div id="profiles" class="chips"></div>
</section>
</main>
<main class="view" id="view-providers" hidden>
<div class="list" id="providers"></div>
<div class="after-list">
<button class="text" id="addProvider">+ Add provider</button>
<span class="grow"></span>
<span class="hidden-line" id="hiddenLine"></span>
</div>
</main>
<footer class="foot">
<span id="status" class="status"></span>
<span class="grow"></span>
<button class="text" id="openMain">Open dial</button>
<button class="text" id="quit">Quit</button>
</footer>
<div id="pop" class="pop" hidden>
<div class="search">
<svg viewBox="0 0 16 16" width="13" height="13"><circle cx="7" cy="7" r="4.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="m10.5 10.5 3 3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
<input id="q" type="text" spellcheck="false" autocomplete="off" placeholder="Filter…">
</div>
<ul id="list"></ul>
</div>
<script src="app.js"></script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

+183
View File
@@ -0,0 +1,183 @@
package gui
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
"github.com/yetone/dial/internal/agent"
"github.com/yetone/dial/internal/catalog"
)
// The providers page: everything dial knows about a vendor, plus which
// agents can use it and which do right now.
type providerJSON struct {
agent.Provider
Host string `json:"host"`
Extra []string `json:"extra"` // the user's own model ids (Provider.Extra is shadowed by Models below)
Key struct {
State string `json:"state"` // "env", "stored" or ""
Masked string `json:"masked"`
} `json:"key"`
Models struct {
Count int `json:"count"`
Live bool `json:"live"` // the list came from the vendor itself
Fetched string `json:"fetched"` // when, as "3h ago"
} `json:"models"`
Agents []providerAgent `json:"agents"`
}
type providerAgent struct {
ID string `json:"id"`
Name string `json:"name"`
Icon string `json:"icon"`
Current bool `json:"current"` // this agent is on this provider now
}
type providersJSON struct {
Providers []providerJSON `json:"providers"`
Hidden []agent.Provider `json:"hidden"`
Catalogs []string `json:"catalogs"` // models.dev provider ids, for the catalog field
}
func providerInfo(p agent.Provider, agents []*agent.Agent) providerJSON {
out := providerJSON{Provider: p, Host: p.Host(), Extra: p.Extra, Agents: []providerAgent{}}
if out.Extra == nil {
out.Extra = []string{}
}
out.Key.State, out.Key.Masked = agent.KeyState(p.EnvKey)
out.Models.Count = len(p.Models())
if n, t, ok := p.LiveModels(); ok {
out.Models.Live, out.Models.Count, out.Models.Fetched = true, n, ago(t)
}
for _, a := range agents {
f := a.Field("provider")
if f == nil {
continue
}
cur := f.Get()
for _, o := range f.Options(nil) {
if o.Value == p.ID {
out.Agents = append(out.Agents, providerAgent{ID: a.ID, Name: a.Name, Icon: a.Icon, Current: cur == p.ID})
break
}
}
}
return out
}
func providersState() providersJSON {
agents := agent.Detected()
s := providersJSON{Providers: []providerJSON{}, Hidden: agent.HiddenProviders(), Catalogs: catalog.Providers()}
if s.Hidden == nil {
s.Hidden = []agent.Provider{}
}
for _, p := range agent.Providers() {
s.Providers = append(s.Providers, providerInfo(p, agents))
}
return s
}
func ago(t time.Time) string {
d := time.Since(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return strings.TrimSuffix(d.Round(time.Minute).String(), "0s") + " ago"
case d < 48*time.Hour:
return d.Round(time.Hour).String() + " ago"
default:
return t.Format("Jan 2")
}
}
func providerRoutes(mux *http.ServeMux, w Windows) {
mux.HandleFunc("GET /api/providers", func(rw http.ResponseWriter, r *http.Request) {
writeJSON(rw, providersState())
})
mux.HandleFunc("POST /api/provider/{action}", func(rw http.ResponseWriter, r *http.Request) {
var in struct {
agent.Provider
Key string `json:"key"` // API key to store alongside a save, if given
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
fail(rw, err)
return
}
var err error
switch r.PathValue("action") {
case "save":
if err = agent.SaveProvider(in.Provider); err == nil && in.Key != "" {
if p, e := agent.FindProvider(in.ID); e == nil {
err = agent.SetKey(p.EnvKey, in.Key)
}
}
case "delete":
err = agent.DeleteProvider(in.ID)
case "reset":
err = agent.ResetProvider(in.ID)
case "test":
p, e := agent.FindProvider(in.ID)
if e != nil {
fail(rw, e)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
writeJSON(rw, struct {
Results []agent.TestResult `json:"results"`
Provider providerJSON `json:"provider"`
}{p.Test(ctx), providerInfo(*p, agent.Detected())})
return
case "models":
p, e := agent.FindProvider(in.ID)
if e != nil {
fail(rw, e)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
if _, err = p.RefreshModels(ctx); err != nil {
fail(rw, err)
return
}
writeJSON(rw, struct {
Models []catalog.Model `json:"models"`
Provider providerJSON `json:"provider"`
}{p.Models(), providerInfo(*p, agent.Detected())})
return
default:
http.NotFound(rw, r)
return
}
if err != nil {
fail(rw, err)
return
}
writeJSON(rw, providersState())
})
mux.HandleFunc("POST /api/key", func(rw http.ResponseWriter, r *http.Request) {
var in struct{ Env, Value string }
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
fail(rw, err)
return
}
if err := agent.SetKey(in.Env, in.Value); err != nil {
fail(rw, err)
return
}
writeJSON(rw, providersState())
})
mux.HandleFunc("POST /api/open", func(rw http.ResponseWriter, r *http.Request) {
var in struct{ URL string }
_ = json.NewDecoder(r.Body).Decode(&in)
if strings.HasPrefix(in.URL, "https://") || strings.HasPrefix(in.URL, "http://") {
w.OpenURL(in.URL)
}
rw.WriteHeader(http.StatusNoContent)
})
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 482 B

+158
View File
@@ -0,0 +1,158 @@
// Package profile stores named snapshots of every agent's settings so a whole
// setup can be switched in one move.
package profile
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/yetone/dial/internal/agent"
"github.com/yetone/dial/internal/edit"
)
// Profile maps "agent.field" to a value.
type Profile map[string]string
// Path is the profiles file.
func Path() string {
if x := os.Getenv("XDG_CONFIG_HOME"); x != "" {
return filepath.Join(x, "dial", "profiles.json")
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "dial", "profiles.json")
}
// Load reads every profile.
func Load() (map[string]Profile, error) {
b, err := edit.Read(Path())
if err != nil {
return nil, err
}
out := map[string]Profile{}
if len(b) == 0 {
return out, nil
}
if err := json.Unmarshal(b, &out); err != nil {
return nil, fmt.Errorf("%s: %w", Path(), err)
}
return out, nil
}
// Names lists profiles alphabetically.
func Names(ps map[string]Profile) []string {
names := make([]string, 0, len(ps))
for n := range ps {
names = append(names, n)
}
sort.Strings(names)
return names
}
func store(ps map[string]Profile) error {
b, err := json.MarshalIndent(ps, "", " ")
if err != nil {
return err
}
return edit.WriteAtomic(Path(), append(b, '\n'))
}
// Snapshot captures the current value of every detected agent's fields.
func Snapshot() Profile {
p := Profile{}
for _, a := range agent.Detected() {
for k, v := range a.Values() {
if v != "" {
p[a.ID+"."+k] = v
}
}
}
return p
}
// Save stores p under name, replacing any existing profile.
func Save(name string, p Profile) error {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("profile name is empty")
}
ps, err := Load()
if err != nil {
return err
}
ps[name] = p
return store(ps)
}
// Delete removes a profile.
func Delete(name string) error {
ps, err := Load()
if err != nil {
return err
}
if _, ok := ps[name]; !ok {
return fmt.Errorf("no profile named %q", name)
}
delete(ps, name)
return store(ps)
}
// Apply writes every value in p that differs from what is set now. It
// returns the number of changes made and the first error encountered.
func Apply(p Profile) (int, error) {
agents := map[string]*agent.Agent{}
for _, a := range agent.All() {
agents[a.ID] = a
}
keys := make([]string, 0, len(p))
for k := range p {
keys = append(keys, k)
}
// providers first: switching one re-settles the model behind it
sort.Slice(keys, func(i, j int) bool {
pi, pj := strings.HasSuffix(keys[i], ".provider"), strings.HasSuffix(keys[j], ".provider")
if pi != pj {
return pi
}
return keys[i] < keys[j]
})
changed := 0
for _, k := range keys {
id, field, ok := strings.Cut(k, ".")
if !ok {
continue
}
a := agents[id]
if a == nil {
continue
}
f := a.Field(field)
if f == nil || f.Get() == p[k] {
continue
}
if err := f.Set(p[k]); err != nil {
return changed, fmt.Errorf("%s: %w", k, err)
}
changed++
}
return changed, nil
}
// Summary renders a profile as a short one-line description.
func Summary(p Profile) string {
keys := make([]string, 0, len(p))
for k := range p {
if strings.HasSuffix(k, ".model") {
keys = append(keys, k)
}
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, strings.TrimSuffix(k, ".model")+" "+p[k])
}
return strings.Join(parts, " · ")
}
+648
View File
@@ -0,0 +1,648 @@
// Package tui is the interactive dial: one row per agent, arrow keys to pick
// a field, enter to change it.
package tui
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/yetone/dial/internal/agent"
"github.com/yetone/dial/internal/catalog"
"github.com/yetone/dial/internal/profile"
)
// ---- styling ---------------------------------------------------------------
var (
cAccent = lipgloss.AdaptiveColor{Light: "#4F46E5", Dark: "#A5B4FC"}
cText = lipgloss.AdaptiveColor{Light: "#1F2328", Dark: "#E6E6E6"}
cMuted = lipgloss.AdaptiveColor{Light: "#8B8F98", Dark: "#7C8290"}
cFaint = lipgloss.AdaptiveColor{Light: "#C4C7CE", Dark: "#4A4F5A"}
cOK = lipgloss.AdaptiveColor{Light: "#0F9D58", Dark: "#7EE2A8"}
cBad = lipgloss.AdaptiveColor{Light: "#D93025", Dark: "#FF8A80"}
cPill = lipgloss.AdaptiveColor{Light: "#EEF0FF", Dark: "#2C2E4A"}
sTitle = lipgloss.NewStyle().Bold(true).Foreground(cAccent)
sCrumb = lipgloss.NewStyle().Foreground(cMuted)
sText = lipgloss.NewStyle().Foreground(cText)
sMuted = lipgloss.NewStyle().Foreground(cMuted)
sFaint = lipgloss.NewStyle().Foreground(cFaint)
sName = lipgloss.NewStyle().Bold(true).Foreground(cText)
sNameOn = lipgloss.NewStyle().Bold(true).Foreground(cAccent)
sPill = lipgloss.NewStyle().Foreground(cAccent).Background(cPill).Padding(0, 1)
sValue = lipgloss.NewStyle().Foreground(cText).Padding(0, 1)
sOK = lipgloss.NewStyle().Foreground(cOK)
sBad = lipgloss.NewStyle().Foreground(cBad)
sKey = lipgloss.NewStyle().Foreground(cText)
sCursor = lipgloss.NewStyle().Foreground(cAccent).Bold(true)
)
const pad = " "
// ---- model -----------------------------------------------------------------
type mode int
const (
modeList mode = iota
modePick
modeProfiles
modeName
)
type picker struct {
crumbs []string
input textinput.Model
items []agent.Option
match []int
cursor int
custom bool
onPick func(string) tea.Cmd
onDel func(string) tea.Cmd
empty string
}
type model struct {
agents []*agent.Agent
values []map[string]string
row int
col int
mode mode
pk picker
name textinput.Model
flash string
flashOK bool
w, h int
syncing bool
}
type flashMsg struct {
text string
ok bool
}
type syncedMsg struct{ err error }
// Run starts the interactive dial.
func Run() error {
m := newModel()
if len(m.agents) == 0 {
return fmt.Errorf("no supported agents found on this machine")
}
_, err := tea.NewProgram(m, tea.WithAltScreen()).Run()
return err
}
func newModel() model {
m := model{agents: agent.Detected()}
m.reload()
return m
}
func (m *model) reload() {
m.values = make([]map[string]string, len(m.agents))
for i, a := range m.agents {
m.values[i] = a.Values()
}
}
func (m model) Init() tea.Cmd {
if catalog.Source() == "" {
m.syncing = true
return syncCmd
}
return nil
}
func syncCmd() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
return syncedMsg{err: catalog.Sync(ctx)}
}
// ---- update ----------------------------------------------------------------
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.w, m.h = msg.Width, msg.Height
return m, nil
case flashMsg:
m.flash, m.flashOK = msg.text, msg.ok
m.reload()
return m, nil
case syncedMsg:
m.syncing = false
if msg.err != nil {
m.flash, m.flashOK = "catalog sync failed: "+msg.err.Error(), false
} else {
m.flash, m.flashOK = "model catalog synced from models.dev", true
}
return m, nil
case tea.KeyMsg:
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
switch m.mode {
case modeList:
return m.updateList(msg)
case modePick, modeProfiles:
return m.updatePicker(msg)
case modeName:
return m.updateName(msg)
}
}
return m, nil
}
func (m model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.flash = ""
switch msg.String() {
case "q", "esc":
return m, tea.Quit
case "j", "down":
m.row = (m.row + 1) % len(m.agents)
m.col = 0
case "k", "up":
m.row = (m.row + len(m.agents) - 1) % len(m.agents)
m.col = 0
case "l", "right", "tab":
m.col = (m.col + 1) % len(m.agents[m.row].Fields)
case "h", "left", "shift+tab":
n := len(m.agents[m.row].Fields)
m.col = (m.col + n - 1) % n
case "enter", " ":
m.openFieldPicker()
case "r":
m.reload()
m.flash, m.flashOK = "reloaded", true
case "S":
if !m.syncing {
m.syncing = true
return m, syncCmd
}
case "s":
m.mode = modeName
m.name = newInput("profile name")
case "p":
m.openProfiles()
}
return m, nil
}
func (m *model) openFieldPicker() {
a := m.agents[m.row]
f := a.Fields[m.col]
cur := m.values[m.row][f.Key]
items := f.Options(m.values[m.row])
items = withCurrent(items, cur)
set := f.Set
label := a.Name + " · " + f.Label
m.pk = picker{
crumbs: []string{a.Name, f.Label},
input: newInput("type to filter, or enter a custom value"),
items: items,
custom: true,
empty: "no models known yet, type one",
onPick: func(v string) tea.Cmd {
return func() tea.Msg {
if err := set(v); err != nil {
return flashMsg{text: err.Error()}
}
return flashMsg{text: label + " → " + v, ok: true}
}
},
}
m.pk.refilter()
m.mode = modePick
}
func (m *model) openProfiles() {
ps, err := profile.Load()
if err != nil {
m.flash, m.flashOK = err.Error(), false
return
}
var items []agent.Option
for _, n := range profile.Names(ps) {
items = append(items, agent.Option{Value: n, Note: profile.Summary(ps[n])})
}
m.pk = picker{
crumbs: []string{"profiles"},
input: newInput("filter profiles"),
items: items,
empty: "no profiles yet · press s in the list to save one",
onPick: func(n string) tea.Cmd {
return func() tea.Msg {
ps, err := profile.Load()
if err != nil {
return flashMsg{text: err.Error()}
}
changed, err := profile.Apply(ps[n])
if err != nil {
return flashMsg{text: err.Error()}
}
return flashMsg{text: fmt.Sprintf("profile %s applied · %d change%s", n, changed, plural(changed)), ok: true}
}
},
onDel: func(n string) tea.Cmd {
return func() tea.Msg {
if err := profile.Delete(n); err != nil {
return flashMsg{text: err.Error()}
}
return flashMsg{text: "profile " + n + " deleted", ok: true}
}
},
}
m.pk.refilter()
m.mode = modeProfiles
}
func (m model) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.mode = modeList
return m, nil
case "down", "ctrl+n":
if len(m.pk.match) > 0 {
m.pk.cursor = (m.pk.cursor + 1) % len(m.pk.match)
}
return m, nil
case "up", "ctrl+p":
if len(m.pk.match) > 0 {
m.pk.cursor = (m.pk.cursor + len(m.pk.match) - 1) % len(m.pk.match)
}
return m, nil
case "ctrl+d":
if m.mode == modeProfiles && m.pk.onDel != nil && len(m.pk.match) > 0 {
n := m.pk.items[m.pk.match[m.pk.cursor]].Value
m.mode = modeList
return m, m.pk.onDel(n)
}
return m, nil
case "enter":
v, ok := m.pk.choice()
if !ok {
return m, nil
}
m.mode = modeList
return m, m.pk.onPick(v)
}
var cmd tea.Cmd
m.pk.input, cmd = m.pk.input.Update(msg)
m.pk.refilter()
return m, cmd
}
func (m model) updateName(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.mode = modeList
return m, nil
case "enter":
n := strings.TrimSpace(m.name.Value())
if n == "" {
return m, nil
}
m.mode = modeList
return m, func() tea.Msg {
if err := profile.Save(n, profile.Snapshot()); err != nil {
return flashMsg{text: err.Error()}
}
return flashMsg{text: "saved profile " + n, ok: true}
}
}
var cmd tea.Cmd
m.name, cmd = m.name.Update(msg)
return m, cmd
}
// ---- picker logic ----------------------------------------------------------
func newInput(placeholder string) textinput.Model {
ti := textinput.New()
ti.Placeholder = placeholder
ti.Prompt = ""
ti.PlaceholderStyle = sFaint
ti.TextStyle = sText
ti.Cursor.Style = sCursor
ti.CharLimit = 200
ti.Focus()
return ti
}
// withCurrent makes sure the current value is offered first, deduplicated.
func withCurrent(items []agent.Option, cur string) []agent.Option {
seen := map[string]bool{}
var out []agent.Option
if cur != "" {
seen[cur] = true
note := "current"
for _, it := range items {
if it.Value == cur && it.Note != "" {
note = it.Note + " · current"
}
}
out = append(out, agent.Option{Value: cur, Note: note})
}
for _, it := range items {
if seen[it.Value] {
continue
}
seen[it.Value] = true
out = append(out, it)
}
return out
}
type scored struct {
idx, score int
}
func (p *picker) refilter() {
q := strings.ToLower(strings.TrimSpace(p.input.Value()))
p.match = p.match[:0]
if q == "" {
for i := range p.items {
p.match = append(p.match, i)
}
p.cursor = clamp(p.cursor, len(p.match))
return
}
var hits []scored
for i, it := range p.items {
if s, ok := score(strings.ToLower(it.Value), strings.ToLower(it.Note), q); ok {
hits = append(hits, scored{i, s})
}
}
sort.SliceStable(hits, func(a, b int) bool { return hits[a].score < hits[b].score })
for _, h := range hits {
p.match = append(p.match, h.idx)
}
p.cursor = 0
}
// score ranks prefix < substring < subsequence < note match; lower is better.
func score(value, note, q string) (int, bool) {
if strings.HasPrefix(value, q) {
return 0, true
}
if i := strings.Index(value, q); i >= 0 {
return 1 + i, true
}
if subseq(value, q) {
return 1000, true
}
if strings.Contains(note, q) {
return 2000, true
}
return 0, false
}
func subseq(s, q string) bool {
j := 0
for i := 0; i < len(s) && j < len(q); i++ {
if s[i] == q[j] {
j++
}
}
return j == len(q)
}
func (p *picker) choice() (string, bool) {
if len(p.match) > 0 {
return p.items[p.match[p.cursor]].Value, true
}
if v := strings.TrimSpace(p.input.Value()); p.custom && v != "" {
return v, true
}
return "", false
}
func clamp(i, n int) int {
if n == 0 {
return 0
}
if i >= n {
return n - 1
}
if i < 0 {
return 0
}
return i
}
func plural(n int) string {
if n == 1 {
return ""
}
return "s"
}
// ---- view ------------------------------------------------------------------
func (m model) View() string {
var body string
var footer string
switch m.mode {
case modeList:
body = m.viewList()
footer = hints("↑↓", "agent", "←→", "field", "↵", "change", "s", "save profile", "p", "profiles", "q", "quit")
case modePick, modeProfiles:
body = m.viewPicker()
if m.mode == modeProfiles {
footer = hints("↑↓", "move", "↵", "apply", "ctrl+d", "delete", "esc", "back")
} else {
footer = hints("↑↓", "move", "↵", "select", "esc", "back")
}
case modeName:
body = m.viewName()
footer = hints("↵", "save", "esc", "cancel")
}
status := ""
switch {
case m.flash != "" && m.flashOK:
status = sOK.Render("✓ ") + sText.Render(m.flash)
case m.flash != "":
status = sBad.Render("✗ ") + sText.Render(m.flash)
case m.syncing:
status = sMuted.Render("… syncing model catalog")
}
lines := strings.Count(body, "\n") + 1
fill := m.h - lines - 3
if fill < 1 {
fill = 1
}
return body + strings.Repeat("\n", fill) + pad + status + "\n" + pad + footer + "\n"
}
func (m model) header(crumbs ...string) string {
s := pad + sTitle.Render("◉ dial")
for _, c := range crumbs {
s += sCrumb.Render(" › ") + sText.Render(c)
}
return s
}
func (m model) viewList() string {
nameW := 0
for _, a := range m.agents {
nameW = max(nameW, lipgloss.Width(a.Name))
}
var b strings.Builder
b.WriteString(m.header())
b.WriteString("\n\n")
for i, a := range m.agents {
sel := i == m.row
marker := " "
name := sName.Render(padRight(a.Name, nameW))
if sel {
marker = sCursor.Render("▸ ")
name = sNameOn.Render(padRight(a.Name, nameW))
}
line := pad + marker + name + " "
for j, f := range a.Fields {
v := m.values[i][f.Key]
var cell string
switch {
case sel && j == m.col:
cell = sPill.Render(dash(v))
case v == "":
cell = sFaint.Render(" — ")
default:
cell = sValue.Render(v)
}
if j > 0 || f.Label != "model" {
line += sMuted.Render(" " + f.Label)
}
line += cell + " "
}
if sel {
p := tilde(a.Path)
if room := m.w - lipgloss.Width(line) - lipgloss.Width(p) - 2; room > 0 {
line += strings.Repeat(" ", room) + sFaint.Render(p)
}
}
b.WriteString(line)
b.WriteString("\n")
}
return strings.TrimRight(b.String(), "\n")
}
func (m model) viewPicker() string {
p := m.pk
var b strings.Builder
b.WriteString(m.header(p.crumbs...))
b.WriteString("\n\n")
b.WriteString(pad + sCursor.Render("❯ ") + p.input.View())
b.WriteString("\n\n")
if len(p.match) == 0 {
if v, ok := p.choice(); ok {
b.WriteString(pad + " " + sMuted.Render("↵ use ") + sText.Render(v) + sMuted.Render(" as a custom value"))
} else if len(p.items) == 0 {
b.WriteString(pad + " " + sMuted.Render(p.empty))
} else {
b.WriteString(pad + " " + sMuted.Render("no match"))
}
return b.String()
}
valW := 0
for _, i := range p.match {
valW = max(valW, lipgloss.Width(p.items[i].Value))
}
valW = min(valW, max(24, m.w/2))
visible := max(3, m.h-8)
start := 0
if p.cursor >= visible {
start = p.cursor - visible + 1
}
end := min(len(p.match), start+visible)
for k := start; k < end; k++ {
it := p.items[p.match[k]]
marker, val := " ", sText.Render(padRight(it.Value, valW))
if k == p.cursor {
marker, val = sCursor.Render("▸ "), sNameOn.Render(padRight(it.Value, valW))
}
line := pad + marker + val
if it.Note != "" {
line += " " + sMuted.Render(it.Note)
}
b.WriteString(line)
if k < end-1 {
b.WriteString("\n")
}
}
if end < len(p.match) || start > 0 {
b.WriteString("\n" + pad + " " + sFaint.Render(fmt.Sprintf("%d–%d of %d", start+1, end, len(p.match))))
}
return b.String()
}
func (m model) viewName() string {
var b strings.Builder
b.WriteString(m.header("save profile"))
b.WriteString("\n\n")
b.WriteString(pad + sCursor.Render("❯ ") + m.name.View())
b.WriteString("\n\n")
b.WriteString(pad + " " + sMuted.Render("snapshots every agent's current settings"))
b.WriteString("\n\n")
snap := profile.Snapshot()
for _, k := range sortedKeys(snap) {
b.WriteString(pad + " " + sFaint.Render(padRight(k, 18)) + sText.Render(snap[k]) + "\n")
}
return strings.TrimRight(b.String(), "\n")
}
func hints(kv ...string) string {
var parts []string
for i := 0; i+1 < len(kv); i += 2 {
parts = append(parts, sKey.Render(kv[i])+" "+sMuted.Render(kv[i+1]))
}
return strings.Join(parts, sFaint.Render(" · "))
}
func padRight(s string, w int) string {
if n := w - lipgloss.Width(s); n > 0 {
return s + strings.Repeat(" ", n)
}
return s
}
func dash(v string) string {
if v == "" {
return "—"
}
return v
}
func tilde(p string) string {
if home, err := os.UserHomeDir(); err == nil {
if rel, err := filepath.Rel(home, p); err == nil && !strings.HasPrefix(rel, "..") {
return "~" + string(filepath.Separator) + rel
}
}
return p
}
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
+315
View File
@@ -0,0 +1,315 @@
// dial — one small dial for every coding agent's model.
package main
import (
"context"
"fmt"
"os"
"sort"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
"github.com/yetone/dial/internal/agent"
"github.com/yetone/dial/internal/catalog"
"github.com/yetone/dial/internal/profile"
"github.com/yetone/dial/internal/tui"
)
var version = "dev"
const usage = `dial — one dial for every coding agent's model
dial open the app: a window plus a menu bar icon
dial tray start in the menu bar only
dial tui the same dial, in the terminal
dial ls list detected agents and their settings
dial <agent> show one agent
dial <agent> <model> set an agent's model
dial <agent> <field> <value> set another field e.g. dial codex effort high
e.g. dial claude provider deepseek
dial save <name> snapshot every agent's settings as a profile
dial use <name> apply a profile
dial profiles list profiles
dial rm <name> delete a profile
dial providers list providers: endpoints, keys, who uses them
dial provider <id> show one provider and its models
dial provider add <name> k=v… add a provider (dial provider for the fields)
dial provider edit|rm|reset|test|models <id>
dial key <VAR> <value> store an API key for apps that see no shell env
dial keys list stored keys (values masked)
dial sync refresh the model catalog and live model lists
dial agents list every supported agent
agents: claude (cc), codex, gemini, opencode (oc), pi, goose, cursor, copilot, crush
`
var (
bold = lipgloss.NewStyle().Bold(true)
muted = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#8B8F98", Dark: "#7C8290"})
faint = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#C4C7CE", Dark: "#4A4F5A"})
green = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#0F9D58", Dark: "#7EE2A8"})
)
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "dial:", err)
os.Exit(1)
}
}
func run(args []string) error {
if len(args) == 0 {
if hasGUI {
return runGUI(true)
}
return tui.Run()
}
switch args[0] {
case "tui":
return tui.Run()
case "app", "gui":
return runGUI(true)
case "tray":
return runGUI(false)
case "-h", "--help", "help":
fmt.Print(usage)
return nil
case "-v", "--version", "version":
fmt.Println("dial", version)
return nil
case "ls", "list":
return list(agent.Detected(), true)
case "agents":
return list(agent.All(), false)
case "sync":
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := catalog.Sync(ctx); err != nil {
return err
}
fmt.Println(green.Render("✓"), "catalog saved to", catalog.CachePath())
refreshLive(ctx)
return nil
case "save", "use", "rm", "profiles":
return profiles(args)
case "providers":
return providers()
case "provider":
return provider(args)
case "key", "keys":
return keys(args)
}
a, err := agent.Find(args[0])
if err != nil {
return err
}
switch len(args) {
case 1:
return list([]*agent.Agent{a}, true)
case 2:
// `dial codex xhigh`: a bare value that belongs to a non-model field
// (effort levels, for instance) is routed there; anything else is a model.
if f := fieldForValue(a, args[1]); f != nil {
return set(a, f.Key, args[1])
}
return set(a, a.Fields[0].Key, args[1])
case 3:
return set(a, args[1], args[2])
}
return fmt.Errorf("too many arguments\n\n%s", usage)
}
func set(a *agent.Agent, key, value string) error {
f := a.Field(key)
if f == nil {
var keys []string
for _, f := range a.Fields {
keys = append(keys, f.Key)
}
return fmt.Errorf("%s has no field %q (fields: %s)", a.Name, key, strings.Join(keys, ", "))
}
if err := f.Set(value); err != nil {
return err
}
fmt.Println(green.Render("✓"), bold.Render(a.Name), muted.Render(f.Label), value)
if a.Notice != nil {
if n := a.Notice(); n != "" {
fmt.Println(muted.Render(" ↻ " + n))
}
}
return nil
}
// keys: `dial keys`, `dial key VAR value`, `dial key VAR` (show), `dial key VAR -` (forget).
func keys(args []string) error {
stored := agent.StoredKeys()
switch len(args) {
case 1:
if len(stored) == 0 {
fmt.Println(muted.Render("no stored keys —"), "dial key DEEPSEEK_API_KEY sk-…")
return nil
}
names := make([]string, 0, len(stored))
for k := range stored {
names = append(names, k)
}
sort.Strings(names)
for _, k := range names {
fmt.Println(" ", pad(k, 24), muted.Render(agent.Mask(stored[k])))
}
fmt.Println(faint.Render(" " + tilde(agent.KeysPath())))
return nil
case 2:
if v, ok := stored[args[1]]; ok {
fmt.Println(agent.Mask(v))
return nil
}
if os.Getenv(args[1]) != "" {
fmt.Println(muted.Render("set in the environment, not stored"))
return nil
}
return fmt.Errorf("%s is not set", args[1])
case 3:
v := args[2]
if v == "-" {
v = ""
}
if err := agent.SetKey(args[1], v); err != nil {
return err
}
if v == "" {
fmt.Println(green.Render("✓"), "forgot", args[1])
} else {
fmt.Println(green.Render("✓"), "stored", args[1], muted.Render(agent.Mask(v)))
}
return nil
}
return fmt.Errorf("usage: dial key <VAR> <value>")
}
func fieldForValue(a *agent.Agent, v string) *agent.Field {
vals := a.Values()
for i := 1; i < len(a.Fields); i++ {
for _, o := range a.Fields[i].Options(vals) {
if o.Value == v {
return &a.Fields[i]
}
}
}
return nil
}
func list(agents []*agent.Agent, detectedOnly bool) error {
if len(agents) == 0 {
return fmt.Errorf("no supported agents found on this machine")
}
type row struct{ name, vals, path string }
var rows []row
nameW, valW := 0, 0
for _, a := range agents {
r := row{name: a.Name, path: tilde(a.Path)}
if !detectedOnly && !a.Detected() {
r.name = faint.Render(a.Name)
r.vals = faint.Render("not detected")
r.path = ""
} else {
vals := a.Values()
var parts []string
for _, f := range a.Fields {
v := vals[f.Key]
if v == "" {
v = faint.Render("—")
}
if f.Label == "model" {
parts = append(parts, v)
} else {
parts = append(parts, muted.Render(f.Label)+" "+v)
}
}
r.name = bold.Render(a.Name)
r.vals = strings.Join(parts, muted.Render(" · "))
}
nameW = max(nameW, lipgloss.Width(r.name))
valW = max(valW, lipgloss.Width(r.vals))
rows = append(rows, r)
}
for _, r := range rows {
fmt.Printf(" %s %s %s\n", pad(r.name, nameW), pad(r.vals, valW), faint.Render(r.path))
}
return nil
}
func tilde(p string) string {
if home, err := os.UserHomeDir(); err == nil && strings.HasPrefix(p, home) {
return "~" + p[len(home):]
}
return p
}
func profiles(args []string) error {
switch args[0] {
case "profiles":
ps, err := profile.Load()
if err != nil {
return err
}
if len(ps) == 0 {
fmt.Println(muted.Render("no profiles yet · dial save <name>"))
return nil
}
for _, n := range profile.Names(ps) {
fmt.Printf(" %s %s\n", bold.Render(n), muted.Render(profile.Summary(ps[n])))
}
return nil
case "save":
if len(args) < 2 {
return fmt.Errorf("usage: dial save <name>")
}
if err := profile.Save(args[1], profile.Snapshot()); err != nil {
return err
}
fmt.Println(green.Render("✓"), "saved profile", bold.Render(args[1]))
return nil
case "use":
if len(args) < 2 {
return fmt.Errorf("usage: dial use <name>")
}
ps, err := profile.Load()
if err != nil {
return err
}
p, ok := ps[args[1]]
if !ok {
return fmt.Errorf("no profile named %q", args[1])
}
n, err := profile.Apply(p)
if err != nil {
return err
}
fmt.Println(green.Render("✓"), "applied", bold.Render(args[1]), muted.Render(fmt.Sprintf("(%d changed)", n)))
return nil
case "rm":
if len(args) < 2 {
return fmt.Errorf("usage: dial rm <name>")
}
if err := profile.Delete(args[1]); err != nil {
return err
}
fmt.Println(green.Render("✓"), "deleted profile", bold.Render(args[1]))
return nil
}
return nil
}
func pad(s string, w int) string {
if n := w - lipgloss.Width(s); n > 0 {
return s + strings.Repeat(" ", n)
}
return s
}
+319
View File
@@ -0,0 +1,319 @@
package main
import (
"context"
"fmt"
"os"
"sort"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
"github.com/yetone/dial/internal/agent"
)
var amber = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#B45309", Dark: "#F2B544"})
const providerUsage = `usage:
dial providers list providers, keys and who uses them
dial provider <id> show one provider
dial provider add <name> k=v… k: anthropic, responses, env, catalog, models, website, keys
dial provider edit <id> k=v… change fields of a provider (built-ins get an edited copy)
dial provider rm <id> delete a custom provider, or hide a built-in
dial provider reset <id> built-in back to dial's defaults (also un-hides it)
dial provider test <id> send a tiny request through each endpoint
dial provider models <id> fetch the vendor's model list and print it
e.g. dial provider add "My Gateway" anthropic=https://gw.example.com/anthropic env=GW_API_KEY catalog=anthropic`
// providers: `dial providers`
func providers() error {
agents := agent.Detected()
type row struct{ name, id, host, key, models, uses string }
var rows []row
w := [5]int{}
for _, p := range agent.Providers() {
r := row{name: bold.Render(p.Name), id: muted.Render(p.ID), host: p.Host()}
if !p.Builtin {
r.name += " " + faint.Render("custom")
}
switch state, _ := agent.KeyState(p.EnvKey); state {
case "env":
r.key = green.Render("●") + " $" + p.EnvKey
case "stored":
r.key = green.Render("●") + " " + p.EnvKey + muted.Render(" (stored)")
default:
r.key = amber.Render("○ no key")
}
n := len(p.Models())
if live, t, ok := p.LiveModels(); ok {
r.models = fmt.Sprintf("%d models", live) + muted.Render(" · fetched "+ago(t))
} else {
r.models = fmt.Sprintf("%d models", n)
}
var uses []string
for _, a := range agents {
if f := a.Field("provider"); f != nil && f.Get() == p.ID {
uses = append(uses, a.Name)
}
}
if len(uses) > 0 {
r.uses = green.Render("← " + strings.Join(uses, ", "))
}
for i, s := range []string{r.name, r.id, r.host, r.key, r.models} {
w[i] = max(w[i], lipgloss.Width(s))
}
rows = append(rows, r)
}
for _, r := range rows {
fmt.Printf(" %s %s %s %s %s %s\n", pad(r.name, w[0]), pad(r.id, w[1]), pad(r.host, w[2]), pad(r.key, w[3]), pad(r.models, w[4]), r.uses)
}
if h := agent.HiddenProviders(); len(h) > 0 {
names := make([]string, 0, len(h))
for _, p := range h {
names = append(names, p.ID)
}
fmt.Println(faint.Render(" hidden: " + strings.Join(names, ", ") + " (dial provider reset <id>)"))
}
return nil
}
// provider: `dial provider <verb> …`
func provider(args []string) error {
if len(args) < 2 {
return fmt.Errorf("%s", providerUsage)
}
verb, rest := args[1], args[2:]
switch verb {
case "add":
if len(rest) == 0 {
return fmt.Errorf("dial provider add <name> k=v…\n\n%s", providerUsage)
}
p := agent.Provider{Name: rest[0]}
if err := applyPairs(&p, rest[1:]); err != nil {
return err
}
if err := agent.SaveProvider(p); err != nil {
return err
}
p.ID = agent.ProviderID(first(p.ID, p.Name))
fmt.Println(green.Render("✓"), "added", p.Name, muted.Render("("+p.ID+")"))
if agent.Key(p.EnvKey) == "" {
fmt.Println(muted.Render(" set a key:"), "dial key", p.EnvKey, "…")
}
return nil
case "edit":
if len(rest) < 2 {
return fmt.Errorf("dial provider edit <id> k=v…\n\n%s", providerUsage)
}
p, err := agent.FindProvider(rest[0])
if err != nil {
return err
}
if err := applyPairs(p, rest[1:]); err != nil {
return err
}
if err := agent.SaveProvider(*p); err != nil {
return err
}
fmt.Println(green.Render("✓"), "saved", p.Name)
return nil
case "rm", "remove", "delete", "hide":
if len(rest) != 1 {
return fmt.Errorf("dial provider rm <id>")
}
p, err := agent.FindProvider(rest[0])
if err != nil {
return err
}
if err := agent.DeleteProvider(p.ID); err != nil {
return err
}
if p.Builtin {
fmt.Println(green.Render("✓"), "hid", p.Name, muted.Render("— dial provider reset "+p.ID+" brings it back"))
} else {
fmt.Println(green.Render("✓"), "deleted", p.Name)
}
return nil
case "reset":
if len(rest) != 1 {
return fmt.Errorf("dial provider reset <id>")
}
if err := agent.ResetProvider(rest[0]); err != nil {
return err
}
fmt.Println(green.Render("✓"), rest[0], "is back to dial's defaults")
return nil
case "test":
if len(rest) != 1 {
return fmt.Errorf("dial provider test <id>")
}
p, err := agent.FindProvider(rest[0])
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ok := true
for _, r := range p.Test(ctx) {
if r.OK {
fmt.Printf(" %s %-10s %s\n", green.Render("✓"), r.Protocol, muted.Render(fmt.Sprintf("%d ms · %s", r.Millis, r.Model)))
} else {
ok = false
msg := r.Error
if r.Status != 0 {
msg = fmt.Sprintf("%d · %s", r.Status, r.Error)
}
fmt.Printf(" %s %-10s %s\n", amber.Render("✗"), r.Protocol, msg)
}
}
if !ok {
os.Exit(1)
}
return nil
case "models":
if len(rest) != 1 {
return fmt.Errorf("dial provider models <id>")
}
p, err := agent.FindProvider(rest[0])
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ms, err := p.RefreshModels(ctx)
if err != nil {
return err
}
fmt.Println(green.Render("✓"), len(ms), "models from", p.Host())
return showProvider(*p)
}
// `dial provider <id>`
p, err := agent.FindProvider(verb)
if err != nil {
return err
}
return showProvider(*p)
}
func showProvider(p agent.Provider) error {
kv := func(k, v string) {
if v != "" {
fmt.Printf(" %s %s\n", muted.Render(pad(k, 10)), v)
}
}
name := bold.Render(p.Name) + muted.Render(" "+p.ID)
if !p.Builtin {
name += faint.Render(" custom")
}
fmt.Println(" ", name)
kv("anthropic", p.Anthropic)
kv("responses", p.Responses)
state, masked := agent.KeyState(p.EnvKey)
switch state {
case "env":
kv("key", "$"+p.EnvKey+" "+muted.Render(masked))
case "stored":
kv("key", p.EnvKey+" "+muted.Render(masked+" · stored"))
default:
kv("key", amber.Render("$"+p.EnvKey+" is not set")+muted.Render(" dial key "+p.EnvKey+" …"))
}
kv("catalog", p.Catalog)
kv("website", p.Website)
kv("keys", p.KeysURL)
ms := p.Models()
src := "catalog"
if _, t, ok := p.LiveModels(); ok {
src = p.Host() + " · fetched " + ago(t)
}
kv("models", fmt.Sprintf("%d %s", len(ms), muted.Render("from "+src)))
for i, m := range ms {
if i == 12 {
fmt.Println(faint.Render(fmt.Sprintf(" … %d more", len(ms)-12)))
break
}
line := " " + m.ID
if m.Name != "" && m.Name != m.ID {
line += muted.Render(" " + m.Name)
}
fmt.Println(line)
}
return nil
}
func applyPairs(p *agent.Provider, pairs []string) error {
for _, kv := range pairs {
k, v, ok := strings.Cut(kv, "=")
if !ok {
return fmt.Errorf("expected key=value, got %q\n\n%s", kv, providerUsage)
}
switch strings.ToLower(k) {
case "id":
p.ID = v
case "name":
p.Name = v
case "anthropic":
p.Anthropic = v
case "responses", "openai":
p.Responses = v
case "env", "key":
p.EnvKey = v
case "catalog":
p.Catalog = v
case "small":
p.Small = v
case "models":
p.Extra = strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ' ' })
case "website":
p.Website = v
case "keys":
p.KeysURL = v
default:
return fmt.Errorf("unknown field %q\n\n%s", k, providerUsage)
}
}
return nil
}
func first(a, b string) string {
if a != "" {
return a
}
return b
}
func ago(t time.Time) string {
d := time.Since(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes()))
case d < 48*time.Hour:
return fmt.Sprintf("%dh ago", int(d.Hours()))
default:
return t.Format("Jan 2")
}
}
// refreshLive re-fetches the vendor model list of every provider that has a
// key; `dial sync` calls it after the catalog download.
func refreshLive(ctx context.Context) {
var names []string
for _, p := range agent.Providers() {
if agent.Key(p.EnvKey) == "" {
continue
}
c, cancel := context.WithTimeout(ctx, 8*time.Second)
ms, err := p.RefreshModels(c)
cancel()
if err == nil {
names = append(names, fmt.Sprintf("%s (%d)", p.ID, len(ms)))
}
}
sort.Strings(names)
if len(names) > 0 {
fmt.Println(green.Render("✓"), "live model lists:", strings.Join(names, ", "))
}
}