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.
- wealthbox_cli-1.0.0.dist-info/METADATA +230 -0
- wealthbox_cli-1.0.0.dist-info/RECORD +54 -0
- wealthbox_cli-1.0.0.dist-info/WHEEL +4 -0
- wealthbox_cli-1.0.0.dist-info/entry_points.txt +3 -0
- wealthbox_cli-1.0.0.dist-info/licenses/LICENSE +201 -0
- wealthbox_tools/__init__.py +51 -0
- wealthbox_tools/cli/__init__.py +3 -0
- wealthbox_tools/cli/_config.py +43 -0
- wealthbox_tools/cli/_util.py +315 -0
- wealthbox_tools/cli/activity.py +42 -0
- wealthbox_tools/cli/categories.py +41 -0
- wealthbox_tools/cli/comments.py +51 -0
- wealthbox_tools/cli/config.py +47 -0
- wealthbox_tools/cli/contacts.py +412 -0
- wealthbox_tools/cli/events.py +168 -0
- wealthbox_tools/cli/households.py +41 -0
- wealthbox_tools/cli/main.py +55 -0
- wealthbox_tools/cli/me.py +25 -0
- wealthbox_tools/cli/notes.py +97 -0
- wealthbox_tools/cli/opportunities.py +198 -0
- wealthbox_tools/cli/projects.py +102 -0
- wealthbox_tools/cli/tasks.py +180 -0
- wealthbox_tools/cli/users.py +24 -0
- wealthbox_tools/cli/workflows.py +172 -0
- wealthbox_tools/client/__init__.py +45 -0
- wealthbox_tools/client/activity.py +13 -0
- wealthbox_tools/client/base.py +216 -0
- wealthbox_tools/client/categories.py +14 -0
- wealthbox_tools/client/comments.py +14 -0
- wealthbox_tools/client/contacts.py +44 -0
- wealthbox_tools/client/events.py +31 -0
- wealthbox_tools/client/households.py +30 -0
- wealthbox_tools/client/me.py +11 -0
- wealthbox_tools/client/notes.py +28 -0
- wealthbox_tools/client/opportunities.py +31 -0
- wealthbox_tools/client/projects.py +28 -0
- wealthbox_tools/client/tasks.py +31 -0
- wealthbox_tools/client/users.py +14 -0
- wealthbox_tools/client/workflows.py +44 -0
- wealthbox_tools/models/__init__.py +134 -0
- wealthbox_tools/models/activity.py +13 -0
- wealthbox_tools/models/comments.py +13 -0
- wealthbox_tools/models/common.py +123 -0
- wealthbox_tools/models/contacts.py +131 -0
- wealthbox_tools/models/custom_fields.py +19 -0
- wealthbox_tools/models/enums.py +209 -0
- wealthbox_tools/models/events.py +63 -0
- wealthbox_tools/models/households.py +17 -0
- wealthbox_tools/models/notes.py +28 -0
- wealthbox_tools/models/opportunities.py +48 -0
- wealthbox_tools/models/projects.py +27 -0
- wealthbox_tools/models/tasks.py +76 -0
- wealthbox_tools/models/workflows.py +50 -0
- wealthbox_tools/py.typed +0 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import csv
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from functools import wraps
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from pydantic import ValidationError
|
|
14
|
+
|
|
15
|
+
from wealthbox_tools.client import WealthboxAPIError, WealthboxClient
|
|
16
|
+
from wealthbox_tools.models import CategoryListQuery, CategoryType, LinkedToRef, TaskResourceType
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_client(token: str | None = None) -> WealthboxClient:
|
|
20
|
+
"""Create a WealthboxClient with token resolution: --token flag > env var > config file > .env."""
|
|
21
|
+
import os
|
|
22
|
+
if token is None:
|
|
23
|
+
token = os.environ.get("WEALTHBOX_TOKEN")
|
|
24
|
+
if token is None:
|
|
25
|
+
from ._config import get_stored_token
|
|
26
|
+
token = get_stored_token()
|
|
27
|
+
if token is None:
|
|
28
|
+
try:
|
|
29
|
+
from dotenv import load_dotenv
|
|
30
|
+
load_dotenv()
|
|
31
|
+
token = os.environ.get("WEALTHBOX_TOKEN")
|
|
32
|
+
except ImportError:
|
|
33
|
+
pass
|
|
34
|
+
return WealthboxClient(token=token)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def run_client(token: str | None, fn: Callable[[WealthboxClient], Awaitable[Any]]) -> Any:
|
|
38
|
+
"""Run an async client operation, managing the event loop and client lifecycle."""
|
|
39
|
+
async def _execute() -> Any:
|
|
40
|
+
async with get_client(token) as client:
|
|
41
|
+
return await fn(client)
|
|
42
|
+
return asyncio.run(_execute())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class OutputFormat(StrEnum):
|
|
46
|
+
JSON = "json"
|
|
47
|
+
TABLE = "table"
|
|
48
|
+
CSV = "csv"
|
|
49
|
+
TSV = "tsv"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _filter_fields(data: Any, fields: list[str]) -> Any:
|
|
53
|
+
if isinstance(data, list):
|
|
54
|
+
return [{f: item[f] for f in fields if f in item} for item in data]
|
|
55
|
+
if isinstance(data, dict):
|
|
56
|
+
if "meta" in data:
|
|
57
|
+
return {
|
|
58
|
+
k: ([{f: item[f] for f in fields if f in item} for item in v] if isinstance(v, list) else v)
|
|
59
|
+
for k, v in data.items()
|
|
60
|
+
}
|
|
61
|
+
return {f: data[f] for f in fields if f in data}
|
|
62
|
+
return data
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def truncate_nested_field(data: Any, parent: str, fields: list[str], max_len: int) -> Any:
|
|
66
|
+
"""Truncate one or more string fields nested inside a dict field in each item of a response."""
|
|
67
|
+
def _trim(item: Any) -> Any:
|
|
68
|
+
if not (isinstance(item, dict) and isinstance(item.get(parent), dict)):
|
|
69
|
+
return item
|
|
70
|
+
nested = item[parent]
|
|
71
|
+
updates = {
|
|
72
|
+
f: nested[f][:max_len] + "..."
|
|
73
|
+
for f in fields
|
|
74
|
+
if f in nested and isinstance(nested[f], str) and len(nested[f]) > max_len
|
|
75
|
+
}
|
|
76
|
+
if updates:
|
|
77
|
+
item = {**item, parent: {**nested, **updates}}
|
|
78
|
+
return item
|
|
79
|
+
|
|
80
|
+
if isinstance(data, list):
|
|
81
|
+
return [_trim(item) for item in data]
|
|
82
|
+
if isinstance(data, dict):
|
|
83
|
+
return {k: [_trim(i) for i in v] if isinstance(v, list) else v for k, v in data.items()}
|
|
84
|
+
return _trim(data)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def truncate_field(data: Any, field: str, max_len: int) -> Any:
|
|
88
|
+
"""Truncate a string field in each item of a response to max_len characters."""
|
|
89
|
+
def _trim(item: Any) -> Any:
|
|
90
|
+
if isinstance(item, dict) and field in item and isinstance(item[field], str):
|
|
91
|
+
if len(item[field]) > max_len:
|
|
92
|
+
item = {**item, field: item[field][:max_len] + "..."}
|
|
93
|
+
return item
|
|
94
|
+
|
|
95
|
+
if isinstance(data, list):
|
|
96
|
+
return [_trim(item) for item in data]
|
|
97
|
+
if isinstance(data, dict):
|
|
98
|
+
return {k: [_trim(i) for i in v] if isinstance(v, list) else v for k, v in data.items()}
|
|
99
|
+
return _trim(data)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _flatten_value(value: Any) -> Any:
|
|
103
|
+
"""Convert a single field value to a scalar suitable for tabular display."""
|
|
104
|
+
if value is None:
|
|
105
|
+
return ""
|
|
106
|
+
if isinstance(value, list):
|
|
107
|
+
if not value:
|
|
108
|
+
return ""
|
|
109
|
+
first = value[0]
|
|
110
|
+
if isinstance(first, str):
|
|
111
|
+
# e.g. tags: ["VIP", "Prospect"]
|
|
112
|
+
return ", ".join(str(v) for v in value)
|
|
113
|
+
if isinstance(first, dict):
|
|
114
|
+
if "address" in first:
|
|
115
|
+
# e.g. email_addresses, phone_numbers — return principal's address
|
|
116
|
+
for item in value:
|
|
117
|
+
if item.get("principal"):
|
|
118
|
+
return item["address"]
|
|
119
|
+
return first["address"]
|
|
120
|
+
if "id" in first and "type" in first:
|
|
121
|
+
# e.g. linked_to, invitees
|
|
122
|
+
return f"{first['type']}:{first['id']}"
|
|
123
|
+
return f"[{len(value)} items]"
|
|
124
|
+
if isinstance(value, dict):
|
|
125
|
+
return json.dumps(value)
|
|
126
|
+
return value
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _flatten_record(record: dict) -> dict: # type: ignore[type-arg]
|
|
130
|
+
return {k: _flatten_value(v) for k, v in record.items()}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _extract_collection(data: Any) -> tuple[list[dict] | None, int | None]: # type: ignore[type-arg]
|
|
134
|
+
"""Return (rows, total_count) or (None, None) for a single-object response."""
|
|
135
|
+
if isinstance(data, list):
|
|
136
|
+
return data, None
|
|
137
|
+
if isinstance(data, dict):
|
|
138
|
+
if "meta" in data:
|
|
139
|
+
rows: list[dict] = [] # type: ignore[type-arg]
|
|
140
|
+
for k, v in data.items():
|
|
141
|
+
if k != "meta" and isinstance(v, list):
|
|
142
|
+
rows = v
|
|
143
|
+
break
|
|
144
|
+
total = data["meta"].get("total_count") or data["meta"].get("total_entries")
|
|
145
|
+
return rows, total
|
|
146
|
+
# Check if any value is a list (collection without meta)
|
|
147
|
+
for v in data.values():
|
|
148
|
+
if isinstance(v, list) and v and isinstance(v[0], dict):
|
|
149
|
+
return v, None
|
|
150
|
+
return None, None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _render_table(rows: list[dict], headers: list[str], total_count: int | None) -> tuple[str, str | None]: # type: ignore[type-arg]
|
|
154
|
+
from tabulate import tabulate
|
|
155
|
+
table_data = [[row.get(h, "") for h in headers] for row in rows]
|
|
156
|
+
rendered = tabulate(table_data, headers=headers, tablefmt="simple_grid")
|
|
157
|
+
footer = f"Showing {len(rows)} of {total_count} results" if total_count is not None else None
|
|
158
|
+
return rendered, footer
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _render_kv_table(record: dict) -> str: # type: ignore[type-arg]
|
|
162
|
+
from tabulate import tabulate
|
|
163
|
+
return tabulate(list(record.items()), headers=["Field", "Value"], tablefmt="simple_grid")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _render_dsv(rows: list[dict], headers: list[str], sep: str) -> str: # type: ignore[type-arg]
|
|
167
|
+
buf = io.StringIO()
|
|
168
|
+
writer = csv.writer(buf, delimiter=sep)
|
|
169
|
+
writer.writerow(headers)
|
|
170
|
+
for row in rows:
|
|
171
|
+
writer.writerow([row.get(h, "") for h in headers])
|
|
172
|
+
return buf.getvalue()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def output_result(data: Any, fmt: OutputFormat = OutputFormat.JSON, fields: list[str] | None = None) -> None:
|
|
176
|
+
"""Print result to stdout in the requested format."""
|
|
177
|
+
if fields is not None:
|
|
178
|
+
data = _filter_fields(data, fields)
|
|
179
|
+
if fmt == OutputFormat.JSON:
|
|
180
|
+
typer.echo(json.dumps(data, indent=2, default=str))
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
# Tabular formats
|
|
184
|
+
sep = "\t" if fmt == OutputFormat.TSV else ","
|
|
185
|
+
rows, total_count = _extract_collection(data)
|
|
186
|
+
if rows is None:
|
|
187
|
+
# Single object
|
|
188
|
+
record = _flatten_record(data if isinstance(data, dict) else {"value": data})
|
|
189
|
+
if fmt == OutputFormat.TABLE:
|
|
190
|
+
typer.echo(_render_kv_table(record))
|
|
191
|
+
else:
|
|
192
|
+
typer.echo(_render_dsv([record], list(record.keys()), sep), nl=False)
|
|
193
|
+
else:
|
|
194
|
+
flat_rows = [_flatten_record(r) for r in rows]
|
|
195
|
+
headers = list(flat_rows[0].keys()) if flat_rows else []
|
|
196
|
+
if fmt == OutputFormat.TABLE:
|
|
197
|
+
rendered, footer = _render_table(flat_rows, headers, total_count)
|
|
198
|
+
typer.echo(rendered)
|
|
199
|
+
if footer:
|
|
200
|
+
typer.echo(footer, err=True)
|
|
201
|
+
else:
|
|
202
|
+
typer.echo(_render_dsv(flat_rows, headers, sep), nl=False)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def handle_errors(func): # type: ignore[no-untyped-def]
|
|
206
|
+
@wraps(func)
|
|
207
|
+
def wrapper(*args, **kwargs): # type: ignore[no-untyped-def]
|
|
208
|
+
try:
|
|
209
|
+
return func(*args, **kwargs)
|
|
210
|
+
|
|
211
|
+
except WealthboxAPIError as e:
|
|
212
|
+
typer.echo(f"API Error ({e.status_code}): {e.detail}", err=True)
|
|
213
|
+
raise typer.Exit(code=1)
|
|
214
|
+
|
|
215
|
+
except ValidationError as e:
|
|
216
|
+
typer.echo("Validation Error:", err=True)
|
|
217
|
+
for err_item in e.errors():
|
|
218
|
+
loc = " -> ".join(str(x) for x in err_item["loc"])
|
|
219
|
+
msg = err_item["msg"]
|
|
220
|
+
typer.echo(f" {loc}: {msg}", err=True)
|
|
221
|
+
raise typer.Exit(code=1)
|
|
222
|
+
|
|
223
|
+
except json.JSONDecodeError as e:
|
|
224
|
+
typer.echo(f"Invalid JSON: {e}", err=True)
|
|
225
|
+
raise typer.Exit(code=1)
|
|
226
|
+
|
|
227
|
+
except ValueError as e:
|
|
228
|
+
typer.echo(f"Error: {e}", err=True)
|
|
229
|
+
raise typer.Exit(code=1)
|
|
230
|
+
|
|
231
|
+
except Exception as e:
|
|
232
|
+
typer.echo(f"Unexpected error: {e}", err=True)
|
|
233
|
+
raise typer.Exit(code=1)
|
|
234
|
+
|
|
235
|
+
return wrapper
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def active_to_status(active: bool | None) -> str | None:
|
|
239
|
+
"""Map --active/--inactive flag to Wealthbox status string."""
|
|
240
|
+
if active is True:
|
|
241
|
+
return "Active"
|
|
242
|
+
if active is False:
|
|
243
|
+
return "Inactive"
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def build_linked_to(
|
|
248
|
+
contact: int | None,
|
|
249
|
+
project: int | None,
|
|
250
|
+
opportunity: int | None,
|
|
251
|
+
) -> list[LinkedToRef] | None:
|
|
252
|
+
"""Build a linked_to list from typed ID options. Returns None if none provided."""
|
|
253
|
+
refs: list[LinkedToRef] = []
|
|
254
|
+
if contact is not None:
|
|
255
|
+
refs.append(LinkedToRef(id=contact, type="Contact"))
|
|
256
|
+
if project is not None:
|
|
257
|
+
refs.append(LinkedToRef(id=project, type="Project"))
|
|
258
|
+
if opportunity is not None:
|
|
259
|
+
refs.append(LinkedToRef(id=opportunity, type="Opportunity"))
|
|
260
|
+
return refs if refs else None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def build_resource_filter(
|
|
264
|
+
contact: int | None,
|
|
265
|
+
project: int | None,
|
|
266
|
+
opportunity: int | None,
|
|
267
|
+
) -> tuple[int | None, TaskResourceType | None]:
|
|
268
|
+
"""Map friendly --contact/--project/--opportunity options to (resource_id, resource_type).
|
|
269
|
+
|
|
270
|
+
Raises BadParameter if more than one is provided.
|
|
271
|
+
Returns (None, None) if none are provided.
|
|
272
|
+
"""
|
|
273
|
+
result: tuple[int | None, TaskResourceType | None] = (None, None)
|
|
274
|
+
for id_, rtype in (
|
|
275
|
+
(contact, TaskResourceType.CONTACT),
|
|
276
|
+
(project, TaskResourceType.PROJECT),
|
|
277
|
+
(opportunity, TaskResourceType.OPPORTUNITY),
|
|
278
|
+
):
|
|
279
|
+
if id_ is not None:
|
|
280
|
+
if result[0] is not None:
|
|
281
|
+
raise typer.BadParameter("Provide only one of --contact, --project, or --opportunity.")
|
|
282
|
+
result = (id_, rtype)
|
|
283
|
+
return result
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def parse_more_fields(more_fields: str, reserved: set[str]) -> dict[str, Any]:
|
|
287
|
+
"""Parse --more-fields JSON and validate against reserved keys.
|
|
288
|
+
|
|
289
|
+
Raises BadParameter if the value is not valid JSON, not an object, or collides with
|
|
290
|
+
a reserved key that has an explicit CLI flag.
|
|
291
|
+
"""
|
|
292
|
+
try:
|
|
293
|
+
extra = json.loads(more_fields)
|
|
294
|
+
except json.JSONDecodeError as e:
|
|
295
|
+
raise typer.BadParameter(f"--more-fields must be valid JSON: {e.msg}") from e
|
|
296
|
+
if not isinstance(extra, dict):
|
|
297
|
+
raise typer.BadParameter("--more-fields must be a JSON object (e.g. {...}), not a list or string.")
|
|
298
|
+
collision = reserved.intersection(extra.keys())
|
|
299
|
+
if collision:
|
|
300
|
+
raise typer.BadParameter(f"--more-fields cannot include {sorted(collision)}; use explicit CLI args instead.")
|
|
301
|
+
return extra
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def make_category_command(category_type: CategoryType): # type: ignore[no-untyped-def]
|
|
305
|
+
"""Factory that returns a Typer command function for listing a category type."""
|
|
306
|
+
@handle_errors
|
|
307
|
+
def cmd(
|
|
308
|
+
page: int | None = typer.Option(None, help="Page number"),
|
|
309
|
+
per_page: int | None = typer.Option(None, "--per-page", help="Results per page (max 100)"),
|
|
310
|
+
token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
|
|
311
|
+
fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
|
|
312
|
+
) -> None:
|
|
313
|
+
query = CategoryListQuery(page=page, per_page=per_page)
|
|
314
|
+
output_result(run_client(token, lambda c: c.list_categories(category_type, query)), fmt)
|
|
315
|
+
return cmd
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from wealthbox_tools.models import ActivityListQuery, ActivityType
|
|
6
|
+
|
|
7
|
+
from ._util import OutputFormat, handle_errors, output_result, run_client, truncate_field
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(
|
|
10
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
11
|
+
help="List Wealthbox activity feed.",
|
|
12
|
+
no_args_is_help=True,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
_BODY_PREVIEW_LEN = 500
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@app.command("list", help="List activity feed. Can filter by contact, activity type, and/or updated date range.")
|
|
19
|
+
@handle_errors
|
|
20
|
+
def list_activity(
|
|
21
|
+
contact: int | None = typer.Option(None, help="Filter by contact ID"),
|
|
22
|
+
cursor: str | None = typer.Option(None, help="Cursor for next page of results"),
|
|
23
|
+
type_: ActivityType | None = typer.Option(None, "--type", help="Activity type filter"),
|
|
24
|
+
updated_since: str | None = typer.Option(None, "--updated-since"),
|
|
25
|
+
updated_before: str | None = typer.Option(None, "--updated-before"),
|
|
26
|
+
verbose: bool = typer.Option(
|
|
27
|
+
False, "--verbose", "-v", help="Show full body content (default truncates to 500 chars)"
|
|
28
|
+
),
|
|
29
|
+
token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
|
|
30
|
+
fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
|
|
31
|
+
) -> None:
|
|
32
|
+
query = ActivityListQuery(
|
|
33
|
+
contact=contact,
|
|
34
|
+
cursor=cursor,
|
|
35
|
+
type=type_,
|
|
36
|
+
updated_since=updated_since,
|
|
37
|
+
updated_before=updated_before,
|
|
38
|
+
)
|
|
39
|
+
result = run_client(token, lambda c: c.list_activity(query))
|
|
40
|
+
if not verbose:
|
|
41
|
+
result = truncate_field(result, "body", _BODY_PREVIEW_LEN)
|
|
42
|
+
output_result(result, fmt)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from wealthbox_tools.models import CategoryListQuery, CategoryType, DocumentType
|
|
6
|
+
|
|
7
|
+
from ._util import OutputFormat, handle_errors, make_category_command, output_result, run_client
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(
|
|
10
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
11
|
+
help="Workspace-level category lookups.",
|
|
12
|
+
no_args_is_help=True,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
app.command("tags", help="List tag categories.")(make_category_command(CategoryType.TAGS))
|
|
16
|
+
app.command("file-categories", help="List file category options.")(make_category_command(CategoryType.FILE_CATEGORIES))
|
|
17
|
+
app.command("opportunity-stages", help="List opportunity stage options.")(
|
|
18
|
+
make_category_command(CategoryType.OPPORTUNITY_STAGES)
|
|
19
|
+
)
|
|
20
|
+
app.command("opportunity-pipelines", help="List opportunity pipeline options.")(
|
|
21
|
+
make_category_command(CategoryType.OPPORTUNITY_PIPELINES)
|
|
22
|
+
)
|
|
23
|
+
app.command("investment-objectives", help="List investment objective options.")(
|
|
24
|
+
make_category_command(CategoryType.INVESTMENT_OBJECTIVES)
|
|
25
|
+
)
|
|
26
|
+
app.command("financial-account-types", help="List financial account type options.")(
|
|
27
|
+
make_category_command(CategoryType.FINANCIAL_ACCOUNT_TYPES)
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.command("custom-fields", help="List custom field categories. Optionally filter by document type.")
|
|
32
|
+
@handle_errors
|
|
33
|
+
def custom_fields(
|
|
34
|
+
document_type: DocumentType | None = typer.Option(None, "--document-type", help="Filter by document type"),
|
|
35
|
+
page: int | None = typer.Option(None, help="Page number"),
|
|
36
|
+
per_page: int | None = typer.Option(None, "--per-page", help="Results per page (max 100)"),
|
|
37
|
+
token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
|
|
38
|
+
fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
|
|
39
|
+
) -> None:
|
|
40
|
+
query = CategoryListQuery(document_type=document_type, page=page, per_page=per_page)
|
|
41
|
+
output_result(run_client(token, lambda c: c.list_categories(CategoryType.CUSTOM_FIELDS, query)), fmt)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from wealthbox_tools.models import CommentListQuery, CommentResourceType
|
|
6
|
+
|
|
7
|
+
from ._util import OutputFormat, handle_errors, output_result, run_client, truncate_nested_field
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(
|
|
10
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
11
|
+
help="Retrieve Wealthbox comments.",
|
|
12
|
+
no_args_is_help=True,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
_DEFAULT_FIELDS = ["id", "creator", "resource_type", "resource_id", "created_at", "updated_at", "body"]
|
|
16
|
+
_BODY_PREVIEW_LEN = 500
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@app.command("list", help="List comments. Filter by resource ID/type and/or updated date range.")
|
|
20
|
+
@handle_errors
|
|
21
|
+
def list_comments(
|
|
22
|
+
resource_id: int | None = typer.Option(
|
|
23
|
+
None, "--resource-id", help="Filter by resource ID (requires --resource-type)"
|
|
24
|
+
),
|
|
25
|
+
resource_type: CommentResourceType | None = typer.Option(
|
|
26
|
+
None, "--resource-type", help="Filter by resource type: Contact, Task, Event"
|
|
27
|
+
),
|
|
28
|
+
updated_since: str | None = typer.Option(
|
|
29
|
+
None, "--updated-since", help="Only comments updated on or after this timestamp"
|
|
30
|
+
),
|
|
31
|
+
updated_before: str | None = typer.Option(
|
|
32
|
+
None, "--updated-before", help="Only comments updated on or before this timestamp"
|
|
33
|
+
),
|
|
34
|
+
page: int | None = typer.Option(None),
|
|
35
|
+
per_page: int | None = typer.Option(None, "--per-page", help="Results per page (max 100)"),
|
|
36
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show all fields"),
|
|
37
|
+
token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
|
|
38
|
+
fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
|
|
39
|
+
) -> None:
|
|
40
|
+
query = CommentListQuery(
|
|
41
|
+
resource_id=resource_id,
|
|
42
|
+
resource_type=resource_type,
|
|
43
|
+
updated_since=updated_since,
|
|
44
|
+
updated_before=updated_before,
|
|
45
|
+
page=page,
|
|
46
|
+
per_page=per_page,
|
|
47
|
+
)
|
|
48
|
+
result = run_client(token, lambda c: c.list_comments(query))
|
|
49
|
+
if not verbose:
|
|
50
|
+
result = truncate_nested_field(result, "body", ["text", "html"], _BODY_PREVIEW_LEN)
|
|
51
|
+
output_result(result, fmt, fields=None if verbose else _DEFAULT_FIELDS)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from ._config import _config_path, load_config, save_config
|
|
6
|
+
|
|
7
|
+
app = typer.Typer(
|
|
8
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
9
|
+
help="Manage wbox CLI configuration.",
|
|
10
|
+
no_args_is_help=True,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command("set-token", help="Store your Wealthbox API token.")
|
|
15
|
+
def set_token(
|
|
16
|
+
token: str = typer.Option(
|
|
17
|
+
..., prompt="Wealthbox API token", hide_input=True,
|
|
18
|
+
help="API token (will prompt if not provided)",
|
|
19
|
+
),
|
|
20
|
+
) -> None:
|
|
21
|
+
config = load_config()
|
|
22
|
+
config["token"] = token
|
|
23
|
+
save_config(config)
|
|
24
|
+
typer.echo(f"Token saved to {_config_path()}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.command("show", help="Show current configuration.")
|
|
28
|
+
def show() -> None:
|
|
29
|
+
config = load_config()
|
|
30
|
+
if not config:
|
|
31
|
+
typer.echo("No configuration found.")
|
|
32
|
+
typer.echo("Run 'wbox config set-token' to store your API token.")
|
|
33
|
+
return
|
|
34
|
+
token = config.get("token", "")
|
|
35
|
+
masked = f"...{token[-4:]}" if len(token) > 4 else "****"
|
|
36
|
+
typer.echo(f"Token: {masked}")
|
|
37
|
+
typer.echo(f"Path: {_config_path()}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@app.command("clear", help="Remove stored configuration.")
|
|
41
|
+
def clear() -> None:
|
|
42
|
+
path = _config_path()
|
|
43
|
+
if path.exists():
|
|
44
|
+
path.unlink()
|
|
45
|
+
typer.echo("Configuration cleared.")
|
|
46
|
+
else:
|
|
47
|
+
typer.echo("No configuration to clear.")
|