Files
magpie/internal/provider/importapps.go
T

819 lines
25 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"
_ "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 _, 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 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":
skip = "magpie doesn't support Azure OpenAI yet"
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 != "" {
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
}