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.
Files changed (117) hide show
  1. {evo_cli-0.17.0 → evo_cli-0.18.0}/PKG-INFO +47 -1
  2. {evo_cli-0.17.0 → evo_cli-0.18.0}/README.md +46 -0
  3. evo_cli-0.18.0/evo_cli/VERSION +1 -0
  4. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/cli.py +3 -0
  5. evo_cli-0.18.0/evo_cli/commands/serp.py +402 -0
  6. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/credentials/registry.py +11 -0
  7. evo_cli-0.18.0/evo_cli/serp/__init__.py +7 -0
  8. evo_cli-0.18.0/evo_cli/serp/client.py +254 -0
  9. evo_cli-0.18.0/evo_cli/serp/creds.py +94 -0
  10. evo_cli-0.18.0/evo_cli/serp/errors.py +2 -0
  11. evo_cli-0.18.0/evo_cli/serp/install.py +239 -0
  12. evo_cli-0.18.0/evo_cli/serp/render.py +173 -0
  13. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli.egg-info/PKG-INFO +47 -1
  14. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli.egg-info/SOURCES.txt +8 -0
  15. evo_cli-0.18.0/tests/test_serp.py +455 -0
  16. evo_cli-0.17.0/evo_cli/VERSION +0 -1
  17. {evo_cli-0.17.0 → evo_cli-0.18.0}/Containerfile +0 -0
  18. {evo_cli-0.17.0 → evo_cli-0.18.0}/HISTORY.md +0 -0
  19. {evo_cli-0.17.0 → evo_cli-0.18.0}/LICENSE +0 -0
  20. {evo_cli-0.17.0 → evo_cli-0.18.0}/MANIFEST.in +0 -0
  21. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/__init__.py +0 -0
  22. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/__main__.py +0 -0
  23. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/base.py +0 -0
  24. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/__init__.py +0 -0
  25. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/agent_toy.py +0 -0
  26. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/claude_code.py +0 -0
  27. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/cloudflare.py +0 -0
  28. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/cred.py +0 -0
  29. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/download.py +0 -0
  30. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/fix_claude.py +0 -0
  31. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/gdrive.py +0 -0
  32. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/gh.py +0 -0
  33. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/__init__.py +0 -0
  34. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/_dag.py +0 -0
  35. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/_git.py +0 -0
  36. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/_model.py +0 -0
  37. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/_mutate.py +0 -0
  38. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/_paths.py +0 -0
  39. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/_render.py +0 -0
  40. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/_server.py +0 -0
  41. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/check.py +0 -0
  42. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/edit.py +0 -0
  43. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/pull.py +0 -0
  44. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/serve.py +0 -0
  45. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/show.py +0 -0
  46. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/views.py +0 -0
  47. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/web/assets/index-CsALFg16.css +0 -0
  48. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/web/assets/index-DwEXL5pV.js +0 -0
  49. {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
  50. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  51. {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
  52. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  53. {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
  54. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  55. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  56. {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
  57. {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
  58. {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
  59. {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
  60. {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
  61. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/harness/web/index.html +0 -0
  62. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/hwid.py +0 -0
  63. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/hwid_reset.py +0 -0
  64. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/localproxy.py +0 -0
  65. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/mcp.py +0 -0
  66. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/miniconda.py +0 -0
  67. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/netcheck.py +0 -0
  68. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/opencode.py +0 -0
  69. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/plantuml.py +0 -0
  70. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/site2s.py +0 -0
  71. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/ssh.py +0 -0
  72. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/sysmon.py +0 -0
  73. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/tts.py +0 -0
  74. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/update.py +0 -0
  75. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/commands/wifi.py +0 -0
  76. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/console.py +0 -0
  77. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/credentials/__init__.py +0 -0
  78. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/credentials/doctor.py +0 -0
  79. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/credentials/google_oauth.py +0 -0
  80. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/credentials/migrate.py +0 -0
  81. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/credentials/oauth_flow.py +0 -0
  82. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/credentials/store.py +0 -0
  83. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/credentials/sync.py +0 -0
  84. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/mcp_registry.py +0 -0
  85. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/tts/__init__.py +0 -0
  86. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/tts/chunking.py +0 -0
  87. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/tts/core.py +0 -0
  88. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/tts/creds.py +0 -0
  89. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/tts/errors.py +0 -0
  90. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/tts/http.py +0 -0
  91. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/tts/openai.py +0 -0
  92. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/tts/player.py +0 -0
  93. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli/tts/vbee.py +0 -0
  94. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli.egg-info/dependency_links.txt +0 -0
  95. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli.egg-info/entry_points.txt +0 -0
  96. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli.egg-info/requires.txt +0 -0
  97. {evo_cli-0.17.0 → evo_cli-0.18.0}/evo_cli.egg-info/top_level.txt +0 -0
  98. {evo_cli-0.17.0 → evo_cli-0.18.0}/pyproject.toml +0 -0
  99. {evo_cli-0.17.0 → evo_cli-0.18.0}/setup.cfg +0 -0
  100. {evo_cli-0.17.0 → evo_cli-0.18.0}/tests/__init__.py +0 -0
  101. {evo_cli-0.17.0 → evo_cli-0.18.0}/tests/test_agent_toy.py +0 -0
  102. {evo_cli-0.17.0 → evo_cli-0.18.0}/tests/test_claude_code.py +0 -0
  103. {evo_cli-0.17.0 → evo_cli-0.18.0}/tests/test_cli.py +0 -0
  104. {evo_cli-0.17.0 → evo_cli-0.18.0}/tests/test_console.py +0 -0
  105. {evo_cli-0.17.0 → evo_cli-0.18.0}/tests/test_cred.py +0 -0
  106. {evo_cli-0.17.0 → evo_cli-0.18.0}/tests/test_download.py +0 -0
  107. {evo_cli-0.17.0 → evo_cli-0.18.0}/tests/test_fix_claude.py +0 -0
  108. {evo_cli-0.17.0 → evo_cli-0.18.0}/tests/test_gh.py +0 -0
  109. {evo_cli-0.17.0 → evo_cli-0.18.0}/tests/test_harness.py +0 -0
  110. {evo_cli-0.17.0 → evo_cli-0.18.0}/tests/test_harness_dag.py +0 -0
  111. {evo_cli-0.17.0 → evo_cli-0.18.0}/tests/test_mcp.py +0 -0
  112. {evo_cli-0.17.0 → evo_cli-0.18.0}/tests/test_opencode.py +0 -0
  113. {evo_cli-0.17.0 → evo_cli-0.18.0}/tests/test_plantuml.py +0 -0
  114. {evo_cli-0.17.0 → evo_cli-0.18.0}/tests/test_sysmon.py +0 -0
  115. {evo_cli-0.17.0 → evo_cli-0.18.0}/tests/test_tts.py +0 -0
  116. {evo_cli-0.17.0 → evo_cli-0.18.0}/tests/test_update.py +0 -0
  117. {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.17.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
+ """