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>
No files matched your search
@@ -0,0 +1,4 @@
|
||||
/dial
|
||||
/dial.exe
|
||||
/dial.app/
|
||||
/dist/
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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`") }
|
||||
@@ -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) }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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, "")
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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] }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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 |
|
After Width: | Height: | Size: 16 KiB |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -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)
|
||||
})
|
||||
}
|
||||
|
After Width: | Height: | Size: 482 B |
@@ -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, " · ")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, ", "))
|
||||
}
|
||||
}
|
||||