From 71cd7543a1dadc80ff13c7bcd96083c7aea0b656 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 31 Jul 2026 15:16:58 -0400 Subject: [PATCH 01/13] Allow the cookie name to be configurable --- cmd/auth/login/login.go | 11 ++++++++--- cmd/config/config.go | 4 ++++ cmd/root.go | 2 +- cmd/up/client/client.go | 2 +- go.mod | 5 +++-- go.sum | 4 ---- internal/api/client.go | 13 ++++++++++++- internal/api/global.go | 13 +++++++++---- internal/config/config.go | 13 +++++++++++++ 9 files changed, 51 insertions(+), 16 deletions(-) diff --git a/cmd/auth/login/login.go b/cmd/auth/login/login.go index dba30f3..779aa69 100644 --- a/cmd/auth/login/login.go +++ b/cmd/auth/login/login.go @@ -34,15 +34,19 @@ func getDeviceName() string { return hostname } -func loginWithWeb(hostname string) (string, error) { +func loginWithWeb(hostname string, sessionCookieName string) (string, error) { // Build base URL for login (use hostname as-is, StartDeviceWebAuth will add /api/v1) baseURL := hostname + if sessionCookieName == "" { + sessionCookieName = "p_session_token" + } + // Create a temporary API client for login (without auth) loginClient, err := api.NewClient(api.ClientConfig{ BaseURL: baseURL, AgentName: "pangolin-cli", - SessionCookieName: "p_session_token", + SessionCookieName: sessionCookieName, CSRFToken: "x-csrf-protection", }) if err != nil { @@ -182,6 +186,7 @@ func loginMain(cmd *cobra.Command, opts *LoginCmdOpts) error { apiClient := api.FromContext(cmd.Context()) accountStore := config.AccountStoreFromContext(cmd.Context()) + cfg := config.ConfigFromContext(cmd.Context()) hostname := opts.Hostname @@ -237,7 +242,7 @@ func loginMain(cmd *cobra.Command, opts *LoginCmdOpts) error { } // Perform web login - sessionToken, err := loginWithWeb(hostname) + sessionToken, err := loginWithWeb(hostname, cfg.SessionCookieName) if err != nil { logger.Error("%v", err) return err diff --git a/cmd/config/config.go b/cmd/config/config.go index 50255bd..07e4145 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -124,6 +124,10 @@ func dumpConfig(cfg *config.Config) error { "disable_companion_mode": cfg.DisableCompanionMode, } + if cfg.SessionCookieName != "" { + out["session_cookie_name"] = cfg.SessionCookieName + } + up := map[string]any{} if cfg.IsSet("up.tunnel_dns") { up["tunnel_dns"] = cfg.GetBool("up.tunnel_dns") diff --git a/cmd/root.go b/cmd/root.go index 377d662..f1b3fe6 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -168,7 +168,7 @@ func initAuthContext(ctx context.Context, cfg *config.Config) (context.Context, sessionToken = activeAccount.SessionToken } - client, err := api.InitClient(apiBaseURL, sessionToken) + client, err := api.InitClient(apiBaseURL, sessionToken, cfg.SessionCookieName) if err != nil { return ctx, err } diff --git a/cmd/up/client/client.go b/cmd/up/client/client.go index 09b3db2..d212050 100644 --- a/cmd/up/client/client.go +++ b/cmd/up/client/client.go @@ -227,7 +227,7 @@ func clientUpMain(cmd *cobra.Command, opts *ClientUpCmdOpts, extraArgs []string) } else { // Create a temporary client for health check var err error - healthClient, err = api.InitClient(endpoint, "") + healthClient, err = api.InitClient(endpoint, "", cfg.SessionCookieName) if err != nil { logger.Error("Error: failed to create API client for health check: %v", err) return err diff --git a/go.mod b/go.mod index 4f01339..8a7e746 100644 --- a/go.mod +++ b/go.mod @@ -78,5 +78,6 @@ require ( // If changes to Olm or Newt are required, use these // replace directives during development. // -// replace github.com/fosrl/olm => ../olm -// replace github.com/fosrl/newt => ../newt +replace github.com/fosrl/olm => ../olm + +replace github.com/fosrl/newt => ../newt diff --git a/go.sum b/go.sum index dfdc6e0..617329f 100644 --- a/go.sum +++ b/go.sum @@ -50,10 +50,6 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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/fosrl/newt v1.15.0 h1:WpL0whZM1FMjUe2Vy5jSH1bgbxm1O9k1qCyF/mqZT+s= -github.com/fosrl/newt v1.15.0/go.mod h1:l6kWoZPSaXT+ZRUjiyPgwflRqZWYaXpUj9oQ0sOPh4o= -github.com/fosrl/olm v1.8.0 h1:9X+3GRLZzVYXrdvJmL3czRuDK/RD7Y2BLbzonm7SpVM= -github.com/fosrl/olm v1.8.0/go.mod h1:r4GTKfN0sf5L6AQgStwx9lyURsNcog+faX/dn94JOLM= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= diff --git a/internal/api/client.go b/internal/api/client.go index 3986297..83a0bac 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -204,10 +204,21 @@ func (c *Client) SetBaseURL(baseURL string) { c.BaseURL = strings.TrimSuffix(baseURL, "/") } -// SetToken updates the token for the client +// SetToken updates the token for the client, preserving any configured +// SessionCookieName/CSRFToken overrides. func (c *Client) SetToken(token string) { + cookieName := c.Session.SessionCookieName + csrfToken := c.Session.CSRFToken + c.Session = NewUserClientSession() c.Session.SessionToken = token + + if cookieName != "" { + c.Session.SessionCookieName = cookieName + } + if csrfToken != "" { + c.Session.CSRFToken = csrfToken + } } // WithIntegrationAPIKey clones the current client and switches it to use diff --git a/internal/api/global.go b/internal/api/global.go index 3b935ac..be79164 100644 --- a/internal/api/global.go +++ b/internal/api/global.go @@ -7,8 +7,9 @@ import ( // InitClient initializes a new API client with stored credentials and // a URL. The client will be created without authentication if no token -// is found. -func InitClient(hostname string, token string) (*Client, error) { +// is found. sessionCookieName overrides the default session cookie name +// when non-empty (see Config.SessionCookieName). +func InitClient(hostname string, token string, sessionCookieName string) (*Client, error) { // Build base URL (hostname should already include protocol from login) baseURL := hostname if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") { @@ -17,13 +18,17 @@ func InitClient(hostname string, token string) (*Client, error) { } baseURL = strings.TrimSuffix(baseURL, "/") + "/api/v1" + if sessionCookieName == "" { + sessionCookieName = defaultSessionCookieName + } + // Create API client (this should never fail, but handle it just in case) client, err := NewClient(ClientConfig{ BaseURL: baseURL, AgentName: "pangolin-cli", Token: token, - SessionCookieName: "p_session_token", - CSRFToken: "x-csrf-protection", + SessionCookieName: sessionCookieName, + CSRFToken: defaultCSRFToken, }) if err != nil { return nil, fmt.Errorf("failed to create API client: %w", err) diff --git a/internal/config/config.go b/internal/config/config.go index 4b2810c..80e74ff 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,6 +24,10 @@ type Config struct { DisableCompanionMode bool `mapstructure:"disable_companion_mode" json:"disable_companion_mode"` CompanionAppDataDirs CompanionAppDataDirs `mapstructure:"companion_app_data_dirs" json:"companion_app_data_dirs"` Up UpConfig `mapstructure:"up" json:"up,omitempty"` + + // SessionCookieName overrides the cookie name used for the CLI's session + // token. Empty means the API client's built-in default is used. + SessionCookieName string `mapstructure:"session_cookie_name" json:"session_cookie_name,omitempty"` } // UpConfig holds persistent defaults for pangolin up DNS-related flags. @@ -65,6 +69,7 @@ var ConfigOptions = []string{ "up.override_dns", "up.match_domains_dns", "up.prefer_local_routes", + "session_cookie_name", } // SupportedConfigKeys returns the settable config keys. @@ -251,6 +256,9 @@ func (c *Config) SetKey(key, value string) error { } c.Up.PreferLocalRoutes = &b c.v.Set(key, b) + case "session_cookie_name": + c.SessionCookieName = value + c.v.Set(key, value) default: return fmt.Errorf("unknown config key %q; supported keys: %s", key, strings.Join(SupportedConfigKeys(), ", ")) } @@ -293,6 +301,8 @@ func (c *Config) GetKey(key string) (string, error) { return "", errConfigKeyUnset(key) } return fmt.Sprintf("%t", c.GetBool(key)), nil + case "session_cookie_name": + return c.SessionCookieName, nil default: return "", fmt.Errorf("unknown config key %q; supported keys: %s", key, strings.Join(SupportedConfigKeys(), ", ")) } @@ -331,6 +341,9 @@ func (c *Config) Save() error { c.v.Set("disable_update_check", c.DisableUpdateCheck) c.v.Set("disable_companion_mode", c.DisableCompanionMode) c.v.Set("companion_app_data_dirs", c.CompanionAppDataDirs) + if c.SessionCookieName != "" { + c.v.Set("session_cookie_name", c.SessionCookieName) + } // Only persist up keys that were explicitly set so we do not write // zero-value bools that would later look like intentional overrides. From 2f0ce67fac668a94677f36c7600398a18260dfc2 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 3 Aug 2026 17:33:16 -0400 Subject: [PATCH 02/13] Bump version --- Makefile | 2 +- internal/version/consts.go | 2 +- pangolin-cli.wxs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 5fee751..f8ab9d9 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ BINARY_NAME=pangolin OUTPUT_DIR=bin -VERSION ?= 0.15.0 +VERSION ?= 0.15.1 LDFLAGS = -s -w -X github.com/fosrl/cli/internal/version.Version=$(VERSION) all: clean build diff --git a/internal/version/consts.go b/internal/version/consts.go index 580d617..0788551 100644 --- a/internal/version/consts.go +++ b/internal/version/consts.go @@ -4,4 +4,4 @@ package version // This value can be overridden at build time using ldflags: // // go build -ldflags "-X github.com/fosrl/cli/internal/version.Version=" -var Version = "0.15.0" +var Version = "0.15.1" diff --git a/pangolin-cli.wxs b/pangolin-cli.wxs index 42affb2..997fb28 100644 --- a/pangolin-cli.wxs +++ b/pangolin-cli.wxs @@ -2,7 +2,7 @@ From 0cbd465f8da13f94873da7ae908c92427549d4ae Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 4 Aug 2026 15:46:06 -0400 Subject: [PATCH 03/13] add uapi to the tunnel config --- cmd/up/client/client.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/up/client/client.go b/cmd/up/client/client.go index d212050..10a0015 100644 --- a/cmd/up/client/client.go +++ b/cmd/up/client/client.go @@ -636,6 +636,7 @@ func clientUpMain(cmd *cobra.Command, opts *ClientUpCmdOpts, extraArgs []string) UserToken: userToken, InitialFingerprint: initialFingerprint, InitialPostures: initialPostures, + EnableUAPI: false, // ONLY FOR DEBUG: TODO MAKE FALSE } // Check if running with elevated permissions (required for network interface creation) From d0421836f2271fcbdcac0a674aa16de652bd3d0f Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 5 Aug 2026 10:33:54 -0400 Subject: [PATCH 04/13] show the exit node status --- cmd/status/client/client.go | 15 +++++++++++++-- internal/olm/client.go | 10 ++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/cmd/status/client/client.go b/cmd/status/client/client.go index 3fd72c3..2d3feba 100644 --- a/cmd/status/client/client.go +++ b/cmd/status/client/client.go @@ -87,12 +87,23 @@ func printStatusTable(status *olm.StatusResponse) { } utils.PrintTable(headers, rows) - // Print peers if there are any - if len(status.PeerStatuses) > 0 { + // Print peers (and the exit node, if connected) if there are any + if len(status.PeerStatuses) > 0 || status.ExitNode != nil { fmt.Println("") peerHeaders := []string{"SITE", "ENDPOINT", "STATUS", "LAST SEEN", "CONNECTION"} peerRows := [][]string{} + if status.ExitNode != nil { + lastSeen := formatLastSeen(status.ExitNode.LastSeen.Format(time.RFC3339)) + peerRows = append(peerRows, []string{ + "Pangolin Server", + status.ExitNode.Endpoint, + formatStatus(status.ExitNode.Connected, true), + lastSeen, + "Direct", + }) + } + for _, peer := range status.PeerStatuses { lastSeen := formatLastSeen(peer.LastSeen.Format(time.RFC3339)) diff --git a/internal/olm/client.go b/internal/olm/client.go index 323f2c7..fd9577c 100644 --- a/internal/olm/client.go +++ b/internal/olm/client.go @@ -36,6 +36,16 @@ type StatusResponse struct { PeerStatuses map[int]*OLMPeerStatus `json:"peers,omitempty"` NetworkSettings map[string]interface{} `json:"networkSettings,omitempty"` Error *StatusError `json:"error,omitempty"` + ExitNode *OLMExitNodeStatus `json:"exitNode,omitempty"` +} + +// OLMExitNodeStatus represents the connectivity status of the client's own exit +// node connection (used for site resources hosted on the exit node) +type OLMExitNodeStatus struct { + Connected bool `json:"connected"` + RTT time.Duration `json:"rtt"` + LastSeen time.Time `json:"lastSeen"` + Endpoint string `json:"endpoint,omitempty"` } // OLMPeerStatus represents the status of a peer connection From 8d7f1b37538989bbcb39a3043ab42dc1f42e5606 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 14 Aug 2026 14:05:04 -0400 Subject: [PATCH 05/13] Support applying blueprints with glob pattern * --- cmd/apply/blueprint/blueprint.go | 72 +++++++++++++++++++++++++++----- 1 file changed, 62 insertions(+), 10 deletions(-) diff --git a/cmd/apply/blueprint/blueprint.go b/cmd/apply/blueprint/blueprint.go index ac84773..99e1b36 100644 --- a/cmd/apply/blueprint/blueprint.go +++ b/cmd/apply/blueprint/blueprint.go @@ -17,7 +17,7 @@ import ( type BlueprintCmdOpts struct { Name string - Path string + Paths []string APIKey string Endpoint string OrgID string @@ -29,7 +29,8 @@ func BlueprintCmd() *cobra.Command { cmd := &cobra.Command{ Use: "blueprint", Short: "Apply a blueprint", - Long: "Apply a YAML blueprint to the Pangolin server", + Long: "Apply a YAML blueprint to the Pangolin server. --file may be a glob pattern (e.g. -f 'inference-*.yaml') or given multiple times; every matching file is applied one by one.", + Args: cobra.ArbitraryArgs, PreRunE: func(cmd *cobra.Command, args []string) error { // Integration API: any of the three flags implies all three are required (avoids silent session fallback). integration := opts.APIKey != "" || opts.Endpoint != "" || opts.OrgID != "" @@ -39,16 +40,29 @@ func BlueprintCmd() *cobra.Command { return nil }, RunE: func(cmd *cobra.Command, args []string) error { - if err := applyBlueprintMain(cmd, opts); err != nil { + // Extra positional args show up when the shell expands a glob (e.g. -f inference-*) + // before we ever see it; fold them in alongside anything passed via -f itself. + paths, err := resolveBlueprintPaths(append(append([]string{}, opts.Paths...), args...)) + if err != nil { return err } - logger.Info("Successfully applied blueprint!") + + if opts.Name != "" && len(paths) > 1 { + return errors.New("--name cannot be used when multiple blueprint files match; the name is derived from each filename instead") + } + + for _, path := range paths { + if err := applyBlueprintMain(cmd, opts, path); err != nil { + return fmt.Errorf("%s: %w", path, err) + } + logger.Info("Successfully applied blueprint: %s", path) + } return nil }, } - cmd.Flags().StringVarP(&opts.Path, "file", "f", "", "Blueprint YAML file path (use '-' for stdin)") - cmd.Flags().StringVarP(&opts.Name, "name", "n", "", "Blueprint name (default: filename without extension)") + cmd.Flags().StringArrayVarP(&opts.Paths, "file", "f", nil, "Blueprint YAML file path, or glob pattern (e.g. 'inference-*.yaml'); repeatable. Use '-' for stdin") + cmd.Flags().StringVarP(&opts.Name, "name", "n", "", "Blueprint name (default: filename without extension); only valid for a single file") cmd.Flags().StringVar(&opts.APIKey, "api-key", "", "Integration API key (id.secret)") cmd.Flags().StringVar(&opts.Endpoint, "endpoint", "", "Integration API host URL") cmd.Flags().StringVar(&opts.OrgID, "org", "", "Organization ID") @@ -57,14 +71,52 @@ func BlueprintCmd() *cobra.Command { return cmd } -func applyBlueprintMain(cmd *cobra.Command, opts BlueprintCmdOpts) error { - if opts.Path == "-" && strings.TrimSpace(opts.Name) == "" { +// resolveBlueprintPaths expands any glob patterns among the given tokens into +// concrete file paths, passes "-" (stdin) and non-glob paths through as-is, +// and dedupes the result while preserving order. +func resolveBlueprintPaths(tokens []string) ([]string, error) { + seen := make(map[string]bool) + var resolved []string + + add := func(path string) { + if !seen[path] { + seen[path] = true + resolved = append(resolved, path) + } + } + + for _, token := range tokens { + if token == "-" || !strings.ContainsAny(token, "*?[") { + add(token) + continue + } + + matches, err := filepath.Glob(token) + if err != nil { + return nil, fmt.Errorf("invalid glob pattern %q: %w", token, err) + } + if len(matches) == 0 { + return nil, fmt.Errorf("no files matched pattern %q", token) + } + for _, m := range matches { + add(m) + } + } + + if len(resolved) == 0 { + return nil, errors.New("no blueprint files specified") + } + return resolved, nil +} + +func applyBlueprintMain(cmd *cobra.Command, opts BlueprintCmdOpts, path string) error { + if path == "-" && strings.TrimSpace(opts.Name) == "" { return errors.New("name is required when using --file -") } name := opts.Name if name == "" { - filename := filepath.Base(opts.Path) + filename := filepath.Base(path) switch ext := strings.ToLower(filepath.Ext(filename)); ext { case ".yaml", ".yml": name = strings.TrimSuffix(filename, ext) @@ -79,7 +131,7 @@ func applyBlueprintMain(cmd *cobra.Command, opts BlueprintCmdOpts) error { apiClient := api.FromContext(cmd.Context()) accountStore := config.AccountStoreFromContext(cmd.Context()) - blueprintContents, err := readBlueprint(opts.Path) + blueprintContents, err := readBlueprint(path) if err != nil { return err } From b8ada5547f8ebc42c58f4687654d6519039d2d47 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 18 Aug 2026 16:58:44 -0400 Subject: [PATCH 06/13] Implement commands to write config for ai clients --- cmd/configure/claude.go | 34 +++++++ cmd/configure/codex.go | 82 ++++++++++++++++ cmd/configure/configfile.go | 73 +++++++++++++++ cmd/configure/configure.go | 154 +++++++++++++++++++++++++++++++ cmd/configure/opencode.go | 69 ++++++++++++++ cmd/root.go | 2 + docs/pangolin.md | 1 + docs/pangolin_apply_blueprint.md | 14 +-- docs/pangolin_config_set.md | 1 + docs/pangolin_configure.md | 29 ++++++ go.mod | 2 +- internal/api/client.go | 80 ++++++++++++++++ internal/api/types.go | 86 +++++++++++++++++ internal/utils/resource.go | 130 ++++++++++++++++++++++++++ 14 files changed, 749 insertions(+), 8 deletions(-) create mode 100644 cmd/configure/claude.go create mode 100644 cmd/configure/codex.go create mode 100644 cmd/configure/configfile.go create mode 100644 cmd/configure/configure.go create mode 100644 cmd/configure/opencode.go create mode 100644 docs/pangolin_configure.md create mode 100644 internal/utils/resource.go diff --git a/cmd/configure/claude.go b/cmd/configure/claude.go new file mode 100644 index 0000000..9a021b1 --- /dev/null +++ b/cmd/configure/claude.go @@ -0,0 +1,34 @@ +package configure + +import ( + "fmt" + "path/filepath" +) + +// writeClaudeConfig merges the Pangolin AI gateway settings into +// ~/.claude/settings.json, preserving any other existing keys. +func writeClaudeConfig(endpoint string, auth Auth) ([]string, error) { + home, err := homeDir() + if err != nil { + return nil, err + } + path := filepath.Join(home, ".claude", "settings.json") + + m, err := readJSONMap(path) + if err != nil { + return nil, err + } + + if auth.Mode == AuthModeKeyed { + m["apiKeyHelper"] = fmt.Sprintf("echo '%s'", auth.Key) + } + + env := ensureMap(m, "env") + env["ANTHROPIC_BASE_URL"] = endpoint + + if err := writeJSONMap(path, m); err != nil { + return nil, err + } + + return []string{path}, nil +} diff --git a/cmd/configure/codex.go b/cmd/configure/codex.go new file mode 100644 index 0000000..334a1cc --- /dev/null +++ b/cmd/configure/codex.go @@ -0,0 +1,82 @@ +package configure + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/fosrl/cli/internal/logger" + toml "github.com/pelletier/go-toml/v2" +) + +// writeCodexConfig merges a "pangolin" model provider into ~/.codex/config.toml, +// preserving any other existing keys. NOTE: go-toml/v2 doesn't preserve +// comments/formatting on round-trip. +func writeCodexConfig(endpoint string, auth Auth) ([]string, error) { + home, err := homeDir() + if err != nil { + return nil, err + } + path := filepath.Join(home, ".codex", "config.toml") + + m, err := readTOMLMap(path) + if err != nil { + return nil, err + } + + m["model_provider"] = "pangolin" + + providers, ok := m["model_providers"].(map[string]interface{}) + if !ok { + providers = map[string]interface{}{} + m["model_providers"] = providers + } + + pangolin := map[string]interface{}{ + "name": "Pangolin AI Gateway", + "base_url": endpoint + "/v1", + "wire_api": "responses", + } + if auth.Mode == AuthModeKeyed { + pangolin["env_key"] = "PANGOLIN_API_KEY" + } + providers["pangolin"] = pangolin + + data, err := toml.Marshal(m) + if err != nil { + return nil, fmt.Errorf("failed to encode %s: %w", path, err) + } + if err := writeFile(path, data); err != nil { + return nil, err + } + + if auth.Mode == AuthModeKeyed { + logger.Info("Codex reads its API key from the PANGOLIN_API_KEY environment variable; run:") + logger.Info(" export PANGOLIN_API_KEY=%s", auth.Key) + } + + return []string{path}, nil +} + +func readTOMLMap(path string) (map[string]interface{}, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return map[string]interface{}{}, nil + } + return nil, fmt.Errorf("failed to read %s: %w", path, err) + } + + if len(data) == 0 { + return map[string]interface{}{}, nil + } + + var m map[string]interface{} + if err := toml.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("failed to parse %s as TOML: %w", path, err) + } + if m == nil { + m = map[string]interface{}{} + } + return m, nil +} diff --git a/cmd/configure/configfile.go b/cmd/configure/configfile.go new file mode 100644 index 0000000..c7e137b --- /dev/null +++ b/cmd/configure/configfile.go @@ -0,0 +1,73 @@ +package configure + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// readJSONMap reads and parses a JSON object file into a map, preserving +// unknown keys. A missing file yields an empty map, not an error. +func readJSONMap(path string) (map[string]interface{}, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return map[string]interface{}{}, nil + } + return nil, fmt.Errorf("failed to read %s: %w", path, err) + } + + if len(data) == 0 { + return map[string]interface{}{}, nil + } + + var m map[string]interface{} + if err := json.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("failed to parse %s as JSON: %w", path, err) + } + if m == nil { + m = map[string]interface{}{} + } + return m, nil +} + +// writeJSONMap writes m as indented JSON to path, creating parent directories +// as needed. Permissions are tightened to 0600 since these files often hold +// secrets. +func writeJSONMap(path string, m map[string]interface{}) error { + data, err := json.MarshalIndent(m, "", " ") + if err != nil { + return fmt.Errorf("failed to encode %s: %w", path, err) + } + return writeFile(path, data) +} + +func writeFile(path string, data []byte) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("failed to create directory for %s: %w", path, err) + } + if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil { + return fmt.Errorf("failed to write %s: %w", path, err) + } + return nil +} + +// ensureMap returns the map value at key in parent, creating (and storing) an +// empty one if it's absent or of the wrong type. +func ensureMap(parent map[string]interface{}, key string) map[string]interface{} { + if existing, ok := parent[key].(map[string]interface{}); ok { + return existing + } + created := map[string]interface{}{} + parent[key] = created + return created +} + +func homeDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to determine home directory: %w", err) + } + return home, nil +} diff --git a/cmd/configure/configure.go b/cmd/configure/configure.go new file mode 100644 index 0000000..ca254c3 --- /dev/null +++ b/cmd/configure/configure.go @@ -0,0 +1,154 @@ +package configure + +import ( + "fmt" + + "github.com/fosrl/cli/internal/api" + "github.com/fosrl/cli/internal/config" + "github.com/fosrl/cli/internal/logger" + "github.com/fosrl/cli/internal/utils" + "github.com/spf13/cobra" +) + +// virtualApiKeyPrefix matches src/lib/virtualApiKeyFormat.ts's VIRTUAL_API_KEY_PREFIX. +const virtualApiKeyPrefix = "pangolin-key-" + +// AuthMode is how the configured client authenticates to the AI gateway. +type AuthMode string + +const ( + AuthModeKeyed AuthMode = "keyed" + AuthModeKeyless AuthMode = "keyless" +) + +// Auth is the resolved credential (if any) to write into a client's config. +type Auth struct { + Mode AuthMode + Key string +} + +// clientWriter writes the local config file(s) for one AI client and returns +// the paths it touched, for the success message. +type clientWriter func(endpoint string, auth Auth) ([]string, error) + +var clientWriters = map[string]clientWriter{ + "claude": writeClaudeConfig, + "codex": writeCodexConfig, + "opencode": writeOpencodeConfig, +} + +type ConfigureCmdOpts struct { + OrgID string + Resource string +} + +func ConfigureCmd() *cobra.Command { + opts := ConfigureCmdOpts{} + + cmd := &cobra.Command{ + Use: "configure [key]", + Short: "Configure a local AI client to use a Pangolin AI gateway resource", + Long: "Writes local config files (e.g. ~/.claude/settings.json) so an AI client talks to a Pangolin\n" + + "resource acting as an AI gateway. Supported clients: claude, codex, opencode.\n\n" + + "If [key] is omitted, an API key is fetched automatically for public resources (private/site\n" + + "resources need no key). Pass [key] to configure with a credential obtained elsewhere without\n" + + "making any API calls for it.", + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + return configureMain(cmd, opts, args) + }, + } + + cmd.Flags().StringVar(&opts.OrgID, "org", "", "Organization ID (defaults to your active org)") + cmd.Flags().StringVar(&opts.Resource, "resource", "", "Resource niceId or domain to configure against (defaults to auto-pick/prompt)") + + return cmd +} + +func configureMain(cmd *cobra.Command, opts ConfigureCmdOpts, args []string) error { + clientArg := args[0] + + writer, ok := clientWriters[clientArg] + if !ok { + if clientArg == "cursor" { + return fmt.Errorf("cursor must be configured manually; see the AI client config instructions in the dashboard") + } + return fmt.Errorf("unsupported client %q; expected one of: claude, codex, opencode", clientArg) + } + + apiClient := api.FromContext(cmd.Context()) + accountStore := config.AccountStoreFromContext(cmd.Context()) + + orgID, err := utils.ResolveOrgID(accountStore, opts.OrgID) + if err != nil { + return err + } + + var resource *api.LauncherResource + if opts.Resource != "" { + resource, err = utils.ResolveResourceByIdentifier(apiClient, orgID, opts.Resource) + } else { + resource, err = utils.SelectResourceForm(apiClient, orgID) + } + if err != nil { + return err + } + + if resource.AccessURL == nil { + return fmt.Errorf( + "resource %q has no stable access URL and can't be used for AI client configuration "+ + "(wildcard domain or unsupported mode)", + resource.NiceID, + ) + } + endpoint := *resource.AccessURL + + auth, err := resolveAuth(apiClient, orgID, resource, args) + if err != nil { + return err + } + + paths, err := writer(endpoint, auth) + if err != nil { + return err + } + + logger.Success("Configured %s for resource %s (%s)", clientArg, resource.Name, resource.NiceID) + for _, path := range paths { + logger.Info("Wrote %s", path) + } + + return nil +} + +// resolveAuth determines the credential (if any) to configure the client with: +// a literal key passed on the command line wins outright and skips all API +// calls; otherwise public resources get a fetched virtual API key, and private +// ("site") resources are keyless. +func resolveAuth(apiClient *api.Client, orgID string, resource *api.LauncherResource, args []string) (Auth, error) { + if len(args) == 2 { + return Auth{Mode: AuthModeKeyed, Key: args[1]}, nil + } + + if resource.ResourceType != "public" { + return Auth{Mode: AuthModeKeyless}, nil + } + + full, err := apiClient.GetResourceByNiceID(orgID, resource.NiceID) + if err != nil { + return Auth{}, fmt.Errorf("failed to look up resource details: %w", err) + } + + keys, err := apiClient.ListMyVirtualApiKeys(orgID, full.ResourceGUID) + if err != nil { + return Auth{}, fmt.Errorf("failed to list your API keys for this resource: %w", err) + } + + secretData, err := apiClient.GetMyVirtualApiKey(orgID, keys.UserKey.VirtualApiKeyID) + if err != nil { + return Auth{}, fmt.Errorf("failed to fetch API key secret: %w", err) + } + + credential := virtualApiKeyPrefix + secretData.VirtualApiKey.VirtualApiKeyID + "." + secretData.VirtualApiKey.Secret + return Auth{Mode: AuthModeKeyed, Key: credential}, nil +} diff --git a/cmd/configure/opencode.go b/cmd/configure/opencode.go new file mode 100644 index 0000000..61d2b05 --- /dev/null +++ b/cmd/configure/opencode.go @@ -0,0 +1,69 @@ +package configure + +import ( + "os" + "path/filepath" +) + +// writeOpencodeConfig merges the Pangolin AI gateway baseURL into OpenCode's +// global config (~/.config/opencode/opencode.json, respecting $XDG_CONFIG_HOME +// if set) and, when keyed, the credential into its auth store +// (~/.local/share/opencode/auth.json), preserving any other existing keys. +func writeOpencodeConfig(endpoint string, auth Auth) ([]string, error) { + home, err := homeDir() + if err != nil { + return nil, err + } + + configPath, err := opencodeConfigPath(home) + if err != nil { + return nil, err + } + + config, err := readJSONMap(configPath) + if err != nil { + return nil, err + } + + if _, ok := config["$schema"]; !ok { + config["$schema"] = "https://opencode.ai/config.json" + } + + provider := ensureMap(config, "provider") + for _, name := range []string{"anthropic", "openai"} { + options := ensureMap(ensureMap(provider, name), "options") + options["baseURL"] = endpoint + "/v1" + } + + if err := writeJSONMap(configPath, config); err != nil { + return nil, err + } + paths := []string{configPath} + + if auth.Mode == AuthModeKeyed { + authPath := filepath.Join(home, ".local", "share", "opencode", "auth.json") + authData, err := readJSONMap(authPath) + if err != nil { + return nil, err + } + authData["anthropic"] = map[string]interface{}{ + "type": "api", + "key": auth.Key, + } + if err := writeJSONMap(authPath, authData); err != nil { + return nil, err + } + paths = append(paths, authPath) + } + + return paths, nil +} + +// opencodeConfigPath resolves OpenCode's global config file path, honoring +// $XDG_CONFIG_HOME if set (per OpenCode's documented XDG support). +func opencodeConfigPath(home string) (string, error) { + if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { + return filepath.Join(xdgConfigHome, "opencode", "opencode.json"), nil + } + return filepath.Join(home, ".config", "opencode", "opencode.json"), nil +} diff --git a/cmd/root.go b/cmd/root.go index f1b3fe6..b31f07b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -13,6 +13,7 @@ import ( "github.com/fosrl/cli/cmd/authdaemon" companioncmd "github.com/fosrl/cli/cmd/companion" configcmd "github.com/fosrl/cli/cmd/config" + "github.com/fosrl/cli/cmd/configure" "github.com/fosrl/cli/cmd/down" "github.com/fosrl/cli/cmd/list" "github.com/fosrl/cli/cmd/logs" @@ -62,6 +63,7 @@ func RootCommand(initResources bool) (*cobra.Command, error) { cmd.AddCommand(selectcmd.SelectCmd()) cmd.AddCommand(list.ListCmd()) cmd.AddCommand(configcmd.ConfigCmd()) + cmd.AddCommand(configure.ConfigureCmd()) // Platform-specific commands - nil on unsupported platforms if upCmd := up.UpCmd(); upCmd != nil { diff --git a/docs/pangolin.md b/docs/pangolin.md index c852567..1c48179 100644 --- a/docs/pangolin.md +++ b/docs/pangolin.md @@ -14,6 +14,7 @@ Pangolin CLI * [pangolin auth](pangolin_auth.md) - Authentication commands * [pangolin auth-daemon](pangolin_auth-daemon.md) - Start the auth daemon * [pangolin config](pangolin_config.md) - View and edit CLI configuration +* [pangolin configure](pangolin_configure.md) - Configure a local AI client to use a Pangolin AI gateway resource * [pangolin down](pangolin_down.md) - Stop a connection * [pangolin list](pangolin_list.md) - List resources and other items from the server * [pangolin login](pangolin_login.md) - Login to Pangolin diff --git a/docs/pangolin_apply_blueprint.md b/docs/pangolin_apply_blueprint.md index 02c9f5c..b726062 100644 --- a/docs/pangolin_apply_blueprint.md +++ b/docs/pangolin_apply_blueprint.md @@ -4,7 +4,7 @@ Apply a blueprint ### Synopsis -Apply a YAML blueprint to the Pangolin server +Apply a YAML blueprint to the Pangolin server. --file may be a glob pattern (e.g. -f 'inference-*.yaml') or given multiple times; every matching file is applied one by one. ``` pangolin apply blueprint [flags] @@ -13,12 +13,12 @@ pangolin apply blueprint [flags] ### Options ``` - --api-key string Integration API key (id.secret) - --endpoint string Integration API host URL - -f, --file string Blueprint YAML file path (use '-' for stdin) - -h, --help help for blueprint - -n, --name string Blueprint name (default: filename without extension) - --org string Organization ID + --api-key string Integration API key (id.secret) + --endpoint string Integration API host URL + -f, --file stringArray Blueprint YAML file path, or glob pattern (e.g. 'inference-*.yaml'); repeatable. Use '-' for stdin + -h, --help help for blueprint + -n, --name string Blueprint name (default: filename without extension); only valid for a single file + --org string Organization ID ``` ### SEE ALSO diff --git a/docs/pangolin_config_set.md b/docs/pangolin_config_set.md index 9e88750..4f71938 100644 --- a/docs/pangolin_config_set.md +++ b/docs/pangolin_config_set.md @@ -15,6 +15,7 @@ Supported keys: up.override_dns up.match_domains_dns up.prefer_local_routes + session_cookie_name Examples: pangolin config set up.tunnel_dns true diff --git a/docs/pangolin_configure.md b/docs/pangolin_configure.md new file mode 100644 index 0000000..0b5549f --- /dev/null +++ b/docs/pangolin_configure.md @@ -0,0 +1,29 @@ +## pangolin configure + +Configure a local AI client to use a Pangolin AI gateway resource + +### Synopsis + +Writes local config files (e.g. ~/.claude/settings.json) so an AI client talks to a Pangolin +resource acting as an AI gateway. Supported clients: claude, codex, opencode. + +If [key] is omitted, an API key is fetched automatically for public resources (private/site +resources need no key). Pass [key] to configure with a credential obtained elsewhere without +making any API calls for it. + +``` +pangolin configure [key] [flags] +``` + +### Options + +``` + -h, --help help for configure + --org string Organization ID (defaults to your active org) + --resource string Resource niceId or domain to configure against (defaults to auto-pick/prompt) +``` + +### SEE ALSO + +* [pangolin](pangolin.md) - Pangolin CLI + diff --git a/go.mod b/go.mod index 8a7e746..11a7d10 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/fosrl/newt v1.15.0 github.com/fosrl/olm v1.8.0 github.com/mattn/go-isatty v0.0.20 + github.com/pelletier/go-toml/v2 v2.2.4 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 @@ -48,7 +49,6 @@ require ( 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/pelletier/go-toml/v2 v2.2.4 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect diff --git a/internal/api/client.go b/internal/api/client.go index 83a0bac..82e4632 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -394,6 +394,86 @@ func (c *Client) ListUserResourceAliases(orgID string, page, pageSize int, opts return &data, nil } +// ListLauncherResourcesOptions configures GET /org/:orgId/launcher/resources. +type ListLauncherResourcesOptions struct { + Query string + Page int + PageSize int +} + +// ListLauncherResources returns one page of the org's "AI Gateway" launcher +// resources - inference-mode resources only (the same pinned group the +// dashboard shows), covering both public and private ("site") resources with +// their access URLs already computed server-side - optionally filtered by a +// case-insensitive search query matched against name/niceId/fullDomain. +// Non-inference resources (e.g. plain "http") can't act as an AI gateway, so +// they're excluded server-side rather than filtered out here. +func (c *Client) ListLauncherResources(orgID string, opts ListLauncherResourcesOptions) (*ListLauncherResourcesData, error) { + path := fmt.Sprintf("/org/%s/launcher/resources", url.PathEscape(orgID)) + page := opts.Page + if page < 1 { + page = 1 + } + pageSize := opts.PageSize + if pageSize < 1 { + pageSize = 1000 + } + query := map[string]string{ + // LAUNCHER_AI_GATEWAY_GROUP_KEY + the default groupBy=site - server-side + // filters to mode=="inference" resources only (see + // server/routers/launcher/launcherResourceAccess.ts's filterResourcesBySite). + "groupKey": "ai-gateway", + "page": strconv.Itoa(page), + "pageSize": strconv.Itoa(pageSize), + } + if opts.Query != "" { + query["query"] = opts.Query + } + var data ListLauncherResourcesData + if err := c.Get(path, &data, RequestOptions{Query: query}); err != nil { + return nil, err + } + return &data, nil +} + +// GetResourceByNiceID fetches a resource's full details (including resourceGuid, +// not present on LauncherResource) by org + niceId. +func (c *Client) GetResourceByNiceID(orgID, niceID string) (*GetResourceData, error) { + path := fmt.Sprintf("/org/%s/resource/%s", url.PathEscape(orgID), url.PathEscape(niceID)) + var data GetResourceData + if err := c.Get(path, &data); err != nil { + return nil, err + } + return &data, nil +} + +// ListMyVirtualApiKeys returns the signed-in user's identity virtual API key +// (auto-created if missing) and any manual keys attributed to them, optionally +// scoped to a resource by GUID. +func (c *Client) ListMyVirtualApiKeys(orgID, resourceGuid string) (*ListMyVirtualApiKeysData, error) { + path := fmt.Sprintf("/org/%s/my-virtual-api-keys", url.PathEscape(orgID)) + var reqOpts []RequestOptions + if resourceGuid != "" { + reqOpts = []RequestOptions{{Query: map[string]string{"resourceGuid": resourceGuid}}} + } + var data ListMyVirtualApiKeysData + if err := c.Get(path, &data, reqOpts...); err != nil { + return nil, err + } + return &data, nil +} + +// GetMyVirtualApiKey fetches the decrypted secret for a virtual API key owned +// by the signed-in user. +func (c *Client) GetMyVirtualApiKey(orgID, virtualApiKeyID string) (*GetMyVirtualApiKeyData, error) { + path := fmt.Sprintf("/org/%s/my-virtual-api-keys/%s", url.PathEscape(orgID), url.PathEscape(virtualApiKeyID)) + var data GetMyVirtualApiKeyData + if err := c.Get(path, &data); err != nil { + return nil, err + } + return &data, nil +} + // SignSSHKey signs an SSH public key for the given org and resource. func (c *Client) SignSSHKey(orgID string, req SignSSHKeyRequest) (*SignSSHKeyData, error) { path := fmt.Sprintf("/org/%s/ssh/sign-key", orgID) diff --git a/internal/api/types.go b/internal/api/types.go index f8cc52c..4c00d78 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -327,3 +327,89 @@ type AliasesPagination struct { PageSize int `json:"pageSize"` Page int `json:"page"` } + +// LauncherLabel is a label attached to a launcher resource. +type LauncherLabel struct { + LabelID int `json:"labelId"` + Name string `json:"name"` + Color string `json:"color"` +} + +// LauncherSiteInfo is the site a private launcher resource belongs to. +type LauncherSiteInfo struct { + SiteID int `json:"siteId"` + Name string `json:"name"` + Type string `json:"type"` + Online *bool `json:"online,omitempty"` +} + +// LauncherResource mirrors server/routers/launcher/types.ts's LauncherResource: a +// unified view of both public resources and private site resources, with the +// access URL already computed server-side. +type LauncherResource struct { + LauncherResourceKey string `json:"launcherResourceKey"` + ResourceType string `json:"resourceType"` // "public" | "site" + ResourceID int `json:"resourceId"` + SiteResourceID *int `json:"siteResourceId,omitempty"` + NiceID string `json:"niceId"` + Name string `json:"name"` + AccessDisplay string `json:"accessDisplay"` + AccessCopyValue string `json:"accessCopyValue"` + AccessURL *string `json:"accessUrl"` + IconURL *string `json:"iconUrl"` + Enabled bool `json:"enabled"` + Mode string `json:"mode"` + Labels []LauncherLabel `json:"labels"` + Site *LauncherSiteInfo `json:"site,omitempty"` +} + +// LauncherPagination matches the paginated API envelope for launcher resources. +type LauncherPagination struct { + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} + +// ListLauncherResourcesData is the inner `data` of GET /org/:orgId/launcher/resources. +type ListLauncherResourcesData struct { + Resources []LauncherResource `json:"resources"` + Pagination LauncherPagination `json:"pagination"` +} + +// GetResourceData is the (partial) inner `data` of GET /org/:orgId/resource/:niceId. +// Only the fields the CLI needs are modeled; the server returns more, which +// json.Unmarshal simply ignores. +type GetResourceData struct { + ResourceID int `json:"resourceId"` + ResourceGUID string `json:"resourceGuid"` + OrgID string `json:"orgId"` + Name string `json:"name"` + NiceID string `json:"niceId"` + Mode string `json:"mode"` +} + +// VirtualApiKeySummary is the public (secret-free) shape of a virtual API key. +type VirtualApiKeySummary struct { + VirtualApiKeyID string `json:"virtualApiKeyId"` + OrgID string `json:"orgId"` + Name string `json:"name"` + LastChars string `json:"lastChars"` + Kind string `json:"kind"` + CreatedAt int64 `json:"createdAt"` +} + +// ListMyVirtualApiKeysData is the inner `data` of GET /org/:orgId/my-virtual-api-keys. +type ListMyVirtualApiKeysData struct { + UserKey VirtualApiKeySummary `json:"userKey"` + ManualKeys []VirtualApiKeySummary `json:"manualKeys"` + ResourceName *string `json:"resourceName,omitempty"` +} + +// GetMyVirtualApiKeyData is the inner `data` of GET /org/:orgId/my-virtual-api-keys/:virtualApiKeyId. +type GetMyVirtualApiKeyData struct { + VirtualApiKey struct { + VirtualApiKeyID string `json:"virtualApiKeyId"` + Secret string `json:"secret"` + LastChars string `json:"lastChars"` + } `json:"virtualApiKey"` +} diff --git a/internal/utils/resource.go b/internal/utils/resource.go new file mode 100644 index 0000000..62f0e91 --- /dev/null +++ b/internal/utils/resource.go @@ -0,0 +1,130 @@ +package utils + +import ( + "fmt" + "net/url" + "strings" + + "github.com/charmbracelet/huh" + "github.com/fosrl/cli/internal/api" + "github.com/fosrl/cli/internal/logger" +) + +const launcherResourcesPageSize = 1000 + +// FetchAllLauncherResources pages through GET /org/:orgId/launcher/resources +// until fully collected, optionally filtered by a search query. +func FetchAllLauncherResources(client *api.Client, orgID, query string) ([]api.LauncherResource, error) { + var all []api.LauncherResource + for page := 1; ; page++ { + data, err := client.ListLauncherResources(orgID, api.ListLauncherResourcesOptions{ + Query: query, + Page: page, + PageSize: launcherResourcesPageSize, + }) + if err != nil { + return nil, fmt.Errorf("failed to list resources: %w", err) + } + all = append(all, data.Resources...) + if len(data.Resources) < launcherResourcesPageSize { + break + } + } + return all, nil +} + +// SelectResourceForm lists AI-gateway-capable (inference mode) launcher +// resources in an org and prompts the user to select one. If there's exactly +// one, it's automatically selected (and logged, since - unlike account/org +// selection - this choice isn't persisted, so the user should see what was +// picked on every run). +func SelectResourceForm(client *api.Client, orgID string) (*api.LauncherResource, error) { + resources, err := FetchAllLauncherResources(client, orgID, "") + if err != nil { + return nil, err + } + + if len(resources) == 0 { + return nil, fmt.Errorf("no AI gateway resources found in this organization") + } + + if len(resources) == 1 { + selected := resources[0] + logger.Info("Only one resource found, using: %s (%s)", selected.Name, selected.NiceID) + return &selected, nil + } + + type resourceOption struct { + Resource *api.LauncherResource + Label string + } + + var options []huh.Option[resourceOption] + for i := range resources { + r := &resources[i] + label := fmt.Sprintf("%s (%s) - %s", r.Name, r.NiceID, r.AccessDisplay) + options = append(options, huh.NewOption(label, resourceOption{Resource: r, Label: label})) + } + + var selectedOption resourceOption + resourceSelectForm := huh.NewForm( + huh.NewGroup( + huh.NewSelect[resourceOption](). + Title("Select a resource"). + Options(options...). + Value(&selectedOption), + ), + ) + + if err := resourceSelectForm.Run(); err != nil { + return nil, fmt.Errorf("error selecting resource: %w", err) + } + + return selectedOption.Resource, nil +} + +// accessURLHostMatches reports whether the host portion of accessUrl matches +// identifier (case-insensitive), tolerating identifier being given either as a +// bare domain or as a full URL. +func accessURLHostMatches(accessURL *string, identifier string) bool { + if accessURL == nil { + return false + } + + target := strings.ToLower(strings.TrimSpace(identifier)) + if u, err := url.Parse(target); err == nil && u.Host != "" { + target = strings.ToLower(u.Host) + } + + parsed, err := url.Parse(*accessURL) + if err != nil { + return false + } + + return strings.ToLower(parsed.Host) == target +} + +// ResolveResourceByIdentifier finds an exact match by niceId or accessUrl host +// among resources matching a server-side fuzzy search for identifier. +func ResolveResourceByIdentifier(client *api.Client, orgID, identifier string) (*api.LauncherResource, error) { + candidates, err := FetchAllLauncherResources(client, orgID, identifier) + if err != nil { + return nil, err + } + + var exact []api.LauncherResource + for _, r := range candidates { + if r.NiceID == identifier || accessURLHostMatches(r.AccessURL, identifier) { + exact = append(exact, r) + } + } + + switch len(exact) { + case 0: + return nil, fmt.Errorf("no resource found matching %q", identifier) + case 1: + return &exact[0], nil + default: + return nil, fmt.Errorf("multiple resources match %q; use a more specific --resource value", identifier) + } +} From f537e1c69baafef9717052f7c02d66b316c48fc6 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 18 Aug 2026 17:11:41 -0400 Subject: [PATCH 07/13] Make sure config is cross platform --- cmd/configure/claude.go | 22 +++++++++++++++++++++- cmd/configure/codex.go | 39 +++++++++++++++++++++++++++++++++------ cmd/configure/opencode.go | 24 +++++++++++++++++++++--- 3 files changed, 75 insertions(+), 10 deletions(-) diff --git a/cmd/configure/claude.go b/cmd/configure/claude.go index 9a021b1..822a557 100644 --- a/cmd/configure/claude.go +++ b/cmd/configure/claude.go @@ -3,6 +3,7 @@ package configure import ( "fmt" "path/filepath" + "runtime" ) // writeClaudeConfig merges the Pangolin AI gateway settings into @@ -20,7 +21,12 @@ func writeClaudeConfig(endpoint string, auth Auth) ([]string, error) { } if auth.Mode == AuthModeKeyed { - m["apiKeyHelper"] = fmt.Sprintf("echo '%s'", auth.Key) + m["apiKeyHelper"] = apiKeyHelperEcho(auth.Key) + } else { + // A non-empty, unusable placeholder - forces Claude to invoke the + // helper at all instead of silently falling back to whatever + // account/key the user already has configured. + m["apiKeyHelper"] = apiKeyHelperEcho("-") } env := ensureMap(m, "env") @@ -32,3 +38,17 @@ func writeClaudeConfig(endpoint string, auth Auth) ([]string, error) { return []string{path}, nil } + +// apiKeyHelperEcho formats an `echo` command that prints value verbatim, in +// the shell Claude Code actually runs apiKeyHelper through on each platform. +// Claude Code runs it via cmd.exe on Windows (not PowerShell or Git Bash), +// and cmd.exe's echo doesn't strip quote characters at all - wrapping value +// in bash-style single quotes there would leak the quotes into the value +// Claude reads. POSIX shells (macOS/Linux) get single-quoted for safety +// against shell interpretation instead. +func apiKeyHelperEcho(value string) string { + if runtime.GOOS == "windows" { + return fmt.Sprintf("echo %s", value) + } + return fmt.Sprintf("echo '%s'", value) +} diff --git a/cmd/configure/codex.go b/cmd/configure/codex.go index 334a1cc..2ff1be8 100644 --- a/cmd/configure/codex.go +++ b/cmd/configure/codex.go @@ -4,20 +4,21 @@ import ( "fmt" "os" "path/filepath" + "runtime" "github.com/fosrl/cli/internal/logger" toml "github.com/pelletier/go-toml/v2" ) -// writeCodexConfig merges a "pangolin" model provider into ~/.codex/config.toml, -// preserving any other existing keys. NOTE: go-toml/v2 doesn't preserve -// comments/formatting on round-trip. +// writeCodexConfig merges a "pangolin" model provider into +// $CODEX_HOME/config.toml (CODEX_HOME defaults to ~/.codex, matching Codex's +// own resolution), preserving any other existing keys. NOTE: go-toml/v2 +// doesn't preserve comments/formatting on round-trip. func writeCodexConfig(endpoint string, auth Auth) ([]string, error) { - home, err := homeDir() + path, err := codexConfigPath() if err != nil { return nil, err } - path := filepath.Join(home, ".codex", "config.toml") m, err := readTOMLMap(path) if err != nil { @@ -52,12 +53,38 @@ func writeCodexConfig(endpoint string, auth Auth) ([]string, error) { if auth.Mode == AuthModeKeyed { logger.Info("Codex reads its API key from the PANGOLIN_API_KEY environment variable; run:") - logger.Info(" export PANGOLIN_API_KEY=%s", auth.Key) + logger.Info(" %s", exportEnvVarCommand("PANGOLIN_API_KEY", auth.Key)) } return []string{path}, nil } +// codexConfigPath resolves Codex's config file path, honoring $CODEX_HOME if +// set (Codex's own override for relocating its whole config directory); +// otherwise it defaults to ~/.codex/config.toml, which is Codex's default on +// every OS (e.g. %USERPROFILE%\.codex\config.toml on Windows, not an AppData +// path). +func codexConfigPath() (string, error) { + if codexHome := os.Getenv("CODEX_HOME"); codexHome != "" { + return filepath.Join(codexHome, "config.toml"), nil + } + home, err := homeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".codex", "config.toml"), nil +} + +// exportEnvVarCommand formats a shell command to set an environment variable +// for the user's current session, using the syntax for their platform's +// default shell (PowerShell on Windows, POSIX export elsewhere). +func exportEnvVarCommand(name, value string) string { + if runtime.GOOS == "windows" { + return fmt.Sprintf(`$env:%s = "%s"`, name, value) + } + return fmt.Sprintf("export %s=%s", name, value) +} + func readTOMLMap(path string) (map[string]interface{}, error) { data, err := os.ReadFile(path) if err != nil { diff --git a/cmd/configure/opencode.go b/cmd/configure/opencode.go index 61d2b05..69bc0d1 100644 --- a/cmd/configure/opencode.go +++ b/cmd/configure/opencode.go @@ -8,7 +8,8 @@ import ( // writeOpencodeConfig merges the Pangolin AI gateway baseURL into OpenCode's // global config (~/.config/opencode/opencode.json, respecting $XDG_CONFIG_HOME // if set) and, when keyed, the credential into its auth store -// (~/.local/share/opencode/auth.json), preserving any other existing keys. +// (~/.local/share/opencode/auth.json, respecting $XDG_DATA_HOME if set), +// preserving any other existing keys. func writeOpencodeConfig(endpoint string, auth Auth) ([]string, error) { home, err := homeDir() if err != nil { @@ -41,7 +42,10 @@ func writeOpencodeConfig(endpoint string, auth Auth) ([]string, error) { paths := []string{configPath} if auth.Mode == AuthModeKeyed { - authPath := filepath.Join(home, ".local", "share", "opencode", "auth.json") + authPath, err := opencodeDataPath(home) + if err != nil { + return nil, err + } authData, err := readJSONMap(authPath) if err != nil { return nil, err @@ -60,10 +64,24 @@ func writeOpencodeConfig(endpoint string, auth Auth) ([]string, error) { } // opencodeConfigPath resolves OpenCode's global config file path, honoring -// $XDG_CONFIG_HOME if set (per OpenCode's documented XDG support). +// $XDG_CONFIG_HOME if set (per OpenCode's documented XDG support). OpenCode +// uses this same literal ~/.config path as its default on every OS, including +// Windows (it doesn't map to %APPDATA%/%LOCALAPPDATA% by default), so no +// GOOS-specific fallback is needed here. func opencodeConfigPath(home string) (string, error) { if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { return filepath.Join(xdgConfigHome, "opencode", "opencode.json"), nil } return filepath.Join(home, ".config", "opencode", "opencode.json"), nil } + +// opencodeDataPath resolves OpenCode's auth store file path, honoring +// $XDG_DATA_HOME if set, for the same reason opencodeConfigPath honors +// $XDG_CONFIG_HOME: OpenCode's data directory follows the same +// (Windows-including) XDG resolution as its config directory. +func opencodeDataPath(home string) (string, error) { + if xdgDataHome := os.Getenv("XDG_DATA_HOME"); xdgDataHome != "" { + return filepath.Join(xdgDataHome, "opencode", "auth.json"), nil + } + return filepath.Join(home, ".local", "share", "opencode", "auth.json"), nil +} From ebdfcbaebb328e0314659338856a76f0aa4afb53 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 19 Aug 2026 15:07:03 -0400 Subject: [PATCH 08/13] Add a reset command to restore the config without the changes --- cmd/configure/claude.go | 32 +++++++++++++++++ cmd/configure/codex.go | 72 +++++++++++++++++++++++++++++++++++++ cmd/configure/configfile.go | 35 ++++++++++++++++++ cmd/configure/configure.go | 57 +++++++++++++++++++++++++---- cmd/configure/opencode.go | 64 +++++++++++++++++++++++++++++++++ 5 files changed, 253 insertions(+), 7 deletions(-) diff --git a/cmd/configure/claude.go b/cmd/configure/claude.go index 822a557..35761cf 100644 --- a/cmd/configure/claude.go +++ b/cmd/configure/claude.go @@ -52,3 +52,35 @@ func apiKeyHelperEcho(value string) string { } return fmt.Sprintf("echo '%s'", value) } + +// resetClaudeConfig removes the keys writeClaudeConfig sets from +// ~/.claude/settings.json, preserving every other existing key. +func resetClaudeConfig() ([]string, error) { + home, err := homeDir() + if err != nil { + return nil, err + } + path := filepath.Join(home, ".claude", "settings.json") + + m, exists, err := readExistingJSONMap(path) + if err != nil || !exists { + return nil, err + } + + changed := deleteKey(m, "apiKeyHelper") + if env, ok := m["env"].(map[string]interface{}); ok { + if deleteKey(env, "ANTHROPIC_BASE_URL") { + changed = true + } + pruneEmptyMap(m, "env") + } + + if !changed { + return nil, nil + } + if err := writeJSONMap(path, m); err != nil { + return nil, err + } + + return []string{path}, nil +} diff --git a/cmd/configure/codex.go b/cmd/configure/codex.go index 2ff1be8..c257ef9 100644 --- a/cmd/configure/codex.go +++ b/cmd/configure/codex.go @@ -107,3 +107,75 @@ func readTOMLMap(path string) (map[string]interface{}, error) { } return m, nil } + +// resetCodexConfig removes the model provider writeCodexConfig adds from +// $CODEX_HOME/config.toml, preserving every other existing key. NOTE: +// go-toml/v2 doesn't preserve comments/formatting on round-trip. +func resetCodexConfig() ([]string, error) { + path, err := codexConfigPath() + if err != nil { + return nil, err + } + + m, exists, err := readExistingTOMLMap(path) + if err != nil || !exists { + return nil, err + } + + changed := false + // Only clear the active provider if it's still pointing at ours - the + // user may have switched back to another provider by hand. + if m["model_provider"] == "pangolin" { + delete(m, "model_provider") + changed = true + } + if providers, ok := m["model_providers"].(map[string]interface{}); ok { + if deleteKey(providers, "pangolin") { + changed = true + } + pruneEmptyMap(m, "model_providers") + } + + if !changed { + return nil, nil + } + + data, err := toml.Marshal(m) + if err != nil { + return nil, fmt.Errorf("failed to encode %s: %w", path, err) + } + if err := writeFile(path, data); err != nil { + return nil, err + } + + logger.Info("If you set PANGOLIN_API_KEY for Codex, unset it; run:") + logger.Info(" %s", unsetEnvVarCommand("PANGOLIN_API_KEY")) + + return []string{path}, nil +} + +// unsetEnvVarCommand formats the counterpart to exportEnvVarCommand, in the +// syntax for the platform's default shell. +func unsetEnvVarCommand(name string) string { + if runtime.GOOS == "windows" { + return fmt.Sprintf(`Remove-Item Env:\%s`, name) + } + return fmt.Sprintf("unset %s", name) +} + +// readExistingTOMLMap reads path like readTOMLMap, but reports whether the +// file exists at all so reset can skip files it would otherwise create. +func readExistingTOMLMap(path string) (map[string]interface{}, bool, error) { + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return nil, false, nil + } + return nil, false, fmt.Errorf("failed to read %s: %w", path, err) + } + + m, err := readTOMLMap(path) + if err != nil { + return nil, false, err + } + return m, true, nil +} diff --git a/cmd/configure/configfile.go b/cmd/configure/configfile.go index c7e137b..f063489 100644 --- a/cmd/configure/configfile.go +++ b/cmd/configure/configfile.go @@ -71,3 +71,38 @@ func homeDir() (string, error) { } return home, nil } + +// readExistingJSONMap reads path like readJSONMap, but reports whether the +// file exists at all so reset can skip files it would otherwise create. +func readExistingJSONMap(path string) (map[string]interface{}, bool, error) { + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return nil, false, nil + } + return nil, false, fmt.Errorf("failed to read %s: %w", path, err) + } + + m, err := readJSONMap(path) + if err != nil { + return nil, false, err + } + return m, true, nil +} + +// deleteKey removes key from parent, reporting whether it was there, so +// callers can tell an actual removal from a no-op. +func deleteKey(parent map[string]interface{}, key string) bool { + if _, ok := parent[key]; !ok { + return false + } + delete(parent, key) + return true +} + +// pruneEmptyMap deletes key from parent when it holds a now-empty map, so +// reset doesn't leave behind the containers configure created. +func pruneEmptyMap(parent map[string]interface{}, key string) { + if child, ok := parent[key].(map[string]interface{}); ok && len(child) == 0 { + delete(parent, key) + } +} diff --git a/cmd/configure/configure.go b/cmd/configure/configure.go index ca254c3..7c0476d 100644 --- a/cmd/configure/configure.go +++ b/cmd/configure/configure.go @@ -31,15 +31,27 @@ type Auth struct { // the paths it touched, for the success message. type clientWriter func(endpoint string, auth Auth) ([]string, error) -var clientWriters = map[string]clientWriter{ - "claude": writeClaudeConfig, - "codex": writeCodexConfig, - "opencode": writeOpencodeConfig, +// clientReset removes the settings the matching clientWriter adds, leaving +// every other key in those files alone. It returns only the paths it actually +// changed, so a no-op reset reports nothing, and it never creates a file that +// wasn't already there. +type clientReset func() ([]string, error) + +type client struct { + write clientWriter + reset clientReset +} + +var clients = map[string]client{ + "claude": {write: writeClaudeConfig, reset: resetClaudeConfig}, + "codex": {write: writeCodexConfig, reset: resetCodexConfig}, + "opencode": {write: writeOpencodeConfig, reset: resetOpencodeConfig}, } type ConfigureCmdOpts struct { OrgID string Resource string + Reset bool } func ConfigureCmd() *cobra.Command { @@ -52,7 +64,9 @@ func ConfigureCmd() *cobra.Command { "resource acting as an AI gateway. Supported clients: claude, codex, opencode.\n\n" + "If [key] is omitted, an API key is fetched automatically for public resources (private/site\n" + "resources need no key). Pass [key] to configure with a credential obtained elsewhere without\n" + - "making any API calls for it.", + "making any API calls for it.\n\n" + + "Pass --reset to undo this: it deletes exactly the settings this command writes (it can't\n" + + "restore values they replaced) and leaves the rest of those files untouched.", Args: cobra.RangeArgs(1, 2), RunE: func(cmd *cobra.Command, args []string) error { return configureMain(cmd, opts, args) @@ -61,6 +75,7 @@ func ConfigureCmd() *cobra.Command { cmd.Flags().StringVar(&opts.OrgID, "org", "", "Organization ID (defaults to your active org)") cmd.Flags().StringVar(&opts.Resource, "resource", "", "Resource niceId or domain to configure against (defaults to auto-pick/prompt)") + cmd.Flags().BoolVar(&opts.Reset, "reset", false, "Remove the settings this command writes for instead of writing them") return cmd } @@ -68,7 +83,7 @@ func ConfigureCmd() *cobra.Command { func configureMain(cmd *cobra.Command, opts ConfigureCmdOpts, args []string) error { clientArg := args[0] - writer, ok := clientWriters[clientArg] + c, ok := clients[clientArg] if !ok { if clientArg == "cursor" { return fmt.Errorf("cursor must be configured manually; see the AI client config instructions in the dashboard") @@ -76,6 +91,13 @@ func configureMain(cmd *cobra.Command, opts ConfigureCmdOpts, args []string) err return fmt.Errorf("unsupported client %q; expected one of: claude, codex, opencode", clientArg) } + if opts.Reset { + if len(args) == 2 { + return fmt.Errorf("--reset takes no [key] argument") + } + return resetMain(clientArg, c.reset) + } + apiClient := api.FromContext(cmd.Context()) accountStore := config.AccountStoreFromContext(cmd.Context()) @@ -108,7 +130,7 @@ func configureMain(cmd *cobra.Command, opts ConfigureCmdOpts, args []string) err return err } - paths, err := writer(endpoint, auth) + paths, err := c.write(endpoint, auth) if err != nil { return err } @@ -121,6 +143,27 @@ func configureMain(cmd *cobra.Command, opts ConfigureCmdOpts, args []string) err return nil } +// resetMain removes one client's Pangolin settings. It needs no org, resource +// or credential, so it makes no API calls and works while signed out. +func resetMain(clientArg string, reset clientReset) error { + paths, err := reset() + if err != nil { + return err + } + + if len(paths) == 0 { + logger.Info("No Pangolin configuration found for %s; nothing to remove", clientArg) + return nil + } + + logger.Success("Removed Pangolin configuration for %s", clientArg) + for _, path := range paths { + logger.Info("Updated %s", path) + } + + return nil +} + // resolveAuth determines the credential (if any) to configure the client with: // a literal key passed on the command line wins outright and skips all API // calls; otherwise public resources get a fetched virtual API key, and private diff --git a/cmd/configure/opencode.go b/cmd/configure/opencode.go index 69bc0d1..21a7941 100644 --- a/cmd/configure/opencode.go +++ b/cmd/configure/opencode.go @@ -85,3 +85,67 @@ func opencodeDataPath(home string) (string, error) { } return filepath.Join(home, ".local", "share", "opencode", "auth.json"), nil } + +// resetOpencodeConfig removes the baseURL overrides writeOpencodeConfig adds +// from OpenCode's global config, and the credential it stores from OpenCode's +// auth store, preserving every other existing key in both. +func resetOpencodeConfig() ([]string, error) { + home, err := homeDir() + if err != nil { + return nil, err + } + + configPath, err := opencodeConfigPath(home) + if err != nil { + return nil, err + } + authPath, err := opencodeDataPath(home) + if err != nil { + return nil, err + } + + var paths []string + + config, exists, err := readExistingJSONMap(configPath) + if err != nil { + return nil, err + } + if exists { + changed := false + if provider, ok := config["provider"].(map[string]interface{}); ok { + for _, name := range []string{"anthropic", "openai"} { + entry, ok := provider[name].(map[string]interface{}) + if !ok { + continue + } + if options, ok := entry["options"].(map[string]interface{}); ok { + if deleteKey(options, "baseURL") { + changed = true + } + pruneEmptyMap(entry, "options") + } + pruneEmptyMap(provider, name) + } + pruneEmptyMap(config, "provider") + } + if changed { + if err := writeJSONMap(configPath, config); err != nil { + return nil, err + } + paths = append(paths, configPath) + } + } + + authData, exists, err := readExistingJSONMap(authPath) + if err != nil { + return nil, err + } + if exists && deleteKey(authData, "anthropic") { + if err := writeJSONMap(authPath, authData); err != nil { + return nil, err + } + paths = append(paths, authPath) + } + + return paths, nil +} From 11948fd7f95d1be5583bd952f9a0ec37054433be Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 19 Aug 2026 15:37:48 -0400 Subject: [PATCH 09/13] Prompt for opencode providers --- cmd/configure/opencode.go | 95 ++++++++++++++++++++++++++++++++++----- 1 file changed, 83 insertions(+), 12 deletions(-) diff --git a/cmd/configure/opencode.go b/cmd/configure/opencode.go index 21a7941..fe56b2c 100644 --- a/cmd/configure/opencode.go +++ b/cmd/configure/opencode.go @@ -1,16 +1,72 @@ package configure import ( + "fmt" "os" "path/filepath" + "strings" + + "github.com/charmbracelet/huh" ) +// promptOpencodeProviders asks which OpenCode provider(s) to point at the +// Pangolin AI gateway. OpenCode configures baseURL and API keys per provider +// (unlike Claude/Codex, which each speak for a single fixed provider), so the +// user types a comma-separated list rather than picking from a fixed set - +// this works for any provider id OpenCode recognizes, not just the two most +// common ones. +func promptOpencodeProviders() ([]string, error) { + input := "anthropic,openai" + + form := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Which OpenCode provider(s) should use this gateway?"). + Description("Comma-separated provider ids, e.g. anthropic,openai"). + Value(&input). + Validate(func(s string) error { + if len(parseProviderList(s)) == 0 { + return fmt.Errorf("enter at least one provider id") + } + return nil + }), + ), + ) + + if err := form.Run(); err != nil { + return nil, fmt.Errorf("error selecting providers: %w", err) + } + + return parseProviderList(input), nil +} + +// parseProviderList splits a comma-separated provider list into trimmed, +// lowercased, deduplicated, non-empty provider ids. +func parseProviderList(s string) []string { + seen := map[string]bool{} + var providers []string + for _, part := range strings.Split(s, ",") { + name := strings.ToLower(strings.TrimSpace(part)) + if name == "" || seen[name] { + continue + } + seen[name] = true + providers = append(providers, name) + } + return providers +} + // writeOpencodeConfig merges the Pangolin AI gateway baseURL into OpenCode's // global config (~/.config/opencode/opencode.json, respecting $XDG_CONFIG_HOME // if set) and, when keyed, the credential into its auth store -// (~/.local/share/opencode/auth.json, respecting $XDG_DATA_HOME if set), -// preserving any other existing keys. +// (~/.local/share/opencode/auth.json, respecting $XDG_DATA_HOME if set), for +// each provider the user chooses, preserving any other existing keys. func writeOpencodeConfig(endpoint string, auth Auth) ([]string, error) { + providers, err := promptOpencodeProviders() + if err != nil { + return nil, err + } + home, err := homeDir() if err != nil { return nil, err @@ -31,7 +87,7 @@ func writeOpencodeConfig(endpoint string, auth Auth) ([]string, error) { } provider := ensureMap(config, "provider") - for _, name := range []string{"anthropic", "openai"} { + for _, name := range providers { options := ensureMap(ensureMap(provider, name), "options") options["baseURL"] = endpoint + "/v1" } @@ -50,9 +106,11 @@ func writeOpencodeConfig(endpoint string, auth Auth) ([]string, error) { if err != nil { return nil, err } - authData["anthropic"] = map[string]interface{}{ - "type": "api", - "key": auth.Key, + for _, name := range providers { + authData[name] = map[string]interface{}{ + "type": "api", + "key": auth.Key, + } } if err := writeJSONMap(authPath, authData); err != nil { return nil, err @@ -105,6 +163,7 @@ func resetOpencodeConfig() ([]string, error) { } var paths []string + var touchedProviders []string config, exists, err := readExistingJSONMap(configPath) if err != nil { @@ -113,14 +172,18 @@ func resetOpencodeConfig() ([]string, error) { if exists { changed := false if provider, ok := config["provider"].(map[string]interface{}); ok { - for _, name := range []string{"anthropic", "openai"} { - entry, ok := provider[name].(map[string]interface{}) + // Providers are user-chosen at configure time (see + // promptOpencodeProviders), so reset can't know their names in + // advance - scan whatever's present instead of a fixed list. + for name, raw := range provider { + entry, ok := raw.(map[string]interface{}) if !ok { continue } if options, ok := entry["options"].(map[string]interface{}); ok { if deleteKey(options, "baseURL") { changed = true + touchedProviders = append(touchedProviders, name) } pruneEmptyMap(entry, "options") } @@ -140,11 +203,19 @@ func resetOpencodeConfig() ([]string, error) { if err != nil { return nil, err } - if exists && deleteKey(authData, "anthropic") { - if err := writeJSONMap(authPath, authData); err != nil { - return nil, err + if exists { + changed := false + for _, name := range touchedProviders { + if deleteKey(authData, name) { + changed = true + } + } + if changed { + if err := writeJSONMap(authPath, authData); err != nil { + return nil, err + } + paths = append(paths, authPath) } - paths = append(paths, authPath) } return paths, nil From da509ac641668bbece1ee23489ad96197b7e2744 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 20 Aug 2026 10:21:35 -0400 Subject: [PATCH 10/13] Add gemini --- cmd/configure/configure.go | 5 +- cmd/configure/gemini.go | 130 +++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 cmd/configure/gemini.go diff --git a/cmd/configure/configure.go b/cmd/configure/configure.go index 7c0476d..dfa2517 100644 --- a/cmd/configure/configure.go +++ b/cmd/configure/configure.go @@ -46,6 +46,7 @@ var clients = map[string]client{ "claude": {write: writeClaudeConfig, reset: resetClaudeConfig}, "codex": {write: writeCodexConfig, reset: resetCodexConfig}, "opencode": {write: writeOpencodeConfig, reset: resetOpencodeConfig}, + "gemini": {write: writeGeminiConfig, reset: resetGeminiConfig}, } type ConfigureCmdOpts struct { @@ -61,7 +62,7 @@ func ConfigureCmd() *cobra.Command { Use: "configure [key]", Short: "Configure a local AI client to use a Pangolin AI gateway resource", Long: "Writes local config files (e.g. ~/.claude/settings.json) so an AI client talks to a Pangolin\n" + - "resource acting as an AI gateway. Supported clients: claude, codex, opencode.\n\n" + + "resource acting as an AI gateway. Supported clients: claude, codex, opencode, gemini.\n\n" + "If [key] is omitted, an API key is fetched automatically for public resources (private/site\n" + "resources need no key). Pass [key] to configure with a credential obtained elsewhere without\n" + "making any API calls for it.\n\n" + @@ -88,7 +89,7 @@ func configureMain(cmd *cobra.Command, opts ConfigureCmdOpts, args []string) err if clientArg == "cursor" { return fmt.Errorf("cursor must be configured manually; see the AI client config instructions in the dashboard") } - return fmt.Errorf("unsupported client %q; expected one of: claude, codex, opencode", clientArg) + return fmt.Errorf("unsupported client %q; expected one of: claude, codex, opencode, gemini", clientArg) } if opts.Reset { diff --git a/cmd/configure/gemini.go b/cmd/configure/gemini.go new file mode 100644 index 0000000..60cdd29 --- /dev/null +++ b/cmd/configure/gemini.go @@ -0,0 +1,130 @@ +package configure + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// writeGeminiConfig sets GOOGLE_GEMINI_BASE_URL and GEMINI_API_KEY in +// ~/.gemini/.env, which Gemini CLI loads automatically on every run (it's the +// last stop in its .env search path, so this applies globally regardless of +// which directory Gemini CLI is started from), preserving any other lines. +func writeGeminiConfig(endpoint string, auth Auth) ([]string, error) { + path, err := geminiEnvPath() + if err != nil { + return nil, err + } + + lines, err := readEnvLines(path) + if err != nil { + return nil, err + } + + key := "none" + if auth.Mode == AuthModeKeyed { + key = auth.Key + } + + lines = setEnvLine(lines, "GOOGLE_GEMINI_BASE_URL", endpoint) + lines = setEnvLine(lines, "GEMINI_API_KEY", key) + + if err := writeFile(path, []byte(strings.Join(lines, "\n"))); err != nil { + return nil, err + } + + return []string{path}, nil +} + +// resetGeminiConfig removes the keys writeGeminiConfig sets from +// ~/.gemini/.env, preserving every other existing line. +func resetGeminiConfig() ([]string, error) { + path, err := geminiEnvPath() + if err != nil { + return nil, err + } + + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("failed to read %s: %w", path, err) + } + + lines, err := readEnvLines(path) + if err != nil { + return nil, err + } + + lines, changedURL := unsetEnvLine(lines, "GOOGLE_GEMINI_BASE_URL") + lines, changedKey := unsetEnvLine(lines, "GEMINI_API_KEY") + if !changedURL && !changedKey { + return nil, nil + } + + if err := writeFile(path, []byte(strings.Join(lines, "\n"))); err != nil { + return nil, err + } + + return []string{path}, nil +} + +// geminiEnvPath resolves Gemini CLI's global .env file path: ~/.gemini/.env, +// Gemini CLI's default on every OS. +func geminiEnvPath() (string, error) { + home, err := homeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".gemini", ".env"), nil +} + +// readEnvLines reads path as a list of raw lines (no trailing blank line for +// a trailing newline), for a dotenv-style KEY=value file. A missing file +// yields no lines, not an error. +func readEnvLines(path string) ([]string, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("failed to read %s: %w", path, err) + } + trimmed := strings.TrimRight(string(data), "\n") + if trimmed == "" { + return nil, nil + } + return strings.Split(trimmed, "\n"), nil +} + +// setEnvLine replaces the first line assigning key (a line starting with +// "key=") with key=value, or appends key=value if key isn't already set. +// Other lines, including comments, are left untouched. +func setEnvLine(lines []string, key, value string) []string { + prefix := key + "=" + newLine := fmt.Sprintf("%s=%s", key, value) + for i, line := range lines { + if strings.HasPrefix(line, prefix) { + lines[i] = newLine + return lines + } + } + return append(lines, newLine) +} + +// unsetEnvLine removes every line assigning key (a line starting with +// "key="), reporting whether it removed anything. +func unsetEnvLine(lines []string, key string) ([]string, bool) { + prefix := key + "=" + out := make([]string, 0, len(lines)) + changed := false + for _, line := range lines { + if strings.HasPrefix(line, prefix) { + changed = true + continue + } + out = append(out, line) + } + return out, changed +} From 2962fee974d60db106e2b6fd4ae66b4eee3d2c8f Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 20 Aug 2026 10:22:25 -0400 Subject: [PATCH 11/13] Update olm and newt --- go.mod | 9 ++++----- go.sum | 4 ++++ 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 11a7d10..e326873 100644 --- a/go.mod +++ b/go.mod @@ -10,8 +10,8 @@ require ( github.com/charmbracelet/huh v0.8.0 github.com/charmbracelet/lipgloss v1.1.0 github.com/creack/pty v1.1.24 - github.com/fosrl/newt v1.15.0 - github.com/fosrl/olm v1.8.0 + github.com/fosrl/newt v1.16.0 + github.com/fosrl/olm v1.9.0 github.com/mattn/go-isatty v0.0.20 github.com/pelletier/go-toml/v2 v2.2.4 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c @@ -78,6 +78,5 @@ require ( // If changes to Olm or Newt are required, use these // replace directives during development. // -replace github.com/fosrl/olm => ../olm - -replace github.com/fosrl/newt => ../newt +//replace github.com/fosrl/olm => ../olm +//replace github.com/fosrl/newt => ../newt diff --git a/go.sum b/go.sum index 617329f..4c76696 100644 --- a/go.sum +++ b/go.sum @@ -50,6 +50,10 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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/fosrl/newt v1.16.0 h1:Nf70uNFn/WqHoTvRg5xTPO3xz9vLgh3BsnMVMoniEEw= +github.com/fosrl/newt v1.16.0/go.mod h1:l6kWoZPSaXT+ZRUjiyPgwflRqZWYaXpUj9oQ0sOPh4o= +github.com/fosrl/olm v1.9.0 h1:e93wvWLzbhXgomST8Pp3pvq0rp7Q6im4Dr7taAqNH/k= +github.com/fosrl/olm v1.9.0/go.mod h1:8xS3GYjDuPuVbt7ANROC6sIOm2HWAM85wIad/ii7xko= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= From 425f036e5f827d51c96fe67d6aed7ad2daf7d16a Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 20 Aug 2026 11:38:30 -0400 Subject: [PATCH 12/13] Properly handled private resources keys for each client type --- cmd/configure/claude.go | 13 +++++-------- cmd/configure/configure.go | 15 +++++++++++++++ cmd/configure/gemini.go | 7 +------ cmd/configure/opencode.go | 37 +++++++++++++++++++------------------ 4 files changed, 40 insertions(+), 32 deletions(-) diff --git a/cmd/configure/claude.go b/cmd/configure/claude.go index 35761cf..172c9b3 100644 --- a/cmd/configure/claude.go +++ b/cmd/configure/claude.go @@ -20,14 +20,11 @@ func writeClaudeConfig(endpoint string, auth Auth) ([]string, error) { return nil, err } - if auth.Mode == AuthModeKeyed { - m["apiKeyHelper"] = apiKeyHelperEcho(auth.Key) - } else { - // A non-empty, unusable placeholder - forces Claude to invoke the - // helper at all instead of silently falling back to whatever - // account/key the user already has configured. - m["apiKeyHelper"] = apiKeyHelperEcho("-") - } + // Keyless resources still get a helper emitting a placeholder: a non-empty, + // unusable value forces Claude to invoke the helper at all instead of + // silently falling back to whatever account/key the user already has + // configured. + m["apiKeyHelper"] = apiKeyHelperEcho(keyOrPlaceholder(auth)) env := ensureMap(m, "env") env["ANTHROPIC_BASE_URL"] = endpoint diff --git a/cmd/configure/configure.go b/cmd/configure/configure.go index dfa2517..f9029d3 100644 --- a/cmd/configure/configure.go +++ b/cmd/configure/configure.go @@ -27,6 +27,21 @@ type Auth struct { Key string } +// keylessPlaceholderKey stands in for a credential on keyless (private/site) +// resources. Those need no credential, but most clients refuse to start +// without *some* key set, so they get an obviously-inert one rather than an +// omitted field. Mirrors KEYLESS_PLACEHOLDER_KEY in src/lib/aiClientConfig.ts. +const keylessPlaceholderKey = "none" + +// keyOrPlaceholder returns the real credential, or the inert placeholder when +// the resource needs none. +func keyOrPlaceholder(auth Auth) string { + if auth.Mode == AuthModeKeyed { + return auth.Key + } + return keylessPlaceholderKey +} + // clientWriter writes the local config file(s) for one AI client and returns // the paths it touched, for the success message. type clientWriter func(endpoint string, auth Auth) ([]string, error) diff --git a/cmd/configure/gemini.go b/cmd/configure/gemini.go index 60cdd29..389b673 100644 --- a/cmd/configure/gemini.go +++ b/cmd/configure/gemini.go @@ -22,13 +22,8 @@ func writeGeminiConfig(endpoint string, auth Auth) ([]string, error) { return nil, err } - key := "none" - if auth.Mode == AuthModeKeyed { - key = auth.Key - } - lines = setEnvLine(lines, "GOOGLE_GEMINI_BASE_URL", endpoint) - lines = setEnvLine(lines, "GEMINI_API_KEY", key) + lines = setEnvLine(lines, "GEMINI_API_KEY", keyOrPlaceholder(auth)) if err := writeFile(path, []byte(strings.Join(lines, "\n"))); err != nil { return nil, err diff --git a/cmd/configure/opencode.go b/cmd/configure/opencode.go index fe56b2c..7c014e0 100644 --- a/cmd/configure/opencode.go +++ b/cmd/configure/opencode.go @@ -97,26 +97,27 @@ func writeOpencodeConfig(endpoint string, auth Auth) ([]string, error) { } paths := []string{configPath} - if auth.Mode == AuthModeKeyed { - authPath, err := opencodeDataPath(home) - if err != nil { - return nil, err - } - authData, err := readJSONMap(authPath) - if err != nil { - return nil, err - } - for _, name := range providers { - authData[name] = map[string]interface{}{ - "type": "api", - "key": auth.Key, - } - } - if err := writeJSONMap(authPath, authData); err != nil { - return nil, err + // The credential is written even for keyless resources: OpenCode refuses + // to start a provider with no key at all ("OpenAI API key is missing"), + // so it gets an inert placeholder instead of an omitted entry. + authPath, err := opencodeDataPath(home) + if err != nil { + return nil, err + } + authData, err := readJSONMap(authPath) + if err != nil { + return nil, err + } + for _, name := range providers { + authData[name] = map[string]interface{}{ + "type": "api", + "key": keyOrPlaceholder(auth), } - paths = append(paths, authPath) } + if err := writeJSONMap(authPath, authData); err != nil { + return nil, err + } + paths = append(paths, authPath) return paths, nil } From 7282f9cf1597d452ff7b2e066d8414c1ef67441f Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 21 Aug 2026 09:18:38 -0400 Subject: [PATCH 13/13] Update flake --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index b544775..0e6900e 100644 --- a/flake.nix +++ b/flake.nix @@ -24,7 +24,7 @@ version = "0.13.0"; src = ./.; - vendorHash = "sha256-mb5IGRRz5pZoi7QJSNVRubQDfhRZu977CWq1Gf6Z7bQ="; + vendorHash = "sha256-Qp0fSkbjxGTo2FHq31oCH2JD13aCZdMwf/rS4lDygio="; ldflags = [ "-s"