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,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.")