evo-cli 0.17.0__tar.gz → 0.18.0__tar.gz
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.
- {evo_cli-0.17.0 → evo_cli-0.18.0}/PKG-INFO +47 -1
- {evo_cli-0.17.0 → evo_cli-0.18.0}/README.md +46 -0
- evo_cli-0.18.0/evo_cli/VERSION +1 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/cli.py +3 -0
- evo_cli-0.18.0/evo_cli/commands/serp.py +402 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/credentials/registry.py +11 -0
- evo_cli-0.18.0/evo_cli/serp/__init__.py +7 -0
- evo_cli-0.18.0/evo_cli/serp/client.py +254 -0
- evo_cli-0.18.0/evo_cli/serp/creds.py +94 -0
- evo_cli-0.18.0/evo_cli/serp/errors.py +2 -0
- evo_cli-0.18.0/evo_cli/serp/install.py +239 -0
- evo_cli-0.18.0/evo_cli/serp/render.py +173 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli.egg-info/PKG-INFO +47 -1
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli.egg-info/SOURCES.txt +8 -0
- evo_cli-0.18.0/tests/test_serp.py +455 -0
- evo_cli-0.17.0/evo_cli/VERSION +0 -1
- {evo_cli-0.17.0 → evo_cli-0.18.0}/Containerfile +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/HISTORY.md +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/LICENSE +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/MANIFEST.in +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/__init__.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/__main__.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/base.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/__init__.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/agent_toy.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/claude_code.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/cloudflare.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/cred.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/download.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/fix_claude.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/gdrive.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/gh.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/__init__.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/_dag.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/_git.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/_model.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/_mutate.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/_paths.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/_render.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/_server.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/check.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/edit.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/pull.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/serve.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/show.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/views.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/web/assets/index-CsALFg16.css +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/web/assets/index-DwEXL5pV.js +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/web/index.html +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/hwid.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/hwid_reset.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/localproxy.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/mcp.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/miniconda.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/netcheck.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/opencode.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/plantuml.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/site2s.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/ssh.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/sysmon.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/tts.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/update.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/wifi.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/console.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/credentials/__init__.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/credentials/doctor.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/credentials/google_oauth.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/credentials/migrate.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/credentials/oauth_flow.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/credentials/store.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/credentials/sync.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/mcp_registry.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/tts/__init__.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/tts/chunking.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/tts/core.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/tts/creds.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/tts/errors.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/tts/http.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/tts/openai.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/tts/player.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/tts/vbee.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli.egg-info/dependency_links.txt +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli.egg-info/entry_points.txt +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli.egg-info/requires.txt +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli.egg-info/top_level.txt +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/pyproject.toml +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/setup.cfg +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/tests/__init__.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/tests/test_agent_toy.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/tests/test_claude_code.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/tests/test_cli.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/tests/test_console.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/tests/test_cred.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/tests/test_download.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/tests/test_fix_claude.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/tests/test_gh.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/tests/test_harness.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/tests/test_harness_dag.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/tests/test_mcp.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/tests/test_opencode.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/tests/test_plantuml.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/tests/test_sysmon.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/tests/test_tts.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/tests/test_update.py +0 -0
- {evo_cli-0.17.0 → evo_cli-0.18.0}/tests/test_wifi.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: evo_cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.18.0
|
|
4
4
|
Summary: Evolution CLI - a developer toolbox for setting up dev machines
|
|
5
5
|
Author: maycuatroi
|
|
6
6
|
Project-URL: Homepage, https://github.com/maycuatroi/evo-cli
|
|
@@ -235,6 +235,52 @@ to disk and the command warns.
|
|
|
235
235
|
The same engine backs the `evo-tts` MCP server in
|
|
236
236
|
[agent-skills](https://github.com/maycuatroi1/agent-skills), which gives an agent a `speak` tool.
|
|
237
237
|
|
|
238
|
+
#### Google Search (SerpApi)
|
|
239
|
+
|
|
240
|
+
Search Google from the terminal through [SerpApi](https://serpapi.com). One-time setup installs the
|
|
241
|
+
official `serpapi` CLI and stores the key in the omelet credential store:
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
evo setup serp # install the CLI + prompt for the key (no echo)
|
|
245
|
+
evo setup serp --from-stdin < key.txt # non-interactive
|
|
246
|
+
evo setup serp --method none # key only; searches use the HTTPS API
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
`--method auto` (the default) tries Homebrew first, then falls back to the GitHub release binary
|
|
250
|
+
dropped into `~/.evo/bin`. Homebrew 5 refuses to build from an untrusted third-party tap, so the
|
|
251
|
+
brew path needs `evo setup serp --trust-tap` (which runs `brew trust --formula
|
|
252
|
+
serpapi/tap/serpapi-cli`); without it the fallback fetches the same official binary.
|
|
253
|
+
|
|
254
|
+
```bash
|
|
255
|
+
evo serp search "evo cli python" # top 10 Google results
|
|
256
|
+
evo serp search coffee -l "Austin, Texas" -n 5 # localized
|
|
257
|
+
evo serp search openai -t news --hl vi --gl vn # news vertical, Vietnamese
|
|
258
|
+
evo serp search "site:github.com serpapi" --links # only URLs, pipe-friendly
|
|
259
|
+
evo serp search "rust async" -p 3 --json > out.json # 3 merged pages of raw JSON
|
|
260
|
+
evo serp search x -P tbs=qdr:d # any extra SerpApi parameter
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
`-t` picks the vertical (`web`, `news`, `images`, `videos`, `shopping`, `scholar`). Google treats
|
|
264
|
+
`num` as a hint and often returns more, so the rendered output is capped at what `-n` asked for
|
|
265
|
+
while `--json` / `-o` keep the full payload.
|
|
266
|
+
|
|
267
|
+
Supporting commands:
|
|
268
|
+
|
|
269
|
+
```bash
|
|
270
|
+
evo serp account # plan, searches used and left this month
|
|
271
|
+
evo serp locations Hanoi # canonical names to pass to --location
|
|
272
|
+
evo serp doctor # binary, key source (masked), remaining quota
|
|
273
|
+
evo serp raw search --jq '.organic_results[0]' engine=google q=coffee
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
`search` runs through the `serpapi` binary when it is installed and falls back to the SerpApi HTTPS
|
|
277
|
+
endpoint otherwise, so a machine that only has the key still works (`--transport cli|http|auto`
|
|
278
|
+
forces the choice). The key is resolved from `SERPAPI_KEY`, then `evo cred get serpapi_api_key`,
|
|
279
|
+
then `~/.config/serpapi/config.toml`, and is passed to the binary through the environment - never on
|
|
280
|
+
the command line, where any process listing would show it. `evo setup serp --write-config` writes
|
|
281
|
+
`~/.config/serpapi/config.toml` (mode 600) if you also want the bare `serpapi` binary to
|
|
282
|
+
authenticate on its own.
|
|
283
|
+
|
|
238
284
|
## `evo harness` - read a repo cluster
|
|
239
285
|
|
|
240
286
|
A harness is a repo that describes a *cluster* of repos: `harness.yaml` lists them, `contracts.yaml`
|
|
@@ -207,6 +207,52 @@ to disk and the command warns.
|
|
|
207
207
|
The same engine backs the `evo-tts` MCP server in
|
|
208
208
|
[agent-skills](https://github.com/maycuatroi1/agent-skills), which gives an agent a `speak` tool.
|
|
209
209
|
|
|
210
|
+
#### Google Search (SerpApi)
|
|
211
|
+
|
|
212
|
+
Search Google from the terminal through [SerpApi](https://serpapi.com). One-time setup installs the
|
|
213
|
+
official `serpapi` CLI and stores the key in the omelet credential store:
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
evo setup serp # install the CLI + prompt for the key (no echo)
|
|
217
|
+
evo setup serp --from-stdin < key.txt # non-interactive
|
|
218
|
+
evo setup serp --method none # key only; searches use the HTTPS API
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
`--method auto` (the default) tries Homebrew first, then falls back to the GitHub release binary
|
|
222
|
+
dropped into `~/.evo/bin`. Homebrew 5 refuses to build from an untrusted third-party tap, so the
|
|
223
|
+
brew path needs `evo setup serp --trust-tap` (which runs `brew trust --formula
|
|
224
|
+
serpapi/tap/serpapi-cli`); without it the fallback fetches the same official binary.
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
evo serp search "evo cli python" # top 10 Google results
|
|
228
|
+
evo serp search coffee -l "Austin, Texas" -n 5 # localized
|
|
229
|
+
evo serp search openai -t news --hl vi --gl vn # news vertical, Vietnamese
|
|
230
|
+
evo serp search "site:github.com serpapi" --links # only URLs, pipe-friendly
|
|
231
|
+
evo serp search "rust async" -p 3 --json > out.json # 3 merged pages of raw JSON
|
|
232
|
+
evo serp search x -P tbs=qdr:d # any extra SerpApi parameter
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
`-t` picks the vertical (`web`, `news`, `images`, `videos`, `shopping`, `scholar`). Google treats
|
|
236
|
+
`num` as a hint and often returns more, so the rendered output is capped at what `-n` asked for
|
|
237
|
+
while `--json` / `-o` keep the full payload.
|
|
238
|
+
|
|
239
|
+
Supporting commands:
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
evo serp account # plan, searches used and left this month
|
|
243
|
+
evo serp locations Hanoi # canonical names to pass to --location
|
|
244
|
+
evo serp doctor # binary, key source (masked), remaining quota
|
|
245
|
+
evo serp raw search --jq '.organic_results[0]' engine=google q=coffee
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
`search` runs through the `serpapi` binary when it is installed and falls back to the SerpApi HTTPS
|
|
249
|
+
endpoint otherwise, so a machine that only has the key still works (`--transport cli|http|auto`
|
|
250
|
+
forces the choice). The key is resolved from `SERPAPI_KEY`, then `evo cred get serpapi_api_key`,
|
|
251
|
+
then `~/.config/serpapi/config.toml`, and is passed to the binary through the environment - never on
|
|
252
|
+
the command line, where any process listing would show it. `evo setup serp --write-config` writes
|
|
253
|
+
`~/.config/serpapi/config.toml` (mode 600) if you also want the bare `serpapi` binary to
|
|
254
|
+
authenticate on its own.
|
|
255
|
+
|
|
210
256
|
## `evo harness` - read a repo cluster
|
|
211
257
|
|
|
212
258
|
A harness is a repo that describes a *cluster* of repos: `harness.yaml` lists them, `contracts.yaml`
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.18.0
|
|
@@ -18,6 +18,7 @@ from evo_cli.commands.miniconda import miniconda
|
|
|
18
18
|
from evo_cli.commands.netcheck import netcheck
|
|
19
19
|
from evo_cli.commands.opencode import setup_opencode
|
|
20
20
|
from evo_cli.commands.plantuml import plantuml
|
|
21
|
+
from evo_cli.commands.serp import serp_group, setup_serp
|
|
21
22
|
from evo_cli.commands.site2s import site2s
|
|
22
23
|
from evo_cli.commands.ssh import setupssh
|
|
23
24
|
from evo_cli.commands.sysmon import sysmon
|
|
@@ -56,6 +57,7 @@ setup_group.add_command(agent_toy)
|
|
|
56
57
|
setup_group.add_command(setup_claude)
|
|
57
58
|
setup_group.add_command(setup_gh)
|
|
58
59
|
setup_group.add_command(setup_opencode)
|
|
60
|
+
setup_group.add_command(setup_serp)
|
|
59
61
|
setup_group.add_command(miniconda)
|
|
60
62
|
setup_group.add_command(setupssh)
|
|
61
63
|
|
|
@@ -73,6 +75,7 @@ cli.add_command(localproxy)
|
|
|
73
75
|
cli.add_command(mcp_group)
|
|
74
76
|
cli.add_command(netcheck)
|
|
75
77
|
cli.add_command(plantuml)
|
|
78
|
+
cli.add_command(serp_group)
|
|
76
79
|
cli.add_command(sysmon)
|
|
77
80
|
cli.add_command(tts_group)
|
|
78
81
|
cli.add_command(update)
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""
|
|
2
|
+
evo serp - Google search from the terminal through SerpApi.
|
|
3
|
+
|
|
4
|
+
`evo setup serp` installs the official serpapi CLI and stores the API key in
|
|
5
|
+
the omelet credential store. `evo serp search` then runs queries through that
|
|
6
|
+
CLI when it is installed, and falls back to the SerpApi HTTPS endpoint when it
|
|
7
|
+
is not, so the command works on a machine where only the key is present.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import getpass
|
|
11
|
+
import json as jsonlib
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import rich_click as click
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
from rich.text import Text
|
|
18
|
+
|
|
19
|
+
from evo_cli.console import console, info, step, success, warning
|
|
20
|
+
from evo_cli.credentials.store import CredentialError, compile_flat, relative_to_store, set_value
|
|
21
|
+
from evo_cli.serp import client, creds, install, render
|
|
22
|
+
from evo_cli.serp.errors import SerpError
|
|
23
|
+
|
|
24
|
+
SEARCH_EPILOG = Text.from_markup(
|
|
25
|
+
"[bold]Examples[/bold]\n\n"
|
|
26
|
+
" [cyan]evo serp search 'evo cli python'[/cyan] top 10 Google results\n"
|
|
27
|
+
" [cyan]evo serp search coffee -l 'Austin, Texas' -n 5[/cyan] localized, 5 results\n"
|
|
28
|
+
" [cyan]evo serp search 'openai' -t news --hl vi --gl vn[/cyan] news vertical, Vietnamese\n"
|
|
29
|
+
" [cyan]evo serp search 'site:github.com serpapi' --links[/cyan] just the URLs, pipe-friendly\n"
|
|
30
|
+
" [cyan]evo serp search 'rust async' -p 3 --json > out.json[/cyan] 3 pages of raw JSON\n"
|
|
31
|
+
" [cyan]evo serp search x -P tbs=qdr:d[/cyan] any extra SerpApi parameter"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
GROUP_EPILOG = Text.from_markup(
|
|
35
|
+
"[bold]Examples[/bold]\n\n"
|
|
36
|
+
" [cyan]evo setup serp[/cyan] install the CLI + store the API key\n"
|
|
37
|
+
" [cyan]evo serp search 'claude code'[/cyan] Google search\n"
|
|
38
|
+
" [cyan]evo serp account[/cyan] plan and searches left this month\n"
|
|
39
|
+
" [cyan]evo serp locations Hanoi[/cyan] canonical names for `--location`\n"
|
|
40
|
+
" [cyan]evo serp doctor[/cyan] binary, key source, quota\n"
|
|
41
|
+
" [cyan]evo serp raw --jq '.organic_results[0]' engine=google q=coffee[/cyan]"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
SETUP_EPILOG = Text.from_markup(
|
|
45
|
+
"[bold]Examples[/bold]\n\n"
|
|
46
|
+
" [cyan]evo setup serp[/cyan] install + prompt for the key (no echo)\n"
|
|
47
|
+
" [cyan]evo setup serp --from-stdin < key.txt[/cyan] read the key from stdin\n"
|
|
48
|
+
" [cyan]evo setup serp --method binary[/cyan] skip brew, drop the binary in ~/.evo/bin\n"
|
|
49
|
+
" [cyan]evo setup serp --method none[/cyan] key only, use the HTTPS fallback\n"
|
|
50
|
+
" [cyan]evo setup serp --write-config[/cyan] also let the bare `serpapi` binary auth\n\n"
|
|
51
|
+
"[dim]The key is stored in the omelet credential store, never in this repo.\n"
|
|
52
|
+
"Push it to your private sync repo afterwards with `evo cred sync push`.[/dim]"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _guard(func, *args, **kwargs):
|
|
57
|
+
try:
|
|
58
|
+
return func(*args, **kwargs)
|
|
59
|
+
except (SerpError, CredentialError) as exc:
|
|
60
|
+
raise click.ClickException(str(exc)) from exc
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _store_key(key):
|
|
64
|
+
path, existed = set_value(creds.CRED_KEY, key)
|
|
65
|
+
count, _, target = compile_flat()
|
|
66
|
+
return path, existed, count, target
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _install_from_release(cli_version):
|
|
70
|
+
binary, version, url = _guard(install.install_binary, cli_version)
|
|
71
|
+
info(f"downloaded {url}")
|
|
72
|
+
success(f"serpapi {version} installed at [accent]{binary}[/accent]")
|
|
73
|
+
return binary
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _install_cli(method, cli_version, trust_tap):
|
|
77
|
+
chosen = install.pick_method(method)
|
|
78
|
+
info(f"installing the serpapi CLI via [accent]{chosen}[/accent]")
|
|
79
|
+
if chosen == "brew":
|
|
80
|
+
try:
|
|
81
|
+
return install.install_brew(trust=trust_tap)
|
|
82
|
+
except install.BrewTrustRequired as exc:
|
|
83
|
+
if method == "brew":
|
|
84
|
+
raise click.ClickException(str(exc)) from exc
|
|
85
|
+
# Same binaries either way: the formula only repackages the GitHub release.
|
|
86
|
+
warning(str(exc))
|
|
87
|
+
info("falling back to the GitHub release binary")
|
|
88
|
+
return _install_from_release(cli_version)
|
|
89
|
+
except SerpError as exc:
|
|
90
|
+
raise click.ClickException(str(exc)) from exc
|
|
91
|
+
if chosen == "go":
|
|
92
|
+
return _guard(install.install_go)
|
|
93
|
+
return _install_from_release(cli_version)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@click.group("serp", epilog=GROUP_EPILOG, context_settings={"help_option_names": ["-h", "--help"]})
|
|
97
|
+
def serp_group():
|
|
98
|
+
"""**Google search** from the terminal, powered by [SerpApi](https://serpapi.com).
|
|
99
|
+
|
|
100
|
+
Runs through the official `serpapi` CLI when it is installed and falls back
|
|
101
|
+
to the SerpApi HTTPS endpoint otherwise. The API key comes from
|
|
102
|
+
`SERPAPI_KEY`, the omelet credential store (`serpapi_api_key`), or
|
|
103
|
+
`~/.config/serpapi/config.toml` - in that order, and is never hardcoded.
|
|
104
|
+
|
|
105
|
+
Install everything in one step with `evo setup serp`.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@serp_group.command("search", epilog=SEARCH_EPILOG)
|
|
110
|
+
@click.argument("query", nargs=-1, required=True)
|
|
111
|
+
@click.option("-n", "--num", type=int, default=10, show_default=True, help="Results per page.")
|
|
112
|
+
@click.option("-p", "--pages", type=int, default=1, show_default=True, help="How many pages to fetch and merge.")
|
|
113
|
+
@click.option(
|
|
114
|
+
"-t",
|
|
115
|
+
"--type",
|
|
116
|
+
"search_type",
|
|
117
|
+
type=click.Choice(sorted(client.SEARCH_TYPES)),
|
|
118
|
+
default="web",
|
|
119
|
+
show_default=True,
|
|
120
|
+
help="Google vertical to search.",
|
|
121
|
+
)
|
|
122
|
+
@click.option("-l", "--location", help="Location to search from, e.g. `Austin, Texas`. See `evo serp locations`.")
|
|
123
|
+
@click.option("--hl", help="Interface language, e.g. `vi`, `en`.")
|
|
124
|
+
@click.option("--gl", help="Country of the search, e.g. `vn`, `us`.")
|
|
125
|
+
@click.option("--domain", help="Google domain, e.g. `google.com.vn`.")
|
|
126
|
+
@click.option("--device", type=click.Choice(["desktop", "tablet", "mobile"]), help="Device to emulate.")
|
|
127
|
+
@click.option("--safe", type=click.Choice(["active", "off"]), help="SafeSearch setting.")
|
|
128
|
+
@click.option("--engine", help="Override the SerpApi engine (default `google`).")
|
|
129
|
+
@click.option("-P", "--param", "extra", multiple=True, metavar="KEY=VALUE", help="Any extra SerpApi parameter.")
|
|
130
|
+
@click.option("--fields", help="Server-side field filter (SerpApi field restrictor syntax).")
|
|
131
|
+
@click.option("--jq", "jq_expr", help="jq filter run by the serpapi CLI; prints its raw output.")
|
|
132
|
+
@click.option("--json", "as_json", is_flag=True, help="Print the raw JSON payload instead of rendering it.")
|
|
133
|
+
@click.option("--links", "links_only", is_flag=True, help="Print only the result URLs, one per line.")
|
|
134
|
+
@click.option("-o", "--out", type=click.Path(dir_okay=False), help="Also write the JSON payload to a file.")
|
|
135
|
+
@click.option(
|
|
136
|
+
"--transport",
|
|
137
|
+
type=click.Choice(["auto", "cli", "http"]),
|
|
138
|
+
default="auto",
|
|
139
|
+
show_default=True,
|
|
140
|
+
help="`auto` uses the serpapi CLI when installed, else the HTTPS API.",
|
|
141
|
+
)
|
|
142
|
+
@click.option("--timeout", type=int, default=90, show_default=True, help="Seconds to wait per request.")
|
|
143
|
+
def search_cmd(
|
|
144
|
+
query,
|
|
145
|
+
num,
|
|
146
|
+
pages,
|
|
147
|
+
search_type,
|
|
148
|
+
location,
|
|
149
|
+
hl,
|
|
150
|
+
gl,
|
|
151
|
+
domain,
|
|
152
|
+
device,
|
|
153
|
+
safe,
|
|
154
|
+
engine,
|
|
155
|
+
extra,
|
|
156
|
+
fields,
|
|
157
|
+
jq_expr,
|
|
158
|
+
as_json,
|
|
159
|
+
links_only,
|
|
160
|
+
out,
|
|
161
|
+
transport,
|
|
162
|
+
timeout,
|
|
163
|
+
):
|
|
164
|
+
"""Search Google and print the results.
|
|
165
|
+
|
|
166
|
+
`QUERY` is taken verbatim, so operators work: `site:`, `intitle:`, quotes.
|
|
167
|
+
Use `-t news|images|videos|shopping|scholar` for the other verticals and
|
|
168
|
+
`-P key=value` for any SerpApi parameter this command does not wrap.
|
|
169
|
+
"""
|
|
170
|
+
text = " ".join(query).strip()
|
|
171
|
+
if not text:
|
|
172
|
+
raise click.UsageError("empty query.")
|
|
173
|
+
|
|
174
|
+
params = _guard(
|
|
175
|
+
client.build_params,
|
|
176
|
+
text,
|
|
177
|
+
search_type=search_type,
|
|
178
|
+
num=num,
|
|
179
|
+
location=location,
|
|
180
|
+
hl=hl,
|
|
181
|
+
gl=gl,
|
|
182
|
+
domain=domain,
|
|
183
|
+
device=device,
|
|
184
|
+
safe=safe,
|
|
185
|
+
engine=engine,
|
|
186
|
+
extra=extra,
|
|
187
|
+
)
|
|
188
|
+
key = _guard(creds.api_key)
|
|
189
|
+
|
|
190
|
+
if jq_expr:
|
|
191
|
+
# jq output is whatever the expression produces, so hand it over untouched.
|
|
192
|
+
_, binary = _guard(client.resolve_transport, "cli")
|
|
193
|
+
result = _guard(
|
|
194
|
+
client.run_binary, binary, client.cli_argv(params, fields=fields, jq=jq_expr), key, timeout, True
|
|
195
|
+
)
|
|
196
|
+
if result.returncode != 0:
|
|
197
|
+
raise click.ClickException((result.stderr or result.stdout or "serpapi CLI failed").strip())
|
|
198
|
+
sys.stdout.write(result.stdout)
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
payload, result_key, used = _guard(
|
|
202
|
+
client.search,
|
|
203
|
+
params,
|
|
204
|
+
search_type=search_type,
|
|
205
|
+
pages=pages,
|
|
206
|
+
transport=transport,
|
|
207
|
+
key=key,
|
|
208
|
+
timeout=timeout,
|
|
209
|
+
fields=fields,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
if out:
|
|
213
|
+
Path(out).write_text(jsonlib.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
214
|
+
|
|
215
|
+
if as_json:
|
|
216
|
+
sys.stdout.write(jsonlib.dumps(payload, indent=2, ensure_ascii=False) + "\n")
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
# Google treats `num` as a hint and often returns more, so hold the rendered
|
|
220
|
+
# output to what was actually asked for. `--json`/`-o` still get everything.
|
|
221
|
+
limit = max(1, num) * max(1, pages)
|
|
222
|
+
|
|
223
|
+
if links_only:
|
|
224
|
+
for link in render.links(payload, result_key)[:limit]:
|
|
225
|
+
sys.stdout.write(link + "\n")
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
console.print()
|
|
229
|
+
render.render_answer_box(payload)
|
|
230
|
+
render.render_knowledge_graph(payload)
|
|
231
|
+
shown = render.render_results(payload, result_key, limit=limit)
|
|
232
|
+
if not shown:
|
|
233
|
+
warning(f"no {result_key.replace('_', ' ')} for [accent]{text}[/accent]")
|
|
234
|
+
return
|
|
235
|
+
render.render_related_questions(payload)
|
|
236
|
+
info(render.summary_line(payload, shown, used))
|
|
237
|
+
if out:
|
|
238
|
+
success(f"payload written to [accent]{out}[/accent]")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@serp_group.command("account")
|
|
242
|
+
@click.option("--json", "as_json", is_flag=True, help="Print the raw JSON payload.")
|
|
243
|
+
def account_cmd(as_json):
|
|
244
|
+
"""Show the SerpApi plan and how many searches are left this month."""
|
|
245
|
+
payload = _guard(client.account)
|
|
246
|
+
if as_json:
|
|
247
|
+
sys.stdout.write(jsonlib.dumps(payload, indent=2, ensure_ascii=False) + "\n")
|
|
248
|
+
return
|
|
249
|
+
render.render_account(payload)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@serp_group.command("locations")
|
|
253
|
+
@click.argument("query", nargs=-1, required=True)
|
|
254
|
+
@click.option("-n", "--limit", type=int, default=10, show_default=True, help="How many matches to show.")
|
|
255
|
+
@click.option("--json", "as_json", is_flag=True, help="Print the raw JSON payload.")
|
|
256
|
+
def locations_cmd(query, limit, as_json):
|
|
257
|
+
"""Look up canonical location names to pass to `search --location`."""
|
|
258
|
+
text = " ".join(query).strip()
|
|
259
|
+
entries = _guard(client.locations, text, limit)
|
|
260
|
+
if as_json:
|
|
261
|
+
sys.stdout.write(jsonlib.dumps(entries, indent=2, ensure_ascii=False) + "\n")
|
|
262
|
+
return
|
|
263
|
+
if not entries:
|
|
264
|
+
warning(f"no location matches [accent]{text}[/accent]")
|
|
265
|
+
return
|
|
266
|
+
render.render_locations(entries, limit)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@serp_group.command(
|
|
270
|
+
"raw",
|
|
271
|
+
context_settings={"ignore_unknown_options": True, "help_option_names": []},
|
|
272
|
+
help="Pass arguments straight to the installed `serpapi` CLI, with the key injected.",
|
|
273
|
+
)
|
|
274
|
+
@click.argument("args", nargs=-1, type=click.UNPROCESSED)
|
|
275
|
+
def raw_cmd(args):
|
|
276
|
+
"""Run the official CLI directly, e.g. `evo serp raw search engine=google q=coffee --all-pages`."""
|
|
277
|
+
if not args:
|
|
278
|
+
raise click.UsageError("pass the serpapi CLI arguments, e.g. `search engine=google q=coffee`.")
|
|
279
|
+
_, binary = _guard(client.resolve_transport, "cli")
|
|
280
|
+
key = _guard(creds.api_key)
|
|
281
|
+
result = _guard(client.run_binary, binary, list(args), key, 300, False)
|
|
282
|
+
raise click.exceptions.Exit(result.returncode)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@serp_group.command("doctor")
|
|
286
|
+
@click.option("--offline", is_flag=True, help="Skip the account lookup; check only the local setup.")
|
|
287
|
+
def doctor_cmd(offline):
|
|
288
|
+
"""Report the binary, the key source, and the remaining quota."""
|
|
289
|
+
step("evo serp doctor")
|
|
290
|
+
binary = install.find_binary()
|
|
291
|
+
key, source = creds.resolve()
|
|
292
|
+
|
|
293
|
+
table = Table(show_header=True, header_style="accent", box=None, pad_edge=False)
|
|
294
|
+
for column in ("COMPONENT", "STATUS", "DETAIL"):
|
|
295
|
+
table.add_column(column, overflow="fold")
|
|
296
|
+
table.add_row(
|
|
297
|
+
"serpapi CLI",
|
|
298
|
+
"[success]ok[/success]" if binary else "[warning]missing[/warning]",
|
|
299
|
+
f"{binary} ({install.binary_version(binary) or 'unknown version'})"
|
|
300
|
+
if binary
|
|
301
|
+
else "not installed - HTTPS fallback in use (evo setup serp)",
|
|
302
|
+
)
|
|
303
|
+
table.add_row(
|
|
304
|
+
"API key",
|
|
305
|
+
"[success]ok[/success]" if key else "[error]missing[/error]",
|
|
306
|
+
f"{creds.mask(key)} from {source}" if key else "run: evo setup serp",
|
|
307
|
+
)
|
|
308
|
+
config = creds.config_file()
|
|
309
|
+
table.add_row(
|
|
310
|
+
"config.toml",
|
|
311
|
+
"[success]ok[/success]" if config.is_file() else "[dim]absent[/dim]",
|
|
312
|
+
str(config) if config.is_file() else f"{config} (optional: evo setup serp --write-config)",
|
|
313
|
+
)
|
|
314
|
+
console.print(table)
|
|
315
|
+
|
|
316
|
+
if not key:
|
|
317
|
+
raise click.exceptions.Exit(1)
|
|
318
|
+
if offline:
|
|
319
|
+
return
|
|
320
|
+
|
|
321
|
+
console.print()
|
|
322
|
+
payload = _guard(client.account)
|
|
323
|
+
render.render_account(payload)
|
|
324
|
+
left = payload.get("plan_searches_left")
|
|
325
|
+
if isinstance(left, (int, float)) and left <= 0:
|
|
326
|
+
warning("no searches left on this plan")
|
|
327
|
+
raise click.exceptions.Exit(1)
|
|
328
|
+
success("SerpApi is ready")
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
@click.command("serp", epilog=SETUP_EPILOG)
|
|
332
|
+
@click.option("--api-key", "api_key_opt", help="The key inline. Avoid: it leaks to shell history.")
|
|
333
|
+
@click.option("--from-stdin", is_flag=True, help="Read the key from stdin.")
|
|
334
|
+
@click.option(
|
|
335
|
+
"--method",
|
|
336
|
+
type=click.Choice(["auto", "brew", "binary", "go", "none"]),
|
|
337
|
+
default="auto",
|
|
338
|
+
show_default=True,
|
|
339
|
+
help="How to install the CLI. `auto` prefers brew, then a release binary. `none` skips it.",
|
|
340
|
+
)
|
|
341
|
+
@click.option("--cli-version", help="Release version for `--method binary` (default: latest).")
|
|
342
|
+
@click.option("-f", "--force", is_flag=True, help="Reinstall the CLI even if it is already present.")
|
|
343
|
+
@click.option("--trust-tap", is_flag=True, help=f"Run `{install.BREW_TRUST_COMMAND}` before installing with brew.")
|
|
344
|
+
@click.option("--write-config", is_flag=True, help="Also write ~/.config/serpapi/config.toml for the bare binary.")
|
|
345
|
+
@click.option("--no-verify", is_flag=True, help="Skip the account lookup at the end.")
|
|
346
|
+
def setup_serp(api_key_opt, from_stdin, method, cli_version, force, trust_tap, write_config, no_verify):
|
|
347
|
+
"""Install the **SerpApi** CLI and store the API key.
|
|
348
|
+
|
|
349
|
+
Installs the official `serpapi` binary (brew, GitHub release, or `go
|
|
350
|
+
install`), saves the key into the omelet credential store as
|
|
351
|
+
`serpapi_api_key`, and verifies it against your SerpApi account. Nothing is
|
|
352
|
+
written into this repository.
|
|
353
|
+
"""
|
|
354
|
+
step("evo setup serp")
|
|
355
|
+
|
|
356
|
+
if method != "none":
|
|
357
|
+
binary = install.find_binary()
|
|
358
|
+
if binary and not force:
|
|
359
|
+
info(f"serpapi CLI already installed: [accent]{binary}[/accent] ({install.binary_version(binary) or '?'})")
|
|
360
|
+
else:
|
|
361
|
+
binary = _install_cli(method, cli_version, trust_tap)
|
|
362
|
+
hint = install.path_hint()
|
|
363
|
+
if hint and str(binary).startswith(str(install.bin_dir())):
|
|
364
|
+
warning(f'{hint} is not on PATH - add it: export PATH="{hint}:$PATH"')
|
|
365
|
+
if binary:
|
|
366
|
+
success(f"serpapi CLI: [accent]{binary}[/accent] ({install.binary_version(binary) or 'unknown version'})")
|
|
367
|
+
else:
|
|
368
|
+
info("skipping the CLI install (--method none): searches will use the HTTPS API")
|
|
369
|
+
|
|
370
|
+
step("API key")
|
|
371
|
+
existing, source = creds.resolve()
|
|
372
|
+
if from_stdin:
|
|
373
|
+
key = sys.stdin.read().strip()
|
|
374
|
+
elif api_key_opt:
|
|
375
|
+
key = api_key_opt.strip()
|
|
376
|
+
elif existing and not force:
|
|
377
|
+
key = existing
|
|
378
|
+
info(f"reusing the key already available from [accent]{source}[/accent] ({creds.mask(key)})")
|
|
379
|
+
else:
|
|
380
|
+
console.print("Get a key at [accent]https://serpapi.com/manage-api-key[/accent]")
|
|
381
|
+
key = getpass.getpass("SerpApi API key (no echo): ").strip()
|
|
382
|
+
if not key:
|
|
383
|
+
raise click.ClickException("empty API key, aborting")
|
|
384
|
+
|
|
385
|
+
if key != existing or source != f"evo cred {creds.CRED_KEY}":
|
|
386
|
+
path, existed, count, target = _guard(_store_key, key)
|
|
387
|
+
verb = "updated" if existed else "stored"
|
|
388
|
+
success(f"{verb} {creds.CRED_KEY} in {relative_to_store(path)}, recompiled {target} ({count} entries)")
|
|
389
|
+
|
|
390
|
+
if write_config:
|
|
391
|
+
path = creds.write_config_file(key)
|
|
392
|
+
success(f"wrote {path} (mode 600) so the bare `serpapi` binary can authenticate")
|
|
393
|
+
|
|
394
|
+
if no_verify:
|
|
395
|
+
return
|
|
396
|
+
|
|
397
|
+
step("Verify")
|
|
398
|
+
payload = _guard(client.account)
|
|
399
|
+
render.render_account(payload)
|
|
400
|
+
console.print()
|
|
401
|
+
success("SerpApi is ready: [accent]evo serp search 'hello world'[/accent]")
|
|
402
|
+
info("push the key to your private sync repo with: [accent]evo cred sync push[/accent]")
|
|
@@ -301,6 +301,17 @@ SPECS = [
|
|
|
301
301
|
"rotate": "Figma -> Settings -> Personal access tokens",
|
|
302
302
|
"keys": ["figma_token"],
|
|
303
303
|
},
|
|
304
|
+
{
|
|
305
|
+
"path": "tools/serpapi.json",
|
|
306
|
+
"id": "serpapi",
|
|
307
|
+
"service": "SerpApi",
|
|
308
|
+
"category": "tools",
|
|
309
|
+
"type": "api_key",
|
|
310
|
+
"lifetime": "stable",
|
|
311
|
+
"description": "SerpApi private API key (evo serp, serpapi CLI)",
|
|
312
|
+
"rotate": "https://serpapi.com/manage-api-key -> regenerate",
|
|
313
|
+
"keys": ["serpapi_api_key"],
|
|
314
|
+
},
|
|
304
315
|
{
|
|
305
316
|
"path": "tools/openclaw.json",
|
|
306
317
|
"id": "openclaw",
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""SerpApi integration.
|
|
2
|
+
|
|
3
|
+
`creds` resolves the API key (env -> omelet store -> serpapi config.toml),
|
|
4
|
+
`install` bootstraps the official Go CLI, `client` runs searches through that
|
|
5
|
+
CLI when it is present and falls back to the HTTPS API when it is not, and
|
|
6
|
+
`render` prints the JSON payload as readable results.
|
|
7
|
+
"""
|