Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 45 additions & 2 deletions src/wealthbox_tools/cli/me.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,40 @@
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"]},
help="Show info about the current authenticated user.",
)


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.",
Expand All @@ -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"])
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
30 changes: 29 additions & 1 deletion src/wealthbox_tools/skills/wealthbox-crm/references/lookups.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
81 changes: 81 additions & 0 deletions tests/test_me.py
Original file line number Diff line number Diff line change
@@ -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
Loading