Files
magpie/internal/provider/importapps.go
T
yetone a999770531 plugins: a plugin's subscription does what a built-in's does, in seven places it didn't (the user: 要保证插件订阅 provider 跟内置的订阅 provider所有逻辑都要一模一样) — an installed plugin's provider id is kept for it signed in or not, as a built-in's is: a provider of the user's own isn't saved on it, renamed onto it or imported as it (freeID gives id-2). A plugin's account whose plan lacks the model is tried after the ones that list it, as a Codex Free account behind a Plus, by the list the plugin told for it. Cursor's plugin has fast mode where the built-in Cursor has it: a model with a -fast one is saved :fast in a group and sent service_tier "priority", one without is refused. Removing a plugin's provider hides it as a built-in's is, its accounts kept and shown again from Hidden or by signing in, rather than signing every account out ("stays signed in" said so already). magpie accounts lists, adds, switches and forgets a plugin's accounts by its provider's id or name. Grok's plugin beside the built-in Grok (grok-plugin) searches by itself as Grok does. A plugin's plan is named as its built-in's ("Cursor Pro", "SuperGrok"), and a plugin's resets are shown but have no Use button or Auto-use, which spent Codex's reset of the same user. TestPluginIDReservedSignedOut, TestPluginAccountLackingTheModelGoesLast, TestCursorPluginGroupFast, TestAccountsOfAPlugin, TestGrokPluginSearchesItself, plugin-plan and codex-auto-reset fail without the change; TestPluginSignIn and TestPluginAccounts now expect a removed plugin hidden. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass (TestClaudeSignedOut failed once under load, passes alone); the gateway, provider and plugin suites and TestAccountsOfAPlugin pass on Windows, the plugin tests on Linux.
2026-10-01 20:26:55 +08:00

838 lines
26 KiB
Go

package provider
// Providers another app has already set up — CC Switch, Alma, Claude Code's
// own settings — offered for the user to bring over: each source is read
// (never written), every entry turned into the provider magpie would add,
// and matched against what is already here, so the picker can say which are
// new, which are already in, and which would take an id that is in use.
// Nothing is added until the user picks.
import (
"database/sql"
"encoding/json"
"errors"
"maps"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"github.com/tidwall/jsonc"
"github.com/yetone/magpie/internal/plugin"
_ "modernc.org/sqlite"
)
// AppSource is one app magpie can import from, with what it holds.
type AppSource struct {
ID string `json:"id"` // cc-switch, alma
Name string `json:"name"` // as the app calls itself
Path string `json:"path"` // the file read
Found bool `json:"found"`
Error string `json:"error,omitempty"`
Items []AppImport `json:"items"`
}
// AppImport is one provider an app holds, as magpie would add it.
type AppImport struct {
Ref string `json:"ref"` // stable within its source
From string `json:"from,omitempty"` // where in the app: Claude Code, Codex…
Provider Provider `json:"provider"`
// Status is "new", "same" (magpie has it already: same endpoint and
// key) or "taken" (its id is another provider's here).
Status string `json:"status"`
Existing string `json:"existing,omitempty"` // the magpie provider it matches or collides with
// KeyOf is a magpie provider at the same address with another key:
// this one can join it as one more key instead of being a provider
// of its own.
KeyOf string `json:"keyOf,omitempty"`
Off string `json:"off,omitempty"` // why it isn't picked by default
Skip string `json:"skip,omitempty"` // why it can't be imported at all
}
// AppPick is one entry the user chose, and how it comes in: as a provider
// ("add", beside any with its id), in place of the one with its id
// ("replace"), or as one more key of the provider KeyOf names ("key").
type AppPick struct {
Source string `json:"source"`
Ref string `json:"ref"`
Mode string `json:"mode,omitempty"`
}
// appReaders are the apps magpie knows, by name as the picker lists them.
var appReaders = []struct {
id, name string
path func() string
read func(path string) ([]AppImport, error)
}{
{"alma", "Alma", almaPath, readAlma},
{"cc-switch", "CC Switch", ccSwitchPath, readCCSwitch},
{"claude-code", "Claude Code", claudeSettingsPath, readClaudeSettings},
{"codex", "Codex", codexConfigPath, readCodexConfig},
}
// ImportSources reads every app magpie can import from.
func ImportSources() []AppSource {
have := load().Providers
used := map[string]bool{} // ids across all sources, so two never collide
var out []AppSource
for _, r := range appReaders {
s := AppSource{ID: r.id, Name: r.name, Path: r.path(), Items: []AppImport{}}
if _, err := os.Stat(s.Path); err == nil {
s.Found = true
items, err := r.read(s.Path)
if err != nil {
s.Error = err.Error()
}
s.Items = append(s.Items, settle(items, have, used)...) // [] rather than null when there is nothing
}
out = append(out, s)
}
return out
}
// ImportFromApps adds what the user picked, reading the sources again so
// keys never pass through the window. It answers the names added.
func ImportFromApps(picks []AppPick) ([]string, error) {
sources := map[string]AppSource{}
for _, s := range ImportSources() {
sources[s.ID] = s
}
var added []string
for _, pk := range picks {
var it *AppImport
for i, x := range sources[pk.Source].Items {
if x.Ref == pk.Ref {
it = &sources[pk.Source].Items[i]
}
}
if it == nil {
return added, errorf("%s no longer has that provider; reopen the import", pk.Source)
}
if it.Skip != "" || it.Status == "same" {
continue
}
p := it.Provider
switch {
case pk.Mode == "key" && it.KeyOf != "":
if h, err := Find(it.KeyOf); err == nil && sameProvider(*h, p) {
continue // picked twice, from two apps
}
if err := AddKey(it.KeyOf, p.Name, p.Key, ""); err != nil {
return added, errorf("%s: %v", p.Name, err)
}
added = append(added, p.Name)
continue
case pk.Mode == "replace":
default:
// the id may have been taken since, by an earlier pick
if h, err := Find(p.ID); err == nil {
if sameProvider(*h, p) {
continue
}
// a second one of a preset is still that preset's, under an
// id of its own
p.ID = freeID(p.ID)
}
p.Name = freeName(p.Name) // found by name too, so never two alike
}
if err := Save(p); err != nil {
return added, errorf("%s: %v", p.Name, err)
}
added = append(added, p.Name)
}
return added, nil
}
// freeID is id, or id-2, id-3… whichever no provider has.
func freeID(id string) string {
taken := map[string]bool{"magpie": true}
for _, id := range accountIDs {
taken[id] = true
}
for _, pp := range plugin.Cached() { // signed in or not, as a built-in's
taken[PluginID(pp.ID)] = true
}
for _, p := range All() {
taken[p.ID] = true
}
for _, p := range load().Providers {
taken[p.ID] = true
}
for n, try := 2, id; ; n++ {
if !taken[try] {
return try
}
try = id + "-" + itoa(n)
}
}
func itoa(n int) string {
b, _ := json.Marshal(n)
return string(b)
}
// settle gives each importable entry a unique id and says how it stands
// against the providers magpie has.
func settle(items []AppImport, have []Provider, used map[string]bool) []AppImport {
for i := range items {
it := &items[i]
if it.Skip != "" {
continue
}
p := &it.Provider
if p.ID == "" || p.ID == "magpie" {
p.ID = Slug(p.Name)
}
if p.ID == "" || p.ID == "magpie" {
p.ID = "imported"
}
for base, n := p.ID, 2; used[p.ID]; n++ {
p.ID = base + "-" + itoa(n)
}
used[p.ID] = true
it.Status = "new"
for _, h := range have {
if h.Hidden {
continue
}
if sameProvider(h, *p) {
it.Status, it.Existing = "same", h.Name
break
}
if h.ID == p.ID {
it.Status, it.Existing = "taken", h.Name
}
if it.KeyOf == "" && h.Key != "" && sameProvider(Provider{
Key: p.Key, Chat: h.Chat, Responses: h.Responses, Anthropic: h.Anthropic, Headers: h.Headers,
}, *p) {
it.KeyOf = h.ID
}
}
if it.Status == "same" {
it.KeyOf = ""
} else if it.KeyOf != "" && it.Existing == "" {
if h, err := Find(it.KeyOf); err == nil {
it.Existing = h.Name
}
}
if _, ok := find(Accounts(), p.ID); ok && it.Status == "new" {
it.Status, it.Existing = "taken", p.ID
}
}
// importable first, then by name; what can't be imported goes last
sort.SliceStable(items, func(i, j int) bool { return (items[i].Skip == "") && (items[j].Skip != "") })
return items
}
// sameProvider: the same key, host, and headers are the same account; a is
// the provider magpie has, which may hold the key among its others.
func sameProvider(a, b Provider) bool {
if !maps.Equal(cleanHeaders(a.Headers), cleanHeaders(b.Headers)) {
return false
}
has := a.Key == b.Key
for _, k := range a.Keys {
has = has || k.Key == b.Key
}
if !has {
return false
}
hosts := map[string]bool{}
for _, u := range []string{a.Chat, a.Responses, a.Anthropic, a.Decide} {
if h := hostOf(u); h != "" {
hosts[h] = true
}
}
for _, u := range []string{b.Chat, b.Responses, b.Anthropic} {
if hosts[hostOf(u)] {
return true
}
}
return false
}
func hostOf(raw string) string {
u, err := url.Parse(raw)
if err != nil {
return ""
}
return strings.ToLower(u.Host)
}
// endpoints are the base URLs an entry names, by protocol.
type endpoints struct{ chat, responses, anthropic string }
// imported builds the provider for an entry: a preset's when the entry
// points at one magpie knows (its endpoints whole when the URL is the
// preset's own, only its name and logo when it is another path on the same
// host, like a coding plan), a custom one otherwise.
func imported(name, key string, e endpoints, models []string) (Provider, string) {
e.chat, e.responses, e.anthropic = cleanBase(e.chat), cleanBase(e.responses), cleanBase(e.anthropic)
for _, u := range []string{e.chat, e.responses, e.anthropic} {
if u == "" {
continue
}
pu, err := url.Parse(u)
if err != nil || (pu.Scheme != "http" && pu.Scheme != "https") || pu.Host == "" || !strings.Contains(pu.Host, ".") && !strings.HasPrefix(pu.Host, "localhost") {
return Provider{}, "its base URL isn't valid"
}
if gatewayURL(pu) {
return Provider{}, "it points at magpie itself"
}
}
if e.chat == "" && e.responses == "" && e.anthropic == "" {
return Provider{}, "it has no base URL"
}
p := Provider{ID: Slug(name), Name: name, Key: key, Chat: e.chat, Responses: e.responses, Anthropic: e.anthropic, Models: models}
if p.IsAzure() {
// an Azure OpenAI resource, from any app: the preset's, its
// endpoints on the resource's v1 API
p = asAzure(p)
} else if pr, exact := presetAt(e); pr != nil {
p.ID, p.Icon, p.Catalog = pr.ID, pr.Icon, pr.Catalog
if exact {
p.Preset, p.Website, p.KeysURL = pr.ID, pr.Website, pr.KeysURL
p.Chat, p.Responses, p.Anthropic = pr.Chat, pr.Responses, pr.Anthropic
if n := strings.ToLower(name); n == "" || n == "default" {
p.Name = pr.Name
}
}
}
if p.Key == "" && !keyOptional(p) {
return Provider{}, "it has no API key"
}
return p, ""
}
func cleanBase(u string) string {
u = strings.TrimRight(strings.TrimSpace(u), "/")
if u != "" && !strings.Contains(u, "://") {
u = "https://" + u
}
return u
}
// gatewayURL is magpie's own address: importing it would loop.
func gatewayURL(u *url.URL) bool {
h := u.Hostname()
return (h == "127.0.0.1" || h == "localhost") && u.Port() == "3425"
}
// presetAt finds the preset serving an entry's host, and whether the entry
// uses exactly that preset's endpoints.
func presetAt(e endpoints) (*PresetDef, bool) {
given := map[string]string{"chat": e.chat, "responses": e.responses, "anthropic": e.anthropic}
for i := range presets {
pr := &presets[i]
if pr.Kind == KindLocal {
continue
}
own := map[string]string{"chat": pr.Chat, "responses": pr.Responses, "anthropic": pr.Anthropic}
hit, exact := false, true
for k, u := range given {
if u == "" {
continue
}
if own[k] != "" && hostOf(own[k]) == hostOf(u) || hostOf(pr.Chat) == hostOf(u) || hostOf(pr.Anthropic) == hostOf(u) {
hit = true
}
if !strings.EqualFold(own[k], u) {
exact = false
}
}
if hit {
return pr, exact
}
}
return nil, false
}
// ---- CC Switch -------------------------------------------------------------
func ccSwitchPath() string {
home, _ := os.UserHomeDir()
dir := filepath.Join(home, ".cc-switch")
if db := filepath.Join(dir, "cc-switch.db"); fileExists(db) {
return db
}
if js := filepath.Join(dir, "config.json"); fileExists(js) {
return js
}
return filepath.Join(dir, "cc-switch.db")
}
func fileExists(p string) bool {
_, err := os.Stat(p)
return err == nil
}
func claudeSettingsPath() string {
if dir := os.Getenv("CLAUDE_CONFIG_DIR"); dir != "" {
return filepath.Join(dir, "settings.json")
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".claude", "settings.json")
}
// readClaudeSettings is the relay Claude Code was pointed at in its own
// settings.json, before magpie: its base URL and token under env. While
// Claude Code goes through magpie those are in magpie's stash, to be put
// back when it leaves; the stash is read then.
func readClaudeSettings(path string) ([]AppImport, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var s map[string]any
if err := json.Unmarshal(jsonc.ToJSON(raw), &s); err != nil {
return nil, errors.New("Claude Code's settings.json: " + err.Error())
}
env := mapOf(s["env"])
if base, _ := env["ANTHROPIC_BASE_URL"].(string); base != "" {
if u, err := url.Parse(cleanBase(base)); err == nil && gatewayURL(u) {
var st map[string]string
if b, err := os.ReadFile(filepath.Join(filepath.Dir(Path()), "stash.json")); err == nil {
json.Unmarshal(b, &st)
}
env = map[string]any{"ANTHROPIC_BASE_URL": st["claude.base_url"], "ANTHROPIC_AUTH_TOKEN": st["claude.auth_token"], "ANTHROPIC_MODEL": st["claude.model"]}
}
}
base, _ := env["ANTHROPIC_BASE_URL"].(string)
if strings.TrimSpace(base) == "" {
return nil, nil // Anthropic's own endpoint, the agent's sign-in
}
e := ccEntry{id: "settings", app: "claude", name: hostOf(cleanBase(base)), settings: map[string]any{"env": env}}
it := AppImport{Ref: "settings", From: "settings.json"}
name, key, eps, models, skip := ccSwitchEntry(e)
if skip == "" {
it.Provider, skip = imported(name, key, eps, models)
}
if skip != "" {
it.Provider = Provider{Name: e.name}
it.Skip = skip
}
return []AppImport{it}, nil
}
// ccSwitchApps names CC Switch's app types as the picker shows them.
var ccSwitchApps = map[string]string{
"claude": "Claude Code", "claude-desktop": "Claude Desktop", "codex": "Codex",
"gemini": "Gemini CLI", "opencode": "OpenCode", "openclaw": "OpenClaw", "pi": "Pi", "hermes": "Hermes",
}
type ccEntry struct {
id, app, name, website, category string
settings map[string]any
}
func readCCSwitch(path string) ([]AppImport, error) {
var entries []ccEntry
if strings.HasSuffix(path, ".db") {
db, err := openReadOnly(path)
if err != nil {
return nil, err
}
defer db.Close()
rows, err := db.Query(`SELECT id, app_type, name, settings_config, COALESCE(website_url,''), COALESCE(category,'') FROM providers ORDER BY app_type, COALESCE(sort_index, 0), created_at`)
if err != nil {
return nil, errors.New("CC Switch's database: " + err.Error())
}
defer rows.Close()
for rows.Next() {
var e ccEntry
var raw string
if rows.Scan(&e.id, &e.app, &e.name, &raw, &e.website, &e.category) != nil {
continue
}
json.Unmarshal([]byte(raw), &e.settings)
entries = append(entries, e)
}
} else {
// CC Switch before its database: one config.json, a section per app
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg map[string]json.RawMessage
if err := json.Unmarshal(jsonc.ToJSON(raw), &cfg); err != nil {
return nil, errors.New("CC Switch's config.json: " + err.Error())
}
for app, sec := range cfg {
var s struct {
Providers map[string]struct {
Name string `json:"name"`
Settings map[string]any `json:"settingsConfig"`
Website string `json:"websiteUrl"`
Category string `json:"category"`
} `json:"providers"`
}
if json.Unmarshal(sec, &s) != nil {
continue
}
for id, p := range s.Providers {
entries = append(entries, ccEntry{id: id, app: app, name: p.Name, website: p.Website, category: p.Category, settings: p.Settings})
}
}
sort.Slice(entries, func(i, j int) bool { return entries[i].app+entries[i].id < entries[j].app+entries[j].id })
}
var out []AppImport
for _, e := range entries {
it := AppImport{Ref: e.app + "/" + e.id, From: ccSwitchApps[e.app]}
if it.From == "" {
it.From = e.app
}
name, key, eps, models, skip := ccSwitchEntry(e)
if skip == "" {
it.Provider, skip = imported(name, key, eps, models)
}
if skip != "" {
it.Provider = Provider{Name: e.name}
it.Skip = skip
} else if w := cleanBase(e.website); strings.HasPrefix(w, "http") && it.Provider.Preset == "" {
it.Provider.Website = w
}
out = append(out, merge(out, it)...)
}
return out, nil
}
// merge folds an entry into one already read for the same account — a
// relay set up for both Claude Code and Codex is one provider with two
// endpoints — or answers it to be added.
func merge(have []AppImport, it AppImport) []AppImport {
if it.Skip != "" {
return []AppImport{it}
}
for i := range have {
h := &have[i].Provider
if have[i].Skip != "" || h.Key != it.Provider.Key || h.Key == "" || !sameProvider(*h, it.Provider) && hostOf(firstURL(*h)) != hostOf(firstURL(it.Provider)) {
continue
}
for _, f := range []struct{ dst, src *string }{{&h.Chat, &it.Provider.Chat}, {&h.Responses, &it.Provider.Responses}, {&h.Anthropic, &it.Provider.Anthropic}} {
if *f.dst == "" {
*f.dst = *f.src
}
}
h.Models = append(h.Models, it.Provider.Models...)
have[i].From += ", " + it.From
return nil
}
return []AppImport{it}
}
func firstURL(p Provider) string {
for _, u := range []string{p.Chat, p.Responses, p.Anthropic} {
if u != "" {
return u
}
}
return ""
}
// ccSwitchEntry reads one CC Switch provider: its settings are the agent's
// own configuration, which differs per app.
func ccSwitchEntry(e ccEntry) (name, key string, eps endpoints, models []string, skip string) {
s := e.settings
name = strings.TrimSpace(e.name)
env := mapOf(s["env"])
str := func(m map[string]any, keys ...string) string {
for _, k := range keys {
if v, ok := m[k].(string); ok && strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
switch e.app {
case "claude", "claude-desktop":
base := str(env, "ANTHROPIC_BASE_URL")
key = str(env, "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY")
if base == "" && key == "" {
return name, "", eps, nil, "it is the agent's own sign-in; add the subscription in magpie"
}
if base == "" {
base = "https://api.anthropic.com"
}
eps.anthropic = base
for _, k := range []string{"ANTHROPIC_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", "ANTHROPIC_SMALL_FAST_MODEL"} {
if m := str(env, k); m != "" {
models = append(models, m)
}
}
case "codex":
key = str(mapOf(s["auth"]), "OPENAI_API_KEY")
cfg, _ := s["config"].(string)
top, tables := parseTOML(cfg)
mp := tables["model_providers."+top["model_provider"]]
if top["model_provider"] != "" && mp == nil {
mp = tables[`model_providers."`+top["model_provider"]+`"`]
}
base := mp["base_url"]
if key == "" {
key = mp["experimental_bearer_token"]
}
if base == "" && key == "" {
return name, "", eps, nil, "it is the agent's own sign-in; add the subscription in magpie"
}
if base == "" {
base = "https://api.openai.com/v1"
}
if mp["wire_api"] == "chat" {
eps.chat = base
} else {
eps.responses = base
}
if m := top["model"]; m != "" {
models = append(models, m)
}
case "gemini":
key = str(env, "GEMINI_API_KEY", "GOOGLE_API_KEY")
if str(env, "GOOGLE_GEMINI_BASE_URL") != "" {
return name, "", eps, nil, "magpie doesn't talk the Gemini protocol to relays yet"
}
if key == "" {
return name, "", eps, nil, "it is the agent's own sign-in"
}
pr := Preset("google")
eps.chat, eps.responses, eps.anthropic = pr.Chat, pr.Responses, pr.Anthropic
if m := str(env, "GEMINI_MODEL"); m != "" {
models = append(models, m)
}
default:
// OpenCode, OpenClaw, Pi…: a base URL and a key, with the API's
// shape named by an api or npm field
opts := mapOf(s["options"])
base := str(s, "baseUrl", "baseURL", "base_url")
if base == "" {
base = str(opts, "baseURL", "baseUrl", "base_url")
}
key = str(s, "apiKey", "api_key")
if key == "" {
key = str(opts, "apiKey", "api_key")
}
if base == "" {
return name, "", eps, nil, "it has no base URL"
}
switch api := strings.ToLower(str(s, "api", "npm", "type")); {
case strings.Contains(api, "anthropic"):
eps.anthropic = base
case strings.Contains(api, "responses"):
eps.responses = base
default:
eps.chat = base
}
switch ms := s["models"].(type) {
case []any:
for _, m := range ms {
if id := str(mapOf(m), "id"); id != "" {
models = append(models, id)
} else if id, ok := m.(string); ok {
models = append(models, id)
}
}
case map[string]any:
for id := range ms {
models = append(models, id)
}
sort.Strings(models)
}
}
return name, key, eps, models, ""
}
func mapOf(v any) map[string]any {
m, _ := v.(map[string]any)
if m == nil {
return map[string]any{}
}
return m
}
// parseTOML reads the string values of a Codex config: top-level keys, and
// each table's keys under its header.
func parseTOML(s string) (map[string]string, map[string]map[string]string) {
top, tables := map[string]string{}, map[string]map[string]string{}
cur := top
for _, line := range strings.Split(s, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
name := strings.TrimSpace(strings.Trim(line, "[]"))
cur = map[string]string{}
tables[name] = cur
continue
}
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
v = strings.TrimSpace(v)
if i := strings.Index(v, " #"); i > 0 && !strings.HasPrefix(v, `"`) {
v = strings.TrimSpace(v[:i])
}
if len(v) >= 2 && (v[0] == '"' || v[0] == '\'') {
if end := strings.LastIndexByte(v, v[0]); end > 0 {
v = v[1:end]
}
}
cur[strings.Trim(strings.TrimSpace(k), `"`)] = v
}
return top, tables
}
// ---- Alma ------------------------------------------------------------------
func almaPath() string {
dir, _ := os.UserConfigDir()
return filepath.Join(dir, "alma", "chat_threads.db")
}
// almaPresets are Alma's built-in provider types by the preset they are.
var almaPresets = map[string]string{
"openai": "openai", "anthropic": "anthropic", "google": "google", "deepseek": "deepseek",
"openrouter": "openrouter", "moonshot": "moonshot", "xai": "xai", "mistral": "mistral",
"groq": "groq", "aihubmix": "aihubmix", "together": "together", "fireworks": "fireworks",
"siliconflow": "siliconflow", "ollama": "ollama", "lmstudio": "lmstudio", "opencode-go": "opencode-go",
}
// almaSignIns are Alma's types that are an app's own sign-in, not a key.
var almaSignIns = map[string]bool{
"acp": true, "copilot": true, "claude-subscription": true, "codex-subscription": true,
"gemini-cli": true, "antigravity": true, "cursor": true,
}
func readAlma(path string) ([]AppImport, error) {
db, err := openReadOnly(path)
if err != nil {
return nil, err
}
defer db.Close()
rows, err := db.Query(`SELECT id, name, type, api_key, models, COALESCE(base_url,''), enabled, COALESCE(api_format,''), COALESCE(is_response_api,0), COALESCE(custom_headers,'') FROM providers ORDER BY created_at`)
if err != nil {
return nil, errors.New("Alma's database: " + err.Error())
}
defer rows.Close()
var out []AppImport
for rows.Next() {
var id, name, typ, key, modelsJSON, base, format, headers string
var enabled, responses bool
if err := rows.Scan(&id, &name, &typ, &key, &modelsJSON, &base, &enabled, &format, &responses, &headers); err != nil {
continue
}
it := AppImport{Ref: id}
var models []string
json.Unmarshal([]byte(modelsJSON), &models)
var eps endpoints
skip := ""
switch {
case almaSignIns[typ]:
skip = "it is a sign-in, not a key; add the subscription in magpie"
case typ == "azure":
// the resource's endpoint (or only its name); one behind a
// gateway of the user's is kept as it is, asked as Azure's
if b, ok := AzureBase(base); ok {
eps.chat = b
} else if base != "" {
eps.chat = base
} else {
skip = "it has no Azure OpenAI endpoint"
}
case base == "" && Preset(almaPresets[typ]) != nil:
pr := Preset(almaPresets[typ])
eps = endpoints{pr.Chat, pr.Responses, pr.Anthropic}
case format == "anthropic" || typ == "anthropic":
eps.anthropic = base
case format == "gemini" || typ == "google" && base != "":
skip = "magpie doesn't talk the Gemini protocol to relays yet"
case responses || format == "openai-responses":
eps.responses = base
default:
eps.chat = base
}
if skip == "" {
it.Provider, skip = imported(name, key, eps, models)
if skip == "" && typ == "azure" {
it.Provider = asAzure(it.Provider)
}
}
if skip != "" {
it.Provider, it.Skip = Provider{Name: name}, skip
} else {
var hs map[string]string
if json.Unmarshal([]byte(headers), &hs) == nil {
it.Provider.Headers = hs
}
if !enabled {
it.Off = "turned off in Alma"
}
}
out = append(out, it)
}
return out, nil
}
// sqlitePath is a file's path as a SQLite URI wants it: slashed, and on
// Windows with a slash before the drive (file:///C:/…) — without it the
// drive letter is read as a host, and the open fails.
func sqlitePath(path string) string {
p := filepath.ToSlash(path)
if len(p) >= 2 && p[1] == ':' {
p = "/" + p
}
return p
}
// openReadOnly opens another app's SQLite database without writing to it,
// waiting out the app's own writes.
func openReadOnly(path string) (*sql.DB, error) {
u := url.URL{Scheme: "file", Path: sqlitePath(path), RawQuery: "mode=ro&_pragma=busy_timeout(3000)"}
db, err := sql.Open("sqlite", u.String())
if err != nil {
return nil, err
}
if err := db.Ping(); err != nil {
db.Close()
return nil, errors.New("can't open " + path + ": " + err.Error())
}
return db, nil
}
// OpenReadOnly opens another app's SQLite database without writing to it.
func OpenReadOnly(path string) (*sql.DB, error) { return openReadOnly(path) }
// CCSwitchSkillsDir is the folder CC Switch keeps the skills it installs in.
func CCSwitchSkillsDir() string { return filepath.Join(filepath.Dir(ccSwitchPath()), "skills") }
// CCSwitchSkillOrigin is the GitHub repository (owner/name) and branch CC
// Switch installed the skill in its folder dir from, as its database
// records it.
func CCSwitchSkillOrigin(dir string) (repo, branch string, ok bool) {
p := ccSwitchPath()
if filepath.Ext(p) != ".db" || !fileExists(p) {
return "", "", false
}
db, err := openReadOnly(p)
if err != nil {
return "", "", false
}
defer db.Close()
rows, err := db.Query(`SELECT directory, COALESCE(repo_owner,''), COALESCE(repo_name,''), COALESCE(repo_branch,'') FROM skills`)
if err != nil {
return "", "", false
}
defer rows.Close()
for rows.Next() {
var d, owner, name, ref string
if rows.Scan(&d, &owner, &name, &ref) != nil || owner == "" || name == "" {
continue
}
if d == dir || filepath.Base(filepath.FromSlash(d)) == dir {
return owner + "/" + name, ref, true
}
}
return "", "", false
}