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.
package/AGENTS.md CHANGED
@@ -47,13 +47,16 @@ Direct commands:
47
47
  Preserve these commands unless the user explicitly asks to change them:
48
48
 
49
49
  - `webctx search <query> [--exclude domain1,domain2] [--keyword phrase]`
50
+ - `webctx status` and `webctx providers enable|disable <brave|tavily|exa>`
51
+ - `webctx keys list|add|remove` for macOS Keychain-managed additional search keys; `BRAVE_API_KEYS`, `TAVILY_API_KEYS`, and `EXA_API_KEYS` also work via environment variables
50
52
  - `webctx read-link <url>`
51
53
  - `webctx map-site <url>`
52
54
  - `webctx --version`
53
55
 
54
56
  Behavioral expectations:
55
57
 
56
- - `search` combines Brave, Tavily, and Exa results, then re-ranks them with duplicate-aware scoring.
58
+ - `search` combines enabled Brave, Tavily, and Exa results, then re-ranks them with duplicate-aware scoring; an unavailable Exa can fall back to quoted-phrase Brave/Tavily queries for `--keyword` (not a strict text filter).
59
+ - Search key rotation tries plural credentials first, legacy singular credentials afterward; failover applies only to credential/quota/rate/server/transport failures, never bad requests. Keep Firecrawl paid operations outside this rotation.
57
60
  - `read-link` keeps the current GitHub raw-content and `.md` fast paths. Firecrawl uses its built-in 30-minute cache, automatic and enhanced proxies, then browser extraction as a final fallback.
58
61
  - `map-site` keeps the current Firecrawl map request settings.
59
62
  - The CLI should remain agent-friendly and emit plain markdown/text output.
@@ -100,3 +103,11 @@ When cutting a release, update `src/content/docs/changelog.md` before tagging.
100
103
  - Do not include docs-site-only changes such as site styling, Zuedocs/package bumps, deploy plumbing, footer/layout changes, or documentation navigation changes.
101
104
  - Rewrite commit subjects into clear release notes instead of pasting raw commit messages.
102
105
  - If a release contains only tagging/release metadata, write: `Maintenance release. No direct code behavior changes beyond release preparation.`
106
+
107
+ ### Docs deployment
108
+
109
+ The Docs GitHub Actions workflow deploys production only for the watched site paths; Vercel Git auto-deployments and `ignoreCommand` stay disabled. Keep the project-scoped `VERCEL_TOKEN` Actions secret and ID variables configured. Check Astro locally; builds run in CI.
110
+
111
+ ### Origo deployment
112
+
113
+ The separate `Origo API` GitHub Actions workflow deploys the Go function only for changes to its import graph and Vercel configuration. Use `VERCEL_ORIGO_TOKEN` and `VERCEL_ORIGO_PROJECT_ID` exclusively for Origo; never substitute the WebCTX Docs token or a static `--prebuilt` site bundle. Keep the `/mcp` rewrite and Origo production environment variables intact.
package/CONTRIBUTORS.md CHANGED
@@ -108,9 +108,9 @@ gh workflow run npm-publish-retry.yml --repo amxv/webctx --ref main -f version=0
108
108
 
109
109
  ## Origo deployment
110
110
 
111
- The `origo-api` Vercel project is linked to this GitHub repo and uses `ORIGO_DEPLOYMENT=1` to select its Go Function configuration. It needs `ORIGO_API_KEY` and `FIRECRAWL_API_KEY` production environment variables; optionally add `GH_TOKEN` for native authenticated GitHub reads. The docs project remains `webctx-docs` and never receives the Origo connection key.
111
+ The `origo-api` Vercel project uses `ORIGO_DEPLOYMENT=1` to select its Go Function configuration. It needs `ORIGO_API_KEY` and `FIRECRAWL_API_KEY` production environment variables; optionally add `GH_TOKEN` for native authenticated GitHub reads. The docs project remains `webctx-docs` and never receives the Origo connection key.
112
112
 
113
- Both projects deploy automatically from `main`. `scripts/should-build.mjs` gates docs and API builds independently, while `v*` GitHub tags still drive the CLI release workflow.
113
+ The independent `Docs` and `Origo API` GitHub Actions workflows filter their own build inputs before deploying to Vercel. Origo uses the `VERCEL_ORIGO_TOKEN` Actions secret and `VERCEL_ORIGO_PROJECT_ID` variable, separate from the docs credentials. Git-triggered Vercel deployments are disabled; `v*` tags still drive CLI releases.
114
114
 
115
115
  ## Notes on package naming
116
116
 
package/README.md CHANGED
@@ -95,7 +95,7 @@ Recognized native GitHub auth, private/not-found, and rate-limit failures stay a
95
95
 
96
96
  ## Search
97
97
 
98
- Normal search asks Brave, Tavily, and Exa, removes duplicate URLs, automatically reads promising sources, follows relevant links, and returns excerpts plus the ranked list. Alexandria discovery is included when available; use `execute` when you need provider-native data.
98
+ Normal search asks the enabled Brave, Tavily, and Exa providers, removes duplicate URLs, automatically reads promising sources, follows relevant links, and returns excerpts plus the ranked list. One working search provider is enough; Alexandria discovery is included when available. Use `execute` when you need provider-native data.
99
99
 
100
100
  ```bash
101
101
  webctx search "next.js server components"
@@ -103,6 +103,31 @@ webctx search "react hooks" --exclude youtube.com,medium.com
103
103
  webctx search "drizzle orm" --keyword "migration guide"
104
104
  ```
105
105
 
106
+ ### Enable or disable search providers
107
+
108
+ ```bash
109
+ webctx status # enabled/disabled providers and locally configured keys
110
+ webctx providers disable exa # stop all Exa search requests, even with a key present
111
+ webctx providers enable exa # turn Exa back on later
112
+ webctx providers disable brave
113
+ webctx providers enable brave
114
+ ```
115
+
116
+ Provider settings persist across runs, independently of environment variables and Keychain credentials. The settings file lives in the OS user config directory (`webctx/config.json`); set `WEBCTX_CONFIG_FILE` to override its location. Disabled providers are never queried. `status` only reports key presence, **not** live credit balances or API health.
117
+
118
+ Multiple keys per provider are supported. Add extra keys on macOS without placing secrets in command arguments or plaintext config files:
119
+
120
+ ```bash
121
+ webctx keys list # count configured and managed keys (never prints them)
122
+ pbpaste | webctx keys add exa --stdin # securely store a copied new key in macOS Keychain
123
+ webctx keys remove exa 1 # remove the newest Keychain-managed Exa key
124
+ webctx providers enable exa
125
+ ```
126
+
127
+ Or supply `BRAVE_API_KEYS`, `TAVILY_API_KEYS`, or `EXA_API_KEYS` as comma-separated keys (or JSON arrays) in the environment or `.env.local`, alongside the corresponding singular `*_API_KEY`. Keys in the plural list are attempted first, then the legacy single key. On a key-specific authentication, quota/credit, rate-limit, or server error, the next key is tried automatically; invalid requests are not retried. Manage only keys/accounts you are authorized to use and honor each provider's usage restrictions. The Firecrawl/Alexandria integration is separate and **is not included in automatic key rotation**, especially for paid operations.
128
+
129
+ `--keyword` prefers Exa's strict include-text filter when available. If Exa is disabled, unconfigured, or fails, Brave/Tavily search for the quoted phrase instead; the output explicitly notes that this fallback cannot guarantee the phrase appears in the page text. Firecrawl is a separate reading/site-map/Alexandria integration, not one of these three search providers.
130
+
106
131
  ## Map a site
107
132
 
108
133
  Use `map-site` when you want to discover the useful pages before reading them.
@@ -131,7 +156,7 @@ FIRECRAWL_API_KEY=...
131
156
  GH_TOKEN=...
132
157
  ```
133
158
 
134
- - `BRAVE_API_KEY`, `TAVILY_API_KEY`, and `EXA_API_KEY` power normal `search`.
159
+ - `BRAVE_API_KEY`, `TAVILY_API_KEY`, and `EXA_API_KEY` power normal `search`. The plural `*_API_KEYS` names supply additional fallback credentials. Configure any one or more, and optionally disable providers you no longer want to query.
135
160
  - `FIRECRAWL_API_KEY` powers `map-site`, page-crawl fallbacks, and Alexandria discovery/inspection/paid execution.
136
161
  - `GH_TOKEN` or `GITHUB_TOKEN` is optional. It increases GitHub capacity and unlocks reads such as blame, Discussions, some Actions job logs, private resources the token can access, and richer PR review state.
137
162
 
@@ -16,8 +16,6 @@ const commandName = "webctx"
16
16
  var version = buildinfo.CurrentVersion()
17
17
 
18
18
  func Run(args []string, stdout, stderr io.Writer) int {
19
- loadEnvLocal()
20
-
21
19
  if len(args) == 0 || isHelpArg(args[0]) {
22
20
  _, _ = fmt.Fprintln(stdout, usageText())
23
21
  return 0
@@ -29,6 +27,71 @@ func Run(args []string, stdout, stderr io.Writer) int {
29
27
  }
30
28
 
31
29
  tool := args[0]
30
+ if tool == "keys" {
31
+ if len(args) == 2 && args[1] == "list" {
32
+ loadEnvLocal()
33
+ if err := printProviderStatus(stdout); err != nil {
34
+ _, _ = fmt.Fprintln(stderr, err)
35
+ return 1
36
+ }
37
+ if err := printManagedSearchKeySlots(stdout); err != nil {
38
+ _, _ = fmt.Fprintln(stderr, err)
39
+ return 1
40
+ }
41
+ return 0
42
+ }
43
+ if len(args) == 4 && args[1] == "add" && args[3] == "--stdin" {
44
+ count, err := addManagedSearchKey(args[2], os.Stdin)
45
+ if err != nil {
46
+ _, _ = fmt.Fprintln(stderr, err)
47
+ return 1
48
+ }
49
+ _, _ = fmt.Fprintf(stdout, "Saved an additional %s API key in macOS Keychain (%d managed key(s)).\n", strings.ToLower(args[2]), count)
50
+ return 0
51
+ }
52
+ if len(args) == 4 && args[1] == "remove" {
53
+ count, err := removeManagedSearchKey(args[2], args[3])
54
+ if err != nil {
55
+ _, _ = fmt.Fprintln(stderr, err)
56
+ return 1
57
+ }
58
+ _, _ = fmt.Fprintf(stdout, "Removed managed %s API key (%d managed key(s) remain).\n", strings.ToLower(args[2]), count)
59
+ return 0
60
+ }
61
+ _, _ = fmt.Fprintln(stderr, "Usage: webctx keys list | keys add <brave|tavily|exa> --stdin | keys remove <provider> <managed-key-index>")
62
+ return 1
63
+ }
64
+ isProviderTool := tool == "status" || tool == "providers" || tool == "provider"
65
+ isProviderStatus := (tool == "status" && len(args) == 1) ||
66
+ ((tool == "providers" || tool == "provider") && (len(args) == 1 || (len(args) == 2 && args[1] == "status")))
67
+ if !isProviderTool || isProviderStatus {
68
+ loadEnvLocal()
69
+ }
70
+ if isProviderTool {
71
+ if isProviderStatus {
72
+ if err := printProviderStatus(stdout); err != nil {
73
+ _, _ = fmt.Fprintln(stderr, err)
74
+ return 1
75
+ }
76
+ return 0
77
+ }
78
+ if tool == "status" || len(args) != 3 || (args[1] != "enable" && args[1] != "disable") {
79
+ _, _ = fmt.Fprintln(stderr, "Usage: webctx providers [status|enable <brave|tavily|exa>|disable <brave|tavily|exa>]")
80
+ return 1
81
+ }
82
+ enabled := args[1] == "enable"
83
+ if _, err := setSearchProviderEnabled(args[2], enabled); err != nil {
84
+ _, _ = fmt.Fprintln(stderr, err)
85
+ return 1
86
+ }
87
+ state := "disabled"
88
+ if enabled {
89
+ state = "enabled"
90
+ }
91
+ _, _ = fmt.Fprintf(stdout, "%s %s. Run 'webctx status' to inspect all providers.\n", strings.ToLower(strings.TrimSpace(args[2])), state)
92
+ return 0
93
+ }
94
+
32
95
  flags, positional := parseArgs(args[1:])
33
96
  input := ""
34
97
  if len(positional) > 0 {
@@ -197,6 +260,11 @@ func usageText() string {
197
260
 
198
261
  Usage:
199
262
  webctx search <query> [--exclude domain1,domain2] [--keyword phrase]
263
+ webctx status
264
+ webctx providers [status|enable <brave|tavily|exa>|disable <brave|tavily|exa>]
265
+ webctx keys list
266
+ webctx keys add <brave|tavily|exa> --stdin
267
+ webctx keys remove <brave|tavily|exa> <managed-key-index>
200
268
  webctx read-link <url> [--question 'what to find']
201
269
  webctx map-site <url> [--query topic] [--limit 30] [--offset 0] [--path-prefix /api] [--language en|all]
202
270
  webctx research [question] [--view sources|groups|operations] [--sources IDs] [--include inputs,output,examples] [--limit N] [--offset N]
@@ -208,6 +276,9 @@ Examples:
208
276
  webctx search "next.js server components"
209
277
  webctx search "react hooks" --exclude youtube.com,vimeo.com
210
278
  webctx search "drizzle orm" --keyword "migration guide"
279
+ webctx providers disable exa
280
+ webctx providers enable exa
281
+ webctx keys add exa --stdin < /path/to/private/key.txt
211
282
  webctx research "podcast conversations about AI agents"
212
283
  webctx research --view sources --limit 5
213
284
  webctx inspect particle/podcasts/episodes/search
@@ -84,9 +84,13 @@ func TestScoreAndRankResultsPreservesInsertionOrderForTies(t *testing.T) {
84
84
  }
85
85
 
86
86
  func TestSearchMissingCredentialsErrorIsHelpful(t *testing.T) {
87
+ t.Setenv("WEBCTX_CONFIG_FILE", filepath.Join(t.TempDir(), "config.json"))
87
88
  t.Setenv("BRAVE_API_KEY", "")
88
89
  t.Setenv("TAVILY_API_KEY", "")
89
90
  t.Setenv("EXA_API_KEY", "")
91
+ t.Setenv("BRAVE_API_KEYS", "")
92
+ t.Setenv("TAVILY_API_KEYS", "")
93
+ t.Setenv("EXA_API_KEYS", "")
90
94
 
91
95
  text, err := Search(SearchParams{Query: "openai api"})
92
96
  if err == nil {
@@ -179,6 +183,9 @@ func TestLoadKeychainEnvLoadsMissingOnly(t *testing.T) {
179
183
  t.Setenv("BRAVE_API_KEY", "already-set")
180
184
  t.Setenv("TAVILY_API_KEY", "")
181
185
  t.Setenv("EXA_API_KEY", "")
186
+ t.Setenv("BRAVE_API_KEYS", "")
187
+ t.Setenv("TAVILY_API_KEYS", "")
188
+ t.Setenv("EXA_API_KEYS", "")
182
189
  t.Setenv("FIRECRAWL_API_KEY", "")
183
190
  t.Setenv("GH_TOKEN", "")
184
191
  t.Setenv("GITHUB_TOKEN", "")
@@ -0,0 +1,165 @@
1
+ package app
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "io"
7
+ "os"
8
+ "path/filepath"
9
+ "slices"
10
+ "strings"
11
+ )
12
+
13
+ // These are the configurable search providers. Firecrawl powers independent
14
+ // read-link, map-site, and Alexandria operations, not federated web search.
15
+ var searchProviderKeys = []struct {
16
+ name string
17
+ key string
18
+ }{
19
+ {"brave", "BRAVE_API_KEY"},
20
+ {"tavily", "TAVILY_API_KEY"},
21
+ {"exa", "EXA_API_KEY"},
22
+ }
23
+
24
+ type providerConfig struct {
25
+ DisabledProviders []string `json:"disabled_providers,omitempty"`
26
+ }
27
+
28
+ func providerConfigPath() (string, error) {
29
+ if custom := strings.TrimSpace(os.Getenv("WEBCTX_CONFIG_FILE")); custom != "" {
30
+ return custom, nil
31
+ }
32
+ dir, err := os.UserConfigDir()
33
+ if err != nil {
34
+ return "", fmt.Errorf("locate WebCTX config directory: %w", err)
35
+ }
36
+ return filepath.Join(dir, "webctx", "config.json"), nil
37
+ }
38
+
39
+ func readProviderConfig(path string) (providerConfig, error) {
40
+ contents, err := os.ReadFile(path)
41
+ if os.IsNotExist(err) {
42
+ return providerConfig{}, nil // all providers default to enabled
43
+ }
44
+ if err != nil {
45
+ return providerConfig{}, fmt.Errorf("read provider settings %s: %w", path, err)
46
+ }
47
+ var config providerConfig
48
+ if err := json.Unmarshal(contents, &config); err != nil {
49
+ return providerConfig{}, fmt.Errorf("invalid provider settings %s: %w", path, err)
50
+ }
51
+ return config, nil
52
+ }
53
+
54
+ func currentProviderConfig() (providerConfig, error) {
55
+ path, err := providerConfigPath()
56
+ if err != nil {
57
+ return providerConfig{}, err
58
+ }
59
+ return readProviderConfig(path)
60
+ }
61
+
62
+ func (c providerConfig) enabled(name string) bool {
63
+ return !slices.Contains(c.DisabledProviders, name)
64
+ }
65
+
66
+ func validSearchProvider(name string) bool {
67
+ for _, p := range searchProviderKeys {
68
+ if p.name == name {
69
+ return true
70
+ }
71
+ }
72
+ return false
73
+ }
74
+
75
+ func setSearchProviderEnabled(name string, enabled bool) (string, error) {
76
+ name = strings.ToLower(strings.TrimSpace(name))
77
+ if !validSearchProvider(name) {
78
+ return "", fmt.Errorf("unknown search provider %q (choose brave, tavily, or exa)", name)
79
+ }
80
+ path, err := providerConfigPath()
81
+ if err != nil {
82
+ return "", err
83
+ }
84
+ config, err := readProviderConfig(path)
85
+ if err != nil {
86
+ return "", err
87
+ }
88
+ if config.enabled(name) == enabled {
89
+ return path, nil
90
+ }
91
+ if enabled {
92
+ config.DisabledProviders = slices.DeleteFunc(config.DisabledProviders, func(p string) bool { return p == name })
93
+ } else {
94
+ config.DisabledProviders = append(config.DisabledProviders, name)
95
+ }
96
+ slices.Sort(config.DisabledProviders)
97
+ if err := writeProviderConfig(path, config); err != nil {
98
+ return "", err
99
+ }
100
+ return path, nil
101
+ }
102
+
103
+ func writeProviderConfig(path string, config providerConfig) error {
104
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
105
+ return fmt.Errorf("create provider config directory: %w", err)
106
+ }
107
+ data, err := json.MarshalIndent(config, "", " ")
108
+ if err != nil {
109
+ return err
110
+ }
111
+ file, err := os.CreateTemp(filepath.Dir(path), ".webctx-config-*")
112
+ if err != nil {
113
+ return fmt.Errorf("write provider settings: %w", err)
114
+ }
115
+ defer os.Remove(file.Name())
116
+ if err := file.Chmod(0o600); err != nil {
117
+ _ = file.Close()
118
+ return err
119
+ }
120
+ if _, err := file.Write(append(data, '\n')); err != nil {
121
+ _ = file.Close()
122
+ return err
123
+ }
124
+ if err := file.Close(); err != nil {
125
+ return err
126
+ }
127
+ if err := os.Rename(file.Name(), path); err != nil {
128
+ return fmt.Errorf("save provider settings: %w", err)
129
+ }
130
+ return nil
131
+ }
132
+
133
+ func printProviderStatus(out io.Writer) error {
134
+ path, err := providerConfigPath()
135
+ if err != nil {
136
+ return err
137
+ }
138
+ config, err := readProviderConfig(path)
139
+ if err != nil {
140
+ return err
141
+ }
142
+ _, _ = fmt.Fprintf(out, "Search providers (settings: %s)\n", path)
143
+ for _, p := range searchProviderKeys {
144
+ state, key := "enabled", "key missing"
145
+ if !config.enabled(p.name) {
146
+ state = "disabled"
147
+ }
148
+ count := len(searchKeysForProvider(p.name))
149
+ if count > 0 {
150
+ key = "key configured"
151
+ if count > 1 {
152
+ key = fmt.Sprintf("key configured (%d keys)", count)
153
+ }
154
+ }
155
+ _, _ = fmt.Fprintf(out, " %-7s %-9s %s\n", p.name, state, key)
156
+ }
157
+ _, _ = fmt.Fprintln(out, "\nFirecrawl (page reading, site maps, and Alexandria; separate from search):")
158
+ if strings.TrimSpace(os.Getenv("FIRECRAWL_API_KEY")) != "" {
159
+ _, _ = fmt.Fprintln(out, " key configured")
160
+ } else {
161
+ _, _ = fmt.Fprintln(out, " key missing")
162
+ }
163
+ _, _ = fmt.Fprintln(out, "\nKey presence is local configuration only; quotas and API availability are not checked.")
164
+ return nil
165
+ }