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/commands/folk.py ADDED
@@ -0,0 +1,238 @@
1
+ """Folk CRM commands."""
2
+
3
+ import json
4
+ import typer
5
+ from typing import Optional
6
+
7
+ app = typer.Typer(help="Folk CRM — contacts, companies, deals, notes")
8
+
9
+
10
+ def _out(data):
11
+ print(json.dumps(data, indent=2, ensure_ascii=False))
12
+
13
+
14
+ def _client():
15
+ from oto.tools.folk import FolkClient
16
+ return FolkClient()
17
+
18
+
19
+ # --- Groups ---
20
+
21
+ @app.command("groups")
22
+ def groups():
23
+ """List all groups (pipelines)."""
24
+ items = _client().list_groups()
25
+ _out({"count": len(items), "groups": items})
26
+
27
+
28
+ # --- People ---
29
+
30
+ @app.command("people")
31
+ def people(
32
+ search: Optional[str] = typer.Argument(None, help="Search by name"),
33
+ group: Optional[str] = typer.Option(None, "--group", "-g", help="Filter by group ID"),
34
+ ):
35
+ """List contacts."""
36
+ c = _client()
37
+ filters = {}
38
+ if search:
39
+ filters["fullName"] = search
40
+ items = c.list_people(**filters)
41
+ result = []
42
+ for p in items:
43
+ result.append({
44
+ "id": p["id"],
45
+ "name": p.get("fullName") or f"{p.get('firstName','')} {p.get('lastName','')}".strip(),
46
+ "jobTitle": p.get("jobTitle", ""),
47
+ "emails": p.get("emails", []),
48
+ "companies": [co.get("name", "") for co in p.get("companies", [])],
49
+ })
50
+ _out({"count": len(result), "people": result})
51
+
52
+
53
+ @app.command("person")
54
+ def person(person_id: str = typer.Argument(..., help="Person ID (per_...)")):
55
+ """Get a person's details."""
56
+ _out(_client().get_person(person_id))
57
+
58
+
59
+ @app.command("add-person")
60
+ def add_person(
61
+ first_name: str = typer.Argument(...),
62
+ last_name: Optional[str] = typer.Option(None, "--last", "-l"),
63
+ email: Optional[str] = typer.Option(None, "--email", "-e"),
64
+ phone: Optional[str] = typer.Option(None, "--phone"),
65
+ job_title: Optional[str] = typer.Option(None, "--title", "-t"),
66
+ company: Optional[str] = typer.Option(None, "--company", "-c", help="Company name (creates if needed)"),
67
+ group: Optional[str] = typer.Option(None, "--group", "-g", help="Group ID"),
68
+ ):
69
+ """Create a contact."""
70
+ emails = [email] if email else None
71
+ phones = [phone] if phone else None
72
+ group_ids = [group] if group else None
73
+ result = _client().create_person(
74
+ first_name=first_name, last_name=last_name,
75
+ emails=emails, phones=phones, job_title=job_title,
76
+ company_name=company, group_ids=group_ids,
77
+ )
78
+ _out(result)
79
+
80
+
81
+ @app.command("update-person")
82
+ def update_person(
83
+ person_id: str = typer.Argument(..., help="Person ID (per_...)"),
84
+ first_name: Optional[str] = typer.Option(None, "--first"),
85
+ last_name: Optional[str] = typer.Option(None, "--last"),
86
+ email: Optional[str] = typer.Option(None, "--email", "-e"),
87
+ job_title: Optional[str] = typer.Option(None, "--title", "-t"),
88
+ company: Optional[str] = typer.Option(None, "--company", "-c"),
89
+ group: Optional[str] = typer.Option(None, "--group", "-g", help="Set group (replaces all)"),
90
+ ):
91
+ """Update a contact."""
92
+ fields = {}
93
+ if first_name:
94
+ fields["firstName"] = first_name
95
+ if last_name:
96
+ fields["lastName"] = last_name
97
+ if email:
98
+ fields["emails"] = [email]
99
+ if job_title:
100
+ fields["jobTitle"] = job_title
101
+ if company:
102
+ fields["companies"] = [{"name": company}]
103
+ if group:
104
+ fields["groups"] = [{"id": group}]
105
+ _out(_client().update_person(person_id, **fields))
106
+
107
+
108
+ @app.command("delete-person")
109
+ def delete_person(person_id: str = typer.Argument(..., help="Person ID (per_...)")):
110
+ """Delete a contact."""
111
+ _client().delete_person(person_id)
112
+ print(f"Deleted {person_id}")
113
+
114
+
115
+ # --- Companies ---
116
+
117
+ @app.command("companies")
118
+ def companies(search: Optional[str] = typer.Argument(None, help="Search by name")):
119
+ """List companies."""
120
+ filters = {}
121
+ if search:
122
+ filters["name"] = search
123
+ items = _client().list_companies(**filters)
124
+ _out({"count": len(items), "companies": items})
125
+
126
+
127
+ def _parse_fields(fields: Optional[list[str]]) -> dict:
128
+ """Parse --field key=value pairs into a dict."""
129
+ if not fields:
130
+ return {}
131
+ result = {}
132
+ for f in fields:
133
+ if "=" not in f:
134
+ raise typer.BadParameter(f"Invalid field format: {f!r} (expected key=value)")
135
+ key, value = f.split("=", 1)
136
+ result[key] = value
137
+ return result
138
+
139
+
140
+ @app.command("add-company")
141
+ def add_company(
142
+ name: str = typer.Argument(...),
143
+ industry: Optional[str] = typer.Option(None, "--industry", "-i"),
144
+ group: Optional[str] = typer.Option(None, "--group", "-g", help="Group ID"),
145
+ fields: Optional[list[str]] = typer.Option(None, "--field", "-f", help="Custom field key=value (requires --group)"),
146
+ ):
147
+ """Create a company."""
148
+ extra: dict = {}
149
+ if group:
150
+ extra["groups"] = [{"id": group}]
151
+ custom = _parse_fields(fields)
152
+ if custom:
153
+ if not group:
154
+ raise typer.BadParameter("--field requires --group")
155
+ extra["customFieldValues"] = {group: custom}
156
+ _out(_client().create_company(name=name, industry=industry, **extra))
157
+
158
+
159
+ @app.command("update-company")
160
+ def update_company(
161
+ company_id: str = typer.Argument(..., help="Company ID (com_...)"),
162
+ name: Optional[str] = typer.Option(None, "--name", "-n"),
163
+ industry: Optional[str] = typer.Option(None, "--industry", "-i"),
164
+ group: Optional[str] = typer.Option(None, "--group", "-g", help="Group ID"),
165
+ fields: Optional[list[str]] = typer.Option(None, "--field", "-f", help="Custom field key=value (requires --group)"),
166
+ ):
167
+ """Update a company."""
168
+ data: dict = {}
169
+ if name:
170
+ data["name"] = name
171
+ if industry:
172
+ data["industry"] = industry
173
+ if group:
174
+ data["groups"] = [{"id": group}]
175
+ custom = _parse_fields(fields)
176
+ if custom:
177
+ if not group:
178
+ raise typer.BadParameter("--field requires --group")
179
+ data["customFieldValues"] = {group: custom}
180
+ _out(_client().update_company(company_id, **data))
181
+
182
+
183
+ @app.command("delete-company")
184
+ def delete_company(company_id: str = typer.Argument(..., help="Company ID (com_...)")):
185
+ """Delete a company."""
186
+ _client().delete_company(company_id)
187
+ print(f"Deleted {company_id}")
188
+
189
+
190
+ # --- Deals ---
191
+
192
+ @app.command("deals")
193
+ def deals(
194
+ group: str = typer.Option(None, "--group", "-g", help="Group ID (default: first group)"),
195
+ object_type: str = typer.Option("deals", "--type", "-t", help="Object type name"),
196
+ ):
197
+ """List deals in a pipeline group."""
198
+ c = _client()
199
+ if not group:
200
+ groups = c.list_groups()
201
+ if not groups:
202
+ print("No groups found.")
203
+ raise typer.Exit(1)
204
+ group = groups[0]["id"]
205
+ items = c.list_deals(group, object_type)
206
+ _out({"count": len(items), "group": group, "deals": items})
207
+
208
+
209
+ # --- Notes ---
210
+
211
+ @app.command("notes")
212
+ def notes(entity_id: str = typer.Argument(..., help="Entity ID (per_/com_/obj_)")):
213
+ """List notes for an entity."""
214
+ items = _client().list_notes(entity_id)
215
+ _out({"count": len(items), "notes": items})
216
+
217
+
218
+ @app.command("add-note")
219
+ def add_note(
220
+ entity_id: str = typer.Argument(..., help="Entity ID"),
221
+ content: str = typer.Argument(..., help="Note content (markdown)"),
222
+ ):
223
+ """Add a note to a person/company/deal."""
224
+ _out(_client().create_note(entity_id, content))
225
+
226
+
227
+ # --- Interactions ---
228
+
229
+ @app.command("add-interaction")
230
+ def add_interaction(
231
+ entity_id: str = typer.Argument(..., help="Person or company ID"),
232
+ type: str = typer.Argument(..., help="Type: call, meeting, email, linkedin, etc."),
233
+ title: str = typer.Argument(..., help="Short title"),
234
+ content: Optional[str] = typer.Option(None, "--content", "-c"),
235
+ date: Optional[str] = typer.Option(None, "--date", "-d", help="ISO datetime"),
236
+ ):
237
+ """Log an interaction (call, meeting, email...)."""
238
+ _out(_client().create_interaction(entity_id, type, title, content, date))
oto/commands/google.py ADDED
@@ -0,0 +1,355 @@
1
+ """Google Workspace commands (Drive, Docs, Sheets, Slides, Gmail, Calendar, Auth)."""
2
+
3
+ import typer
4
+ from typing import Optional
5
+
6
+ app = typer.Typer(help="Google Workspace tools (Drive, Docs, Sheets, Slides, Gmail, Calendar)")
7
+
8
+
9
+ def _apply_signature(client, body: str, html: Optional[str]) -> Optional[str]:
10
+ """Convert plain text body to HTML with Gmail signature appended."""
11
+ import html as html_mod
12
+ signature = client.get_signature()
13
+ if not signature:
14
+ return html
15
+ body_html = html or '<div dir="ltr">' + html_mod.escape(body).replace('\n', '<br>') + '</div>'
16
+ return body_html + '<br>--<br>' + signature
17
+
18
+
19
+ @app.command("drive-list")
20
+ def drive_list(
21
+ folder_id: Optional[str] = typer.Option(None, help="Filter by parent folder ID"),
22
+ query: Optional[str] = typer.Option(None, help="Custom query filter"),
23
+ limit: int = typer.Option(100, help="Max results"),
24
+ account: Optional[str] = typer.Option(None, "--account", "-a", help="Google account name"),
25
+ ):
26
+ """List files in Google Drive."""
27
+ from oto.tools.google.drive.lib.drive_client import DriveClient
28
+ import json
29
+
30
+ client = DriveClient(account=account)
31
+ files = client.list_files(folder_id=folder_id, query=query, page_size=limit)
32
+ print(json.dumps({"count": len(files), "files": files}, indent=2))
33
+
34
+
35
+ @app.command("drive-download")
36
+ def drive_download(
37
+ file_id: str = typer.Argument(..., help="Google Drive file ID"),
38
+ output: str = typer.Argument(..., help="Output path"),
39
+ account: Optional[str] = typer.Option(None, "--account", "-a", help="Google account name"),
40
+ ):
41
+ """Download a file from Google Drive."""
42
+ from oto.tools.google.drive.lib.drive_client import DriveClient
43
+
44
+ client = DriveClient(account=account)
45
+ result = client.download_file(file_id, output)
46
+ print(f"Downloaded: {result['filename']} -> {result['output_path']}")
47
+
48
+
49
+ @app.command("drive-upload")
50
+ def drive_upload(
51
+ file_path: str = typer.Argument(..., help="Local file path to upload"),
52
+ folder_id: Optional[str] = typer.Option(None, help="Target folder ID in Drive"),
53
+ name: Optional[str] = typer.Option(None, help="Custom filename (defaults to local name)"),
54
+ account: Optional[str] = typer.Option(None, "--account", "-a", help="Google account name"),
55
+ ):
56
+ """Upload a file to Google Drive."""
57
+ from oto.tools.google.drive.lib.drive_client import DriveClient
58
+ import json
59
+
60
+ client = DriveClient(account=account)
61
+ result = client.upload_file(local_path=file_path, folder_id=folder_id, file_name=name)
62
+ print(json.dumps(result, indent=2))
63
+
64
+
65
+ @app.command("docs-headings")
66
+ def docs_headings(
67
+ doc_id: str = typer.Argument(..., help="Google Docs document ID"),
68
+ ):
69
+ """List headings in a Google Doc."""
70
+ from oto.tools.google.docs.lib.docs_client import DocsClient
71
+ import json
72
+
73
+ client = DocsClient()
74
+ headings = client.list_headings(doc_id)
75
+ print(json.dumps(headings, indent=2))
76
+
77
+
78
+ @app.command("docs-section")
79
+ def docs_section(
80
+ doc_id: str = typer.Argument(..., help="Google Docs document ID"),
81
+ heading: str = typer.Argument(..., help="Heading text to find"),
82
+ ):
83
+ """Get content of a section in a Google Doc."""
84
+ from oto.tools.google.docs.lib.docs_client import DocsClient
85
+
86
+ client = DocsClient()
87
+ section = client.get_section_content(doc_id, heading)
88
+ if section:
89
+ print(f"# {section.title}\n")
90
+ print(section.content)
91
+ else:
92
+ print(f"Section not found: {heading}")
93
+ raise typer.Exit(1)
94
+
95
+
96
+ @app.command("auth")
97
+ def auth(
98
+ name: str = typer.Argument("default", help="Account name (e.g. 'gmail', 'work')"),
99
+ list_accounts: bool = typer.Option(False, "--list", "-l", help="List configured accounts"),
100
+ ):
101
+ """Set up or list Google OAuth accounts."""
102
+ from oto.tools.google.credentials import list_accounts as _list_accounts, setup_account, DEFAULT_SCOPES as DRIVE_SCOPES
103
+ from oto.tools.google.gmail.lib.gmail_client import SCOPES as GMAIL_SCOPES
104
+ from oto.tools.google.calendar.lib.calendar_client import SCOPES as CALENDAR_SCOPES
105
+
106
+ ALL_SCOPES = list(set(GMAIL_SCOPES + CALENDAR_SCOPES + DRIVE_SCOPES))
107
+
108
+ if list_accounts:
109
+ accounts = _list_accounts()
110
+ if not accounts:
111
+ print("No accounts configured. Run: oto google auth <name>")
112
+ else:
113
+ for a in accounts:
114
+ print(f" {a}")
115
+ return
116
+
117
+ print(f"Setting up account '{name}'... Opening browser for Google consent.")
118
+ setup_account(name, ALL_SCOPES)
119
+ print(f"Account '{name}' configured.")
120
+
121
+
122
+ @app.command("calendar-list")
123
+ def calendar_list(
124
+ account: Optional[str] = typer.Option(None, "--account", "-a", help="Google account name"),
125
+ ):
126
+ """List available calendars."""
127
+ from oto.tools.google.calendar.lib.calendar_client import CalendarClient
128
+ import json
129
+
130
+ client = CalendarClient(account=account)
131
+ calendars = client.list_calendars()
132
+ print(json.dumps({"count": len(calendars), "calendars": calendars}, indent=2, ensure_ascii=False))
133
+
134
+
135
+ @app.command("calendar-today")
136
+ def calendar_today(
137
+ calendar_id: str = typer.Option("primary", "--calendar", "-c", help="Calendar ID"),
138
+ account: Optional[str] = typer.Option(None, "--account", "-a", help="Google account name"),
139
+ ):
140
+ """List today's events."""
141
+ from oto.tools.google.calendar.lib.calendar_client import CalendarClient
142
+ import json
143
+
144
+ client = CalendarClient(account=account)
145
+ events = client.today(calendar_id=calendar_id)
146
+ print(json.dumps({"count": len(events), "events": events}, indent=2, ensure_ascii=False))
147
+
148
+
149
+ @app.command("calendar-upcoming")
150
+ def calendar_upcoming(
151
+ days: int = typer.Option(7, "--days", "-d", help="Number of days ahead"),
152
+ calendar_id: str = typer.Option("primary", "--calendar", "-c", help="Calendar ID"),
153
+ limit: int = typer.Option(50, "--limit", "-n", help="Max events"),
154
+ account: Optional[str] = typer.Option(None, "--account", "-a", help="Google account name"),
155
+ ):
156
+ """List upcoming events (default: next 7 days)."""
157
+ from oto.tools.google.calendar.lib.calendar_client import CalendarClient
158
+ import json
159
+
160
+ client = CalendarClient(account=account)
161
+ events = client.upcoming(days=days, calendar_id=calendar_id, max_results=limit)
162
+ print(json.dumps({"count": len(events), "events": events}, indent=2, ensure_ascii=False))
163
+
164
+
165
+ @app.command("calendar-search")
166
+ def calendar_search(
167
+ query: str = typer.Argument(..., help="Search query"),
168
+ days: int = typer.Option(30, "--days", "-d", help="Search window in days"),
169
+ calendar_id: str = typer.Option("primary", "--calendar", "-c", help="Calendar ID"),
170
+ limit: int = typer.Option(20, "--limit", "-n", help="Max events"),
171
+ account: Optional[str] = typer.Option(None, "--account", "-a", help="Google account name"),
172
+ ):
173
+ """Search calendar events."""
174
+ from oto.tools.google.calendar.lib.calendar_client import CalendarClient
175
+ from datetime import datetime, timedelta, timezone
176
+ import json
177
+
178
+ client = CalendarClient(account=account)
179
+ now = datetime.now(timezone.utc)
180
+ events = client.list_events(
181
+ calendar_id=calendar_id,
182
+ time_min=now.isoformat(),
183
+ time_max=(now + timedelta(days=days)).isoformat(),
184
+ max_results=limit,
185
+ query=query,
186
+ )
187
+ print(json.dumps({"count": len(events), "events": events}, indent=2, ensure_ascii=False))
188
+
189
+
190
+ @app.command("calendar-get")
191
+ def calendar_get(
192
+ event_id: str = typer.Argument(..., help="Event ID"),
193
+ calendar_id: str = typer.Option("primary", "--calendar", "-c", help="Calendar ID"),
194
+ account: Optional[str] = typer.Option(None, "--account", "-a", help="Google account name"),
195
+ ):
196
+ """Get details of a calendar event."""
197
+ from oto.tools.google.calendar.lib.calendar_client import CalendarClient
198
+ import json
199
+
200
+ client = CalendarClient(account=account)
201
+ event = client.get_event(event_id, calendar_id=calendar_id)
202
+ print(json.dumps(event, indent=2, ensure_ascii=False))
203
+
204
+
205
+ @app.command("gmail-list")
206
+ def gmail_list(
207
+ query: Optional[str] = typer.Option(None, help="Gmail search query"),
208
+ label: Optional[str] = typer.Option(None, help="Filter by label ID"),
209
+ limit: int = typer.Option(20, "--limit", "-n", help="Max messages"),
210
+ account: Optional[str] = typer.Option(None, "--account", "-a", help="Google account name"),
211
+ ):
212
+ """List recent Gmail messages."""
213
+ from oto.tools.google.gmail.lib.gmail_client import GmailClient
214
+ import json
215
+
216
+ client = GmailClient(account=account)
217
+ label_ids = [label] if label else None
218
+ messages = client.list_messages(query=query, label_ids=label_ids, max_results=limit)
219
+ print(json.dumps({"count": len(messages), "messages": messages}, indent=2, ensure_ascii=False))
220
+
221
+
222
+ @app.command("gmail-search")
223
+ def gmail_search(
224
+ query: str = typer.Argument(..., help="Gmail search query (e.g. 'is:unread', 'from:user@example.com')"),
225
+ limit: int = typer.Option(20, "--limit", "-n", help="Max messages"),
226
+ account: Optional[str] = typer.Option(None, "--account", "-a", help="Google account name"),
227
+ ):
228
+ """Search Gmail messages."""
229
+ from oto.tools.google.gmail.lib.gmail_client import GmailClient
230
+ import json
231
+
232
+ client = GmailClient(account=account)
233
+ messages = client.search(query=query, max_results=limit)
234
+ print(json.dumps({"count": len(messages), "messages": messages}, indent=2, ensure_ascii=False))
235
+
236
+
237
+ @app.command("gmail-get")
238
+ def gmail_get(
239
+ message_id: str = typer.Argument(..., help="Gmail message ID"),
240
+ account: Optional[str] = typer.Option(None, "--account", "-a", help="Google account name"),
241
+ ):
242
+ """Read a Gmail message."""
243
+ from oto.tools.google.gmail.lib.gmail_client import GmailClient
244
+ import json
245
+
246
+ client = GmailClient(account=account)
247
+ message = client.get_message(message_id)
248
+ print(json.dumps(message, indent=2, ensure_ascii=False))
249
+
250
+
251
+ @app.command("gmail-attachments")
252
+ def gmail_attachments(
253
+ message_id: str = typer.Argument(..., help="Gmail message ID"),
254
+ output: str = typer.Option(".", "--output", "-o", help="Output directory"),
255
+ account: Optional[str] = typer.Option(None, "--account", "-a", help="Google account name"),
256
+ ):
257
+ """Download attachments from a Gmail message."""
258
+ from oto.tools.google.gmail.lib.gmail_client import GmailClient
259
+ import json
260
+
261
+ client = GmailClient(account=account)
262
+ files = client.download_attachments(message_id, output)
263
+ print(json.dumps({"count": len(files), "files": files}, indent=2, ensure_ascii=False))
264
+
265
+
266
+ @app.command("gmail-draft")
267
+ def gmail_draft(
268
+ to: Optional[str] = typer.Option(None, help="Recipient email (auto-detected with --reply-to)"),
269
+ subject: Optional[str] = typer.Option(None, help="Email subject (auto-detected with --reply-to)"),
270
+ body: str = typer.Option(..., help="Email body (plain text)"),
271
+ html: Optional[str] = typer.Option(None, help="Email body (HTML). If provided, sent as multipart alternative with plain text."),
272
+ cc: Optional[str] = typer.Option(None, help="CC recipients"),
273
+ bcc: Optional[str] = typer.Option(None, help="BCC recipients"),
274
+ reply_to: Optional[str] = typer.Option(None, "--reply-to", "-r", help="Message ID to reply to (threads the draft)"),
275
+ attach: Optional[list[str]] = typer.Option(None, "--attach", "-f", help="File paths to attach"),
276
+ sign: bool = typer.Option(True, "--sign/--no-sign", help="Append Gmail signature"),
277
+ account: Optional[str] = typer.Option(None, "--account", "-a", help="Google account name"),
278
+ ):
279
+ """Create a draft email in Gmail. Use --reply-to for threaded replies."""
280
+ from oto.tools.google.gmail.lib.gmail_client import GmailClient
281
+ import json
282
+
283
+ client = GmailClient(account=account)
284
+ final_html = _apply_signature(client, body, html) if sign else html
285
+ if reply_to:
286
+ result = client.create_draft_reply(message_id=reply_to, body=body, html=final_html, cc=cc, attachments=attach)
287
+ else:
288
+ if not to or not subject:
289
+ raise typer.BadParameter("--to and --subject are required (unless using --reply-to)")
290
+ result = client.create_draft(to=to, subject=subject, body=body, html=final_html, cc=cc, bcc=bcc, attachments=attach)
291
+ print(json.dumps(result, indent=2))
292
+
293
+
294
+ @app.command("gmail-reply")
295
+ def gmail_reply(
296
+ message_id: str = typer.Argument(..., help="Gmail message ID to reply to"),
297
+ body: str = typer.Option(..., help="Reply body (plain text)"),
298
+ html: Optional[str] = typer.Option(None, help="Reply body (HTML)"),
299
+ cc: Optional[str] = typer.Option(None, help="CC recipients"),
300
+ attach: Optional[list[str]] = typer.Option(None, "--attach", "-f", help="File paths to attach"),
301
+ sign: bool = typer.Option(True, "--sign/--no-sign", help="Append Gmail signature"),
302
+ account: Optional[str] = typer.Option(None, "--account", "-a", help="Google account name"),
303
+ ):
304
+ """Reply to a Gmail message (preserves thread)."""
305
+ from oto.tools.google.gmail.lib.gmail_client import GmailClient
306
+ import json
307
+
308
+ client = GmailClient(account=account)
309
+ final_html = _apply_signature(client, body, html) if sign else html
310
+ result = client.reply(message_id=message_id, body=body, html=final_html, cc=cc, attachments=attach)
311
+ print(json.dumps(result, indent=2))
312
+
313
+
314
+ @app.command("gmail-send")
315
+ def gmail_send(
316
+ to: str = typer.Option(..., help="Recipient email"),
317
+ subject: str = typer.Option(..., help="Email subject"),
318
+ body: str = typer.Option(..., help="Email body (plain text)"),
319
+ html: Optional[str] = typer.Option(None, help="Email body (HTML)"),
320
+ cc: Optional[str] = typer.Option(None, help="CC recipients"),
321
+ bcc: Optional[str] = typer.Option(None, help="BCC recipients"),
322
+ attach: Optional[list[str]] = typer.Option(None, "--attach", "-f", help="File paths to attach"),
323
+ sign: bool = typer.Option(True, "--sign/--no-sign", help="Append Gmail signature"),
324
+ account: Optional[str] = typer.Option(None, "--account", "-a", help="Google account name"),
325
+ ):
326
+ """Send an email via Gmail."""
327
+ from oto.tools.google.gmail.lib.gmail_client import GmailClient
328
+ import json
329
+
330
+ client = GmailClient(account=account)
331
+ final_html = _apply_signature(client, body, html) if sign else html
332
+ result = client.send(to=to, subject=subject, body=body, html=final_html, cc=cc, bcc=bcc, attachments=attach)
333
+ print(json.dumps(result, indent=2))
334
+
335
+
336
+ @app.command("gmail-archive")
337
+ def gmail_archive(
338
+ message_ids: Optional[list[str]] = typer.Argument(None, help="Gmail message IDs to archive"),
339
+ query: Optional[str] = typer.Option(None, "--query", "-q", help="Archive all messages matching this Gmail query"),
340
+ account: Optional[str] = typer.Option(None, "--account", "-a", help="Google account name"),
341
+ ):
342
+ """Archive Gmail messages (remove from inbox)."""
343
+ from oto.tools.google.gmail.lib.gmail_client import GmailClient
344
+ import json
345
+
346
+ client = GmailClient(account=account)
347
+ ids = list(message_ids or [])
348
+ if query:
349
+ msgs = client.search(query=query, max_results=100)
350
+ ids.extend(m['id'] for m in msgs if 'INBOX' in m.get('labelIds', []))
351
+ if not ids:
352
+ print(json.dumps({"archived": 0, "message": "No messages to archive"}))
353
+ return
354
+ results = client.archive_messages(ids)
355
+ print(json.dumps({"archived": len(results), "results": results}, indent=2))
oto/commands/notion.py ADDED
@@ -0,0 +1,58 @@
1
+ """Notion commands (search, page, database)."""
2
+
3
+ import typer
4
+ from typing import Optional
5
+
6
+ app = typer.Typer(help="Notion tools")
7
+
8
+
9
+ @app.command("search")
10
+ def search(
11
+ query: str = typer.Argument(..., help="Search query"),
12
+ filter_type: Optional[str] = typer.Option(None, help="Filter by type (page, database)"),
13
+ ):
14
+ """Search Notion workspace."""
15
+ from oto.tools.notion.lib.notion_client import NotionClient
16
+ import json
17
+
18
+ client = NotionClient()
19
+ results = client.search(query, filter_type=filter_type)
20
+ print(json.dumps(results, indent=2, ensure_ascii=False))
21
+
22
+
23
+ @app.command("page")
24
+ def page(
25
+ page_id: str = typer.Argument(..., help="Notion page ID"),
26
+ blocks: bool = typer.Option(False, "--blocks", "-b", help="Include page blocks/content"),
27
+ ):
28
+ """Get a Notion page."""
29
+ from oto.tools.notion.lib.notion_client import NotionClient
30
+ import json
31
+
32
+ client = NotionClient()
33
+ page = client.get_page(page_id)
34
+
35
+ if blocks:
36
+ page["blocks"] = client.get_page_blocks(page_id, recursive=True)
37
+
38
+ print(json.dumps(page, indent=2, ensure_ascii=False))
39
+
40
+
41
+ @app.command("database")
42
+ def database(
43
+ database_id: str = typer.Argument(..., help="Notion database ID"),
44
+ query: bool = typer.Option(False, "--query", "-q", help="Query database entries"),
45
+ limit: int = typer.Option(100, help="Max results when querying"),
46
+ ):
47
+ """Get a Notion database schema or query its entries."""
48
+ from oto.tools.notion.lib.notion_client import NotionClient
49
+ import json
50
+
51
+ client = NotionClient()
52
+
53
+ if query:
54
+ results = client.query_database(database_id, page_size=limit)
55
+ print(json.dumps(results, indent=2, ensure_ascii=False))
56
+ else:
57
+ db = client.get_database(database_id)
58
+ print(json.dumps(db, indent=2, ensure_ascii=False))