From 5a8b8b9e8b332da06d2cef4cd913d5949c37c381 Mon Sep 17 00:00:00 2001 From: Kadin Bullock Date: Wed, 13 May 2026 09:46:41 -0600 Subject: [PATCH] feat(me): add `wbox me user-id` and disambiguate IDs in table output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /me response overloads `id` — top-level `id` is the login profile, while `current_user.id` is the workspace user ID that filters like `--assigned-to` actually accept. Agents grabbing the top-level value got silent zero results. - New `wbox me user-id` prints `current_user.id` as a bare integer for `--assigned-to "$(wbox me user-id)"` composition. - `wbox me --format table` now labels the two IDs `login_id` and `user_id (--assigned-to)`. JSON output is byte-identical. - Skill docs (lookups.md, tasks.md, contacts.md) flag the trap inline. Closes #90 --- src/wealthbox_tools/cli/me.py | 47 ++++++++++- .../wealthbox-crm/references/contacts.md | 2 +- .../wealthbox-crm/references/lookups.md | 30 ++++++- .../skills/wealthbox-crm/references/tasks.md | 2 +- tests/test_me.py | 81 +++++++++++++++++++ 5 files changed, 157 insertions(+), 5 deletions(-) create mode 100644 tests/test_me.py diff --git a/src/wealthbox_tools/cli/me.py b/src/wealthbox_tools/cli/me.py index c49835b..a2d0527 100644 --- a/src/wealthbox_tools/cli/me.py +++ b/src/wealthbox_tools/cli/me.py @@ -1,8 +1,17 @@ from __future__ import annotations +from typing import Any + import typer -from ._util import OutputFormat, handle_errors, output_result, run_client +from ._util import ( + OutputFormat, + _flatten_record, + _render_kv_table, + handle_errors, + output_result, + run_client, +) app = typer.Typer( context_settings={"help_option_names": ["-h", "--help"]}, @@ -10,6 +19,22 @@ ) +def _relabel_for_table(data: dict[str, Any]) -> dict[str, Any]: + """Rename top-level `id` to `login_id` and add `user_id (--assigned-to)` + so the two IDs aren't confusable in tabular output. + + The top-level `id` is a login profile (not usable for `--assigned-to`); + `current_user.id` is the workspace user ID that filters accept. + """ + relabeled: dict[str, Any] = {} + for key, value in data.items(): + relabeled["login_id" if key == "id" else key] = value + current_user = data.get("current_user") + if isinstance(current_user, dict) and "id" in current_user: + relabeled["user_id (--assigned-to)"] = current_user["id"] + return relabeled + + @app.callback( invoke_without_command=True, help="Show info about the current authenticated user. Use subcommands for more specific info.", @@ -22,4 +47,22 @@ def get_me( ) -> None: if ctx.invoked_subcommand is not None: return - output_result(run_client(token, lambda c: c.get_me()), fmt) + data = run_client(token, lambda c: c.get_me()) + if fmt == OutputFormat.TABLE and isinstance(data, dict): + # Bypass output_result's collection-detection heuristic: once we drop + # the top-level `id` key, it would mistake `users[]` for a collection. + typer.echo(_render_kv_table(_flatten_record(_relabel_for_table(data)))) + return + output_result(data, fmt) + + +@app.command("user-id", help="Print the workspace user ID (for --assigned-to) as a bare integer.") +@handle_errors +def user_id( + token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True), +) -> None: + data = run_client(token, lambda c: c.get_me()) + current_user = data.get("current_user") if isinstance(data, dict) else None + if not isinstance(current_user, dict) or "id" not in current_user: + raise ValueError("`current_user.id` missing from /me response; cannot determine user ID.") + typer.echo(current_user["id"]) diff --git a/src/wealthbox_tools/skills/wealthbox-crm/references/contacts.md b/src/wealthbox_tools/skills/wealthbox-crm/references/contacts.md index c4e4ee5..1e4052b 100644 --- a/src/wealthbox_tools/skills/wealthbox-crm/references/contacts.md +++ b/src/wealthbox_tools/skills/wealthbox-crm/references/contacts.md @@ -20,7 +20,7 @@ wbox contacts list [OPTIONS] | `--household-title` | STR | Filter by household title | | `--tags` | STR | Comma-separated tag names | | `--order` | asc\|desc | Sort order | -| `--assigned-to` | INT | User ID (fetches all pages, client-side filter) | +| `--assigned-to` | INT | User ID (fetches all pages, client-side filter). Get yours via `wbox me user-id`, **not** `wbox me \| jq .id` (that's the login profile, which silently returns zero results). | | `--updated-since` | ISO datetime | Modified after this datetime | | `--updated-before` | ISO datetime | Modified before this datetime | | `--deleted-since` | ISO datetime | Deleted after this datetime | diff --git a/src/wealthbox_tools/skills/wealthbox-crm/references/lookups.md b/src/wealthbox_tools/skills/wealthbox-crm/references/lookups.md index 145baab..5faee90 100644 --- a/src/wealthbox_tools/skills/wealthbox-crm/references/lookups.md +++ b/src/wealthbox_tools/skills/wealthbox-crm/references/lookups.md @@ -6,10 +6,35 @@ Read-only resources: categories, users, activity feed, and current user info. ```bash wbox me [--format json|table|csv|tsv] +wbox me user-id ``` Returns info about the authenticated user. Good for verifying token setup and getting your user ID. +### Two IDs — don't confuse them + +`wbox me` returns two distinct ID fields, and they are NOT interchangeable: + +| JSON field | What it is | Use for `--assigned-to`? | +|------------|------------|--------------------------| +| top-level `id` | Login profile ID (one per Wealthbox account, across workspaces) | **No** | +| `current_user.id` (also `users[].id` for the active workspace) | Workspace user ID | **Yes** | + +Filters like `--assigned-to`, `--assigned-to-team`, and `--created-by` expect the workspace user ID. Passing the top-level `id` returns zero results silently — the API just ignores the filter. + +The safe ways to get your workspace user ID: + +```bash +wbox me user-id # prints just the integer, ready for shell substitution +wbox me --format table # labels the rows `login_id` vs `user_id (--assigned-to)` +``` + +Composing it: + +```bash +wbox tasks list --assigned-to "$(wbox me user-id)" +``` + ## Users ```bash @@ -64,7 +89,10 @@ The contact-related types are also reachable as aliases under `wbox contacts cat ## Examples ```bash -# Get your user ID +# Get your workspace user ID (for --assigned-to) +wbox me user-id + +# Or see both IDs side by side wbox me --format table # Find a user ID for assignment diff --git a/src/wealthbox_tools/skills/wealthbox-crm/references/tasks.md b/src/wealthbox_tools/skills/wealthbox-crm/references/tasks.md index fcb0fd2..cd830ad 100644 --- a/src/wealthbox_tools/skills/wealthbox-crm/references/tasks.md +++ b/src/wealthbox_tools/skills/wealthbox-crm/references/tasks.md @@ -13,7 +13,7 @@ wbox tasks list [OPTIONS] | `--contact` | INT | Filter by linked contact ID | | `--project` | INT | Filter by linked project ID | | `--opportunity` | INT | Filter by linked opportunity ID | -| `--assigned-to` | INT | Filter by assigned user ID | +| `--assigned-to` | INT | Filter by assigned user ID — get yours via `wbox me user-id`, **not** `wbox me \| jq .id` (that's the login profile, which silently returns zero results). | | `--assigned-to-team` | INT | Filter by assigned team ID | | `--created-by` | INT | Filter by creator user ID | | `--include-completed` | flag | Include completed tasks (default: outstanding only) | diff --git a/tests/test_me.py b/tests/test_me.py new file mode 100644 index 0000000..e19ec9f --- /dev/null +++ b/tests/test_me.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import json + +import httpx +import respx + +from wealthbox_tools.cli.main import app + +_BASE = "https://api.crmworkspace.com/v1" + +_ME_RESPONSE = { + "id": 70416, + "name": "Kadin Bullock", + "email": "kadin@example.com", + "current_user": { + "id": 152760, + "name": "Kadin Bullock", + "account": 31965, + }, + "users": [ + {"id": 152760, "account": 31965}, + ], +} + + +@respx.mock +def test_me_user_id_prints_bare_integer(runner) -> None: + """`wbox me user-id` must print current_user.id as a bare integer with a + single trailing newline — no JSON wrapping — so it composes cleanly with + command substitution: `--assigned-to "$(wbox me user-id)"`.""" + respx.get(f"{_BASE}/me").mock(return_value=httpx.Response(200, json=_ME_RESPONSE)) + + result = runner.invoke(app, ["me", "user-id"]) + assert result.exit_code == 0, result.output + assert result.output == "152760\n" + + +@respx.mock +def test_me_json_output_unchanged(runner) -> None: + """Default `wbox me` (JSON) must be byte-identical to today's behaviour — + the relabeling only affects --format table. No new keys, no renamed keys.""" + respx.get(f"{_BASE}/me").mock(return_value=httpx.Response(200, json=_ME_RESPONSE)) + + result = runner.invoke(app, ["me"]) + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data == _ME_RESPONSE + assert "login_id" not in data + assert "user_id (--assigned-to)" not in data + + +@respx.mock +def test_me_table_relabels_ids(runner) -> None: + """`wbox me --format table` should rename top-level `id` to `login_id` and + add a `user_id (--assigned-to)` row pulled from `current_user.id`, so the + two IDs aren't confusable at a glance.""" + respx.get(f"{_BASE}/me").mock(return_value=httpx.Response(200, json=_ME_RESPONSE)) + + result = runner.invoke(app, ["me", "--format", "table"]) + assert result.exit_code == 0, result.output + assert "login_id" in result.output + assert "70416" in result.output + assert "user_id (--assigned-to)" in result.output + assert "152760" in result.output + # The bare `id` label should no longer appear on its own row. + # tabulate renders `| login_id |`, so a standalone `| id |` would indicate + # we failed to relabel. + assert "| id " not in result.output + + +@respx.mock +def test_me_user_id_missing_current_user(runner) -> None: + """If the API response somehow lacks `current_user.id`, fail loudly with a + non-zero exit code rather than printing nothing or `None`.""" + respx.get(f"{_BASE}/me").mock( + return_value=httpx.Response(200, json={"id": 70416, "name": "X"}) + ) + + result = runner.invoke(app, ["me", "user-id"]) + assert result.exit_code != 0