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,227 @@
1
+ package app
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "encoding/json"
7
+ "fmt"
8
+ "io"
9
+ "net/http"
10
+ "os"
11
+ "path/filepath"
12
+ "reflect"
13
+ "strings"
14
+ "testing"
15
+ )
16
+
17
+ func TestSearchKeyOrderDeduplicationAndMissingKeys(t *testing.T) {
18
+ t.Setenv("EXA_API_KEYS", ` ["new","new","old"] `)
19
+ t.Setenv("EXA_API_KEY", "old")
20
+ if got, want := searchKeysForProvider("exa"), []string{"new", "old"}; !reflect.DeepEqual(got, want) {
21
+ t.Fatalf("deduplication: got %v, want %v", got, want)
22
+ }
23
+ t.Setenv("EXA_API_KEYS", "new,another\n old\n")
24
+ if got, want := searchKeysForProvider("exa"), []string{"new", "another", "old"}; !reflect.DeepEqual(got, want) {
25
+ t.Fatalf("CSV/newline parsing: got %v, want %v", got, want)
26
+ }
27
+ t.Setenv("EXA_API_KEYS", `[invalid`)
28
+ t.Setenv("EXA_API_KEY", "")
29
+ if got := searchKeysForProvider("exa"); len(got) != 0 {
30
+ t.Fatalf("malformed JSON should not be used as credential: %v", got)
31
+ }
32
+ if got := searchKeysForProvider("unknown"); len(got) != 0 {
33
+ t.Fatalf("unknown provider should have no keys: %v", got)
34
+ }
35
+ }
36
+
37
+ func TestStatusReportsMultipleKeysWithoutRevealingThem(t *testing.T) {
38
+ t.Setenv("WEBCTX_CONFIG_FILE", filepath.Join(t.TempDir(), "webctx", "config.json"))
39
+ t.Setenv("EXA_API_KEYS", "extra-secret-a,extra-secret-b")
40
+ t.Setenv("EXA_API_KEY", "legacy-secret")
41
+ t.Setenv("BRAVE_API_KEY", "")
42
+ t.Setenv("BRAVE_API_KEYS", "")
43
+ t.Setenv("TAVILY_API_KEY", "")
44
+ t.Setenv("TAVILY_API_KEYS", "")
45
+ t.Setenv("FIRECRAWL_API_KEY", "")
46
+ var out, errOut bytes.Buffer
47
+ if code := Run([]string{"status"}, &out, &errOut); code != 0 {
48
+ t.Fatalf("status error: %s", errOut.String())
49
+ }
50
+ if !strings.Contains(out.String(), "exa enabled key configured (3 keys)") {
51
+ t.Fatalf("incorrect key count: %q", out.String())
52
+ }
53
+ for _, secret := range []string{"extra-secret-a", "extra-secret-b", "legacy-secret"} {
54
+ if strings.Contains(out.String()+errOut.String(), secret) {
55
+ t.Fatal("status exposed a configured API key")
56
+ }
57
+ }
58
+ }
59
+
60
+ func TestManagedSearchKeychainAddRemoveAndInputValidation(t *testing.T) {
61
+ oldLookup, oldStore := keychainLookup, keychainStore
62
+ t.Cleanup(func() { keychainLookup, keychainStore = oldLookup, oldStore })
63
+ managed := map[string]string{}
64
+ keychainLookup = func(account string) (string, error) { return managed[account], nil }
65
+ keychainStore = func(account, secret string) error { managed[account] = secret; return nil }
66
+
67
+ for _, candidate := range []string{"", "one two", "line1\nline2", strings.Repeat("z", 4097)} {
68
+ if _, err := addManagedSearchKey("exa", strings.NewReader(candidate)); err == nil {
69
+ t.Fatalf("invalid secret accepted")
70
+ }
71
+ }
72
+ if len(managed) != 0 {
73
+ t.Fatal("invalid keys must not modify Keychain")
74
+ }
75
+ if _, err := addManagedSearchKey("unknown", strings.NewReader("token")); err == nil {
76
+ t.Fatal("unknown provider accepted")
77
+ }
78
+ for _, pair := range []struct {
79
+ key string
80
+ count int
81
+ }{{"older", 1}, {"newer", 2}, {"newer", 2}} {
82
+ count, err := addManagedSearchKey("exa", strings.NewReader(pair.key+"\n"))
83
+ if err != nil || count != pair.count {
84
+ t.Fatalf("add: count=%d error=%v", count, err)
85
+ }
86
+ }
87
+ list, err := readManagedKeys("exa")
88
+ if err != nil || !reflect.DeepEqual(list, []string{"newer", "older"}) {
89
+ t.Fatalf("priority order changed: %v %v", list, err)
90
+ }
91
+ if strings.Contains(managed["EXA_API_KEYS"], "[REDACTED]") {
92
+ t.Fatal("managed keys stored as placeholder instead of actual tokens")
93
+ }
94
+ if _, err := removeManagedSearchKey("exa", "3"); err == nil {
95
+ t.Fatal("out-of-range removal accepted")
96
+ }
97
+ if count, err := removeManagedSearchKey("exa", "1"); err != nil || count != 1 {
98
+ t.Fatalf("remove: count=%d error=%v", count, err)
99
+ }
100
+ if list, err := readManagedKeys("exa"); err != nil || !reflect.DeepEqual(list, []string{"older"}) {
101
+ t.Fatalf("wrong managed key removed: %v %v", list, err)
102
+ }
103
+ managed["EXA_API_KEYS"] = "{invalid"
104
+ if _, err := addManagedSearchKey("exa", strings.NewReader("another")); err == nil {
105
+ t.Fatal("malformed Keychain JSON silently overwritten")
106
+ }
107
+ }
108
+
109
+ func TestManagedSearchKeyCommandNeverPrintsSecrets(t *testing.T) {
110
+ oldLookup, oldStore, oldStdin := keychainLookup, keychainStore, os.Stdin
111
+ t.Cleanup(func() { keychainLookup, keychainStore, os.Stdin = oldLookup, oldStore, oldStdin })
112
+ keychainLookup = func(account string) (string, error) { return "", nil }
113
+ keychainStore = func(account, value string) error { return nil }
114
+ input, writer, err := os.Pipe()
115
+ if err != nil {
116
+ t.Fatal(err)
117
+ }
118
+ if _, err := io.WriteString(writer, "sensitive-api-token\n"); err != nil {
119
+ t.Fatal(err)
120
+ }
121
+ _ = writer.Close()
122
+ os.Stdin = input
123
+ var out, errOut bytes.Buffer
124
+ code := Run([]string{"keys", "add", "exa", "--stdin"}, &out, &errOut)
125
+ _ = input.Close()
126
+ if code != 0 || strings.Contains(out.String()+errOut.String(), "sensitive-api-token") {
127
+ t.Fatalf("unsafe CLI output or command failed: code=%d out=%q err=%q", code, out.String(), errOut.String())
128
+ }
129
+ }
130
+
131
+ // Exercise the real HTTP request construction and key-selection order for all
132
+ // three providers, without making network requests or disclosing API keys.
133
+ func TestSearchProviderFallbackAcrossKeys(t *testing.T) {
134
+ t.Setenv("BRAVE_API_KEY", "legacy-brave")
135
+ t.Setenv("TAVILY_API_KEY", "legacy-tavily")
136
+ t.Setenv("EXA_API_KEY", "legacy-exa")
137
+ for _, name := range []string{"BRAVE", "TAVILY", "EXA"} {
138
+ t.Setenv(name+"_API_KEYS", "bad-"+strings.ToLower(name)+",good-"+strings.ToLower(name))
139
+ }
140
+ previous := http.DefaultClient.Transport
141
+ requests := map[string][]string{}
142
+ http.DefaultClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
143
+ provider, key := "", ""
144
+ switch req.URL.Host {
145
+ case "api.search.brave.com":
146
+ provider, key = "brave", req.Header.Get("x-subscription-token")
147
+ case "api.exa.ai":
148
+ provider, key = "exa", req.Header.Get("x-api-key")
149
+ case "api.tavily.com":
150
+ provider = "tavily"
151
+ var payload map[string]any
152
+ _ = json.NewDecoder(req.Body).Decode(&payload)
153
+ key, _ = payload["api_key"].(string)
154
+ default:
155
+ t.Errorf("unexpected host: %s", req.URL.Host)
156
+ }
157
+ requests[provider] = append(requests[provider], key)
158
+ if strings.HasPrefix(key, "bad-") {
159
+ code := http.StatusUnauthorized
160
+ switch provider {
161
+ case "brave":
162
+ return testHTTPResponse(req, 422, `{"error":{"code":"SUBSCRIPTION_TOKEN_INVALID"}}`, nil), nil
163
+ case "tavily":
164
+ code = 432
165
+ case "exa":
166
+ code = http.StatusPaymentRequired
167
+ }
168
+ return testHTTPResponse(req, code, `{"error":"credits exhausted"}`, nil), nil
169
+ }
170
+ if strings.HasPrefix(key, "good-") {
171
+ switch provider {
172
+ case "brave":
173
+ return testHTTPResponse(req, 200, `{"web":{"results":[{"url":"https://example.com/guide","title":"Brave result"}]}}`, nil), nil
174
+ case "tavily", "exa":
175
+ return testHTTPResponse(req, 200, `{"results":[{"url":"https://example.com/guide","title":"API result"}]}`, nil), nil
176
+ }
177
+ }
178
+ return testHTTPResponse(req, 500, "unexpected key", nil), nil
179
+ })
180
+ t.Cleanup(func() { http.DefaultClient.Transport = previous })
181
+
182
+ for _, test := range []struct {
183
+ provider string
184
+ call func() (SearchResult, error)
185
+ }{
186
+ {"brave", func() (SearchResult, error) { return searchWithBrave(context.Background(), "test query") }},
187
+ {"tavily", func() (SearchResult, error) { return searchWithTavily(context.Background(), "test query", nil) }},
188
+ {"exa", func() (SearchResult, error) {
189
+ return searchWithExa(context.Background(), "test query", nil, "exact phrase")
190
+ }},
191
+ } {
192
+ results, err := test.call()
193
+ if err != nil || len(results.Docs) != 1 {
194
+ t.Fatalf("%s: search failed after key rotation: %v %+v", test.provider, err, results)
195
+ }
196
+ want := []string{"bad-" + test.provider, "good-" + test.provider}
197
+ if got := requests[test.provider]; !reflect.DeepEqual(got, want) {
198
+ t.Fatalf("%s: key rotation order got %v, want %v", test.provider, got, want)
199
+ }
200
+ }
201
+ }
202
+
203
+ func TestSearchKeyRetryDoesNotMaskBadInputOrLeakKeys(t *testing.T) {
204
+ t.Setenv("EXA_API_KEYS", "first-secret,second-secret")
205
+ t.Setenv("EXA_API_KEY", "")
206
+ previous := http.DefaultClient.Transport
207
+ count, code := 0, http.StatusBadRequest
208
+ http.DefaultClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
209
+ count++
210
+ return testHTTPResponse(req, code, fmt.Sprintf(`{"error":"key %s failed"}`, req.Header.Get("x-api-key")), nil), nil
211
+ })
212
+ t.Cleanup(func() { http.DefaultClient.Transport = previous })
213
+ _, err := searchWithExa(context.Background(), "test", nil, "")
214
+ if err == nil || count != 1 || strings.Contains(err.Error(), "first-secret") {
215
+ t.Fatalf("400 should not retry or leak a credential: count=%d error=%v", count, err)
216
+ }
217
+ count, code = 0, http.StatusUnprocessableEntity
218
+ _, err = searchWithExa(context.Background(), "test", nil, "")
219
+ if err == nil || count != 1 {
220
+ t.Fatalf("non-credential 422 should not retry: count=%d error=%v", count, err)
221
+ }
222
+ count, code = 0, http.StatusTooManyRequests
223
+ _, err = searchWithExa(context.Background(), "test", nil, "")
224
+ if err == nil || count != 2 || strings.Contains(err.Error(), "first-secret") || strings.Contains(err.Error(), "second-secret") || !strings.Contains(err.Error(), "up to 2") {
225
+ t.Fatalf("exhausted-key error is unsafe or not actionable: count=%d error=%v", count, err)
226
+ }
227
+ }
@@ -10,6 +10,7 @@ import (
10
10
  "net/url"
11
11
  "os"
12
12
  "sort"
13
+ "strconv"
13
14
  "strings"
14
15
  "sync"
15
16
  "time"
@@ -37,6 +38,11 @@ type providerDocs struct {
37
38
  Err error
38
39
  }
39
40
 
41
+ type namedSearch struct {
42
+ name string
43
+ fn func(context.Context) (SearchResult, error)
44
+ }
45
+
40
46
  type scoredURL struct {
41
47
  Result SearchDoc
42
48
  InsertionOrder int
@@ -47,6 +53,13 @@ type scoredURL struct {
47
53
  }
48
54
 
49
55
  func Search(params SearchParams) (string, error) {
56
+ config, err := currentProviderConfig()
57
+ if err != nil {
58
+ return "", err
59
+ }
60
+ if !config.enabled("brave") && !config.enabled("tavily") && !config.enabled("exa") {
61
+ return "", errors.New("Error searching the web: all search providers are disabled; run 'webctx providers enable brave', 'webctx providers enable tavily', or 'webctx providers enable exa'")
62
+ }
50
63
  defaultExcludedDomains := []string{"youtube.com", "vimeo.com", "dailymotion.com", "twitch.tv", "tiktok.com", "instagram.com", "facebook.com"}
51
64
  allExcludedDomains := append(append([]string{}, defaultExcludedDomains...), params.ExcludeDomains...)
52
65
  truncatedKeyword := truncateWords(params.IncludeKeyword, 5)
@@ -56,46 +69,44 @@ func Search(params SearchParams) (string, error) {
56
69
  alexandriaDone := make(chan string, 1)
57
70
  go func() { alexandriaDone <- discoverAlexandria(params.Query) }()
58
71
 
59
- type namedSearch struct {
60
- name string
61
- fn func(context.Context) (SearchResult, error)
62
- }
63
-
64
72
  searches := []namedSearch{}
73
+ keywordFallback := false
65
74
  if strings.TrimSpace(truncatedKeyword) != "" {
66
- searches = append(searches, namedSearch{name: "Exa", fn: func(ctx context.Context) (SearchResult, error) {
67
- return searchWithExa(ctx, params.Query, nil, truncatedKeyword)
68
- }})
75
+ if config.enabled("exa") && len(searchKeysForProvider("exa")) > 0 {
76
+ searches = append(searches, namedSearch{name: "Exa", fn: func(ctx context.Context) (SearchResult, error) {
77
+ return searchWithExa(ctx, params.Query, nil, truncatedKeyword)
78
+ }})
79
+ }
69
80
  } else {
70
- searches = append(searches,
71
- namedSearch{name: "Brave", fn: func(ctx context.Context) (SearchResult, error) { return searchWithBrave(ctx, params.Query) }},
72
- namedSearch{name: "Tavily", fn: func(ctx context.Context) (SearchResult, error) { return searchWithTavily(ctx, params.Query, nil) }},
73
- namedSearch{name: "Exa", fn: func(ctx context.Context) (SearchResult, error) { return searchWithExa(ctx, params.Query, nil, "") }},
74
- )
81
+ searches = append(searches, alternativeSearches(config, params.Query)...)
82
+ if config.enabled("exa") {
83
+ searches = append(searches, namedSearch{name: "Exa", fn: func(ctx context.Context) (SearchResult, error) {
84
+ return searchWithExa(ctx, params.Query, nil, "")
85
+ }})
86
+ }
75
87
  }
76
88
 
77
- results := make([]providerDocs, len(searches))
78
- var wg sync.WaitGroup
79
- for i, s := range searches {
80
- wg.Add(1)
81
- go func(i int, s namedSearch) {
82
- defer wg.Done()
83
- ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
84
- defer cancel()
85
- res, err := s.fn(ctx)
86
- if err != nil {
87
- results[i] = providerDocs{Provider: s.name, Docs: []SearchDoc{}, Err: err}
88
- return
89
- }
90
- results[i] = providerDocs{Provider: s.name, Docs: res.Docs}
91
- }(i, s)
89
+ results := runSearches(searches)
90
+ if truncatedKeyword != "" && (len(searches) == 0 || (len(results) == 1 && results[0].Err != nil)) {
91
+ // Exa's includeText filter has no equivalent in Brave or Tavily. When Exa
92
+ // is disabled, unconfigured, or fails, search for the quoted phrase instead
93
+ // of making keyword searches unusable. Disclose the weaker semantics.
94
+ fallbackQuery := params.Query + " " + strconv.Quote(truncatedKeyword)
95
+ fallbackSearches := alternativeSearches(config, fallbackQuery)
96
+ if len(fallbackSearches) > 0 {
97
+ results = append(results, runSearches(fallbackSearches)...)
98
+ keywordFallback = true
99
+ } else if len(searches) == 0 && config.enabled("exa") {
100
+ // Preserve the helpful missing-key error if Exa is the only enabled
101
+ // provider and it has no key.
102
+ results = append(results, providerDocs{Provider: "Exa", Err: errors.New("missing EXA_API_KEY")})
103
+ }
92
104
  }
93
- wg.Wait()
94
-
95
- total := 0
96
- for _, r := range results {
97
- total += len(r.Docs)
105
+ if len(results) == 0 {
106
+ return "", errors.New("Error searching the web: no enabled search providers are available for this query; use 'webctx status' to inspect settings")
98
107
  }
108
+
109
+ total := countSearchDocs(results)
99
110
  if total == 0 {
100
111
  missingKeys := uniqueMissingCredentialKeys(results)
101
112
  if len(missingKeys) > 0 {
@@ -123,6 +134,9 @@ func Search(params SearchParams) (string, error) {
123
134
  }
124
135
 
125
136
  parts := []string{fmt.Sprintf("Total Results: %d\n", len(ranked))}
137
+ if keywordFallback {
138
+ parts = append(parts, "> **Keyword fallback:** Exa was disabled or unavailable. Brave/Tavily searched for the quoted phrase; this is not a strict page-text inclusion filter.", "")
139
+ }
126
140
  // The ranked index remains available, but agents also get useful source
127
141
  // context without having to issue several follow-up read-link calls.
128
142
  if context := enrichSearch(params.Query, ranked); context != "" {
@@ -144,6 +158,46 @@ func Search(params SearchParams) (string, error) {
144
158
  return strings.Join(parts, "\n"), nil
145
159
  }
146
160
 
161
+ func alternativeSearches(config providerConfig, query string) []namedSearch {
162
+ searches := []namedSearch{}
163
+ if config.enabled("brave") {
164
+ searches = append(searches, namedSearch{name: "Brave", fn: func(ctx context.Context) (SearchResult, error) { return searchWithBrave(ctx, query) }})
165
+ }
166
+ if config.enabled("tavily") {
167
+ searches = append(searches, namedSearch{name: "Tavily", fn: func(ctx context.Context) (SearchResult, error) { return searchWithTavily(ctx, query, nil) }})
168
+ }
169
+ return searches
170
+ }
171
+
172
+ func countSearchDocs(results []providerDocs) int {
173
+ count := 0
174
+ for _, r := range results {
175
+ count += len(r.Docs)
176
+ }
177
+ return count
178
+ }
179
+
180
+ func runSearches(searches []namedSearch) []providerDocs {
181
+ results := make([]providerDocs, len(searches))
182
+ var wg sync.WaitGroup
183
+ for i, s := range searches {
184
+ wg.Add(1)
185
+ go func(i int, s namedSearch) {
186
+ defer wg.Done()
187
+ ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
188
+ defer cancel()
189
+ res, err := s.fn(ctx)
190
+ if err != nil {
191
+ results[i] = providerDocs{Provider: s.name, Docs: []SearchDoc{}, Err: err}
192
+ return
193
+ }
194
+ results[i] = providerDocs{Provider: s.name, Docs: res.Docs}
195
+ }(i, s)
196
+ }
197
+ wg.Wait()
198
+ return results
199
+ }
200
+
147
201
  func ReadLink(rawURL string) (string, error) {
148
202
  if err := validateSourceURL(rawURL); err != nil {
149
203
  return "", err
@@ -327,20 +381,17 @@ func missingCredentialsError(prefix string, keys []string, detail string) error
327
381
  }
328
382
 
329
383
  func searchWithBrave(ctx context.Context, query string) (SearchResult, error) {
330
- apiKey := strings.TrimSpace(os.Getenv("BRAVE_API_KEY"))
331
- if apiKey == "" {
332
- return SearchResult{}, errors.New("missing BRAVE_API_KEY")
333
- }
334
384
  params := url.Values{}
335
385
  params.Set("q", query)
336
386
  params.Set("text_decorations", "false")
337
387
  params.Set("result_filter", "web")
338
388
  params.Set("limit", "20")
339
- body, err := doRawRequest(ctx, http.MethodGet, "https://api.search.brave.com/res/v1/web/search?"+params.Encode(), map[string]string{
340
- "Accept": "application/json",
341
- "Accept-Encoding": "gzip",
342
- "x-subscription-token": apiKey,
343
- }, nil)
389
+ body, err := useSearchKeys(ctx, "brave", func(apiKey string) ([]byte, error) {
390
+ return doRawRequest(ctx, http.MethodGet, "https://api.search.brave.com/res/v1/web/search?"+params.Encode(), map[string]string{
391
+ "Accept": "application/json",
392
+ "x-subscription-token": apiKey,
393
+ }, nil)
394
+ })
344
395
  if err != nil {
345
396
  return SearchResult{}, err
346
397
  }
@@ -367,22 +418,20 @@ func searchWithBrave(ctx context.Context, query string) (SearchResult, error) {
367
418
  }
368
419
 
369
420
  func searchWithTavily(ctx context.Context, query string, excludeDomains []string) (SearchResult, error) {
370
- apiKey := strings.TrimSpace(os.Getenv("TAVILY_API_KEY"))
371
- if apiKey == "" {
372
- return SearchResult{}, errors.New("missing TAVILY_API_KEY")
373
- }
374
421
  requestBody := map[string]any{
375
- "api_key": apiKey,
376
422
  "query": query,
377
423
  "max_results": 20,
378
424
  }
379
425
  if len(excludeDomains) > 0 {
380
426
  requestBody["exclude_domains"] = excludeDomains
381
427
  }
382
- body, err := doJSONRequest(ctx, http.MethodPost, "https://api.tavily.com/search", map[string]string{
383
- "Content-Type": "application/json",
384
- "Accept": "application/json",
385
- }, requestBody)
428
+ body, err := useSearchKeys(ctx, "tavily", func(apiKey string) ([]byte, error) {
429
+ requestBody["api_key"] = apiKey
430
+ return doJSONRequest(ctx, http.MethodPost, "https://api.tavily.com/search", map[string]string{
431
+ "Content-Type": "application/json",
432
+ "Accept": "application/json",
433
+ }, requestBody)
434
+ })
386
435
  if err != nil {
387
436
  return SearchResult{}, err
388
437
  }
@@ -407,10 +456,6 @@ func searchWithTavily(ctx context.Context, query string, excludeDomains []string
407
456
  }
408
457
 
409
458
  func searchWithExa(ctx context.Context, query string, excludeDomains []string, includeKeyword string) (SearchResult, error) {
410
- apiKey := strings.TrimSpace(os.Getenv("EXA_API_KEY"))
411
- if apiKey == "" {
412
- return SearchResult{}, errors.New("missing EXA_API_KEY")
413
- }
414
459
  requestBody := map[string]any{
415
460
  "query": query,
416
461
  "type": "auto",
@@ -425,11 +470,13 @@ func searchWithExa(ctx context.Context, query string, excludeDomains []string, i
425
470
  if strings.TrimSpace(includeKeyword) != "" {
426
471
  requestBody["includeText"] = []string{includeKeyword}
427
472
  }
428
- body, err := doJSONRequest(ctx, http.MethodPost, "https://api.exa.ai/search", map[string]string{
429
- "Accept": "application/json",
430
- "Content-Type": "application/json",
431
- "x-api-key": apiKey,
432
- }, requestBody)
473
+ body, err := useSearchKeys(ctx, "exa", func(apiKey string) ([]byte, error) {
474
+ return doJSONRequest(ctx, http.MethodPost, "https://api.exa.ai/search", map[string]string{
475
+ "Accept": "application/json",
476
+ "Content-Type": "application/json",
477
+ "x-api-key": apiKey,
478
+ }, requestBody)
479
+ })
433
480
  if err != nil {
434
481
  return SearchResult{}, err
435
482
  }
@@ -631,13 +678,20 @@ func doRawRequest(ctx context.Context, method, rawURL string, headers map[string
631
678
  }
632
679
  if resp.StatusCode < 200 || resp.StatusCode >= 300 {
633
680
  if len(respBody) > 0 {
634
- return nil, fmt.Errorf("API request failed: %s - %s", resp.Status, strings.TrimSpace(string(respBody)))
681
+ return nil, &httpStatusError{Code: resp.StatusCode, Message: fmt.Sprintf("API request failed: %s - %s", resp.Status, strings.TrimSpace(string(respBody)))}
635
682
  }
636
- return nil, fmt.Errorf("API request failed: %s", resp.Status)
683
+ return nil, &httpStatusError{Code: resp.StatusCode, Message: "API request failed: " + resp.Status}
637
684
  }
638
685
  return respBody, nil
639
686
  }
640
687
 
688
+ type httpStatusError struct {
689
+ Code int
690
+ Message string
691
+ }
692
+
693
+ func (e *httpStatusError) Error() string { return e.Message }
694
+
641
695
  func stringValue(v any) string {
642
696
  if s, ok := v.(string); ok {
643
697
  return s
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webctx",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
4
4
  "description": "Go CLI for agent-friendly web research, structured-data discovery, and source-native querying",
5
5
  "license": "MIT",
6
6
  "author": "amxv",