wealthbox-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 (54) hide show
  1. wealthbox_cli-1.0.0.dist-info/METADATA +230 -0
  2. wealthbox_cli-1.0.0.dist-info/RECORD +54 -0
  3. wealthbox_cli-1.0.0.dist-info/WHEEL +4 -0
  4. wealthbox_cli-1.0.0.dist-info/entry_points.txt +3 -0
  5. wealthbox_cli-1.0.0.dist-info/licenses/LICENSE +201 -0
  6. wealthbox_tools/__init__.py +51 -0
  7. wealthbox_tools/cli/__init__.py +3 -0
  8. wealthbox_tools/cli/_config.py +43 -0
  9. wealthbox_tools/cli/_util.py +315 -0
  10. wealthbox_tools/cli/activity.py +42 -0
  11. wealthbox_tools/cli/categories.py +41 -0
  12. wealthbox_tools/cli/comments.py +51 -0
  13. wealthbox_tools/cli/config.py +47 -0
  14. wealthbox_tools/cli/contacts.py +412 -0
  15. wealthbox_tools/cli/events.py +168 -0
  16. wealthbox_tools/cli/households.py +41 -0
  17. wealthbox_tools/cli/main.py +55 -0
  18. wealthbox_tools/cli/me.py +25 -0
  19. wealthbox_tools/cli/notes.py +97 -0
  20. wealthbox_tools/cli/opportunities.py +198 -0
  21. wealthbox_tools/cli/projects.py +102 -0
  22. wealthbox_tools/cli/tasks.py +180 -0
  23. wealthbox_tools/cli/users.py +24 -0
  24. wealthbox_tools/cli/workflows.py +172 -0
  25. wealthbox_tools/client/__init__.py +45 -0
  26. wealthbox_tools/client/activity.py +13 -0
  27. wealthbox_tools/client/base.py +216 -0
  28. wealthbox_tools/client/categories.py +14 -0
  29. wealthbox_tools/client/comments.py +14 -0
  30. wealthbox_tools/client/contacts.py +44 -0
  31. wealthbox_tools/client/events.py +31 -0
  32. wealthbox_tools/client/households.py +30 -0
  33. wealthbox_tools/client/me.py +11 -0
  34. wealthbox_tools/client/notes.py +28 -0
  35. wealthbox_tools/client/opportunities.py +31 -0
  36. wealthbox_tools/client/projects.py +28 -0
  37. wealthbox_tools/client/tasks.py +31 -0
  38. wealthbox_tools/client/users.py +14 -0
  39. wealthbox_tools/client/workflows.py +44 -0
  40. wealthbox_tools/models/__init__.py +134 -0
  41. wealthbox_tools/models/activity.py +13 -0
  42. wealthbox_tools/models/comments.py +13 -0
  43. wealthbox_tools/models/common.py +123 -0
  44. wealthbox_tools/models/contacts.py +131 -0
  45. wealthbox_tools/models/custom_fields.py +19 -0
  46. wealthbox_tools/models/enums.py +209 -0
  47. wealthbox_tools/models/events.py +63 -0
  48. wealthbox_tools/models/households.py +17 -0
  49. wealthbox_tools/models/notes.py +28 -0
  50. wealthbox_tools/models/opportunities.py +48 -0
  51. wealthbox_tools/models/projects.py +27 -0
  52. wealthbox_tools/models/tasks.py +76 -0
  53. wealthbox_tools/models/workflows.py +50 -0
  54. wealthbox_tools/py.typed +0 -0
@@ -0,0 +1,412 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ import typer
7
+
8
+ from wealthbox_tools.models import (
9
+ CategoryType,
10
+ ContactCreateInput,
11
+ ContactListQuery,
12
+ ContactsOrder,
13
+ ContactUpdateInput,
14
+ Gender,
15
+ HouseholdTitle,
16
+ MaritalStatus,
17
+ RecordType,
18
+ )
19
+
20
+ from ._util import (
21
+ OutputFormat,
22
+ active_to_status,
23
+ handle_errors,
24
+ make_category_command,
25
+ output_result,
26
+ parse_more_fields,
27
+ run_client,
28
+ )
29
+
30
+ app = typer.Typer(
31
+ context_settings={"help_option_names": ["-h", "--help"]},
32
+ help="Manage Wealthbox contacts.",
33
+ no_args_is_help=True,
34
+ )
35
+
36
+ _DEFAULT_FIELDS = ["id", "name", "type", "contact_type", "assigned_to", "status"]
37
+
38
+ # -- categories sub-app -------------------------------------------------------
39
+ categories_app = typer.Typer(
40
+ context_settings={"help_option_names": ["-h", "--help"]},
41
+ help="List available category values for contact fields.",
42
+ no_args_is_help=True,
43
+ )
44
+ categories_app.command("contact-types", help="List contact type options.")(
45
+ make_category_command(CategoryType.CONTACT_TYPES)
46
+ )
47
+ categories_app.command("contact-sources", help="List contact source options.")(
48
+ make_category_command(CategoryType.CONTACT_SOURCES)
49
+ )
50
+ categories_app.command("email-types", help="List email type options.")(make_category_command(CategoryType.EMAIL_TYPES))
51
+ categories_app.command("phone-types", help="List phone type options.")(make_category_command(CategoryType.PHONE_TYPES))
52
+ categories_app.command("address-types", help="List address type options.")(
53
+ make_category_command(CategoryType.ADDRESS_TYPES)
54
+ )
55
+ categories_app.command("website-types", help="List website type options.")(
56
+ make_category_command(CategoryType.WEBSITE_TYPES)
57
+ )
58
+ categories_app.command("contact-roles", help="List contact role options.")(
59
+ make_category_command(CategoryType.CONTACT_ROLES)
60
+ )
61
+ app.add_typer(categories_app, name="categories")
62
+
63
+
64
+ @app.command("list", help="List contacts with optional filters.")
65
+ @handle_errors
66
+ def list_contacts(
67
+ type_: RecordType | None = typer.Option(
68
+ None, "--type", help="Record Type - Person, Household, Organization, or Trust"
69
+ ),
70
+ name: str | None = typer.Option(None, help="Filter by name - Contains"),
71
+ email: str | None = typer.Option(None, help="Filter by email - Full Match"),
72
+ phone: str | None = typer.Option(None, help="Filter by phone - Full Match - Parsing handled by Wealthbox"),
73
+ contact_type: str | None = typer.Option(
74
+ None, "--contact-type", help="Client, Prospect, Vendor, etc. - see wbox contacts categories contact-types"
75
+ ),
76
+ active: bool | None = typer.Option(None, "--active/--inactive", help="Filter by active status"),
77
+ deleted: bool | None = typer.Option(
78
+ None, "--deleted", help="Filter to deleted contacts only (omit to see non-deleted, which is the API default)"
79
+ ),
80
+ household_title: HouseholdTitle | None = typer.Option(
81
+ None, help="The household title you wish to filter the household title"
82
+ ),
83
+ tags: str | None = typer.Option(None, help="Comma-separated tags"),
84
+ order: ContactsOrder = typer.Option(ContactsOrder.ASC, help="The order that the contacts should be returned in"),
85
+ updated_since: str | None = typer.Option(None, "--updated-since", help="Format of 'YYYY-MM-DD 07:00 AM -0700'"),
86
+ updated_before: str | None = typer.Option(None, "--updated-before", help="Format of 'YYYY-MM-DD 07:00 AM -0700'"),
87
+ deleted_since: str | None = typer.Option(
88
+ None, help="Only returns deleted contacts that were deleted on or after this timestamp"
89
+ ),
90
+ assigned_to: int | None = typer.Option(
91
+ None, "--assigned-to", help="Filter by assigned user ID (client-side scan — fetches all pages)."
92
+ ),
93
+ page: int | None = typer.Option(None, help="Page number"),
94
+ per_page: int | None = typer.Option(None, "--per-page", help="Results per page (max 100)"),
95
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show all fields"),
96
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
97
+ fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
98
+ ) -> None:
99
+ if assigned_to is not None:
100
+ if page is not None or per_page is not None:
101
+ typer.echo("Warning: --page and --per-page are ignored when --assigned-to is active.", err=True)
102
+ typer.echo("Note: --assigned-to requires fetching all contacts. This may take a moment.", err=True)
103
+
104
+ tag_list = [t.strip() for t in tags.split(",")] if tags else None
105
+ query = ContactListQuery(
106
+ type=type_,
107
+ name=name,
108
+ email=email,
109
+ phone=phone,
110
+ contact_type=contact_type,
111
+ active=active,
112
+ deleted=deleted,
113
+ household_title=household_title,
114
+ tags=tag_list,
115
+ order=order,
116
+ updated_since=updated_since,
117
+ updated_before=updated_before,
118
+ deleted_since=deleted_since,
119
+ page=None if assigned_to is not None else page,
120
+ per_page=None if assigned_to is not None else per_page,
121
+ )
122
+
123
+ if assigned_to is not None:
124
+ def _progress(page_num: int, total_fetched: int) -> None:
125
+ typer.echo(f"Scanning page {page_num}... ({total_fetched} fetched so far)", err=True)
126
+
127
+ raw = run_client(token, lambda c: c.list_all_contacts(query, on_progress=_progress))
128
+ matched = [c for c in raw.get("contacts", []) if c.get("assigned_to") == assigned_to]
129
+ result = {"contacts": matched, "meta": {"total_count": len(matched)}}
130
+ else:
131
+ result = run_client(token, lambda c: c.list_contacts(query))
132
+
133
+ output_result(result, fmt, fields=None if verbose else _DEFAULT_FIELDS)
134
+
135
+
136
+ @app.command("get", help="Get a single contact by ID.")
137
+ @handle_errors
138
+ def get_contact(
139
+ contact_id: int = typer.Argument(..., help="Contact ID"),
140
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
141
+ fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
142
+ ) -> None:
143
+ output_result(run_client(token, lambda c: c.get_contact(contact_id)), fmt)
144
+
145
+
146
+ # -- add sub-app --------------------------------------------------------------
147
+ add_app = typer.Typer(
148
+ context_settings={"help_option_names": ["-h", "--help"]},
149
+ help="Create a new contact.",
150
+ no_args_is_help=True,
151
+ )
152
+ app.add_typer(add_app, name="add")
153
+
154
+ _PERSON_RESERVED = {
155
+ "type", "first_name", "middle_name", "last_name", "prefix", "suffix", "nickname",
156
+ "gender", "marital_status", "birth_date", "anniversary", "job_title", "company_name",
157
+ "contact_type", "contact_source", "status", "assigned_to", "email_addresses", "phone_numbers",
158
+ }
159
+ _HOUSEHOLD_RESERVED = {"type", "name", "contact_type", "contact_source", "status", "assigned_to", "email_addresses"}
160
+ _ORG_TRUST_RESERVED = _HOUSEHOLD_RESERVED | {"phone_numbers"}
161
+
162
+
163
+ def _build_contact_entry(value: str | None, kind: str | None) -> list[dict[str, Any]] | None:
164
+ if not value:
165
+ return None
166
+ entry: dict[str, Any] = {"address": value, "principal": True}
167
+ if kind:
168
+ entry["kind"] = kind
169
+ return [entry]
170
+
171
+
172
+ def _create_named_contact(
173
+ record_type: RecordType,
174
+ reserved: set[str],
175
+ name: str,
176
+ contact_type: str | None,
177
+ contact_source: str | None,
178
+ active: bool | None,
179
+ assigned_to: int | None,
180
+ email: str | None,
181
+ email_type: str | None,
182
+ phone: str | None,
183
+ phone_type: str | None,
184
+ more_fields: str | None,
185
+ token: str | None,
186
+ fmt: OutputFormat,
187
+ ) -> None:
188
+ payload: dict[str, Any] = {k: v for k, v in {
189
+ "type": record_type,
190
+ "name": name,
191
+ "contact_type": contact_type,
192
+ "contact_source": contact_source,
193
+ "status": active_to_status(active),
194
+ "assigned_to": assigned_to,
195
+ }.items() if v is not None}
196
+ emails = _build_contact_entry(email, email_type)
197
+ if emails:
198
+ payload["email_addresses"] = emails
199
+ phones = _build_contact_entry(phone, phone_type)
200
+ if phones:
201
+ payload["phone_numbers"] = phones
202
+ if more_fields:
203
+ payload.update(parse_more_fields(more_fields, reserved))
204
+ input_model = ContactCreateInput(**payload)
205
+ output_result(run_client(token, lambda c: c.create_contact(input_model)), fmt)
206
+
207
+
208
+ @add_app.command("person", help="Create a Person contact.")
209
+ @handle_errors
210
+ def add_person(
211
+ first_name: str | None = typer.Option(None, "--first-name"),
212
+ middle_name: str | None = typer.Option(None, "--middle-name"),
213
+ last_name: str | None = typer.Option(None, "--last-name"),
214
+ prefix: str | None = typer.Option(None, "--prefix"),
215
+ suffix: str | None = typer.Option(None, "--suffix"),
216
+ nickname: str | None = typer.Option(None, "--nickname"),
217
+ gender: Gender | None = typer.Option(None, "--gender"),
218
+ marital_status: MaritalStatus | None = typer.Option(None, "--marital-status"),
219
+ birth_date: str | None = typer.Option(None, "--birth-date", help="Format: YYYY-MM-DD"),
220
+ anniversary: str | None = typer.Option(None, "--anniversary", help="Format: YYYY-MM-DD"),
221
+ job_title: str | None = typer.Option(None, "--job-title"),
222
+ company_name: str | None = typer.Option(None, "--company-name"),
223
+ contact_type: str | None = typer.Option(None, "--contact-type", help="e.g. Client, Prospect"),
224
+ contact_source: str | None = typer.Option(None, "--contact-source"),
225
+ active: bool | None = typer.Option(None, "--active/--inactive", help="Set contact status to Active or Inactive"),
226
+ assigned_to: int | None = typer.Option(None, "--assigned-to", help="Assign to a user by ID"),
227
+ email: str | None = typer.Option(None, "--email", help="Primary email address"),
228
+ email_type: str | None = typer.Option(
229
+ None, "--email-type", help="Email kind (e.g. Work, Personal) — see: wbox contacts categories email-types"
230
+ ),
231
+ phone: str | None = typer.Option(None, "--phone", help="Primary phone number"),
232
+ phone_type: str | None = typer.Option(
233
+ None, "--phone-type", help="Phone kind (e.g. Work, Mobile) — see: wbox contacts categories phone-types"
234
+ ),
235
+ more_fields: str | None = typer.Option(
236
+ None, "--more-fields", help="Extra fields as JSON object (merged with flags; cannot override explicit flags)"
237
+ ),
238
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
239
+ fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
240
+ ) -> None:
241
+ payload: dict[str, Any] = {k: v for k, v in {
242
+ "type": RecordType.PERSON,
243
+ "first_name": first_name,
244
+ "middle_name": middle_name,
245
+ "last_name": last_name,
246
+ "prefix": prefix,
247
+ "suffix": suffix,
248
+ "nickname": nickname,
249
+ "gender": gender,
250
+ "marital_status": marital_status,
251
+ "birth_date": birth_date,
252
+ "anniversary": anniversary,
253
+ "job_title": job_title,
254
+ "company_name": company_name,
255
+ "contact_type": contact_type,
256
+ "contact_source": contact_source,
257
+ "status": active_to_status(active),
258
+ "assigned_to": assigned_to,
259
+ }.items() if v is not None}
260
+ emails = _build_contact_entry(email, email_type)
261
+ if emails:
262
+ payload["email_addresses"] = emails
263
+ phones = _build_contact_entry(phone, phone_type)
264
+ if phones:
265
+ payload["phone_numbers"] = phones
266
+ if more_fields:
267
+ payload.update(parse_more_fields(more_fields, _PERSON_RESERVED))
268
+ input_model = ContactCreateInput(**payload)
269
+ output_result(run_client(token, lambda c: c.create_contact(input_model)), fmt)
270
+
271
+
272
+ @add_app.command("household", help="Create a Household contact.")
273
+ @handle_errors
274
+ def add_household(
275
+ name: str = typer.Option(..., "--name", help="Household name (required)"),
276
+ contact_type: str | None = typer.Option(None, "--contact-type", help="e.g. Client, Prospect"),
277
+ contact_source: str | None = typer.Option(None, "--contact-source"),
278
+ active: bool | None = typer.Option(None, "--active/--inactive", help="Set contact status to Active or Inactive"),
279
+ assigned_to: int | None = typer.Option(None, "--assigned-to", help="Assign to a user by ID"),
280
+ email: str | None = typer.Option(None, "--email", help="Primary email address"),
281
+ email_type: str | None = typer.Option(
282
+ None, "--email-type", help="Email kind (e.g. Work, Personal) — see: wbox contacts categories email-types"
283
+ ),
284
+ more_fields: str | None = typer.Option(
285
+ None, "--more-fields", help="Extra fields as JSON object (merged with flags; cannot override explicit flags)"
286
+ ),
287
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
288
+ fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
289
+ ) -> None:
290
+ payload: dict[str, Any] = {k: v for k, v in {
291
+ "type": RecordType.HOUSEHOLD,
292
+ "name": name,
293
+ "contact_type": contact_type,
294
+ "contact_source": contact_source,
295
+ "status": active_to_status(active),
296
+ "assigned_to": assigned_to,
297
+ }.items() if v is not None}
298
+ emails = _build_contact_entry(email, email_type)
299
+ if emails:
300
+ payload["email_addresses"] = emails
301
+ if more_fields:
302
+ payload.update(parse_more_fields(more_fields, _HOUSEHOLD_RESERVED))
303
+ input_model = ContactCreateInput(**payload)
304
+ output_result(run_client(token, lambda c: c.create_contact(input_model)), fmt)
305
+
306
+
307
+ @add_app.command("org", help="Create an Organization contact.")
308
+ @handle_errors
309
+ def add_org(
310
+ name: str = typer.Option(..., "--name", help="Organization name (required)"),
311
+ contact_type: str | None = typer.Option(None, "--contact-type", help="e.g. Client, Prospect"),
312
+ contact_source: str | None = typer.Option(None, "--contact-source"),
313
+ active: bool | None = typer.Option(None, "--active/--inactive", help="Set contact status to Active or Inactive"),
314
+ assigned_to: int | None = typer.Option(None, "--assigned-to", help="Assign to a user by ID"),
315
+ email: str | None = typer.Option(None, "--email", help="Primary email address"),
316
+ email_type: str | None = typer.Option(
317
+ None, "--email-type", help="Email kind (e.g. Work, Personal) — see: wbox contacts categories email-types"
318
+ ),
319
+ phone: str | None = typer.Option(None, "--phone", help="Primary phone number"),
320
+ phone_type: str | None = typer.Option(
321
+ None, "--phone-type", help="Phone kind (e.g. Work, Mobile) — see: wbox contacts categories phone-types"
322
+ ),
323
+ more_fields: str | None = typer.Option(
324
+ None, "--more-fields", help="Extra fields as JSON object (merged with flags; cannot override explicit flags)"
325
+ ),
326
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
327
+ fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
328
+ ) -> None:
329
+ _create_named_contact(
330
+ RecordType.ORGANIZATION, _ORG_TRUST_RESERVED, name, contact_type, contact_source,
331
+ active, assigned_to, email, email_type, phone, phone_type, more_fields, token, fmt,
332
+ )
333
+
334
+
335
+ @add_app.command("trust", help="Create a Trust contact.")
336
+ @handle_errors
337
+ def add_trust(
338
+ name: str = typer.Option(..., "--name", help="Trust name (required)"),
339
+ contact_type: str | None = typer.Option(None, "--contact-type", help="e.g. Client, Prospect"),
340
+ contact_source: str | None = typer.Option(None, "--contact-source"),
341
+ active: bool | None = typer.Option(None, "--active/--inactive", help="Set contact status to Active or Inactive"),
342
+ assigned_to: int | None = typer.Option(None, "--assigned-to", help="Assign to a user by ID"),
343
+ email: str | None = typer.Option(None, "--email", help="Primary email address"),
344
+ email_type: str | None = typer.Option(
345
+ None, "--email-type", help="Email kind (e.g. Work, Personal) — see: wbox contacts categories email-types"
346
+ ),
347
+ phone: str | None = typer.Option(None, "--phone", help="Primary phone number"),
348
+ phone_type: str | None = typer.Option(
349
+ None, "--phone-type", help="Phone kind (e.g. Work, Mobile) — see: wbox contacts categories phone-types"
350
+ ),
351
+ more_fields: str | None = typer.Option(
352
+ None, "--more-fields", help="Extra fields as JSON object (merged with flags; cannot override explicit flags)"
353
+ ),
354
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
355
+ fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
356
+ ) -> None:
357
+ _create_named_contact(
358
+ RecordType.TRUST, _ORG_TRUST_RESERVED, name, contact_type, contact_source,
359
+ active, assigned_to, email, email_type, phone, phone_type, more_fields, token, fmt,
360
+ )
361
+
362
+
363
+ @app.command("update", help="Update an existing contact. Pass only the fields you want to change.")
364
+ @handle_errors
365
+ def update_contact(
366
+ contact_id: int = typer.Argument(..., help="Contact ID"),
367
+ # Advanced path for nested arrays (email_addresses, phone_numbers, etc.)
368
+ json_data: str | None = typer.Option(
369
+ None, "--json", help="Full update as JSON (for nested fields like email_addresses)"
370
+ ),
371
+ # Scalar flags
372
+ first_name: str | None = typer.Option(None, "--first-name"),
373
+ middle_name: str | None = typer.Option(None, "--middle-name"),
374
+ last_name: str | None = typer.Option(None, "--last-name"),
375
+ name: str | None = typer.Option(None, "--name", help="Full name (for Household/Org/Trust)"),
376
+ job_title: str | None = typer.Option(None, "--job-title"),
377
+ company_name: str | None = typer.Option(None, "--company-name"),
378
+ contact_type: str | None = typer.Option(None, "--contact-type", help="e.g. Client, Prospect"),
379
+ contact_source: str | None = typer.Option(None, "--contact-source"),
380
+ active: bool | None = typer.Option(None, "--active/--inactive", help="Set contact status to Active or Inactive"),
381
+ assigned_to: int | None = typer.Option(None, "--assigned-to", help="Reassign to a user by ID"),
382
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
383
+ fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
384
+ ) -> None:
385
+ if json_data is not None:
386
+ input_model = ContactUpdateInput(**json.loads(json_data))
387
+ else:
388
+ payload: dict[str, Any] = {k: v for k, v in {
389
+ "first_name": first_name,
390
+ "middle_name": middle_name,
391
+ "last_name": last_name,
392
+ "name": name,
393
+ "job_title": job_title,
394
+ "company_name": company_name,
395
+ "contact_type": contact_type,
396
+ "contact_source": contact_source,
397
+ "status": active_to_status(active),
398
+ "assigned_to": assigned_to,
399
+ }.items() if v is not None}
400
+ input_model = ContactUpdateInput(**payload)
401
+
402
+ output_result(run_client(token, lambda c: c.update_contact(contact_id, input_model)), fmt)
403
+
404
+
405
+ @app.command("delete", help="Delete an existing contact.")
406
+ @handle_errors
407
+ def delete_contact(
408
+ contact_id: int = typer.Argument(..., help="Contact ID"),
409
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
410
+ ) -> None:
411
+ run_client(token, lambda c: c.delete_contact(contact_id))
412
+ typer.echo(f"Contact {contact_id} deleted.")
@@ -0,0 +1,168 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import typer
6
+
7
+ from wealthbox_tools.models import (
8
+ CategoryType,
9
+ EventCreateInput,
10
+ EventListQuery,
11
+ EventsOrder,
12
+ EventsState,
13
+ EventUpdateInput,
14
+ TaskResourceType,
15
+ )
16
+
17
+ from ._util import OutputFormat, build_linked_to, handle_errors, make_category_command, output_result, run_client
18
+
19
+ app = typer.Typer(
20
+ context_settings={"help_option_names": ["-h", "--help"]},
21
+ help="Manage Wealthbox events.",
22
+ no_args_is_help=True,
23
+ )
24
+ app.command("categories", help="List event category options.")(make_category_command(CategoryType.EVENT_CATEGORIES))
25
+
26
+ _DEFAULT_FIELDS = ["id", "title", "starts_at", "ends_at", "state", "event_category"]
27
+
28
+
29
+ @app.command("list", help="List events with optional filters.")
30
+ @handle_errors
31
+ def list_events(
32
+ resource_id: int | None = typer.Option(None, "--resource-id", help="Filter by resource ID"),
33
+ resource_type: TaskResourceType | None = typer.Option(
34
+ None, "--resource-type", help="Supports: Contact, Project, Opportunity"
35
+ ),
36
+ start_date_min: str | None = typer.Option(
37
+ None, "--start-date-min", help="Format example: '2015-05-24 10:00 AM -0400'"
38
+ ),
39
+ start_date_max: str | None = typer.Option(
40
+ None, "--start-date-max", help="Format example: '2015-05-24 10:00 AM -0400'"
41
+ ),
42
+ order: EventsOrder | None = typer.Option(None, "--order", help="Sort order: asc, desc, recent, created"),
43
+ updated_since: str | None = typer.Option(
44
+ None, "--updated-since", help="Format example: '2015-05-24 10:00 AM -0400'"
45
+ ),
46
+ updated_before: str | None = typer.Option(
47
+ None, "--updated-before", help="Format example: '2015-05-24 10:00 AM -0400'"
48
+ ),
49
+ page: int | None = typer.Option(None, help="Page number"),
50
+ per_page: int | None = typer.Option(None, "--per-page", help="Results per page (max 100)"),
51
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show all fields"),
52
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
53
+ fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
54
+ ) -> None:
55
+ query = EventListQuery(
56
+ resource_id=resource_id,
57
+ resource_type=resource_type,
58
+ start_date_min=start_date_min,
59
+ start_date_max=start_date_max,
60
+ order=order,
61
+ updated_since=updated_since,
62
+ updated_before=updated_before,
63
+ page=page,
64
+ per_page=per_page,
65
+ )
66
+
67
+ output_result(run_client(token, lambda c: c.list_events(query)), fmt, fields=None if verbose else _DEFAULT_FIELDS)
68
+
69
+
70
+ @app.command("get", help="Get a single event by ID.")
71
+ @handle_errors
72
+ def get_event(
73
+ event_id: int = typer.Argument(..., help="Event ID"),
74
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show all fields"),
75
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
76
+ fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
77
+ ) -> None:
78
+ output_result(run_client(token, lambda c: c.get_event(event_id)), fmt, fields=None if verbose else _DEFAULT_FIELDS)
79
+
80
+
81
+ @app.command("add", help="Create a new event.")
82
+ @handle_errors
83
+ def add_event(
84
+ title: str = typer.Argument(..., help="Event title"),
85
+ starts_at: str = typer.Option(
86
+ ..., "--starts-at", help="Start datetime in ISO 8601, e.g. 2026-01-15T10:00:00-07:00"
87
+ ),
88
+ ends_at: str = typer.Option(
89
+ ..., "--ends-at", help="End datetime in ISO 8601, e.g. 2026-01-15T11:00:00-07:00"
90
+ ),
91
+ location: str | None = typer.Option(None, "--location"),
92
+ state: EventsState | None = typer.Option(
93
+ None, "--state", help="unconfirmed, confirmed, tentative, completed, cancelled"
94
+ ),
95
+ all_day: bool | None = typer.Option(None, "--all-day/--no-all-day"),
96
+ description: str | None = typer.Option(None, "--description"),
97
+ event_category: int | None = typer.Option(None, "--category", help="Event category ID"),
98
+ contact: int | None = typer.Option(None, "--contact", help="Link to a Contact by ID"),
99
+ project: int | None = typer.Option(None, "--project", help="Link to a Project by ID"),
100
+ opportunity: int | None = typer.Option(None, "--opportunity", help="Link to an Opportunity by ID"),
101
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
102
+ fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
103
+ ) -> None:
104
+ input_model = EventCreateInput(
105
+ title=title,
106
+ starts_at=starts_at,
107
+ ends_at=ends_at,
108
+ location=location,
109
+ state=state,
110
+ all_day=all_day,
111
+ description=description,
112
+ event_category=event_category,
113
+ linked_to=build_linked_to(contact, project, opportunity),
114
+ )
115
+ output_result(run_client(token, lambda c: c.create_event(input_model)), fmt)
116
+
117
+
118
+ @app.command("update", help="Update an existing event. Pass only the fields you want to change.")
119
+ @handle_errors
120
+ def update_event(
121
+ event_id: int = typer.Argument(..., help="Event ID"),
122
+ title: str | None = typer.Option(None, "--title", help="Event title"),
123
+ starts_at: str | None = typer.Option(
124
+ None, "--starts-at", help="Start datetime in ISO 8601, e.g. 2026-01-15T10:00:00-07:00"
125
+ ),
126
+ ends_at: str | None = typer.Option(
127
+ None, "--ends-at", help="End datetime in ISO 8601, e.g. 2026-01-15T11:00:00-07:00"
128
+ ),
129
+ location: str | None = typer.Option(None, "--location"),
130
+ state: EventsState | None = typer.Option(
131
+ None, "--state", help="unconfirmed, confirmed, tentative, completed, cancelled"
132
+ ),
133
+ all_day: bool | None = typer.Option(None, "--all-day/--no-all-day"),
134
+ description: str | None = typer.Option(None, "--description"),
135
+ event_category: int | None = typer.Option(None, "--category", help="Event category ID"),
136
+ contact: int | None = typer.Option(None, "--contact", help="Replace linked Contact (by ID)"),
137
+ project: int | None = typer.Option(None, "--project", help="Replace linked Project (by ID)"),
138
+ opportunity: int | None = typer.Option(None, "--opportunity", help="Replace linked Opportunity (by ID)"),
139
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
140
+ fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
141
+ ) -> None:
142
+ payload: dict[str, Any] = {k: v for k, v in {
143
+ "title": title,
144
+ "starts_at": starts_at,
145
+ "ends_at": ends_at,
146
+ "location": location,
147
+ "state": state,
148
+ "description": description,
149
+ "event_category": event_category,
150
+ }.items() if v is not None}
151
+ if all_day is not None:
152
+ payload["all_day"] = all_day
153
+ linked = build_linked_to(contact, project, opportunity)
154
+ if linked is not None:
155
+ payload["linked_to"] = linked
156
+ input_model = EventUpdateInput(**payload)
157
+
158
+ output_result(run_client(token, lambda c: c.update_event(event_id, input_model)), fmt)
159
+
160
+
161
+ @app.command("delete", help="Delete an existing event.")
162
+ @handle_errors
163
+ def delete_event(
164
+ event_id: int = typer.Argument(..., help="Event ID"),
165
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
166
+ ) -> None:
167
+ run_client(token, lambda c: c.delete_event(event_id))
168
+ typer.echo(f"Event {event_id} deleted.")
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+
5
+ from wealthbox_tools.models import HouseholdMemberInput, HouseholdTitle
6
+
7
+ from ._util import OutputFormat, handle_errors, output_result, run_client
8
+
9
+ app = typer.Typer(
10
+ context_settings={"help_option_names": ["-h", "--help"]},
11
+ help="Manage household members.",
12
+ no_args_is_help=True,
13
+ )
14
+
15
+
16
+ @app.command(
17
+ "add-member",
18
+ help="Add a member to a household. Usage: add-member <household_id> <member_id> --title <title>",
19
+ )
20
+ @handle_errors
21
+ def add_member(
22
+ household_id: int = typer.Argument(..., help="Household contact ID"),
23
+ member_id: int = typer.Argument(..., help="Member contact ID to add"),
24
+ title: HouseholdTitle = typer.Option(..., help="Household title for member (e.g. Spouse, Head)"),
25
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
26
+ fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
27
+ ) -> None:
28
+ payload = HouseholdMemberInput(id=member_id, title=title)
29
+
30
+ output_result(run_client(token, lambda c: c.add_household_member(household_id, payload)), fmt)
31
+
32
+
33
+ @app.command("remove-member", help="Remove a member from a household.")
34
+ @handle_errors
35
+ def remove_member(
36
+ household_id: int = typer.Argument(..., help="Household contact ID"),
37
+ member_id: int = typer.Argument(..., help="Member contact ID to remove"),
38
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
39
+ fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
40
+ ) -> None:
41
+ output_result(run_client(token, lambda c: c.remove_household_member(household_id, member_id)), fmt)
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib.metadata import version as _pkg_version
4
+
5
+ import typer
6
+
7
+ from .activity import app as activity_app
8
+ from .categories import app as categories_app
9
+ from .comments import app as comments_app
10
+ from .config import app as config_app
11
+ from .contacts import app as contacts_app
12
+ from .events import app as events_app
13
+ from .households import app as households_app
14
+ from .me import app as me_app
15
+ from .notes import app as notes_app
16
+ from .opportunities import app as opportunities_app
17
+ from .projects import app as projects_app
18
+ from .tasks import app as tasks_app
19
+ from .users import app as users_app
20
+ from .workflows import app as workflows_app
21
+
22
+ app = typer.Typer(context_settings={"help_option_names": ["-h", "--help"]},
23
+ name="wbox",
24
+ help="Wealthbox CRM CLI — interact with contacts, households, tasks, events, opportunities, and notes.",
25
+ no_args_is_help=True,
26
+ )
27
+
28
+ @app.callback(invoke_without_command=True)
29
+ def _main(
30
+ ctx: typer.Context,
31
+ version: bool = typer.Option(False, "--version", "-v", is_eager=True, help="Show version and exit."),
32
+ ) -> None:
33
+ if version:
34
+ typer.echo(_pkg_version("wealthbox-cli"))
35
+ raise typer.Exit()
36
+
37
+
38
+ app.add_typer(activity_app, name="activity")
39
+ app.add_typer(categories_app, name="categories")
40
+ app.add_typer(config_app, name="config")
41
+ app.add_typer(comments_app, name="comments")
42
+ app.add_typer(contacts_app, name="contacts")
43
+ app.add_typer(events_app, name="events")
44
+ app.add_typer(households_app, name="households")
45
+ app.add_typer(me_app, name="me")
46
+ app.add_typer(notes_app, name="notes")
47
+ app.add_typer(opportunities_app, name="opportunities")
48
+ app.add_typer(projects_app, name="projects")
49
+ app.add_typer(tasks_app, name="tasks")
50
+ app.add_typer(users_app, name="users")
51
+ app.add_typer(workflows_app, name="workflows")
52
+
53
+
54
+ if __name__ == "__main__":
55
+ app()