wealthbox-cli 1.0.0__tar.gz → 1.0.2__tar.gz

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 (59) hide show
  1. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/CONTRIBUTING.md +19 -4
  2. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/PKG-INFO +40 -10
  3. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/README.md +39 -9
  4. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/pyproject.toml +1 -1
  5. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/cli/_util.py +133 -6
  6. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/cli/contacts.py +2 -5
  7. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/cli/events.py +23 -8
  8. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/cli/main.py +0 -2
  9. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/cli/notes.py +23 -8
  10. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/cli/opportunities.py +25 -17
  11. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/cli/projects.py +18 -7
  12. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/cli/tasks.py +34 -6
  13. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/cli/workflows.py +19 -7
  14. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/client/comments.py +8 -0
  15. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/models/enums.py +8 -1
  16. wealthbox_cli-1.0.0/src/wealthbox_tools/cli/comments.py +0 -51
  17. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/.env.example +0 -0
  18. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/.github/workflows/ci.yml +0 -0
  19. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/.gitignore +0 -0
  20. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/LICENSE +0 -0
  21. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/SECURITY.md +0 -0
  22. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/docs/cli-reference.md +0 -0
  23. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/__init__.py +0 -0
  24. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/cli/__init__.py +0 -0
  25. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/cli/_config.py +0 -0
  26. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/cli/activity.py +0 -0
  27. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/cli/categories.py +0 -0
  28. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/cli/config.py +0 -0
  29. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/cli/households.py +0 -0
  30. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/cli/me.py +0 -0
  31. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/cli/users.py +0 -0
  32. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/client/__init__.py +0 -0
  33. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/client/activity.py +0 -0
  34. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/client/base.py +0 -0
  35. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/client/categories.py +0 -0
  36. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/client/contacts.py +0 -0
  37. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/client/events.py +0 -0
  38. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/client/households.py +0 -0
  39. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/client/me.py +0 -0
  40. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/client/notes.py +0 -0
  41. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/client/opportunities.py +0 -0
  42. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/client/projects.py +0 -0
  43. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/client/tasks.py +0 -0
  44. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/client/users.py +0 -0
  45. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/client/workflows.py +0 -0
  46. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/models/__init__.py +0 -0
  47. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/models/activity.py +0 -0
  48. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/models/comments.py +0 -0
  49. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/models/common.py +0 -0
  50. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/models/contacts.py +0 -0
  51. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/models/custom_fields.py +0 -0
  52. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/models/events.py +0 -0
  53. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/models/households.py +0 -0
  54. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/models/notes.py +0 -0
  55. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/models/opportunities.py +0 -0
  56. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/models/projects.py +0 -0
  57. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/models/tasks.py +0 -0
  58. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/models/workflows.py +0 -0
  59. {wealthbox_cli-1.0.0 → wealthbox_cli-1.0.2}/src/wealthbox_tools/py.typed +0 -0
@@ -15,7 +15,13 @@ source .venv/bin/activate # macOS/Linux
15
15
  pip install -e ".[dev]"
16
16
  ```
17
17
 
18
- Copy `.env.example` to `.env` and add your Wealthbox API token.
18
+ Configure your Wealthbox API token:
19
+
20
+ ```bash
21
+ wbox config set-token
22
+ ```
23
+
24
+ Or copy `.env.example` to `.env` and add your token there.
19
25
 
20
26
  ## Running Tests
21
27
 
@@ -32,8 +38,8 @@ ruff check src/
32
38
  mypy src/
33
39
  ```
34
40
 
35
- - **ruff** for linting (E, F, I rules; 100-char line length)
36
- - **mypy** in strict mode
41
+ - **ruff** for linting (E, F, I rules; 120-char line length)
42
+ - **mypy** in strict mode (has known `untyped-decorator` warnings from Typer — not currently enforced in CI)
37
43
 
38
44
  ## Project Architecture
39
45
 
@@ -55,11 +61,20 @@ Three layers under `src/wealthbox_tools/`:
55
61
  6. If the resource has category types, add them via `make_category_command()` in the resource's `categories` sub-app
56
62
  7. Add tests in `tests/test_<resource>_create.py` and `tests/test_<resource>_update.py`
57
63
 
64
+ ## CI
65
+
66
+ Pull requests and pushes to `main` run GitHub Actions CI:
67
+
68
+ - **Lint:** `ruff check src/ tests/`
69
+ - **Test:** `pytest` across Python 3.11, 3.12, 3.13
70
+
71
+ Both must pass before merging.
72
+
58
73
  ## Pull Requests
59
74
 
60
75
  - Keep PRs focused — one feature or fix per PR
61
76
  - All tests must pass (`pytest`)
62
- - No ruff or mypy errors
77
+ - No ruff errors
63
78
  - Update the CLI reference docs if commands change
64
79
 
65
80
  ## Reporting Issues
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wealthbox-cli
3
- Version: 1.0.0
3
+ Version: 1.0.2
4
4
  Summary: CLI and client library for the Wealthbox CRM API
5
5
  Project-URL: Homepage, https://github.com/massive-value/wealthbox-cli
6
6
  Project-URL: Issues, https://github.com/massive-value/wealthbox-cli/issues
@@ -76,6 +76,18 @@ Official API documentation: https://dev.wealthbox.com
76
76
  pip install wealthbox-cli
77
77
  ```
78
78
 
79
+ ### With pipx (recommended for Ubuntu/Debian)
80
+
81
+ On systems where the system Python is externally managed (Ubuntu 23.04+, Debian 12+),
82
+ `pip install` outside a virtual environment is blocked by [PEP 668](https://peps.python.org/pep-0668/).
83
+ Use [pipx](https://pipx.pypa.io/) to install CLI tools in isolated environments:
84
+
85
+ ``` bash
86
+ pipx install wealthbox-cli
87
+ ```
88
+
89
+ This puts `wbox` and `wb` on your PATH without touching system Python.
90
+
79
91
  ### From source (development)
80
92
 
81
93
  ``` bash
@@ -92,26 +104,44 @@ pip install -e ".[dev]"
92
104
 
93
105
  ## Configuration
94
106
 
95
- Copy `.env.example` to `.env` and fill in your token:
107
+ ### Getting your API token
96
108
 
97
- ```
98
- WEALTHBOX_TOKEN=your_api_token_here
109
+ 1. Log in to [Wealthbox](https://app.crmworkspace.com)
110
+ 2. Click the three dots menu (**...**) in the top right
111
+ 3. Go to **Settings** -> **API Access**
112
+ 4. Click **Create Access Token**
113
+
114
+ ### Storing your token
115
+
116
+ ``` bash
117
+ wbox config set-token
99
118
  ```
100
119
 
101
- Or set it as an environment variable:
120
+ This prompts for your Wealthbox API token (input is masked) and stores it in
121
+ `~/.config/wbox/config.json` (Linux/macOS) or `%APPDATA%\wbox\config.json` (Windows).
102
122
 
103
- **macOS/Linux**
123
+ Other configuration commands:
104
124
 
105
125
  ``` bash
106
- export WEALTHBOX_TOKEN="your_api_token_here"
126
+ wbox config show # display stored config (token masked)
127
+ wbox config clear # remove stored config
107
128
  ```
108
129
 
109
- **Windows (PowerShell)**
130
+ **Alternative methods** (for CI, scripting, or containers):
131
+
132
+ ``` bash
133
+ # Environment variable
134
+ export WEALTHBOX_TOKEN="your_api_token_here"
110
135
 
111
- ``` powershell
112
- setx WEALTHBOX_TOKEN "your_api_token_here"
136
+ # .env file in working directory
137
+ echo 'WEALTHBOX_TOKEN=your_api_token_here' > .env
138
+
139
+ # Per-command flag
140
+ wbox contacts list --token your_api_token_here
113
141
  ```
114
142
 
143
+ Token is resolved in this order: `--token` flag > `WEALTHBOX_TOKEN` env var > config file > `.env` file.
144
+
115
145
  ------------------------------------------------------------------------
116
146
 
117
147
  ## Usage
@@ -45,6 +45,18 @@ Official API documentation: https://dev.wealthbox.com
45
45
  pip install wealthbox-cli
46
46
  ```
47
47
 
48
+ ### With pipx (recommended for Ubuntu/Debian)
49
+
50
+ On systems where the system Python is externally managed (Ubuntu 23.04+, Debian 12+),
51
+ `pip install` outside a virtual environment is blocked by [PEP 668](https://peps.python.org/pep-0668/).
52
+ Use [pipx](https://pipx.pypa.io/) to install CLI tools in isolated environments:
53
+
54
+ ``` bash
55
+ pipx install wealthbox-cli
56
+ ```
57
+
58
+ This puts `wbox` and `wb` on your PATH without touching system Python.
59
+
48
60
  ### From source (development)
49
61
 
50
62
  ``` bash
@@ -61,26 +73,44 @@ pip install -e ".[dev]"
61
73
 
62
74
  ## Configuration
63
75
 
64
- Copy `.env.example` to `.env` and fill in your token:
76
+ ### Getting your API token
65
77
 
66
- ```
67
- WEALTHBOX_TOKEN=your_api_token_here
78
+ 1. Log in to [Wealthbox](https://app.crmworkspace.com)
79
+ 2. Click the three dots menu (**...**) in the top right
80
+ 3. Go to **Settings** -> **API Access**
81
+ 4. Click **Create Access Token**
82
+
83
+ ### Storing your token
84
+
85
+ ``` bash
86
+ wbox config set-token
68
87
  ```
69
88
 
70
- Or set it as an environment variable:
89
+ This prompts for your Wealthbox API token (input is masked) and stores it in
90
+ `~/.config/wbox/config.json` (Linux/macOS) or `%APPDATA%\wbox\config.json` (Windows).
71
91
 
72
- **macOS/Linux**
92
+ Other configuration commands:
73
93
 
74
94
  ``` bash
75
- export WEALTHBOX_TOKEN="your_api_token_here"
95
+ wbox config show # display stored config (token masked)
96
+ wbox config clear # remove stored config
76
97
  ```
77
98
 
78
- **Windows (PowerShell)**
99
+ **Alternative methods** (for CI, scripting, or containers):
100
+
101
+ ``` bash
102
+ # Environment variable
103
+ export WEALTHBOX_TOKEN="your_api_token_here"
79
104
 
80
- ``` powershell
81
- setx WEALTHBOX_TOKEN "your_api_token_here"
105
+ # .env file in working directory
106
+ echo 'WEALTHBOX_TOKEN=your_api_token_here' > .env
107
+
108
+ # Per-command flag
109
+ wbox contacts list --token your_api_token_here
82
110
  ```
83
111
 
112
+ Token is resolved in this order: `--token` flag > `WEALTHBOX_TOKEN` env var > config file > `.env` file.
113
+
84
114
  ------------------------------------------------------------------------
85
115
 
86
116
  ## Usage
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "wealthbox-cli"
7
- version = "1.0.0"
7
+ version = "1.0.2"
8
8
  description = "CLI and client library for the Wealthbox CRM API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -11,11 +11,42 @@ from typing import Any
11
11
 
12
12
  import typer
13
13
  from pydantic import ValidationError
14
+ from typer.core import TyperGroup
14
15
 
15
16
  from wealthbox_tools.client import WealthboxAPIError, WealthboxClient
16
17
  from wealthbox_tools.models import CategoryListQuery, CategoryType, LinkedToRef, TaskResourceType
17
18
 
18
19
 
20
+ class _GetShortcutGroup(TyperGroup):
21
+ """Typer Group that routes numeric first arguments to the ``get`` subcommand."""
22
+
23
+ def resolve_command(self, ctx: typer.Context, args: list[str]) -> tuple: # type: ignore[type-arg]
24
+ if args and args[0].isdigit() and args[0] not in self.commands:
25
+ args.insert(0, "get")
26
+ return super().resolve_command(ctx, args)
27
+
28
+
29
+ def make_resource_app(*, help: str) -> typer.Typer:
30
+ """Create a Typer app that supports ``wbox <resource> <id>`` as shorthand for ``get <id>``."""
31
+ return typer.Typer(
32
+ cls=_GetShortcutGroup,
33
+ context_settings={"help_option_names": ["-h", "--help"]},
34
+ help=help,
35
+ no_args_is_help=True,
36
+ )
37
+
38
+
39
+ # Maps CLI resource names to the CommentResourceType string expected by GET /comments
40
+ COMMENT_RESOURCE_TYPES: dict[str, str] = {
41
+ "tasks": "Task",
42
+ "events": "Event",
43
+ "notes": "StatusUpdates",
44
+ "opportunities": "Opportunity",
45
+ "projects": "Project",
46
+ "workflows": "Workflow",
47
+ }
48
+
49
+
19
50
  def get_client(token: str | None = None) -> WealthboxClient:
20
51
  """Create a WealthboxClient with token resolution: --token flag > env var > config file > .env."""
21
52
  import os
@@ -42,6 +73,95 @@ def run_client(token: str | None, fn: Callable[[WealthboxClient], Awaitable[Any]
42
73
  return asyncio.run(_execute())
43
74
 
44
75
 
76
+ def run_client_with_comments(
77
+ token: str | None,
78
+ fn: Callable[[WealthboxClient], Awaitable[Any]],
79
+ resource_type: str,
80
+ resource_id: int,
81
+ include_comments: bool = True,
82
+ ) -> Any:
83
+ """Run an async client operation and optionally fetch + merge comments concurrently."""
84
+ async def _with_comments(client: WealthboxClient) -> Any:
85
+ if include_comments:
86
+ result, comments = await asyncio.gather(
87
+ fn(client),
88
+ client.get_comments_for_resource(resource_type, resource_id),
89
+ )
90
+ if isinstance(result, dict):
91
+ result["comments"] = comments
92
+ else:
93
+ result = await fn(client)
94
+ return result
95
+ return run_client(token, _with_comments)
96
+
97
+
98
+ _COMMENT_PREVIEW_LEN = 50
99
+ _SLIM_COMMENT_FIELDS = ("updated_at", "created_at", "creator")
100
+
101
+
102
+ def _strip_html(text: str) -> str:
103
+ """Remove HTML tags and decode common HTML entities."""
104
+ import html
105
+ import re
106
+ return html.unescape(re.sub(r"<[^>]+>", "", text)).strip()
107
+
108
+
109
+ def clean_comments(data: dict[str, Any]) -> dict[str, Any]:
110
+ """Strip HTML from body.text in each comment, keeping full structure (for verbose output)."""
111
+ comments = data.get("comments")
112
+ if not isinstance(comments, list):
113
+ return data
114
+ cleaned = []
115
+ for c in comments:
116
+ body = c.get("body")
117
+ if isinstance(body, dict) and "text" in body:
118
+ c = {**c, "body": {**body, "text": _strip_html(body["text"])}}
119
+ cleaned.append(c)
120
+ return {**data, "comments": cleaned}
121
+
122
+
123
+ def slim_comments(data: dict[str, Any]) -> dict[str, Any]:
124
+ """Strip HTML, trim to essential fields, and unnest body.text → text (single pass)."""
125
+ comments = data.get("comments")
126
+ if not isinstance(comments, list):
127
+ return data
128
+ slimmed = []
129
+ for c in comments:
130
+ entry = {k: c[k] for k in _SLIM_COMMENT_FIELDS if k in c}
131
+ body = c.get("body")
132
+ raw = body.get("text", "") if isinstance(body, dict) else str(body or "")
133
+ entry["text"] = _strip_html(raw)
134
+ slimmed.append(entry)
135
+ return {**data, "comments": slimmed}
136
+
137
+
138
+ def summarize_comments(data: dict[str, Any]) -> dict[str, Any]:
139
+ """Replace a ``comments`` list with ``comment_count`` and ``latest_comment`` summary fields."""
140
+ comments = data.get("comments")
141
+ if comments is None:
142
+ return data
143
+ data = {k: v for k, v in data.items() if k != "comments"}
144
+ data["comment_count"] = len(comments)
145
+ if comments:
146
+ newest = max(comments, key=lambda c: c.get("created_at", c.get("updated_at", "")))
147
+ text = newest.get("text", "")
148
+ preview = text[:_COMMENT_PREVIEW_LEN] + "..." if len(text) > _COMMENT_PREVIEW_LEN else text
149
+ data["latest_comment"] = preview
150
+ else:
151
+ data["latest_comment"] = ""
152
+ return data
153
+
154
+
155
+ def output_get_result(
156
+ result: dict[str, Any], fmt: OutputFormat, fields: list[str] | None = None
157
+ ) -> None:
158
+ """Standard output pipeline for get commands with comments: slim → summarize → output."""
159
+ result = slim_comments(result)
160
+ if fmt != OutputFormat.JSON:
161
+ result = summarize_comments(result)
162
+ output_result(result, fmt, fields=fields)
163
+
164
+
45
165
  class OutputFormat(StrEnum):
46
166
  JSON = "json"
47
167
  TABLE = "table"
@@ -118,8 +238,12 @@ def _flatten_value(value: Any) -> Any:
118
238
  return item["address"]
119
239
  return first["address"]
120
240
  if "id" in first and "type" in first:
121
- # e.g. linked_to, invitees
122
- return f"{first['type']}:{first['id']}"
241
+ # e.g. linked_to, invitees — prefer name if available
242
+ parts = []
243
+ for item in value:
244
+ name = item.get("name")
245
+ parts.append(name if name else f"{item['type']}:{item['id']}")
246
+ return ", ".join(parts)
123
247
  return f"[{len(value)} items]"
124
248
  if isinstance(value, dict):
125
249
  return json.dumps(value)
@@ -143,10 +267,13 @@ def _extract_collection(data: Any) -> tuple[list[dict] | None, int | None]: # t
143
267
  break
144
268
  total = data["meta"].get("total_count") or data["meta"].get("total_entries")
145
269
  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
270
+ # Check if any value is a list (collection without meta).
271
+ # Skip when the dict looks like a single resource (has an "id" key) —
272
+ # nested lists like linked_to, subtasks, comments are not collections.
273
+ if "id" not in data:
274
+ for v in data.values():
275
+ if isinstance(v, list) and v and isinstance(v[0], dict):
276
+ return v, None
150
277
  return None, None
151
278
 
152
279
 
@@ -22,16 +22,13 @@ from ._util import (
22
22
  active_to_status,
23
23
  handle_errors,
24
24
  make_category_command,
25
+ make_resource_app,
25
26
  output_result,
26
27
  parse_more_fields,
27
28
  run_client,
28
29
  )
29
30
 
30
- app = typer.Typer(
31
- context_settings={"help_option_names": ["-h", "--help"]},
32
- help="Manage Wealthbox contacts.",
33
- no_args_is_help=True,
34
- )
31
+ app = make_resource_app(help="Manage Wealthbox contacts.")
35
32
 
36
33
  _DEFAULT_FIELDS = ["id", "name", "type", "contact_type", "assigned_to", "status"]
37
34
 
@@ -14,16 +14,26 @@ from wealthbox_tools.models import (
14
14
  TaskResourceType,
15
15
  )
16
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,
17
+ from ._util import (
18
+ COMMENT_RESOURCE_TYPES,
19
+ OutputFormat,
20
+ build_linked_to,
21
+ handle_errors,
22
+ make_category_command,
23
+ make_resource_app,
24
+ output_get_result,
25
+ output_result,
26
+ run_client,
27
+ run_client_with_comments,
23
28
  )
29
+
30
+ app = make_resource_app(help="Manage Wealthbox events.")
24
31
  app.command("categories", help="List event category options.")(make_category_command(CategoryType.EVENT_CATEGORIES))
25
32
 
26
- _DEFAULT_FIELDS = ["id", "title", "starts_at", "ends_at", "state", "event_category"]
33
+ _DEFAULT_FIELDS = [
34
+ "id", "title", "starts_at", "ends_at", "state", "event_category",
35
+ "comments", "comment_count", "latest_comment",
36
+ ]
27
37
 
28
38
 
29
39
  @app.command("list", help="List events with optional filters.")
@@ -71,11 +81,16 @@ def list_events(
71
81
  @handle_errors
72
82
  def get_event(
73
83
  event_id: int = typer.Argument(..., help="Event ID"),
84
+ no_comments: bool = typer.Option(False, "--no-comments", help="Omit comments from output"),
74
85
  verbose: bool = typer.Option(False, "--verbose", "-v", help="Show all fields"),
75
86
  token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
76
87
  fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
77
88
  ) -> None:
78
- output_result(run_client(token, lambda c: c.get_event(event_id)), fmt, fields=None if verbose else _DEFAULT_FIELDS)
89
+ result = run_client_with_comments(
90
+ token, lambda c: c.get_event(event_id),
91
+ COMMENT_RESOURCE_TYPES["events"], event_id, include_comments=not no_comments,
92
+ )
93
+ output_get_result(result, fmt, fields=None if verbose else _DEFAULT_FIELDS)
79
94
 
80
95
 
81
96
  @app.command("add", help="Create a new event.")
@@ -6,7 +6,6 @@ import typer
6
6
 
7
7
  from .activity import app as activity_app
8
8
  from .categories import app as categories_app
9
- from .comments import app as comments_app
10
9
  from .config import app as config_app
11
10
  from .contacts import app as contacts_app
12
11
  from .events import app as events_app
@@ -38,7 +37,6 @@ def _main(
38
37
  app.add_typer(activity_app, name="activity")
39
38
  app.add_typer(categories_app, name="categories")
40
39
  app.add_typer(config_app, name="config")
41
- app.add_typer(comments_app, name="comments")
42
40
  app.add_typer(contacts_app, name="contacts")
43
41
  app.add_typer(events_app, name="events")
44
42
  app.add_typer(households_app, name="households")
@@ -6,15 +6,25 @@ import typer
6
6
 
7
7
  from wealthbox_tools.models import NoteCreateInput, NoteListQuery, NoteResourceType, NotesOrder, NoteUpdateInput
8
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,
9
+ from ._util import (
10
+ COMMENT_RESOURCE_TYPES,
11
+ OutputFormat,
12
+ build_linked_to,
13
+ handle_errors,
14
+ make_resource_app,
15
+ output_get_result,
16
+ output_result,
17
+ run_client,
18
+ run_client_with_comments,
19
+ truncate_field,
15
20
  )
16
21
 
17
- _DEFAULT_FIELDS = ["id", "content", "linked_to", "creator_id", "updated_at"]
22
+ app = make_resource_app(help="Manage Wealthbox notes.")
23
+
24
+ _DEFAULT_FIELDS = [
25
+ "id", "content", "linked_to", "creator_id", "updated_at",
26
+ "comments", "comment_count", "latest_comment",
27
+ ]
18
28
  _CONTENT_PREVIEW_LEN = 500
19
29
 
20
30
 
@@ -51,11 +61,16 @@ def list_notes(
51
61
  @handle_errors
52
62
  def get_note(
53
63
  note_id: int = typer.Argument(..., help="Note ID"),
64
+ no_comments: bool = typer.Option(False, "--no-comments", help="Omit comments from output"),
54
65
  verbose: bool = typer.Option(False, "--verbose", "-v", help="Show all fields"),
55
66
  token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
56
67
  fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
57
68
  ) -> None:
58
- output_result(run_client(token, lambda c: c.get_note(note_id)), fmt, fields=None if verbose else _DEFAULT_FIELDS)
69
+ result = run_client_with_comments(
70
+ token, lambda c: c.get_note(note_id),
71
+ COMMENT_RESOURCE_TYPES["notes"], note_id, include_comments=not no_comments,
72
+ )
73
+ output_get_result(result, fmt, fields=None if verbose else _DEFAULT_FIELDS)
59
74
 
60
75
 
61
76
  @app.command("add", help="Create a new note.")
@@ -13,15 +13,26 @@ from wealthbox_tools.models import (
13
13
  OpportunityUpdateInput,
14
14
  )
15
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,
16
+ from ._util import (
17
+ COMMENT_RESOURCE_TYPES,
18
+ OutputFormat,
19
+ build_linked_to,
20
+ handle_errors,
21
+ make_resource_app,
22
+ output_get_result,
23
+ output_result,
24
+ parse_more_fields,
25
+ run_client,
26
+ run_client_with_comments,
22
27
  )
23
28
 
29
+ app = make_resource_app(help="Manage Wealthbox opportunities.")
30
+
24
31
  _DEFAULT_FIELDS = ["id", "name", "stage", "probability", "target_close", "manager", "linked_to"]
32
+ _MORE_FIELDS_RESERVED = {
33
+ "name", "target_close", "probability", "stage", "description",
34
+ "manager", "visible_to", "linked_to", "amounts",
35
+ }
25
36
 
26
37
 
27
38
  def _build_amounts(
@@ -83,10 +94,15 @@ def list_opportunities(
83
94
  @handle_errors
84
95
  def get_opportunity(
85
96
  opportunity_id: int = typer.Argument(..., help="Opportunity ID"),
97
+ no_comments: bool = typer.Option(False, "--no-comments", help="Omit comments from output"),
86
98
  token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
87
99
  fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
88
100
  ) -> None:
89
- output_result(run_client(token, lambda c: c.get_opportunity(opportunity_id)), fmt)
101
+ result = run_client_with_comments(
102
+ token, lambda c: c.get_opportunity(opportunity_id),
103
+ COMMENT_RESOURCE_TYPES["opportunities"], opportunity_id, include_comments=not no_comments,
104
+ )
105
+ output_get_result(result, fmt)
90
106
 
91
107
 
92
108
  @app.command("add", help="Create a new opportunity.")
@@ -125,11 +141,7 @@ def add_opportunity(
125
141
  }
126
142
 
127
143
  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))
144
+ payload.update(parse_more_fields(more_fields, _MORE_FIELDS_RESERVED))
133
145
 
134
146
  input_model = OpportunityCreateInput(**{k: v for k, v in payload.items() if v is not None})
135
147
  output_result(run_client(token, lambda c: c.create_opportunity(input_model)), fmt)
@@ -178,11 +190,7 @@ def update_opportunity(
178
190
  payload["amounts"] = amounts
179
191
 
180
192
  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))
193
+ payload.update(parse_more_fields(more_fields, _MORE_FIELDS_RESERVED))
186
194
 
187
195
  input_model = OpportunityUpdateInput(**payload)
188
196
  output_result(run_client(token, lambda c: c.update_opportunity(opportunity_id, input_model)), fmt)
@@ -6,14 +6,20 @@ import typer
6
6
 
7
7
  from wealthbox_tools.models import ProjectCreateInput, ProjectListQuery, ProjectUpdateInput
8
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,
9
+ from ._util import (
10
+ COMMENT_RESOURCE_TYPES,
11
+ OutputFormat,
12
+ handle_errors,
13
+ make_resource_app,
14
+ output_get_result,
15
+ output_result,
16
+ parse_more_fields,
17
+ run_client,
18
+ run_client_with_comments,
15
19
  )
16
20
 
21
+ app = make_resource_app(help="Manage Wealthbox projects.")
22
+
17
23
  _DEFAULT_FIELDS = ["id", "name", "description", "organizer", "updated_at"]
18
24
 
19
25
 
@@ -41,10 +47,15 @@ def list_projects(
41
47
  @handle_errors
42
48
  def get_project(
43
49
  project_id: int = typer.Argument(..., help="Project ID"),
50
+ no_comments: bool = typer.Option(False, "--no-comments", help="Omit comments from output"),
44
51
  token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
45
52
  fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
46
53
  ) -> None:
47
- output_result(run_client(token, lambda c: c.get_project(project_id)), fmt)
54
+ result = run_client_with_comments(
55
+ token, lambda c: c.get_project(project_id),
56
+ COMMENT_RESOURCE_TYPES["projects"], project_id, include_comments=not no_comments,
57
+ )
58
+ output_get_result(result, fmt)
48
59
 
49
60
 
50
61
  @app.command("add", help="Create a new project.")
@@ -15,24 +15,37 @@ from wealthbox_tools.models import (
15
15
  )
16
16
 
17
17
  from ._util import (
18
+ COMMENT_RESOURCE_TYPES,
18
19
  OutputFormat,
19
20
  build_linked_to,
20
21
  build_resource_filter,
22
+ clean_comments,
21
23
  handle_errors,
22
24
  make_category_command,
25
+ make_resource_app,
23
26
  output_result,
24
27
  parse_more_fields,
25
28
  run_client,
29
+ run_client_with_comments,
30
+ slim_comments,
31
+ summarize_comments,
26
32
  )
27
33
 
28
- app = typer.Typer(
29
- context_settings={"help_option_names": ["-h", "--help"]},
30
- help="Manage Wealthbox tasks.",
31
- no_args_is_help=True,
32
- )
34
+ app = make_resource_app(help="Manage Wealthbox tasks.")
33
35
  app.command("categories", help="List task category options.")(make_category_command(CategoryType.TASK_CATEGORIES))
34
36
 
35
37
  _DEFAULT_FIELDS = ["id", "name", "due_date", "frame", "complete", "category"]
38
+ _GET_FIELDS = [
39
+ "id", "name", "description", "due_date", "created_at", "complete",
40
+ "priority", "assigned_to", "category", "linked_to",
41
+ "comment_count", "latest_comment",
42
+ ]
43
+ _GET_JSON_FIELDS = [
44
+ "id", "name", "description", "due_date", "created_at", "updated_at",
45
+ "frame", "complete", "repeats", "priority",
46
+ "assigned_to", "assigned_to_team", "creator", "completer",
47
+ "category", "linked_to", "comments",
48
+ ]
36
49
 
37
50
  _TASK_CREATE_RESERVED = {"name", "due_date", "frame", "priority", "assigned_to", "linked_to"}
38
51
 
@@ -87,10 +100,25 @@ def list_tasks(
87
100
  @handle_errors
88
101
  def get_task(
89
102
  task_id: int = typer.Argument(..., help="Task ID"),
103
+ no_comments: bool = typer.Option(False, "--no-comments", help="Omit comments from output"),
104
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show all fields"),
90
105
  token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
91
106
  fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
92
107
  ) -> None:
93
- output_result(run_client(token, lambda c: c.get_task(task_id)), fmt)
108
+ result = run_client_with_comments(
109
+ token, lambda c: c.get_task(task_id),
110
+ COMMENT_RESOURCE_TYPES["tasks"], task_id, include_comments=not no_comments,
111
+ )
112
+ result = clean_comments(result)
113
+ if not verbose:
114
+ result = {k: result[k] for k in _GET_JSON_FIELDS if k in result}
115
+ result = slim_comments(result)
116
+ if fmt != OutputFormat.JSON:
117
+ result = summarize_comments(result)
118
+ desc = result.get("description", "")
119
+ if isinstance(desc, str) and len(desc) > 50:
120
+ result = {**result, "description": desc[:50] + "..."}
121
+ output_result(result, fmt, fields=None if (verbose or fmt == OutputFormat.JSON) else _GET_FIELDS)
94
122
 
95
123
 
96
124
  @app.command("add", help="Create a new task. Required: name, and either due_date or frame.")
@@ -13,14 +13,21 @@ from wealthbox_tools.models import (
13
13
  WorkflowTemplateListQuery,
14
14
  )
15
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 workflows.",
21
- no_args_is_help=True,
16
+ from ._util import (
17
+ COMMENT_RESOURCE_TYPES,
18
+ OutputFormat,
19
+ build_linked_to,
20
+ handle_errors,
21
+ make_resource_app,
22
+ output_get_result,
23
+ output_result,
24
+ parse_more_fields,
25
+ run_client,
26
+ run_client_with_comments,
22
27
  )
23
28
 
29
+ app = make_resource_app(help="Manage Wealthbox workflows.")
30
+
24
31
  templates_app = typer.Typer(
25
32
  context_settings={"help_option_names": ["-h", "--help"]},
26
33
  help="List workflow templates.",
@@ -68,10 +75,15 @@ def list_workflows(
68
75
  @handle_errors
69
76
  def get_workflow(
70
77
  workflow_id: int = typer.Argument(..., help="Workflow ID"),
78
+ no_comments: bool = typer.Option(False, "--no-comments", help="Omit comments from output"),
71
79
  token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
72
80
  fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
73
81
  ) -> None:
74
- output_result(run_client(token, lambda c: c.get_workflow(workflow_id)), fmt)
82
+ result = run_client_with_comments(
83
+ token, lambda c: c.get_workflow(workflow_id),
84
+ COMMENT_RESOURCE_TYPES["workflows"], workflow_id, include_comments=not no_comments,
85
+ )
86
+ output_get_result(result, fmt)
75
87
 
76
88
 
77
89
  @app.command("add", help="Create a new workflow from a template.")
@@ -12,3 +12,11 @@ class CommentsMixin:
12
12
  params = query.model_dump(exclude_none=True) if query else None
13
13
  resp = await self._request("GET", "/comments", params=params) # type: ignore[attr-defined]
14
14
  return resp.json()
15
+
16
+ async def get_comments_for_resource(
17
+ self, resource_type: str, resource_id: int
18
+ ) -> list[dict[str, Any]]:
19
+ """Fetch all comments for a specific resource."""
20
+ params = {"resource_type": resource_type, "resource_id": resource_id}
21
+ resp = await self._request("GET", "/comments", params=params) # type: ignore[attr-defined]
22
+ return resp.json().get("comments", [])
@@ -148,9 +148,16 @@ class NoteResourceType(StrEnum):
148
148
 
149
149
 
150
150
  class CommentResourceType(StrEnum):
151
- CONTACT = "Contact"
152
151
  TASK = "Task"
153
152
  EVENT = "Event"
153
+ STATUS_UPDATES = "StatusUpdates" # Notes
154
+ # CONTACT = "Contact"
155
+ OPPORTUNITY = "Opportunity"
156
+ PROJECT = "Project"
157
+ WORKFLOW = "Workflow"
158
+ WORKFLOW_STEP = "WorkflowStep"
159
+ SELECTED_OUTCOME = "SelectedOutcome"
160
+ MEETING = "Meeting"
154
161
 
155
162
 
156
163
  class OpportunityResourceType(StrEnum):
@@ -1,51 +0,0 @@
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)
File without changes
File without changes
File without changes