oto-cli 1.0.0__py3-none-any.whl

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 (217) hide show
  1. oto/__init__.py +3 -0
  2. oto/cli.py +74 -0
  3. oto/commands/__init__.py +0 -0
  4. oto/commands/anthropic.py +64 -0
  5. oto/commands/audio.py +91 -0
  6. oto/commands/browser.py +257 -0
  7. oto/commands/company.py +23 -0
  8. oto/commands/enrichment.py +152 -0
  9. oto/commands/folk.py +238 -0
  10. oto/commands/google.py +355 -0
  11. oto/commands/notion.py +58 -0
  12. oto/commands/pennylane.py +272 -0
  13. oto/commands/search.py +36 -0
  14. oto/commands/sirene.py +201 -0
  15. oto/commands/skills.py +91 -0
  16. oto/commands/whatsapp.py +47 -0
  17. oto/config.py +167 -0
  18. oto/tools/.cache/notion/07a366cf2d7a0f77e7a1736805e94786e6053bb581b6b7179591b71d34843ce8.json +98 -0
  19. oto/tools/.cache/notion/0b21878dbd4bf5a67ff52104361a07ee575b03a075b7b81cbc4a0d5d4045f759.json +142 -0
  20. oto/tools/.cache/notion/0bf47e753ec29da03776bca010122393b4fc188af7f28dd11b456a74e5cae8f4.json +98 -0
  21. oto/tools/.cache/notion/1b2c924688fdcef8b7e3748f92ed4fdb232cadb387a7e9e157c438d32374ff97.json +4356 -0
  22. oto/tools/.cache/notion/2a66512043d3531dc900b8244fc13972c0abfe7760c10fbb7fc21f309238ad0b.json +132 -0
  23. oto/tools/.cache/notion/2b59aed15d0c747ec2cae70a1eb3f554b3e170875110b82f8e4614b761c84dcf.json +142 -0
  24. oto/tools/.cache/notion/2f7d1284021d30e842cc9a2b506e87b4a32c61e8e9abb616aec37828c441231d.json +220 -0
  25. oto/tools/.cache/notion/33eb548bce70fb7b0e8eb09211ba6c03ad22b6e7a289606b05c5d690f724d8e4.json +1659 -0
  26. oto/tools/.cache/notion/364d3a5e790f3d8b5137aae0a5a9ee151cd8c9f7dacb4fd669220f968405ea36.json +88 -0
  27. oto/tools/.cache/notion/3e7e836a6bf18f951d4887fefeacb146473491ad2cbaa1793c79af08b7ef45b6.json +132 -0
  28. oto/tools/.cache/notion/3f95b2d8bdf3d47e8f6c167d6d9a0889f27b64e8c78f0afdad538fecae070858.json +98 -0
  29. oto/tools/.cache/notion/46c38d10065fb68ca912692df53b6e54eb1f6f9cdff342de563020917e27ccf5.json +98 -0
  30. oto/tools/.cache/notion/51fa4d44044d751ca2319d87d607bd58a01004a24a8065808d65c273b35d3a10.json +210 -0
  31. oto/tools/.cache/notion/59888e9c297cda6836afb560fb4048b100f98eb3abead8b175fe92fbf858210b.json +186 -0
  32. oto/tools/.cache/notion/60df8d17a4e08fdbafb0ea398ff68d45cb79da7c01bd129e98264be1bff7ed1d.json +176 -0
  33. oto/tools/.cache/notion/62a44af3f86b5841d905789565a0208f80c1a304bcfc8889ccd55f61f29508b2.json +132 -0
  34. oto/tools/.cache/notion/69ec094fc12a40d1591abbd4f21295af672389963f900ea421a6d75b6f21f03b.json +176 -0
  35. oto/tools/.cache/notion/703fc49f07e73447e176fa69f31c7ff7bbb7c8c95325af4ca1a8664385bfeb0b.json +98 -0
  36. oto/tools/.cache/notion/7e247cf818c2a930de8a5a8599716900cb5af90570a9f701699dd8897fe43c8d.json +88 -0
  37. oto/tools/.cache/notion/86741d020ca89789bda8156315ead5e980068c19754baf6e611ffc618557dc06.json +115 -0
  38. oto/tools/.cache/notion/8cc6e5e1d955310412df09c9179ec67d42767d1b557262e28bc2afea05ab7c24.json +210 -0
  39. oto/tools/.cache/notion/989d88c22dd009e5683987e70671d1e20fd6a5a3e9ee7670db9ea602fe8fbded.json +4605 -0
  40. oto/tools/.cache/notion/999d01d80c8ef0c856572028e31e701cad8c5704b8886388b756d32ef50cbfaf.json +132 -0
  41. oto/tools/.cache/notion/9f10e5fa6404211b156b2f5c22a0644e5eb0c49774d2b4f8d768211f0c6aea22.json +3380 -0
  42. oto/tools/.cache/notion/9f950bdfc2aeb9ae5e1b185e827636f83fa44a9d7de0d1462d87926508b26907.json +142 -0
  43. oto/tools/.cache/notion/a4719c76c9ff4c4b056e87689e2f487770c35d82d70acb098ead7712e0d3be8d.json +142 -0
  44. oto/tools/.cache/notion/a808160a7352fa1a3029ef1e2929dfa99b5021b0d22ce1cfb9efe3c68f7e7af1.json +98 -0
  45. oto/tools/.cache/notion/a9c29806556409fbfcd7622b876244caed085c63c70380dc6caacd162e6d05a9.json +132 -0
  46. oto/tools/.cache/notion/b3142c5509452a15382cade5cb81324a26d1946d128a1b4342a143872f9354fe.json +149 -0
  47. oto/tools/.cache/notion/b516a0820a66ae04229bfcb7f0463be0867f31a8dc1b875e57aba02b970a7a31.json +98 -0
  48. oto/tools/.cache/notion/c175f8c35c81ebd2fb9e4bf4d2f1cc29f98b45006f59354c9f33ee780609d896.json +247 -0
  49. oto/tools/.cache/notion/c950254390b901d475937c868e27e74960720fe65e2d8506bd39e30c634e619d.json +98 -0
  50. oto/tools/.cache/notion/cac99fb54fa178e1a7f72e5212e1e99eff5fe72a1fa52f65fcee432bcf528b53.json +166 -0
  51. oto/tools/.cache/notion/e38237abecf19ce5b9a92ed90723d2642a11b0f1ef189b1a1fc10b7ebcd3d4a4.json +88 -0
  52. oto/tools/.cache/notion/e58e933def9b7000d791c83f6660adbecac59ab61e54f8d855a52d80200eb929.json +292 -0
  53. oto/tools/.cache/notion/f8088377a1049850b0ec1acabf09da8504fb1f194479cc43ce4c31db3895afcd.json +176 -0
  54. oto/tools/.cache/notion/fc2ccb6edbe2020010e036a69e7ece3ab3f007c9a4fed39066d6792d332e65af.json +142 -0
  55. oto/tools/.cache/notion/ff117c74176f8fc26aba7f06649c7794dc4a0c51194033f453b16c8af8ac4201.json +36 -0
  56. oto/tools/__init__.py +1 -0
  57. oto/tools/anthropic/__init__.py +5 -0
  58. oto/tools/anthropic/client.py +354 -0
  59. oto/tools/anthropic_batch/__init__.py +5 -0
  60. oto/tools/anthropic_batch/client.py +352 -0
  61. oto/tools/apollo/__init__.py +5 -0
  62. oto/tools/apollo/client.py +191 -0
  63. oto/tools/attio/__init__.py +5 -0
  64. oto/tools/attio/client.py +242 -0
  65. oto/tools/audio/__init__.py +5 -0
  66. oto/tools/audio/client.py +116 -0
  67. oto/tools/browser/__init__.py +26 -0
  68. oto/tools/browser/crunchbase.py +423 -0
  69. oto/tools/browser/g2.py +236 -0
  70. oto/tools/browser/indeed.py +282 -0
  71. oto/tools/browser/linkedin.py +821 -0
  72. oto/tools/browser/pappers.py +344 -0
  73. oto/tools/clearbit/__init__.py +5 -0
  74. oto/tools/clearbit/client.py +126 -0
  75. oto/tools/collective/__init__.py +5 -0
  76. oto/tools/collective/client.py +333 -0
  77. oto/tools/common/__init__.py +5 -0
  78. oto/tools/common/rate_limiter.py +457 -0
  79. oto/tools/figma/__init__.py +5 -0
  80. oto/tools/figma/client.py +254 -0
  81. oto/tools/folk/__init__.py +5 -0
  82. oto/tools/folk/client.py +203 -0
  83. oto/tools/gemini/__init__.py +5 -0
  84. oto/tools/gemini/client.py +264 -0
  85. oto/tools/google/__init__.py +1 -0
  86. oto/tools/google/calendar/__init__.py +0 -0
  87. oto/tools/google/calendar/lib/__init__.py +0 -0
  88. oto/tools/google/calendar/lib/calendar_client.py +155 -0
  89. oto/tools/google/credentials.py +177 -0
  90. oto/tools/google/docs/get_section.py +53 -0
  91. oto/tools/google/docs/insert_section.py +56 -0
  92. oto/tools/google/docs/insert_text.py +99 -0
  93. oto/tools/google/docs/lib/__init__.py +0 -0
  94. oto/tools/google/docs/lib/docs_client.py +341 -0
  95. oto/tools/google/docs/list_headings.py +44 -0
  96. oto/tools/google/docs/move_section.py +37 -0
  97. oto/tools/google/docs/replace_section.py +47 -0
  98. oto/tools/google/docs/sync/__init__.py +1 -0
  99. oto/tools/google/docs/sync/gdoc_sync.py +427 -0
  100. oto/tools/google/drive/README.md +308 -0
  101. oto/tools/google/drive/check_quota.py +31 -0
  102. oto/tools/google/drive/copy_file.py +76 -0
  103. oto/tools/google/drive/create_folder.py +58 -0
  104. oto/tools/google/drive/download_file.py +60 -0
  105. oto/tools/google/drive/export_doc.py +64 -0
  106. oto/tools/google/drive/export_slides.py +95 -0
  107. oto/tools/google/drive/extract_slides_content.py +113 -0
  108. oto/tools/google/drive/lib/drive_client.py +454 -0
  109. oto/tools/google/drive/list_files.py +97 -0
  110. oto/tools/google/drive/list_shared_drives.py +32 -0
  111. oto/tools/google/drive/move_file.py +57 -0
  112. oto/tools/google/drive/requirements.txt +4 -0
  113. oto/tools/google/drive/tool.yaml +210 -0
  114. oto/tools/google/drive/upload_file.py +90 -0
  115. oto/tools/google/gmail/__init__.py +1 -0
  116. oto/tools/google/gmail/get_message.py +33 -0
  117. oto/tools/google/gmail/lib/__init__.py +1 -0
  118. oto/tools/google/gmail/lib/gmail_client.py +385 -0
  119. oto/tools/google/gmail/list_messages.py +37 -0
  120. oto/tools/google/gmail/requirements.txt +3 -0
  121. oto/tools/google/gmail/search.py +34 -0
  122. oto/tools/google/gmail/send.py +39 -0
  123. oto/tools/google/gmail/tool.yaml +107 -0
  124. oto/tools/google/keep/__init__.py +0 -0
  125. oto/tools/google/keep/create_note.py +42 -0
  126. oto/tools/google/keep/get_master_token.py +101 -0
  127. oto/tools/google/keep/get_note.py +33 -0
  128. oto/tools/google/keep/lib/__init__.py +0 -0
  129. oto/tools/google/keep/lib/keep_client.py +273 -0
  130. oto/tools/google/keep/list_notes.py +42 -0
  131. oto/tools/google/keep/search.py +34 -0
  132. oto/tools/google/sheets/create_sheet_direct.py +157 -0
  133. oto/tools/google/sheets/create_sheet_from_csv.py +167 -0
  134. oto/tools/google/sheets/create_sheet_in_folder.py +165 -0
  135. oto/tools/google/sheets/create_sheet_via_drive.py +157 -0
  136. oto/tools/google/sheets/tool.yaml +74 -0
  137. oto/tools/google/slides/.folders +19 -0
  138. oto/tools/google/slides/321-LAYOUTS.md +203 -0
  139. oto/tools/google/slides/README.md +210 -0
  140. oto/tools/google/slides/USAGE-EDIT.md +626 -0
  141. oto/tools/google/slides/USAGE.md +158 -0
  142. oto/tools/google/slides/create-demo-presentation.py +348 -0
  143. oto/tools/google/slides/generate_slides.py +437 -0
  144. oto/tools/google/slides/lib/__init__.py +1 -0
  145. oto/tools/google/slides/lib/content_filler.py +579 -0
  146. oto/tools/google/slides/lib/layout_mappings.py +182 -0
  147. oto/tools/google/slides/lib/slides_client.py +1239 -0
  148. oto/tools/google/slides/requirements.txt +5 -0
  149. oto/tools/google/slides/test-copy-and-edit.py +187 -0
  150. oto/tools/google/slides/test-copy-slide.py +148 -0
  151. oto/tools/google/slides/test-edit.py +183 -0
  152. oto/tools/google/slides/tool.yaml +37 -0
  153. oto/tools/groq/__init__.py +5 -0
  154. oto/tools/groq/client.py +165 -0
  155. oto/tools/hithorizons/__init__.py +5 -0
  156. oto/tools/hithorizons/client.py +168 -0
  157. oto/tools/hunter/__init__.py +5 -0
  158. oto/tools/hunter/client.py +104 -0
  159. oto/tools/kaspr/__init__.py +5 -0
  160. oto/tools/kaspr/client.py +80 -0
  161. oto/tools/lemlist/__init__.py +5 -0
  162. oto/tools/lemlist/client.py +486 -0
  163. oto/tools/mistral/__init__.py +5 -0
  164. oto/tools/mistral/client.py +149 -0
  165. oto/tools/naf/__init__.py +5 -0
  166. oto/tools/naf/suggester.py +140 -0
  167. oto/tools/notion/README.md +310 -0
  168. oto/tools/notion/append_blocks.py +204 -0
  169. oto/tools/notion/append_blocks_chunked.py +296 -0
  170. oto/tools/notion/count_database_entries.py +47 -0
  171. oto/tools/notion/create_database_from_csv.py +269 -0
  172. oto/tools/notion/create_page.py +65 -0
  173. oto/tools/notion/get_database.py +67 -0
  174. oto/tools/notion/get_page.py +78 -0
  175. oto/tools/notion/known-pages.md +75 -0
  176. oto/tools/notion/lib/markdown_converter.py +287 -0
  177. oto/tools/notion/lib/notion_client.py +313 -0
  178. oto/tools/notion/list_teamspaces.py +244 -0
  179. oto/tools/notion/notion +122 -0
  180. oto/tools/notion/query_database.py +77 -0
  181. oto/tools/notion/requirements.txt +1 -0
  182. oto/tools/notion/search.py +93 -0
  183. oto/tools/notion/tool.yaml +235 -0
  184. oto/tools/notion/update_page.py +65 -0
  185. oto/tools/pennylane/__init__.py +5 -0
  186. oto/tools/pennylane/client.py +386 -0
  187. oto/tools/phantombuster/__init__.py +5 -0
  188. oto/tools/phantombuster/client.py +177 -0
  189. oto/tools/resend/__init__.py +5 -0
  190. oto/tools/resend/client.py +149 -0
  191. oto/tools/serpapi/__init__.py +5 -0
  192. oto/tools/serpapi/client.py +128 -0
  193. oto/tools/serper/__init__.py +5 -0
  194. oto/tools/serper/client.py +210 -0
  195. oto/tools/sirene/__init__.py +34 -0
  196. oto/tools/sirene/client.py +474 -0
  197. oto/tools/sirene/data/naf_codes.txt +732 -0
  198. oto/tools/sirene/entreprises.py +175 -0
  199. oto/tools/sirene/stock.py +377 -0
  200. oto/tools/slack/__init__.py +5 -0
  201. oto/tools/slack/client.py +202 -0
  202. oto/tools/unsplash/__init__.py +5 -0
  203. oto/tools/unsplash/client.py +197 -0
  204. oto/tools/whatsapp/__init__.py +3 -0
  205. oto/tools/whatsapp/client.py +68 -0
  206. oto/tools/whatsapp/node/package-lock.json +1564 -0
  207. oto/tools/whatsapp/node/package.json +11 -0
  208. oto/tools/whatsapp/node/whatsapp.mjs +357 -0
  209. oto/tools/wttj/__init__.py +5 -0
  210. oto/tools/wttj/client.py +251 -0
  211. oto/tools/zerobounce/__init__.py +5 -0
  212. oto/tools/zerobounce/client.py +93 -0
  213. oto_cli-1.0.0.dist-info/METADATA +171 -0
  214. oto_cli-1.0.0.dist-info/RECORD +217 -0
  215. oto_cli-1.0.0.dist-info/WHEEL +4 -0
  216. oto_cli-1.0.0.dist-info/entry_points.txt +2 -0
  217. oto_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
oto/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Oto - CLI tools for automation."""
2
+
3
+ __version__ = "1.0.0"
oto/cli.py ADDED
@@ -0,0 +1,74 @@
1
+ """Oto CLI - composable toolkit for AI agents."""
2
+
3
+ import importlib
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ app = typer.Typer(
10
+ name="oto",
11
+ help="CLI toolkit for AI agents. JSON on stdout, composable with pipes.",
12
+ no_args_is_help=True,
13
+ )
14
+
15
+ # Auto-discover commands from oto/commands/*.py
16
+ _commands_dir = Path(__file__).parent / "commands"
17
+ for _cmd_file in sorted(_commands_dir.glob("*.py")):
18
+ if _cmd_file.name.startswith("_"):
19
+ continue
20
+ _module_name = _cmd_file.stem
21
+ try:
22
+ _module = importlib.import_module(f"oto.commands.{_module_name}")
23
+ except ImportError:
24
+ continue
25
+ if hasattr(_module, "app"):
26
+ app.add_typer(_module.app, name=_module_name)
27
+
28
+
29
+ @app.command("config")
30
+ def show_config():
31
+ """Show current configuration and detected secrets."""
32
+ from oto.config import _find_project_secrets, _get_user_secrets, get_secret
33
+
34
+ project_secrets = _find_project_secrets()
35
+ user_secrets = _get_user_secrets()
36
+
37
+ print("Secrets files:")
38
+ print(f" Project: {project_secrets or '.otomata/secrets.env (not found)'}")
39
+ print(f" User: {user_secrets}{' (exists)' if user_secrets.exists() else ' (not found)'}")
40
+ print()
41
+ print("Secrets status:")
42
+ secrets = [
43
+ "GOOGLE_SERVICE_ACCOUNT",
44
+ "GOOGLE_OAUTH_CLIENT",
45
+ "NOTION_API_KEY",
46
+ "LINKEDIN_COOKIE",
47
+ "SIRENE_API_KEY",
48
+ "SERPER_API_KEY",
49
+ "KASPR_API_KEY",
50
+ "HUNTER_API_KEY",
51
+ "LEMLIST_API_KEY",
52
+ "PENNYLANE_API_KEY",
53
+ "GROQ_API_KEY",
54
+ "ANTHROPIC_ADMIN_API_KEY",
55
+ "FOLK_API_KEY",
56
+ "TULS_API_TOKEN",
57
+ ]
58
+ for name in secrets:
59
+ status = "+" if get_secret(name) else "-"
60
+ print(f" {status} {name}")
61
+
62
+
63
+ def main():
64
+ try:
65
+ app()
66
+ except ValueError as e:
67
+ if "not found. Set it via:" in str(e):
68
+ print(f"Error: {e}", file=sys.stderr)
69
+ raise SystemExit(1)
70
+ raise
71
+
72
+
73
+ if __name__ == "__main__":
74
+ main()
File without changes
@@ -0,0 +1,64 @@
1
+ """Anthropic Admin API commands (usage & cost tracking)."""
2
+
3
+ import typer
4
+ from typing import Optional
5
+
6
+ app = typer.Typer(help="Anthropic Admin API (usage & cost tracking)")
7
+
8
+
9
+ @app.command("usage")
10
+ def usage(
11
+ days: int = typer.Option(7, "--days", "-d", help="Number of days to look back"),
12
+ bucket: str = typer.Option("1d", "--bucket", "-b", help="Bucket width: 1m, 1h, 1d"),
13
+ group_by: Optional[str] = typer.Option("model", "--group-by", "-g", help="Group by: model, api_key_id, workspace_id, service_tier"),
14
+ model: Optional[str] = typer.Option(None, "--model", "-m", help="Filter to specific model"),
15
+ ):
16
+ """Get token usage report."""
17
+ import json
18
+ from oto.tools.anthropic import AnthropicAdminClient
19
+
20
+ client = AnthropicAdminClient()
21
+ groups = [g.strip() for g in group_by.split(",")] if group_by else None
22
+ models = [model] if model else None
23
+ data = client.get_usage(bucket_width=bucket, group_by=groups, models=models,
24
+ limit=days if bucket == "1d" else None)
25
+ print(json.dumps(data, indent=2, ensure_ascii=False))
26
+
27
+
28
+ @app.command("cost")
29
+ def cost(
30
+ days: int = typer.Option(30, "--days", "-d", help="Number of days to look back"),
31
+ group_by: Optional[str] = typer.Option(None, "--group-by", "-g", help="Group by: workspace_id, description"),
32
+ ):
33
+ """Get cost report (daily, USD)."""
34
+ import json
35
+ from oto.tools.anthropic import AnthropicAdminClient
36
+
37
+ client = AnthropicAdminClient()
38
+ groups = [g.strip() for g in group_by.split(",")] if group_by else None
39
+ data = client.get_costs(group_by=groups)
40
+ print(json.dumps(data, indent=2, ensure_ascii=False))
41
+
42
+
43
+ @app.command("summary")
44
+ def summary(
45
+ days: int = typer.Option(7, "--days", "-d", help="Number of days to look back"),
46
+ ):
47
+ """Daily usage summary with estimated costs by model."""
48
+ import json
49
+ from oto.tools.anthropic import AnthropicAdminClient
50
+
51
+ client = AnthropicAdminClient()
52
+ result = client.get_daily_summary(days=days)
53
+ print(json.dumps(result, indent=2, ensure_ascii=False))
54
+
55
+
56
+ @app.command("today")
57
+ def today():
58
+ """Today's usage and estimated cost."""
59
+ import json
60
+ from oto.tools.anthropic import AnthropicAdminClient
61
+
62
+ client = AnthropicAdminClient()
63
+ result = client.get_today_cost()
64
+ print(json.dumps(result, indent=2, ensure_ascii=False))
oto/commands/audio.py ADDED
@@ -0,0 +1,91 @@
1
+ """Audio recorder commands — push transcripts to tuls.me."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ import typer
8
+
9
+ app = typer.Typer(help="Audio recorder — transcripts & summaries on tuls.me")
10
+
11
+
12
+ def _out(data):
13
+ print(json.dumps(data, indent=2, ensure_ascii=False))
14
+
15
+
16
+ def _client():
17
+ from oto.tools.audio import AudioClient
18
+ return AudioClient()
19
+
20
+
21
+ @app.command("push")
22
+ def push(
23
+ folder: Optional[str] = typer.Argument(None, help="Recording folder path (default: latest in ~/Recordings)"),
24
+ with_audio: bool = typer.Option(False, "--with-audio", help="Include audio.mp3 in upload"),
25
+ ):
26
+ """Push a local recording to tuls.me."""
27
+ path = _resolve_folder(folder)
28
+ try:
29
+ result = _client().push(path, with_audio=with_audio)
30
+ _out(result)
31
+ except FileNotFoundError as e:
32
+ print(f"Error: {e}")
33
+ raise typer.Exit(1)
34
+ except Exception as e:
35
+ if "409" in str(e):
36
+ print(f"Already pushed: {path.name}")
37
+ raise typer.Exit(0)
38
+ raise
39
+
40
+
41
+ @app.command("list")
42
+ def list_recordings():
43
+ """List all recordings."""
44
+ _out(_client().list())
45
+
46
+
47
+ @app.command("get")
48
+ def get(recording_id: str = typer.Argument(..., help="Recording ID")):
49
+ """Get a recording with transcript and summary."""
50
+ _out(_client().get(recording_id))
51
+
52
+
53
+ @app.command("delete")
54
+ def delete(recording_id: str = typer.Argument(..., help="Recording ID")):
55
+ """Delete a recording."""
56
+ _client().delete(recording_id)
57
+ print(f"Deleted {recording_id}")
58
+
59
+
60
+ @app.command("summarize")
61
+ def summarize(
62
+ recording_id: str = typer.Argument(..., help="Recording ID"),
63
+ prompt: Optional[str] = typer.Option(None, "--prompt", "-p", help="Custom summary prompt"),
64
+ ):
65
+ """Trigger AI summary on a transcribed recording."""
66
+ _out(_client().summarize(recording_id, prompt=prompt))
67
+
68
+
69
+ def _resolve_folder(folder: Optional[str]) -> Path:
70
+ """Resolve recording folder path (default: latest in ~/Recordings)."""
71
+ if folder:
72
+ path = Path(folder).expanduser()
73
+ if not path.is_absolute():
74
+ recordings_dir = Path.home() / "Recordings"
75
+ path = recordings_dir / folder
76
+ if not path.is_dir():
77
+ print(f"Error: Not a directory: {path}")
78
+ raise typer.Exit(1)
79
+ return path
80
+
81
+ recordings_dir = Path.home() / "Recordings"
82
+ if not recordings_dir.is_dir():
83
+ print(f"Error: {recordings_dir} not found")
84
+ raise typer.Exit(1)
85
+
86
+ folders = sorted(recordings_dir.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True)
87
+ folders = [f for f in folders if f.is_dir()]
88
+ if not folders:
89
+ print("Error: No recordings found")
90
+ raise typer.Exit(1)
91
+ return folders[0]
@@ -0,0 +1,257 @@
1
+ """Browser automation commands (LinkedIn, Crunchbase, Pappers, Indeed, G2)."""
2
+
3
+ import typer
4
+ from typing import Optional
5
+
6
+ app = typer.Typer(help="Browser automation tools (LinkedIn, Crunchbase, Indeed, etc.)")
7
+
8
+ # LinkedIn subcommands
9
+ linkedin_app = typer.Typer(help="LinkedIn scraping (profile, company, employees, search)")
10
+ app.add_typer(linkedin_app, name="linkedin")
11
+
12
+
13
+ def _linkedin_client(**kwargs):
14
+ """Create LinkedInClient with common options."""
15
+ from oto.tools.browser import LinkedInClient
16
+ return LinkedInClient(**kwargs)
17
+
18
+
19
+ @linkedin_app.command("profile")
20
+ def linkedin_profile(
21
+ url: str = typer.Argument(..., help="LinkedIn profile URL"),
22
+ cookie: Optional[str] = typer.Option(None, envvar="LINKEDIN_COOKIE", help="li_at cookie"),
23
+ cdp_url: Optional[str] = typer.Option(None, "--cdp-url", help="Connect to existing Chrome via CDP"),
24
+ identity: str = typer.Option("default", help="Identity for rate limiting"),
25
+ profile: Optional[str] = typer.Option(None, help="Chrome profile directory path"),
26
+ channel: Optional[str] = typer.Option(None, envvar="BROWSER_CHANNEL", help="Chrome channel"),
27
+ no_rate_limit: bool = typer.Option(False, "--no-rate-limit", help="Disable rate limiting"),
28
+ headless: bool = typer.Option(True, help="Run headless"),
29
+ ):
30
+ """Scrape LinkedIn profile page."""
31
+ import asyncio
32
+ import json
33
+
34
+ async def run():
35
+ async with _linkedin_client(cookie=cookie, cdp_url=cdp_url, identity=identity, profile=profile, channel=channel, headless=headless, rate_limit=not no_rate_limit) as client:
36
+ return await client.scrape_profile(url)
37
+
38
+ result = asyncio.run(run())
39
+ print(json.dumps(result, indent=2, ensure_ascii=False))
40
+
41
+
42
+ @linkedin_app.command("company")
43
+ def linkedin_company(
44
+ url: str = typer.Argument(..., help="LinkedIn company URL"),
45
+ cookie: Optional[str] = typer.Option(None, envvar="LINKEDIN_COOKIE", help="li_at cookie"),
46
+ cdp_url: Optional[str] = typer.Option(None, "--cdp-url", help="Connect to existing Chrome via CDP"),
47
+ identity: str = typer.Option("default", help="Identity for rate limiting"),
48
+ profile: Optional[str] = typer.Option(None, help="Chrome profile directory path"),
49
+ channel: Optional[str] = typer.Option(None, envvar="BROWSER_CHANNEL", help="Chrome channel"),
50
+ no_rate_limit: bool = typer.Option(False, "--no-rate-limit", help="Disable rate limiting"),
51
+ headless: bool = typer.Option(True, help="Run headless"),
52
+ ):
53
+ """Scrape LinkedIn company page."""
54
+ import asyncio
55
+ import json
56
+
57
+ async def run():
58
+ async with _linkedin_client(cookie=cookie, cdp_url=cdp_url, identity=identity, profile=profile, channel=channel, headless=headless, rate_limit=not no_rate_limit) as client:
59
+ return await client.scrape_company(url)
60
+
61
+ result = asyncio.run(run())
62
+ print(json.dumps(result, indent=2, ensure_ascii=False))
63
+
64
+
65
+ @linkedin_app.command("search")
66
+ def linkedin_search(
67
+ query: str = typer.Argument(..., help="Search query"),
68
+ limit: int = typer.Option(5, help="Max results"),
69
+ cookie: Optional[str] = typer.Option(None, envvar="LINKEDIN_COOKIE", help="li_at cookie"),
70
+ cdp_url: Optional[str] = typer.Option(None, "--cdp-url", help="Connect to existing Chrome via CDP"),
71
+ profile: Optional[str] = typer.Option(None, help="Chrome profile directory path"),
72
+ channel: Optional[str] = typer.Option(None, envvar="BROWSER_CHANNEL", help="Chrome channel"),
73
+ no_rate_limit: bool = typer.Option(False, "--no-rate-limit", help="Disable rate limiting"),
74
+ headless: bool = typer.Option(True, help="Run headless"),
75
+ ):
76
+ """Search LinkedIn companies."""
77
+ import asyncio
78
+ import json
79
+
80
+ async def run():
81
+ async with _linkedin_client(cookie=cookie, cdp_url=cdp_url, profile=profile, channel=channel, headless=headless, rate_limit=not no_rate_limit) as client:
82
+ return await client.search_companies(query, limit=limit)
83
+
84
+ result = asyncio.run(run())
85
+ print(json.dumps(result, indent=2, ensure_ascii=False))
86
+
87
+
88
+ @linkedin_app.command("people")
89
+ def linkedin_people(
90
+ slug: str = typer.Argument(..., help="LinkedIn company slug"),
91
+ limit: int = typer.Option(20, help="Max results"),
92
+ cookie: Optional[str] = typer.Option(None, envvar="LINKEDIN_COOKIE", help="li_at cookie"),
93
+ cdp_url: Optional[str] = typer.Option(None, "--cdp-url", help="Connect to existing Chrome via CDP"),
94
+ profile: Optional[str] = typer.Option(None, help="Chrome profile directory path"),
95
+ channel: Optional[str] = typer.Option(None, envvar="BROWSER_CHANNEL", help="Chrome channel"),
96
+ no_rate_limit: bool = typer.Option(False, "--no-rate-limit", help="Disable rate limiting"),
97
+ headless: bool = typer.Option(True, help="Run headless"),
98
+ ):
99
+ """List people from a LinkedIn company page."""
100
+ import asyncio
101
+ import json
102
+
103
+ async def run():
104
+ async with _linkedin_client(cookie=cookie, cdp_url=cdp_url, profile=profile, channel=channel, headless=headless, rate_limit=not no_rate_limit) as client:
105
+ return await client.get_company_people(slug, limit=limit)
106
+
107
+ result = asyncio.run(run())
108
+ print(json.dumps(result, indent=2, ensure_ascii=False))
109
+
110
+
111
+ @linkedin_app.command("employees")
112
+ def linkedin_employees(
113
+ company: str = typer.Argument(..., help="LinkedIn company slug"),
114
+ keywords: Optional[str] = typer.Option(None, help="Title keywords (comma-separated)"),
115
+ limit: int = typer.Option(10, help="Max results"),
116
+ cookie: Optional[str] = typer.Option(None, envvar="LINKEDIN_COOKIE", help="li_at cookie"),
117
+ cdp_url: Optional[str] = typer.Option(None, "--cdp-url", help="Connect to existing Chrome via CDP"),
118
+ profile: Optional[str] = typer.Option(None, help="Chrome profile directory path"),
119
+ channel: Optional[str] = typer.Option(None, envvar="BROWSER_CHANNEL", help="Chrome channel"),
120
+ no_rate_limit: bool = typer.Option(False, "--no-rate-limit", help="Disable rate limiting"),
121
+ headless: bool = typer.Option(True, help="Run headless"),
122
+ ):
123
+ """Search company employees on LinkedIn."""
124
+ import asyncio
125
+ import json
126
+
127
+ async def run():
128
+ kw_list = keywords.split(",") if keywords else None
129
+ async with _linkedin_client(cookie=cookie, cdp_url=cdp_url, profile=profile, channel=channel, headless=headless, rate_limit=not no_rate_limit) as client:
130
+ return await client.search_employees(company, keywords=kw_list, limit=limit)
131
+
132
+ result = asyncio.run(run())
133
+ print(json.dumps(result, indent=2, ensure_ascii=False))
134
+
135
+
136
+ @linkedin_app.command("search-people")
137
+ def linkedin_search_people(
138
+ keywords: str = typer.Argument(..., help="Search keywords (e.g., 'credit manager')"),
139
+ geo: Optional[str] = typer.Option("105015875", help="Geo URN ID (default: France)"),
140
+ limit: int = typer.Option(50, help="Max results"),
141
+ pages: int = typer.Option(5, help="Max pages to scrape"),
142
+ cookie: Optional[str] = typer.Option(None, envvar="LINKEDIN_COOKIE", help="li_at cookie"),
143
+ cdp_url: Optional[str] = typer.Option(None, "--cdp-url", help="Connect to existing Chrome via CDP"),
144
+ profile: Optional[str] = typer.Option(None, help="Chrome profile directory path"),
145
+ channel: Optional[str] = typer.Option(None, envvar="BROWSER_CHANNEL", help="Chrome channel"),
146
+ no_rate_limit: bool = typer.Option(False, "--no-rate-limit", help="Disable rate limiting"),
147
+ headless: bool = typer.Option(True, help="Run headless"),
148
+ ):
149
+ """Search people on LinkedIn by keywords and location."""
150
+ import asyncio
151
+ import json
152
+
153
+ async def run():
154
+ async with _linkedin_client(cookie=cookie, cdp_url=cdp_url, profile=profile, channel=channel, headless=headless, rate_limit=not no_rate_limit) as client:
155
+ return await client.search_people(keywords, geo=geo, limit=limit, pages=pages)
156
+
157
+ result = asyncio.run(run())
158
+ print(json.dumps(result, indent=2, ensure_ascii=False))
159
+
160
+
161
+ @linkedin_app.command("posts")
162
+ def linkedin_posts(
163
+ url: str = typer.Argument(..., help="LinkedIn profile URL"),
164
+ limit: int = typer.Option(10, "--limit", "-n", help="Max posts"),
165
+ cookie: Optional[str] = typer.Option(None, envvar="LINKEDIN_COOKIE", help="li_at cookie"),
166
+ cdp_url: Optional[str] = typer.Option(None, "--cdp-url", help="Connect to existing Chrome via CDP"),
167
+ profile: Optional[str] = typer.Option(None, help="Chrome profile directory path"),
168
+ channel: Optional[str] = typer.Option(None, envvar="BROWSER_CHANNEL", help="Chrome channel"),
169
+ no_rate_limit: bool = typer.Option(False, "--no-rate-limit", help="Disable rate limiting"),
170
+ headless: bool = typer.Option(True, help="Run headless"),
171
+ ):
172
+ """Scrape posts from a LinkedIn profile."""
173
+ import asyncio
174
+ import json
175
+
176
+ async def run():
177
+ async with _linkedin_client(cookie=cookie, cdp_url=cdp_url, profile=profile, channel=channel, headless=headless, rate_limit=not no_rate_limit) as client:
178
+ return await client.scrape_profile_posts(url, max_posts=limit)
179
+
180
+ result = asyncio.run(run())
181
+ print(json.dumps(result, indent=2, ensure_ascii=False))
182
+
183
+
184
+ @app.command("crunchbase-company")
185
+ def crunchbase_company(
186
+ slug: str = typer.Argument(..., help="Company slug or URL"),
187
+ headless: bool = typer.Option(True, help="Run headless"),
188
+ ):
189
+ """Get company from Crunchbase."""
190
+ import asyncio
191
+ import json
192
+ from oto.tools.browser import CrunchbaseClient
193
+
194
+ async def run():
195
+ async with CrunchbaseClient(headless=headless) as client:
196
+ return await client.get_company(slug)
197
+
198
+ result = asyncio.run(run())
199
+ print(json.dumps(result, indent=2, ensure_ascii=False))
200
+
201
+
202
+ @app.command("pappers-siren")
203
+ def pappers_siren(
204
+ siren: str = typer.Argument(..., help="SIREN number"),
205
+ headless: bool = typer.Option(True, help="Run headless"),
206
+ ):
207
+ """Get French company data from Pappers."""
208
+ import asyncio
209
+ import json
210
+ from oto.tools.browser import PappersClient
211
+
212
+ async def run():
213
+ async with PappersClient(headless=headless) as client:
214
+ return await client.get_company_by_siren(siren)
215
+
216
+ result = asyncio.run(run())
217
+ print(json.dumps(result, indent=2, ensure_ascii=False))
218
+
219
+
220
+ @app.command("indeed-search")
221
+ def indeed_search(
222
+ query: str = typer.Argument(..., help="Job search query"),
223
+ location: str = typer.Option("", help="Location"),
224
+ country: str = typer.Option("fr", help="Country code (fr, us, uk, de)"),
225
+ limit: int = typer.Option(25, help="Max results"),
226
+ headless: bool = typer.Option(True, help="Run headless"),
227
+ ):
228
+ """Search jobs on Indeed."""
229
+ import asyncio
230
+ import json
231
+ from oto.tools.browser import IndeedClient
232
+
233
+ async def run():
234
+ async with IndeedClient(country=country, headless=headless) as client:
235
+ return await client.search_jobs(query, location=location, max_results=limit)
236
+
237
+ result = asyncio.run(run())
238
+ print(json.dumps(result, indent=2, ensure_ascii=False))
239
+
240
+
241
+ @app.command("g2-reviews")
242
+ def g2_reviews(
243
+ url: str = typer.Argument(..., help="G2 product reviews URL"),
244
+ limit: int = typer.Option(50, help="Max reviews"),
245
+ headless: bool = typer.Option(True, help="Run headless"),
246
+ ):
247
+ """Scrape product reviews from G2."""
248
+ import asyncio
249
+ import json
250
+ from oto.tools.browser import G2Client
251
+
252
+ async def run():
253
+ async with G2Client(headless=headless) as client:
254
+ return await client.get_product_reviews(url, max_reviews=limit)
255
+
256
+ result = asyncio.run(run())
257
+ print(json.dumps(result, indent=2, ensure_ascii=False))
@@ -0,0 +1,23 @@
1
+ """Company lookup command (SIREN multi-source)."""
2
+
3
+ import typer
4
+
5
+ app = typer.Typer(help="Company lookup")
6
+
7
+
8
+ @app.command("info")
9
+ def info(
10
+ siren: str = typer.Argument(..., help="SIREN number (9 digits)"),
11
+ ):
12
+ """Get French company info by SIREN (directors, finances, address). No API key needed."""
13
+ import json
14
+ from oto.tools.sirene import EntreprisesClient
15
+
16
+ client = EntreprisesClient()
17
+ result = client.get_by_siren(siren)
18
+
19
+ if not result:
20
+ print(f"Company not found: {siren}")
21
+ raise typer.Exit(1)
22
+
23
+ print(json.dumps(result, indent=2, ensure_ascii=False))
@@ -0,0 +1,152 @@
1
+ """Contact enrichment commands (Kaspr, Hunter, Lemlist)."""
2
+
3
+ import typer
4
+ from typing import Optional
5
+
6
+ app = typer.Typer(help="Contact enrichment tools")
7
+
8
+ # Kaspr subcommands
9
+ kaspr_app = typer.Typer(help="Kaspr contact enrichment")
10
+ app.add_typer(kaspr_app, name="kaspr")
11
+
12
+ # Hunter subcommands
13
+ hunter_app = typer.Typer(help="Hunter.io email tools")
14
+ app.add_typer(hunter_app, name="hunter")
15
+
16
+ # Lemlist subcommands
17
+ lemlist_app = typer.Typer(help="Lemlist campaign & lead management")
18
+ app.add_typer(lemlist_app, name="lemlist")
19
+
20
+
21
+ @kaspr_app.command("enrich")
22
+ def kaspr_enrich(
23
+ linkedin_slug: str = typer.Argument(..., help="LinkedIn profile slug"),
24
+ name: Optional[str] = typer.Option(None, "--name", help="Person full name"),
25
+ ):
26
+ """Enrich a LinkedIn profile with Kaspr (email, phone)."""
27
+ import json
28
+ from oto.tools.kaspr import KasprClient
29
+
30
+ client = KasprClient()
31
+ result = client.enrich_linkedin(linkedin_slug, name=name)
32
+ print(json.dumps(result, indent=2, ensure_ascii=False))
33
+
34
+
35
+ @hunter_app.command("domain")
36
+ def hunter_domain(
37
+ domain: str = typer.Argument(..., help="Domain to search"),
38
+ limit: int = typer.Option(10, "--limit", "-n", help="Max results"),
39
+ ):
40
+ """Search emails for a domain via Hunter."""
41
+ import json
42
+ from oto.tools.hunter import HunterClient
43
+
44
+ client = HunterClient()
45
+ result = client.domain_search(domain, limit=limit)
46
+ print(json.dumps(result, indent=2, ensure_ascii=False))
47
+
48
+
49
+ @hunter_app.command("find")
50
+ def hunter_find(
51
+ domain: str = typer.Argument(..., help="Domain"),
52
+ name: str = typer.Option(..., "--name", help="Full name"),
53
+ ):
54
+ """Find email for a person at a domain via Hunter."""
55
+ import json
56
+ from oto.tools.hunter import HunterClient
57
+
58
+ client = HunterClient()
59
+ result = client.email_finder(domain, full_name=name)
60
+ print(json.dumps(result, indent=2, ensure_ascii=False))
61
+
62
+
63
+ @hunter_app.command("verify")
64
+ def hunter_verify(
65
+ email: str = typer.Argument(..., help="Email to verify"),
66
+ ):
67
+ """Verify an email address via Hunter."""
68
+ import json
69
+ from oto.tools.hunter import HunterClient
70
+
71
+ client = HunterClient()
72
+ result = client.email_verifier(email)
73
+ print(json.dumps(result, indent=2, ensure_ascii=False))
74
+
75
+
76
+ @lemlist_app.command("campaigns")
77
+ def lemlist_campaigns():
78
+ """List all Lemlist campaigns."""
79
+ import json
80
+ from oto.tools.lemlist import LemlistClient
81
+
82
+ client = LemlistClient()
83
+ campaigns = client.list_campaigns()
84
+ result = [{"id": c.id, "name": c.name, "status": c.status, "senders": c.senders} for c in campaigns]
85
+ print(json.dumps(result, indent=2, ensure_ascii=False))
86
+
87
+
88
+ @lemlist_app.command("leads")
89
+ def lemlist_leads(
90
+ campaign_id: str = typer.Argument(..., help="Campaign ID"),
91
+ ):
92
+ """List leads in a campaign."""
93
+ import json
94
+ from oto.tools.lemlist import LemlistClient
95
+
96
+ client = LemlistClient()
97
+ leads = client.get_all_leads(campaign_id)
98
+ print(json.dumps(leads, indent=2, ensure_ascii=False))
99
+
100
+
101
+ @lemlist_app.command("add-lead")
102
+ def lemlist_add_lead(
103
+ campaign_id: str = typer.Argument(..., help="Campaign ID"),
104
+ email: str = typer.Option(..., "--email", "-e", help="Lead email"),
105
+ first_name: str = typer.Option(None, "--first-name", help="First name"),
106
+ last_name: str = typer.Option(None, "--last-name", help="Last name"),
107
+ company: str = typer.Option(None, "--company", help="Company name"),
108
+ phone: str = typer.Option(None, "--phone", help="Phone number"),
109
+ linkedin: str = typer.Option(None, "--linkedin", help="LinkedIn URL"),
110
+ ):
111
+ """Add a lead to a Lemlist campaign."""
112
+ import json
113
+ from oto.tools.lemlist import LemlistClient
114
+ from oto.tools.lemlist.client import Lead
115
+
116
+ client = LemlistClient()
117
+ lead = Lead(
118
+ email=email,
119
+ firstName=first_name,
120
+ lastName=last_name,
121
+ companyName=company,
122
+ phone=phone,
123
+ linkedinUrl=linkedin,
124
+ )
125
+ result = client.add_lead(campaign_id, lead)
126
+ print(json.dumps(result, indent=2, ensure_ascii=False))
127
+
128
+
129
+ @lemlist_app.command("delete-lead")
130
+ def lemlist_delete_lead(
131
+ campaign_id: str = typer.Argument(..., help="Campaign ID"),
132
+ email: str = typer.Argument(..., help="Lead email to remove"),
133
+ ):
134
+ """Remove a lead from a Lemlist campaign."""
135
+ import json
136
+ from oto.tools.lemlist import LemlistClient
137
+
138
+ client = LemlistClient()
139
+ result = client.delete_lead(campaign_id, email)
140
+ print(json.dumps(result, indent=2, ensure_ascii=False))
141
+
142
+
143
+ @lemlist_app.command("export")
144
+ def lemlist_export(
145
+ campaign_id: str = typer.Argument(..., help="Campaign ID"),
146
+ ):
147
+ """Export leads from a campaign as CSV."""
148
+ from oto.tools.lemlist import LemlistClient
149
+
150
+ client = LemlistClient()
151
+ csv_data = client.export_leads(campaign_id)
152
+ print(csv_data)