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,25 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+
5
+ from ._util import OutputFormat, handle_errors, output_result, run_client
6
+
7
+ app = typer.Typer(
8
+ context_settings={"help_option_names": ["-h", "--help"]},
9
+ help="Show info about the current authenticated user.",
10
+ )
11
+
12
+
13
+ @app.callback(
14
+ invoke_without_command=True,
15
+ help="Show info about the current authenticated user. Use subcommands for more specific info.",
16
+ )
17
+ @handle_errors
18
+ def get_me(
19
+ ctx: typer.Context,
20
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
21
+ fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
22
+ ) -> None:
23
+ if ctx.invoked_subcommand is not None:
24
+ return
25
+ output_result(run_client(token, lambda c: c.get_me()), fmt)
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import typer
6
+
7
+ from wealthbox_tools.models import NoteCreateInput, NoteListQuery, NoteResourceType, NotesOrder, NoteUpdateInput
8
+
9
+ from ._util import OutputFormat, build_linked_to, handle_errors, output_result, run_client, truncate_field
10
+
11
+ app = typer.Typer(
12
+ context_settings={"help_option_names": ["-h", "--help"]},
13
+ help="Manage Wealthbox notes.",
14
+ no_args_is_help=True,
15
+ )
16
+
17
+ _DEFAULT_FIELDS = ["id", "content", "linked_to", "creator_id", "updated_at"]
18
+ _CONTENT_PREVIEW_LEN = 500
19
+
20
+
21
+ @app.command("list", help="List notes. Can filter by linked resource and/or updated date range.")
22
+ @handle_errors
23
+ def list_notes(
24
+ contact: int | None = typer.Option(None, "--contact", help="Filter notes linked to a Contact (by ID)"),
25
+ order: NotesOrder | None = typer.Option("updated", help="Sort order: updated or created"),
26
+ updated_since: str | None = typer.Option(None, "--updated-since"),
27
+ updated_before: str | None = typer.Option(None, "--updated-before"),
28
+ page: int | None = typer.Option(None),
29
+ per_page: int | None = typer.Option(None, "--per-page", help="Results per page (max 100)"),
30
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show all fields; content is not truncated"),
31
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
32
+ fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
33
+ ) -> None:
34
+ query = NoteListQuery(
35
+ resource_id=contact,
36
+ resource_type=NoteResourceType.CONTACT if contact is not None else None,
37
+ order=order,
38
+ updated_since=updated_since,
39
+ updated_before=updated_before,
40
+ page=page,
41
+ per_page=per_page,
42
+ )
43
+
44
+ result = run_client(token, lambda c: c.list_notes(query))
45
+ if not verbose:
46
+ result = truncate_field(result, "content", _CONTENT_PREVIEW_LEN)
47
+ output_result(result, fmt, fields=None if verbose else _DEFAULT_FIELDS)
48
+
49
+
50
+ @app.command("get", help="Get a single note by ID.")
51
+ @handle_errors
52
+ def get_note(
53
+ note_id: int = typer.Argument(..., help="Note ID"),
54
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show all fields"),
55
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
56
+ fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
57
+ ) -> None:
58
+ output_result(run_client(token, lambda c: c.get_note(note_id)), fmt, fields=None if verbose else _DEFAULT_FIELDS)
59
+
60
+
61
+ @app.command("add", help="Create a new note.")
62
+ @handle_errors
63
+ def add_note(
64
+ content: str = typer.Argument(..., help="Note body text"),
65
+ contact: int | None = typer.Option(None, "--contact", help="Link to a Contact by ID"),
66
+ project: int | None = typer.Option(None, "--project", help="Link to a Project by ID"),
67
+ opportunity: int | None = typer.Option(None, "--opportunity", help="Link to an Opportunity by ID"),
68
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
69
+ fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
70
+ ) -> None:
71
+ input_model = NoteCreateInput(
72
+ content=content,
73
+ linked_to=build_linked_to(contact, project, opportunity),
74
+ )
75
+ output_result(run_client(token, lambda c: c.create_note(input_model)), fmt)
76
+
77
+
78
+ @app.command("update", help="Update an existing note. Note: the API does not support deleting notes.")
79
+ @handle_errors
80
+ def update_note(
81
+ note_id: int = typer.Argument(..., help="Note ID"),
82
+ content: str | None = typer.Option(None, "--content", help="New note body text"),
83
+ contact: int | None = typer.Option(None, "--contact", help="Replace linked Contact (by ID)"),
84
+ project: int | None = typer.Option(None, "--project", help="Replace linked Project (by ID)"),
85
+ opportunity: int | None = typer.Option(None, "--opportunity", help="Replace linked Opportunity (by ID)"),
86
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
87
+ fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
88
+ ) -> None:
89
+ payload: dict[str, Any] = {}
90
+ if content is not None:
91
+ payload["content"] = content
92
+ linked = build_linked_to(contact, project, opportunity)
93
+ if linked is not None:
94
+ payload["linked_to"] = linked
95
+ input_model = NoteUpdateInput(**payload)
96
+
97
+ output_result(run_client(token, lambda c: c.update_note(note_id, input_model)), fmt)
@@ -0,0 +1,198 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import typer
6
+
7
+ from wealthbox_tools.models import (
8
+ OpportunityAmountKind,
9
+ OpportunityCreateInput,
10
+ OpportunityListQuery,
11
+ OpportunityOrder,
12
+ OpportunityResourceType,
13
+ OpportunityUpdateInput,
14
+ )
15
+
16
+ from ._util import OutputFormat, build_linked_to, handle_errors, output_result, parse_more_fields, run_client
17
+
18
+ app = typer.Typer(
19
+ context_settings={"help_option_names": ["-h", "--help"]},
20
+ help="Manage Wealthbox opportunities.",
21
+ no_args_is_help=True,
22
+ )
23
+
24
+ _DEFAULT_FIELDS = ["id", "name", "stage", "probability", "target_close", "manager", "linked_to"]
25
+
26
+
27
+ def _build_amounts(
28
+ fee: float | None,
29
+ commission: float | None,
30
+ aum: float | None,
31
+ other: float | None,
32
+ currency: str,
33
+ ) -> list[dict[str, Any]] | None:
34
+ amounts = []
35
+ for value, kind in (
36
+ (fee, OpportunityAmountKind.FEE),
37
+ (commission, OpportunityAmountKind.COMMISSION),
38
+ (aum, OpportunityAmountKind.AUM),
39
+ (other, OpportunityAmountKind.OTHER),
40
+ ):
41
+ if value is not None:
42
+ amounts.append({"amount": value, "currency": currency, "kind": kind})
43
+ return amounts or None
44
+
45
+
46
+ @app.command("list", help="List opportunities with optional filters.")
47
+ @handle_errors
48
+ def list_opportunities(
49
+ resource_id: int | None = typer.Option(
50
+ None, "--resource-id", help="Filter by linked resource ID (requires --resource-type)"
51
+ ),
52
+ resource_type: OpportunityResourceType | None = typer.Option(
53
+ None, "--resource-type", help="Filter by linked resource type: Contact, Project"
54
+ ),
55
+ order: OpportunityOrder | None = typer.Option(None, "--order", help="Sort order: asc, desc, recent, created"),
56
+ include_closed: bool | None = typer.Option(
57
+ None, "--include-closed/--no-include-closed", help="Include closed opportunities"
58
+ ),
59
+ updated_since: str | None = typer.Option(None, "--updated-since"),
60
+ updated_before: str | None = typer.Option(None, "--updated-before"),
61
+ page: int | None = typer.Option(None),
62
+ per_page: int | None = typer.Option(None, "--per-page", help="Results per page (max 100)"),
63
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show all fields"),
64
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
65
+ fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
66
+ ) -> None:
67
+ query = OpportunityListQuery(
68
+ resource_id=resource_id,
69
+ resource_type=resource_type,
70
+ order=order,
71
+ include_closed=include_closed,
72
+ updated_since=updated_since,
73
+ updated_before=updated_before,
74
+ page=page,
75
+ per_page=per_page,
76
+ )
77
+ output_result(
78
+ run_client(token, lambda c: c.list_opportunities(query)), fmt, fields=None if verbose else _DEFAULT_FIELDS
79
+ )
80
+
81
+
82
+ @app.command("get", help="Get a single opportunity by ID.")
83
+ @handle_errors
84
+ def get_opportunity(
85
+ opportunity_id: int = typer.Argument(..., help="Opportunity ID"),
86
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
87
+ fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
88
+ ) -> None:
89
+ output_result(run_client(token, lambda c: c.get_opportunity(opportunity_id)), fmt)
90
+
91
+
92
+ @app.command("add", help="Create a new opportunity.")
93
+ @handle_errors
94
+ def add_opportunity(
95
+ name: str = typer.Argument(..., help="Opportunity name"),
96
+ target_close: str = typer.Option(..., "--target-close", help="Target close date (e.g. 2026-06-30)"),
97
+ probability: int = typer.Option(..., "--probability", help="Close probability 0–100"),
98
+ stage: int = typer.Option(..., "--stage", help="Stage ID — see: wbox categories"),
99
+ description: str | None = typer.Option(None, "--description"),
100
+ manager: int | None = typer.Option(None, "--manager", help="Assign a manager by user ID"),
101
+ visible_to: str | None = typer.Option(None, "--visible-to"),
102
+ contact: int | None = typer.Option(None, "--contact", help="Link to a Contact by ID"),
103
+ project: int | None = typer.Option(None, "--project", help="Link to a Project by ID"),
104
+ fee: float | None = typer.Option(None, "--fee", help="Fee amount"),
105
+ commission: float | None = typer.Option(None, "--commission", help="Commission amount"),
106
+ aum: float | None = typer.Option(None, "--aum", help="AUM amount"),
107
+ other_amount: float | None = typer.Option(None, "--other-amount", help="Other amount"),
108
+ currency: str = typer.Option("USD", "--currency", help="Currency code for all amounts (default: USD)"),
109
+ more_fields: str | None = typer.Option(
110
+ None, "--more-fields", help="JSON object for additional fields (e.g. custom_fields)"
111
+ ),
112
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
113
+ fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
114
+ ) -> None:
115
+ payload: dict[str, Any] = {
116
+ "name": name,
117
+ "target_close": target_close,
118
+ "probability": probability,
119
+ "stage": stage,
120
+ "description": description,
121
+ "manager": manager,
122
+ "visible_to": visible_to,
123
+ "linked_to": build_linked_to(contact, project, None),
124
+ "amounts": _build_amounts(fee, commission, aum, other_amount, currency),
125
+ }
126
+
127
+ if more_fields:
128
+ _reserved = {
129
+ "name", "target_close", "probability", "stage", "description",
130
+ "manager", "visible_to", "linked_to", "amounts",
131
+ }
132
+ payload.update(parse_more_fields(more_fields, _reserved))
133
+
134
+ input_model = OpportunityCreateInput(**{k: v for k, v in payload.items() if v is not None})
135
+ output_result(run_client(token, lambda c: c.create_opportunity(input_model)), fmt)
136
+
137
+
138
+ @app.command("update", help="Update an existing opportunity. Pass only the fields you want to change.")
139
+ @handle_errors
140
+ def update_opportunity(
141
+ opportunity_id: int = typer.Argument(..., help="Opportunity ID"),
142
+ name: str | None = typer.Option(None, "--name"),
143
+ target_close: str | None = typer.Option(None, "--target-close"),
144
+ probability: int | None = typer.Option(None, "--probability", help="Close probability 0–100"),
145
+ stage: int | None = typer.Option(None, "--stage", help="Stage ID — see: wbox categories"),
146
+ description: str | None = typer.Option(None, "--description"),
147
+ manager: int | None = typer.Option(None, "--manager"),
148
+ visible_to: str | None = typer.Option(None, "--visible-to"),
149
+ contact: int | None = typer.Option(None, "--contact", help="Replace linked Contact (by ID)"),
150
+ project: int | None = typer.Option(None, "--project", help="Replace linked Project (by ID)"),
151
+ fee: float | None = typer.Option(None, "--fee", help="Fee amount"),
152
+ commission: float | None = typer.Option(None, "--commission", help="Commission amount"),
153
+ aum: float | None = typer.Option(None, "--aum", help="AUM amount"),
154
+ other_amount: float | None = typer.Option(None, "--other-amount", help="Other amount"),
155
+ currency: str = typer.Option("USD", "--currency", help="Currency code for all amounts (default: USD)"),
156
+ more_fields: str | None = typer.Option(
157
+ None, "--more-fields", help="JSON object for additional fields (e.g. custom_fields)"
158
+ ),
159
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
160
+ fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
161
+ ) -> None:
162
+ payload: dict[str, Any] = {k: v for k, v in {
163
+ "name": name,
164
+ "target_close": target_close,
165
+ "probability": probability,
166
+ "stage": stage,
167
+ "description": description,
168
+ "manager": manager,
169
+ "visible_to": visible_to,
170
+ }.items() if v is not None}
171
+
172
+ linked = build_linked_to(contact, project, None)
173
+ if linked is not None:
174
+ payload["linked_to"] = linked
175
+
176
+ amounts = _build_amounts(fee, commission, aum, other_amount, currency)
177
+ if amounts is not None:
178
+ payload["amounts"] = amounts
179
+
180
+ if more_fields:
181
+ _update_reserved = {
182
+ "name", "target_close", "probability", "stage", "description",
183
+ "manager", "visible_to", "linked_to", "amounts",
184
+ }
185
+ payload.update(parse_more_fields(more_fields, _update_reserved))
186
+
187
+ input_model = OpportunityUpdateInput(**payload)
188
+ output_result(run_client(token, lambda c: c.update_opportunity(opportunity_id, input_model)), fmt)
189
+
190
+
191
+ @app.command("delete", help="Delete an opportunity by ID.")
192
+ @handle_errors
193
+ def delete_opportunity(
194
+ opportunity_id: int = typer.Argument(..., help="Opportunity ID"),
195
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
196
+ ) -> None:
197
+ run_client(token, lambda c: c.delete_opportunity(opportunity_id))
198
+ typer.echo(f"Opportunity {opportunity_id} deleted.")
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import typer
6
+
7
+ from wealthbox_tools.models import ProjectCreateInput, ProjectListQuery, ProjectUpdateInput
8
+
9
+ from ._util import OutputFormat, handle_errors, output_result, parse_more_fields, run_client
10
+
11
+ app = typer.Typer(
12
+ context_settings={"help_option_names": ["-h", "--help"]},
13
+ help="Manage Wealthbox projects.",
14
+ no_args_is_help=True,
15
+ )
16
+
17
+ _DEFAULT_FIELDS = ["id", "name", "description", "organizer", "updated_at"]
18
+
19
+
20
+ @app.command("list", help="List projects with optional date range filters.")
21
+ @handle_errors
22
+ def list_projects(
23
+ updated_since: str | None = typer.Option(None, "--updated-since"),
24
+ updated_before: str | None = typer.Option(None, "--updated-before"),
25
+ page: int | None = typer.Option(None),
26
+ per_page: int | None = typer.Option(None, "--per-page", help="Results per page (max 100)"),
27
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show all fields"),
28
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
29
+ fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
30
+ ) -> None:
31
+ query = ProjectListQuery(
32
+ updated_since=updated_since,
33
+ updated_before=updated_before,
34
+ page=page,
35
+ per_page=per_page,
36
+ )
37
+ output_result(run_client(token, lambda c: c.list_projects(query)), fmt, fields=None if verbose else _DEFAULT_FIELDS)
38
+
39
+
40
+ @app.command("get", help="Get a single project by ID.")
41
+ @handle_errors
42
+ def get_project(
43
+ project_id: int = typer.Argument(..., help="Project ID"),
44
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
45
+ fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
46
+ ) -> None:
47
+ output_result(run_client(token, lambda c: c.get_project(project_id)), fmt)
48
+
49
+
50
+ @app.command("add", help="Create a new project.")
51
+ @handle_errors
52
+ def add_project(
53
+ name: str = typer.Argument(..., help="Project name"),
54
+ description: str = typer.Option(..., "--description", help="Project description"),
55
+ organizer: int | None = typer.Option(None, "--organizer", help="Organizer user ID"),
56
+ visible_to: str | None = typer.Option(None, "--visible-to"),
57
+ more_fields: str | None = typer.Option(
58
+ None, "--more-fields", help="JSON object for additional fields (e.g. custom_fields)"
59
+ ),
60
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
61
+ fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
62
+ ) -> None:
63
+ payload: dict[str, Any] = {
64
+ "name": name,
65
+ "description": description,
66
+ "organizer": organizer,
67
+ "visible_to": visible_to,
68
+ }
69
+
70
+ if more_fields:
71
+ payload.update(parse_more_fields(more_fields, {"name", "description", "organizer", "visible_to"}))
72
+
73
+ input_model = ProjectCreateInput(**{k: v for k, v in payload.items() if v is not None})
74
+ output_result(run_client(token, lambda c: c.create_project(input_model)), fmt)
75
+
76
+
77
+ @app.command("update", help="Update an existing project. Pass only the fields you want to change.")
78
+ @handle_errors
79
+ def update_project(
80
+ project_id: int = typer.Argument(..., help="Project ID"),
81
+ name: str | None = typer.Option(None, "--name"),
82
+ description: str | None = typer.Option(None, "--description"),
83
+ organizer: int | None = typer.Option(None, "--organizer", help="Organizer user ID"),
84
+ visible_to: str | None = typer.Option(None, "--visible-to"),
85
+ more_fields: str | None = typer.Option(
86
+ None, "--more-fields", help="JSON object for additional fields (e.g. custom_fields)"
87
+ ),
88
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
89
+ fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
90
+ ) -> None:
91
+ payload: dict[str, Any] = {k: v for k, v in {
92
+ "name": name,
93
+ "description": description,
94
+ "organizer": organizer,
95
+ "visible_to": visible_to,
96
+ }.items() if v is not None}
97
+
98
+ if more_fields:
99
+ payload.update(parse_more_fields(more_fields, {"name", "description", "organizer", "visible_to"}))
100
+
101
+ input_model = ProjectUpdateInput(**payload)
102
+ output_result(run_client(token, lambda c: c.update_project(project_id, input_model)), fmt)
@@ -0,0 +1,180 @@
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
+ TaskCreateInput,
10
+ TaskFrame,
11
+ TaskListQuery,
12
+ TaskPriority,
13
+ TaskType,
14
+ TaskUpdateInput,
15
+ )
16
+
17
+ from ._util import (
18
+ OutputFormat,
19
+ build_linked_to,
20
+ build_resource_filter,
21
+ handle_errors,
22
+ make_category_command,
23
+ output_result,
24
+ parse_more_fields,
25
+ run_client,
26
+ )
27
+
28
+ app = typer.Typer(
29
+ context_settings={"help_option_names": ["-h", "--help"]},
30
+ help="Manage Wealthbox tasks.",
31
+ no_args_is_help=True,
32
+ )
33
+ app.command("categories", help="List task category options.")(make_category_command(CategoryType.TASK_CATEGORIES))
34
+
35
+ _DEFAULT_FIELDS = ["id", "name", "due_date", "frame", "complete", "category"]
36
+
37
+ _TASK_CREATE_RESERVED = {"name", "due_date", "frame", "priority", "assigned_to", "linked_to"}
38
+
39
+
40
+ @app.command(
41
+ "list",
42
+ help=(
43
+ "List tasks with optional filters. By default only outstanding tasks are returned; "
44
+ "use --include-completed to include completed tasks"
45
+ ),
46
+ )
47
+ @handle_errors
48
+ def list_tasks(
49
+ contact: int | None = typer.Option(None, "--contact", help="Filter tasks linked to a Contact (by ID)"),
50
+ project: int | None = typer.Option(None, "--project", help="Filter tasks linked to a Project (by ID)"),
51
+ opportunity: int | None = typer.Option(None, "--opportunity", help="Filter tasks linked to an Opportunity (by ID)"),
52
+ assigned_to: int | None = typer.Option(None, "--assigned-to", help="Filter by assigned user ID"),
53
+ assigned_to_team: int | None = typer.Option(None, "--assigned-to-team", help="Filter by assigned team ID"),
54
+ created_by: int | None = typer.Option(None, "--created-by", help="Filter by creator user ID"),
55
+ include_completed: bool = typer.Option(
56
+ False, "--include-completed", help="Include completed tasks (default returns outstanding tasks only)"
57
+ ),
58
+ task_type: TaskType | None = typer.Option(None, "--type", help="all, parents, subtasks"),
59
+ updated_since: str | None = typer.Option(None, "--updated-since"),
60
+ updated_before: str | None = typer.Option(None, "--updated-before"),
61
+ page: int | None = typer.Option(None),
62
+ per_page: int | None = typer.Option(None, "--per-page", help="Results per page (max 100)"),
63
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show all fields"),
64
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
65
+ fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
66
+ ) -> None:
67
+ resource_id, resource_type = build_resource_filter(contact, project, opportunity)
68
+
69
+ query = TaskListQuery(
70
+ resource_id=resource_id,
71
+ resource_type=resource_type,
72
+ assigned_to=assigned_to,
73
+ assigned_to_team=assigned_to_team,
74
+ created_by=created_by,
75
+ completed=True if include_completed else None,
76
+ task_type=task_type,
77
+ updated_since=updated_since,
78
+ updated_before=updated_before,
79
+ page=page,
80
+ per_page=per_page,
81
+ )
82
+
83
+ output_result(run_client(token, lambda c: c.list_tasks(query)), fmt, fields=None if verbose else _DEFAULT_FIELDS)
84
+
85
+
86
+ @app.command("get", help="Get a single task by ID.")
87
+ @handle_errors
88
+ def get_task(
89
+ task_id: int = typer.Argument(..., help="Task ID"),
90
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
91
+ fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
92
+ ) -> None:
93
+ output_result(run_client(token, lambda c: c.get_task(task_id)), fmt)
94
+
95
+
96
+ @app.command("add", help="Create a new task. Required: name, and either due_date or frame.")
97
+ @handle_errors
98
+ def add_task(
99
+ name: str = typer.Argument(..., help="Task title/name"),
100
+ due_date: str | None = typer.Option(
101
+ None, "--due-date", help="Example: '2025-05-24 10:00 AM -0700' (must match Wealthbox format)"
102
+ ),
103
+ frame: TaskFrame | None = typer.Option(None, "--frame", help="Friendly due timeframe"),
104
+ priority: TaskPriority | None = typer.Option(None, "--priority", help="Low, Medium, or High"),
105
+ assigned_to: int | None = typer.Option(None, "--assigned-to", help="Assign to a user by ID"),
106
+ contact: int | None = typer.Option(None, "--contact", help="Link to a Contact by ID"),
107
+ project: int | None = typer.Option(None, "--project", help="Link to a Project by ID"),
108
+ opportunity: int | None = typer.Option(None, "--opportunity", help="Link to an Opportunity by ID"),
109
+ more_fields: str | None = typer.Option(
110
+ None, "--more-fields",
111
+ help='JSON: {"category": 123, "complete": false, "assigned_to_team": 456, "description": "..."}',
112
+ ),
113
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
114
+ fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
115
+ ) -> None:
116
+ # Friendly CLI-level guardrail (still keep model validation too)
117
+ if (due_date is None) == (frame is None):
118
+ raise typer.BadParameter("Provide exactly one --due-date or --frame.")
119
+
120
+ payload: dict[str, Any] = {
121
+ "name": name,
122
+ "due_date": due_date,
123
+ "frame": frame,
124
+ "priority": priority,
125
+ "assigned_to": assigned_to,
126
+ "linked_to": build_linked_to(contact, project, opportunity),
127
+ }
128
+
129
+ if more_fields:
130
+ payload.update(parse_more_fields(more_fields, _TASK_CREATE_RESERVED))
131
+
132
+ # Strip None before model construction (due_date XOR frame validator needs clean input)
133
+ input_model = TaskCreateInput(**{k: v for k, v in payload.items() if v is not None})
134
+
135
+ output_result(run_client(token, lambda c: c.create_task(input_model)), fmt)
136
+
137
+
138
+ @app.command("update", help="Update an existing task. Pass only the fields you want to change.")
139
+ @handle_errors
140
+ def update_task(
141
+ task_id: int = typer.Argument(..., help="Task ID"),
142
+ name: str | None = typer.Option(None, "--name", help="Task name"),
143
+ due_date: str | None = typer.Option(None, "--due-date", help="ISO 8601 datetime, e.g. '2026-04-01T09:00:00-07:00'"),
144
+ frame: TaskFrame | None = typer.Option(None, "--frame", help="Friendly due timeframe"),
145
+ priority: TaskPriority | None = typer.Option(None, "--priority", help="Low, Medium, or High"),
146
+ assigned_to: int | None = typer.Option(None, "--assigned-to", help="Reassign to a user by ID"),
147
+ complete: bool | None = typer.Option(None, "--complete/--no-complete", help="Mark as complete or incomplete"),
148
+ description: str | None = typer.Option(None, "--description"),
149
+ contact: int | None = typer.Option(None, "--contact", help="Replace linked Contact (by ID)"),
150
+ project: int | None = typer.Option(None, "--project", help="Replace linked Project (by ID)"),
151
+ opportunity: int | None = typer.Option(None, "--opportunity", help="Replace linked Opportunity (by ID)"),
152
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
153
+ fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
154
+ ) -> None:
155
+ payload: dict[str, Any] = {k: v for k, v in {
156
+ "name": name,
157
+ "due_date": due_date,
158
+ "frame": frame,
159
+ "priority": priority,
160
+ "assigned_to": assigned_to,
161
+ "description": description,
162
+ }.items() if v is not None}
163
+ if complete is not None:
164
+ payload["complete"] = complete
165
+ linked = build_linked_to(contact, project, opportunity)
166
+ if linked is not None:
167
+ payload["linked_to"] = linked
168
+ input_model = TaskUpdateInput(**payload)
169
+
170
+ output_result(run_client(token, lambda c: c.update_task(task_id, input_model)), fmt)
171
+
172
+
173
+ @app.command("delete", help="Delete a task by ID.")
174
+ @handle_errors
175
+ def delete_task(
176
+ task_id: int = typer.Argument(..., help="Task ID"),
177
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
178
+ ) -> None:
179
+ run_client(token, lambda c: c.delete_task(task_id))
180
+ typer.echo(f"Task {task_id} deleted.")
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+
5
+ from ._util import OutputFormat, handle_errors, output_result, run_client
6
+
7
+ app = typer.Typer(
8
+ context_settings={"help_option_names": ["-h", "--help"]},
9
+ help="Manage Wealthbox users.",
10
+ no_args_is_help=True,
11
+ )
12
+
13
+
14
+ _DEFAULT_FIELDS = ["id", "name", "email"]
15
+
16
+
17
+ @app.command("list", help="List users.")
18
+ @handle_errors
19
+ def list_users(
20
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show all fields"),
21
+ token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
22
+ fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
23
+ ) -> None:
24
+ output_result(run_client(token, lambda c: c.list_users()), fmt, fields=None if verbose else _DEFAULT_FIELDS)