webctx 0.2.8 → 0.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,305 @@
1
+ package app
2
+
3
+ import (
4
+ "bytes"
5
+ "compress/gzip"
6
+ "context"
7
+ "encoding/json"
8
+ "io"
9
+ "net/http"
10
+ "net/http/httptest"
11
+ "os"
12
+ "path/filepath"
13
+ "strings"
14
+ "sync"
15
+ "testing"
16
+ )
17
+
18
+ func TestProviderCommandsPersistAndReport(t *testing.T) {
19
+ path := filepath.Join(t.TempDir(), "webctx", "config.json")
20
+ t.Setenv("WEBCTX_CONFIG_FILE", path)
21
+ t.Setenv("BRAVE_API_KEY", "test-brave-secret")
22
+ t.Setenv("TAVILY_API_KEY", "")
23
+ t.Setenv("EXA_API_KEY", "test-exa-secret")
24
+ t.Setenv("FIRECRAWL_API_KEY", "")
25
+
26
+ run := func(args ...string) (int, string, string) {
27
+ t.Helper()
28
+ var out, errOut bytes.Buffer
29
+ code := Run(args, &out, &errOut)
30
+ return code, out.String(), errOut.String()
31
+ }
32
+ if code, text, errText := run("status"); code != 0 || !strings.Contains(text, "exa enabled") || !strings.Contains(text, "brave enabled") || errText != "" {
33
+ t.Fatalf("default status: code=%d text=%q error=%q", code, text, errText)
34
+ }
35
+ if _, err := os.Stat(path); !os.IsNotExist(err) {
36
+ t.Fatalf("status should not create config file, got error %v", err)
37
+ }
38
+ if code, text, errText := run("providers", "disable", "ExA"); code != 0 || !strings.Contains(text, "exa disabled") || errText != "" {
39
+ t.Fatalf("disable: code=%d text=%q error=%q", code, text, errText)
40
+ }
41
+ if code, text, _ := run("providers"); code != 0 || !strings.Contains(text, "exa disabled") || !strings.Contains(text, "key configured") {
42
+ t.Fatalf("persisted status: code=%d text=%q", code, text)
43
+ } else if strings.Contains(text, "test-exa-secret") || strings.Contains(text, "test-brave-secret") {
44
+ t.Fatalf("status exposed a credential: %q", text)
45
+ }
46
+ content, err := os.ReadFile(path)
47
+ if err != nil {
48
+ t.Fatal(err)
49
+ }
50
+ var config providerConfig
51
+ if err := json.Unmarshal(content, &config); err != nil || !strings.EqualFold(strings.Join(config.DisabledProviders, ","), "exa") {
52
+ t.Fatalf("settings not persisted: %s; error=%v", content, err)
53
+ }
54
+ if info, err := os.Stat(path); err != nil || info.Mode().Perm() != 0o600 {
55
+ t.Fatalf("config permissions: info=%v error=%v", info, err)
56
+ }
57
+ if code, text, errText := run("providers", "enable", "exa"); code != 0 || !strings.Contains(text, "exa enabled") || errText != "" {
58
+ t.Fatalf("enable: code=%d text=%q error=%q", code, text, errText)
59
+ }
60
+ if code, text, _ := run("status"); code != 0 || !strings.Contains(text, "exa enabled") {
61
+ t.Fatalf("restored status: code=%d text=%q", code, text)
62
+ }
63
+ }
64
+
65
+ func TestProviderCommandsRejectInvalidInputAndMalformedConfig(t *testing.T) {
66
+ path := filepath.Join(t.TempDir(), "config.json")
67
+ t.Setenv("WEBCTX_CONFIG_FILE", path)
68
+ for _, args := range [][]string{{"providers", "disable", "firecrawl"}, {"providers", "disable", "unknown"}, {"providers", "disable"}, {"status", "enable", "exa"}} {
69
+ var out, errOut bytes.Buffer
70
+ if Run(args, &out, &errOut) == 0 || errOut.Len() == 0 {
71
+ t.Fatalf("invalid command %q succeeded: %q", args, out.String())
72
+ }
73
+ }
74
+ if _, err := os.Stat(path); !os.IsNotExist(err) {
75
+ t.Fatalf("invalid command wrote config: %v", err)
76
+ }
77
+ if err := os.WriteFile(path, []byte("{invalid"), 0o600); err != nil {
78
+ t.Fatal(err)
79
+ }
80
+ var out, errOut bytes.Buffer
81
+ if Run([]string{"status"}, &out, &errOut) == 0 || !strings.Contains(errOut.String(), "invalid provider settings") {
82
+ t.Fatalf("malformed config not reported: %q", errOut.String())
83
+ }
84
+ if Run([]string{"providers", "disable", "exa"}, &out, &errOut) == 0 {
85
+ t.Fatal("malformed config should not be overwritten")
86
+ }
87
+ }
88
+
89
+ type providerRequests struct {
90
+ mu sync.Mutex
91
+ queries map[string][]string
92
+ failedExa bool
93
+ emptyExa bool
94
+ }
95
+
96
+ func mockSearchProviders(t *testing.T, exaFailure bool) *providerRequests {
97
+ t.Helper()
98
+ prev := http.DefaultClient.Transport
99
+ stats := &providerRequests{queries: make(map[string][]string), failedExa: exaFailure}
100
+ http.DefaultClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
101
+ provider, query := "", ""
102
+ switch req.URL.Host {
103
+ case "api.search.brave.com":
104
+ provider, query = "brave", req.URL.Query().Get("q")
105
+ case "api.tavily.com", "api.exa.ai":
106
+ if req.URL.Host == "api.tavily.com" {
107
+ provider = "tavily"
108
+ } else {
109
+ provider = "exa"
110
+ }
111
+ data, _ := io.ReadAll(req.Body)
112
+ var payload map[string]any
113
+ _ = json.Unmarshal(data, &payload)
114
+ query, _ = payload["query"].(string)
115
+ case "provider-test.example":
116
+ return testHTTPResponse(req, http.StatusOK, "# Example documentation\n\nThis markdown page contains enough content for a test.", map[string]string{"Content-Type": "text/markdown"}), nil
117
+ default:
118
+ return testHTTPResponse(req, http.StatusNotFound, "", nil), nil
119
+ }
120
+ stats.mu.Lock()
121
+ stats.queries[provider] = append(stats.queries[provider], query)
122
+ stats.mu.Unlock()
123
+ switch provider {
124
+ case "brave":
125
+ return testHTTPResponse(req, http.StatusOK, `{"web":{"results":[{"url":"https://provider-test.example/guide.md","title":"Guide","description":"Documentation"}]}}`, nil), nil
126
+ case "tavily":
127
+ return testHTTPResponse(req, http.StatusOK, `{"results":[]}`, nil), nil
128
+ case "exa":
129
+ if stats.failedExa {
130
+ return testHTTPResponse(req, http.StatusPaymentRequired, `{"error":"NO_MORE_CREDITS"}`, nil), nil
131
+ }
132
+ if stats.emptyExa {
133
+ return testHTTPResponse(req, http.StatusOK, `{"results":[]}`, nil), nil
134
+ }
135
+ return testHTTPResponse(req, http.StatusOK, `{"results":[{"url":"https://provider-test.example/guide.md","title":"Guide","text":"Documentation"}]}`, nil), nil
136
+ }
137
+ return testHTTPResponse(req, http.StatusNotFound, "", nil), nil
138
+ })
139
+ t.Cleanup(func() { http.DefaultClient.Transport = prev })
140
+ return stats
141
+ }
142
+
143
+ func (p *providerRequests) received(provider string) []string {
144
+ p.mu.Lock()
145
+ defer p.mu.Unlock()
146
+ return append([]string(nil), p.queries[provider]...)
147
+ }
148
+
149
+ func searchTestEnv(t *testing.T) {
150
+ t.Helper()
151
+ t.Setenv("WEBCTX_CONFIG_FILE", filepath.Join(t.TempDir(), "config.json"))
152
+ t.Setenv("BRAVE_API_KEY", "test-brave")
153
+ t.Setenv("TAVILY_API_KEY", "test-tavily")
154
+ t.Setenv("EXA_API_KEY", "test-exa")
155
+ t.Setenv("BRAVE_API_KEYS", "")
156
+ t.Setenv("TAVILY_API_KEYS", "")
157
+ t.Setenv("EXA_API_KEYS", "")
158
+ t.Setenv("FIRECRAWL_API_KEY", "")
159
+ }
160
+
161
+ func TestSearchDoesNotCallDisabledExa(t *testing.T) {
162
+ searchTestEnv(t)
163
+ stats := mockSearchProviders(t, false)
164
+ if _, err := setSearchProviderEnabled("exa", false); err != nil {
165
+ t.Fatal(err)
166
+ }
167
+ result, err := Search(SearchParams{Query: "runtime docs"})
168
+ if err != nil || !strings.Contains(result, "Total Results: 1") {
169
+ t.Fatalf("search without Exa failed: %v; %q", err, result)
170
+ }
171
+ if got := stats.received("exa"); len(got) != 0 {
172
+ t.Fatalf("Exa was contacted despite being disabled: %v", got)
173
+ }
174
+ if got := stats.received("brave"); len(got) != 1 || got[0] != "runtime docs" {
175
+ t.Fatalf("Brave was not used: %v", got)
176
+ }
177
+ if got := stats.received("tavily"); len(got) != 1 {
178
+ t.Fatalf("Tavily was not used: %v", got)
179
+ }
180
+ }
181
+
182
+ func TestSearchSupportsOnlyBraveEnabled(t *testing.T) {
183
+ searchTestEnv(t)
184
+ stats := mockSearchProviders(t, false)
185
+ for _, name := range []string{"exa", "tavily"} {
186
+ if _, err := setSearchProviderEnabled(name, false); err != nil {
187
+ t.Fatal(err)
188
+ }
189
+ }
190
+ result, err := Search(SearchParams{Query: "runtime docs"})
191
+ if err != nil || !strings.Contains(result, "Total Results: 1") {
192
+ t.Fatalf("single-provider search failed: %v; %q", err, result)
193
+ }
194
+ if len(stats.received("exa")) != 0 || len(stats.received("tavily")) != 0 || len(stats.received("brave")) != 1 {
195
+ t.Fatalf("unexpected requests: %#v", stats.queries)
196
+ }
197
+ }
198
+
199
+ func TestKeywordFallbackSkipsDisabledExa(t *testing.T) {
200
+ searchTestEnv(t)
201
+ stats := mockSearchProviders(t, false)
202
+ if _, err := setSearchProviderEnabled("exa", false); err != nil {
203
+ t.Fatal(err)
204
+ }
205
+ result, err := Search(SearchParams{Query: "drizzle orm", IncludeKeyword: "migration guide"})
206
+ if err != nil || !strings.Contains(result, "Keyword fallback") {
207
+ t.Fatalf("keyword fallback failed: %v; %q", err, result)
208
+ }
209
+ if len(stats.received("exa")) != 0 {
210
+ t.Fatal("disabled Exa received keyword request")
211
+ }
212
+ for _, provider := range []string{"brave", "tavily"} {
213
+ got := stats.received(provider)
214
+ if len(got) != 1 || !strings.Contains(got[0], `"migration guide"`) {
215
+ t.Fatalf("%s did not receive a quoted phrase: %v", provider, got)
216
+ }
217
+ }
218
+ }
219
+
220
+ func TestKeywordFallbackOnExaCreditExhaustion(t *testing.T) {
221
+ searchTestEnv(t)
222
+ stats := mockSearchProviders(t, true)
223
+ result, err := Search(SearchParams{Query: "drizzle orm", IncludeKeyword: "migration guide"})
224
+ if err != nil || !strings.Contains(result, "Keyword fallback") || !strings.Contains(result, "Total Results: 1") {
225
+ t.Fatalf("keyword fallback after Exa error failed: %v; %q", err, result)
226
+ }
227
+ if len(stats.received("exa")) != 1 || len(stats.received("brave")) != 1 {
228
+ t.Fatalf("unexpected provider request sequence: %#v", stats.queries)
229
+ }
230
+ }
231
+
232
+ func TestKeywordExaSuccessDoesNotContactOtherProviders(t *testing.T) {
233
+ searchTestEnv(t)
234
+ stats := mockSearchProviders(t, false)
235
+ result, err := Search(SearchParams{Query: "drizzle orm", IncludeKeyword: "migration guide"})
236
+ if err != nil || strings.Contains(result, "Keyword fallback") || !strings.Contains(result, "Total Results: 1") {
237
+ t.Fatalf("unexpected keyword results: %v; %q", err, result)
238
+ }
239
+ if len(stats.received("exa")) != 1 || len(stats.received("brave")) != 0 || len(stats.received("tavily")) != 0 {
240
+ t.Fatalf("unexpected provider requests: %#v", stats.queries)
241
+ }
242
+ }
243
+
244
+ func TestKeywordExaNoMatchesDoesNotBroadenSearch(t *testing.T) {
245
+ searchTestEnv(t)
246
+ stats := mockSearchProviders(t, false)
247
+ stats.emptyExa = true
248
+ _, err := Search(SearchParams{Query: "drizzle orm", IncludeKeyword: "migration guide"})
249
+ if err == nil || !strings.Contains(err.Error(), "failed to return results") {
250
+ t.Fatalf("unexpected result for zero Exa matches: %v", err)
251
+ }
252
+ if len(stats.received("exa")) != 1 || len(stats.received("brave")) != 0 || len(stats.received("tavily")) != 0 {
253
+ t.Fatalf("strict keyword search was broadened: %#v", stats.queries)
254
+ }
255
+ }
256
+
257
+ func TestSearchWithAllProvidersDisabledFailsFast(t *testing.T) {
258
+ searchTestEnv(t)
259
+ stats := mockSearchProviders(t, false)
260
+ for _, name := range []string{"brave", "tavily", "exa"} {
261
+ if _, err := setSearchProviderEnabled(name, false); err != nil {
262
+ t.Fatal(err)
263
+ }
264
+ }
265
+ _, err := Search(SearchParams{Query: "anything"})
266
+ if err == nil || !strings.Contains(err.Error(), "all search providers are disabled") {
267
+ t.Fatalf("unexpected error when all disabled: %v", err)
268
+ }
269
+ if len(stats.received("exa"))+len(stats.received("tavily"))+len(stats.received("brave")) != 0 {
270
+ t.Fatal("search contacted provider with all disabled")
271
+ }
272
+ }
273
+
274
+ func TestBraveSearchTransparentlyDecodesGzip(t *testing.T) {
275
+ t.Setenv("BRAVE_API_KEY", "test-brave")
276
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
277
+ if r.Header.Get("Accept-Encoding") != "gzip" {
278
+ t.Errorf("expected Go HTTP transport to request gzip, got %q", r.Header.Get("Accept-Encoding"))
279
+ }
280
+ w.Header().Set("Content-Encoding", "gzip")
281
+ w.Header().Set("Content-Type", "application/json")
282
+ writer := gzip.NewWriter(w)
283
+ _, _ = writer.Write([]byte(`{"web":{"results":[{"url":"https://example.com/guide","title":"Guide","description":"Documentation"}]}}`))
284
+ _ = writer.Close()
285
+ }))
286
+ defer server.Close()
287
+
288
+ originalTransport := http.DefaultClient.Transport
289
+ http.DefaultClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
290
+ if req.Header.Get("Accept-Encoding") != "" {
291
+ t.Errorf("Brave search forced Accept-Encoding instead of letting Go decode it: %q", req.Header.Get("Accept-Encoding"))
292
+ }
293
+ copy := req.Clone(req.Context())
294
+ copy.URL.Scheme = "http"
295
+ copy.URL.Host = strings.TrimPrefix(server.URL, "http://")
296
+ copy.Host = copy.URL.Host
297
+ return http.DefaultTransport.RoundTrip(copy)
298
+ })
299
+ t.Cleanup(func() { http.DefaultClient.Transport = originalTransport })
300
+
301
+ result, err := searchWithBrave(context.Background(), "Go docs")
302
+ if err != nil || len(result.Docs) != 1 || result.Docs[0].Title != "Guide" {
303
+ t.Fatalf("Brave gzip response not decoded: %v; %#v", err, result)
304
+ }
305
+ }
@@ -21,7 +21,7 @@ type MarkdownResult struct {
21
21
  const keychainServiceName = "webctx"
22
22
 
23
23
  var (
24
- credentialEnvKeys = []string{"BRAVE_API_KEY", "TAVILY_API_KEY", "EXA_API_KEY", "FIRECRAWL_API_KEY", "GH_TOKEN", "GITHUB_TOKEN"}
24
+ credentialEnvKeys = []string{"BRAVE_API_KEY", "BRAVE_API_KEYS", "TAVILY_API_KEY", "TAVILY_API_KEYS", "EXA_API_KEY", "EXA_API_KEYS", "FIRECRAWL_API_KEY", "GH_TOKEN", "GITHUB_TOKEN"}
25
25
  getwdFunc = os.Getwd
26
26
  executableFunc = os.Executable
27
27
  keychainLookup = lookupKeychainSecret
@@ -0,0 +1,233 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "fmt"
8
+ "io"
9
+ "net/url"
10
+ "os"
11
+ "os/exec"
12
+ "runtime"
13
+ "slices"
14
+ "strconv"
15
+ "strings"
16
+ )
17
+
18
+ // A provider's legacy singular key remains supported. Additional keys are
19
+ // checked first so newly provisioned keys take precedence over exhausted ones.
20
+ // Plural values can be comma/newline-separated or a JSON string array (the
21
+ // latter is how the macOS Keychain stores them).
22
+ func searchKeysForProvider(name string) []string {
23
+ name = strings.ToLower(strings.TrimSpace(name))
24
+ if !validSearchProvider(name) {
25
+ return nil
26
+ }
27
+ prefix := strings.ToUpper(name) + "_API_KEY"
28
+ keys := parseSearchKeyList(os.Getenv(prefix + "S"))
29
+ if key := strings.TrimSpace(os.Getenv(prefix)); key != "" {
30
+ keys = append(keys, key)
31
+ }
32
+ return uniqueSearchKeys(keys)
33
+ }
34
+
35
+ func parseSearchKeyList(raw string) []string {
36
+ raw = strings.TrimSpace(raw)
37
+ if raw == "" {
38
+ return nil
39
+ }
40
+ if strings.HasPrefix(raw, "[") {
41
+ var keys []string
42
+ if json.Unmarshal([]byte(raw), &keys) != nil {
43
+ return nil // fail closed rather than sending malformed credential data
44
+ }
45
+ return uniqueSearchKeys(keys)
46
+ }
47
+ return uniqueSearchKeys(strings.FieldsFunc(raw, func(r rune) bool {
48
+ return r == ',' || r == '\n' || r == '\r'
49
+ }))
50
+ }
51
+
52
+ func uniqueSearchKeys(keys []string) []string {
53
+ seen := make(map[string]bool, len(keys))
54
+ unique := make([]string, 0, len(keys))
55
+ for _, key := range keys {
56
+ key = strings.TrimSpace(key)
57
+ if key != "" && !seen[key] {
58
+ seen[key] = true
59
+ unique = append(unique, key)
60
+ }
61
+ }
62
+ return unique
63
+ }
64
+
65
+ // Retrying with another key is appropriate for auth, quota, rate-limit, and
66
+ // server/transport failures. Request validation errors or malformed successful
67
+ // responses won't improve with another credential and should not be retried.
68
+ func retryWithNextSearchKey(err error) bool {
69
+ var status *httpStatusError
70
+ if errors.As(err, &status) {
71
+ code := status.Code
72
+ // Brave Search rejects invalid subscription tokens with HTTP 422,
73
+ // rather than 401. Only retry that exact provider error code: a
74
+ // different 422 can indicate a bad query that no key can fix.
75
+ if code == 422 && strings.Contains(status.Message, "SUBSCRIPTION_TOKEN_INVALID") {
76
+ return true
77
+ }
78
+ return code == 401 || code == 402 || code == 403 || code == 408 || code == 429 || code == 432 || code >= 500
79
+ }
80
+ var transport *url.Error
81
+ return errors.As(err, &transport)
82
+ }
83
+
84
+ func redactSearchError(err error, keys []string) error {
85
+ if err == nil {
86
+ return nil
87
+ }
88
+ message := err.Error()
89
+ for _, key := range keys {
90
+ if key != "" {
91
+ message = strings.ReplaceAll(message, key, "[REDACTED]")
92
+ }
93
+ }
94
+ return errors.New(message)
95
+ }
96
+
97
+ func useSearchKeys(ctx context.Context, name string, attempt func(string) ([]byte, error)) ([]byte, error) {
98
+ keys := searchKeysForProvider(name)
99
+ if len(keys) == 0 {
100
+ return nil, fmt.Errorf("missing %s_API_KEY", strings.ToUpper(name))
101
+ }
102
+ var lastErr error
103
+ for index, key := range keys {
104
+ if ctx.Err() != nil {
105
+ return nil, ctx.Err()
106
+ }
107
+ body, err := attempt(key)
108
+ if err == nil {
109
+ return body, nil
110
+ }
111
+ lastErr = redactSearchError(err, keys)
112
+ if index+1 == len(keys) || !retryWithNextSearchKey(err) {
113
+ break
114
+ }
115
+ }
116
+ if len(keys) == 1 {
117
+ return nil, lastErr
118
+ }
119
+ return nil, fmt.Errorf("%s: search failed after trying available API keys (up to %d): %w", name, len(keys), lastErr)
120
+ }
121
+
122
+ var keychainStore = storeKeychainSecret
123
+
124
+ // Additional keys are stored in one macOS Keychain entry per provider, without
125
+ // exposing secrets in argv, config.json, logs, or CLI output. The security CLI
126
+ // supports a prompt from stdin when -w is the final argument. It prompts for
127
+ // the password twice, even when updating an entry, so feed matching lines.
128
+ func storeKeychainSecret(account, secret string) error {
129
+ if runtime.GOOS != "darwin" {
130
+ return errors.New("key management requires macOS Keychain; on other platforms set PROVIDER_API_KEYS in the environment")
131
+ }
132
+ cmd := exec.Command("security", "add-generic-password", "-U", "-s", keychainServiceName, "-a", account, "-w")
133
+ cmd.Stdin = strings.NewReader(secret + "\n" + secret + "\n")
134
+ if err := cmd.Run(); err != nil {
135
+ return fmt.Errorf("could not save API keys to macOS Keychain: %w", err)
136
+ }
137
+ return nil
138
+ }
139
+
140
+ func managedKeychainAccount(name string) (string, error) {
141
+ name = strings.ToLower(strings.TrimSpace(name))
142
+ if !validSearchProvider(name) {
143
+ return "", fmt.Errorf("unknown search provider %q (choose brave, tavily, or exa)", name)
144
+ }
145
+ return strings.ToUpper(name) + "_API_KEYS", nil
146
+ }
147
+
148
+ func readManagedKeys(name string) ([]string, error) {
149
+ account, err := managedKeychainAccount(name)
150
+ if err != nil {
151
+ return nil, err
152
+ }
153
+ raw, err := keychainLookup(account)
154
+ if err != nil {
155
+ return nil, fmt.Errorf("could not read API keys from macOS Keychain: %w", err)
156
+ }
157
+ if raw == "" {
158
+ return nil, nil
159
+ }
160
+ var keys []string
161
+ if err := json.Unmarshal([]byte(raw), &keys); err != nil {
162
+ return nil, fmt.Errorf("the stored %s key list is not valid JSON; no keys were changed", name)
163
+ }
164
+ return uniqueSearchKeys(keys), nil
165
+ }
166
+
167
+ func writeManagedKeys(name string, keys []string) error {
168
+ account, err := managedKeychainAccount(name)
169
+ if err != nil {
170
+ return err
171
+ }
172
+ serialized, err := json.Marshal(uniqueSearchKeys(keys))
173
+ if err != nil {
174
+ return err
175
+ }
176
+ return keychainStore(account, string(serialized))
177
+ }
178
+
179
+ func addManagedSearchKey(name string, from io.Reader) (int, error) {
180
+ if _, err := managedKeychainAccount(name); err != nil {
181
+ return 0, err
182
+ }
183
+ input, err := io.ReadAll(io.LimitReader(from, 4097))
184
+ if err != nil {
185
+ return 0, fmt.Errorf("read API key from stdin: %w", err)
186
+ }
187
+ key := strings.TrimSpace(string(input))
188
+ if key == "" || len(input) > 4096 || strings.ContainsAny(key, "\r\n\t ") {
189
+ return 0, errors.New("API key must be a single nonempty token no longer than 4096 bytes")
190
+ }
191
+ keys, err := readManagedKeys(name)
192
+ if err != nil {
193
+ return 0, err
194
+ }
195
+ if slices.Contains(keys, key) {
196
+ return len(keys), nil
197
+ }
198
+ // Newest keys take priority, so an old exhausted legacy key is never
199
+ // attempted before a recently added working key.
200
+ keys = append([]string{key}, keys...)
201
+ if err := writeManagedKeys(name, keys); err != nil {
202
+ return 0, err
203
+ }
204
+ return len(keys), nil
205
+ }
206
+
207
+ func removeManagedSearchKey(name, rawIndex string) (int, error) {
208
+ keys, err := readManagedKeys(name)
209
+ if err != nil {
210
+ return 0, err
211
+ }
212
+ index, err := strconv.Atoi(rawIndex)
213
+ if err != nil || index < 1 || index > len(keys) {
214
+ return 0, fmt.Errorf("key index must be between 1 and %d (managed Keychain keys only)", len(keys))
215
+ }
216
+ keys = slices.Delete(keys, index-1, index)
217
+ if err := writeManagedKeys(name, keys); err != nil {
218
+ return 0, err
219
+ }
220
+ return len(keys), nil
221
+ }
222
+
223
+ func printManagedSearchKeySlots(out io.Writer) error {
224
+ _, _ = fmt.Fprintln(out, "\nManaged macOS Keychain keys (numbered newest-first; indexes start at 1):")
225
+ for _, provider := range searchProviderKeys {
226
+ keys, err := readManagedKeys(provider.name)
227
+ if err != nil {
228
+ return err
229
+ }
230
+ _, _ = fmt.Fprintf(out, " %-7s %d managed\n", provider.name, len(keys))
231
+ }
232
+ return nil
233
+ }