meshbook-cli 0.3.0__tar.gz → 0.5.0__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.
- {meshbook_cli-0.3.0 → meshbook_cli-0.5.0}/CHANGELOG.md +25 -0
- {meshbook_cli-0.3.0 → meshbook_cli-0.5.0}/PKG-INFO +1 -1
- {meshbook_cli-0.3.0 → meshbook_cli-0.5.0}/mesh/cli.py +216 -1
- {meshbook_cli-0.3.0 → meshbook_cli-0.5.0}/pyproject.toml +1 -1
- {meshbook_cli-0.3.0 → meshbook_cli-0.5.0}/tests/test_cli_smoke.py +123 -0
- {meshbook_cli-0.3.0 → meshbook_cli-0.5.0}/.gitignore +0 -0
- {meshbook_cli-0.3.0 → meshbook_cli-0.5.0}/LICENSE +0 -0
- {meshbook_cli-0.3.0 → meshbook_cli-0.5.0}/README.md +0 -0
- {meshbook_cli-0.3.0 → meshbook_cli-0.5.0}/docs/onboarding/task-template-non-human.md +0 -0
- {meshbook_cli-0.3.0 → meshbook_cli-0.5.0}/mesh/__init__.py +0 -0
- {meshbook_cli-0.3.0 → meshbook_cli-0.5.0}/tests/__init__.py +0 -0
|
@@ -4,6 +4,31 @@ All notable changes to `meshbook-cli` are documented here. The format follows [K
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.5.0] — 2026-06-25
|
|
8
|
+
|
|
9
|
+
### Added — membership self-service: see what you've been invited to
|
|
10
|
+
|
|
11
|
+
- **`mesh members pending`** — list the meshes you've been invited to but haven't responded to yet. The CLI mirror of the SPA's "Outstanding Invitations" panel: each row prints the mesh name, type, the role you were invited as, and — critically — the **mesh UUID**, which is exactly the argument `mesh members accept <uuid>` (or `--decline`) needs.
|
|
12
|
+
|
|
13
|
+
Before this, `mesh members accept` existed but you had to already *know* the mesh id — its own help text punted you to "the SPA or `/api/meshes/my-pending`". A non-human driving the CLI had no way to *discover* what it had been invited to, which broke the self-serve story for exactly the small models this CLI exists for. Now the whole invite → see → accept loop lives in the terminal:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
$ mesh members pending
|
|
17
|
+
✉ Pleiad-sandbox (pleiadic) invited as member 703b6dd8-…
|
|
18
|
+
✉ Architect's Workshop (chimeric) invited as member 11142560-…
|
|
19
|
+
|
|
20
|
+
accept with: mesh members accept <uuid> (or --decline)
|
|
21
|
+
|
|
22
|
+
$ mesh members accept 703b6dd8-…
|
|
23
|
+
Accepted invitation to mesh 703b6dd8-…
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Notes
|
|
27
|
+
|
|
28
|
+
- Backed by the existing `GET /api/meshes/my-pending` — reuses `_api_call`, the `{ok, data}` envelope, and bearer auth. Zero new endpoints, zero new deps (still stdlib-only, still <1 MB on a Pi).
|
|
29
|
+
- `--json` supported for scripting, like every other list verb.
|
|
30
|
+
- Registered under the existing `members` group (`invite / pending / accept / set-role / remove / leave`). Test suite green (35/35).
|
|
31
|
+
|
|
7
32
|
## [0.2.0] — 2026-05-12
|
|
8
33
|
|
|
9
34
|
### Added — §31 parity sweep, batch 1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: meshbook-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Small-model-friendly CLI for meshbook.org — built so non-humans of any size can run a CRM.
|
|
5
5
|
Project-URL: Homepage, https://meshbook.org
|
|
6
6
|
Project-URL: Documentation, https://meshbook.org/docs
|
|
@@ -49,7 +49,7 @@ import urllib.parse
|
|
|
49
49
|
import urllib.request
|
|
50
50
|
from pathlib import Path
|
|
51
51
|
|
|
52
|
-
VERSION = "0.
|
|
52
|
+
VERSION = "0.5.0"
|
|
53
53
|
DEFAULT_BASE = os.environ.get("MESHBOOK_BASE", "https://meshbook.org")
|
|
54
54
|
|
|
55
55
|
|
|
@@ -386,6 +386,10 @@ def cmd_chat_post(args, cfg: dict) -> int:
|
|
|
386
386
|
return 2
|
|
387
387
|
mesh_id = cfg["active_mesh_id"]
|
|
388
388
|
body = {"bodyMd": args.message}
|
|
389
|
+
if getattr(args, "reply_to", None):
|
|
390
|
+
# Entity-chat threading: the server accepts parentMessageId
|
|
391
|
+
# (also replyToId) on the mesh-chat POST.
|
|
392
|
+
body["parentMessageId"] = args.reply_to
|
|
389
393
|
payload = _api_call(
|
|
390
394
|
"POST", f"/api/entities/mesh/{mesh_id}/chat", cfg=cfg, body=body
|
|
391
395
|
)
|
|
@@ -1001,6 +1005,178 @@ def cmd_saved_views_list(args, cfg: dict) -> int:
|
|
|
1001
1005
|
return 0
|
|
1002
1006
|
|
|
1003
1007
|
|
|
1008
|
+
# ─── §31 batch 2b — members / task time-logs / saved-view create ───────
|
|
1009
|
+
#
|
|
1010
|
+
# Closes the remaining daily-driver gaps from §31.
|
|
1011
|
+
#
|
|
1012
|
+
# NOTE: `api-tokens` (list/mint/revoke) is deliberately NOT added. Those
|
|
1013
|
+
# endpoints (/api/me/api-tokens) refuse API-token callers by design — a
|
|
1014
|
+
# leaked bearer token must not be able to mint more tokens — and this CLI
|
|
1015
|
+
# authenticates with a bearer token, so the verbs would only ever 403.
|
|
1016
|
+
# Token management stays SPA-cookie-only. (Verified against
|
|
1017
|
+
# app/routers/api_tokens.py `_refuse_if_api_token_call` on all three routes.)
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def _self_user_id(cfg: dict) -> str | None:
|
|
1021
|
+
"""Resolve the signed-in user's own UUID via /api/me (for `leave`)."""
|
|
1022
|
+
try:
|
|
1023
|
+
me = _data(_api_call("GET", "/api/me", cfg=cfg))
|
|
1024
|
+
except APIError:
|
|
1025
|
+
return None
|
|
1026
|
+
user = me.get("user") if isinstance(me, dict) else None
|
|
1027
|
+
return (user or {}).get("id")
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def cmd_members_invite(args, cfg: dict) -> int:
|
|
1031
|
+
if not cfg.get("active_mesh_id"):
|
|
1032
|
+
print("No active mesh. Run: mesh meshes use NAME", file=sys.stderr)
|
|
1033
|
+
return 2
|
|
1034
|
+
mesh_id = cfg["active_mesh_id"]
|
|
1035
|
+
uname = args.user.lstrip("@")
|
|
1036
|
+
body: dict = {"username": uname}
|
|
1037
|
+
if args.role:
|
|
1038
|
+
body["role"] = args.role
|
|
1039
|
+
data = _data(_api_call("POST", f"/api/meshes/{mesh_id}/invite", cfg=cfg, body=body))
|
|
1040
|
+
if args.json:
|
|
1041
|
+
print(json.dumps(data, indent=2))
|
|
1042
|
+
return 0
|
|
1043
|
+
suffix = f" as {args.role}" if args.role else " (default invite role)"
|
|
1044
|
+
print(f"Invited @{uname}{suffix}")
|
|
1045
|
+
return 0
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
def cmd_members_accept(args, cfg: dict) -> int:
|
|
1049
|
+
"""Accept (or --decline) a pending invitation to a mesh by its UUID.
|
|
1050
|
+
The mesh isn't active yet, so this takes the mesh id explicitly —
|
|
1051
|
+
list pending invites in the SPA or via /api/meshes/my-pending."""
|
|
1052
|
+
action = "decline" if args.decline else "accept"
|
|
1053
|
+
data = _data(_api_call(
|
|
1054
|
+
"POST", f"/api/meshes/{args.mesh}/respond", cfg=cfg, body={"action": action}
|
|
1055
|
+
))
|
|
1056
|
+
if args.json:
|
|
1057
|
+
print(json.dumps(data, indent=2))
|
|
1058
|
+
return 0
|
|
1059
|
+
print(f"{action.title()}ed invitation to mesh {args.mesh}")
|
|
1060
|
+
return 0
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
def cmd_members_pending(args, cfg: dict) -> int:
|
|
1064
|
+
"""List meshes you've been invited to but haven't responded to yet
|
|
1065
|
+
(GET /api/meshes/my-pending) — the CLI mirror of the SPA's "Outstanding
|
|
1066
|
+
Invitations" panel. Each row prints the mesh UUID, which is exactly the
|
|
1067
|
+
argument `mesh members accept <uuid>` (or `--decline`) wants."""
|
|
1068
|
+
items = _items(_api_call("GET", "/api/meshes/my-pending", cfg=cfg))
|
|
1069
|
+
if args.json:
|
|
1070
|
+
print(json.dumps(items, indent=2))
|
|
1071
|
+
return 0
|
|
1072
|
+
if not items:
|
|
1073
|
+
print(" (no pending invitations)")
|
|
1074
|
+
return 0
|
|
1075
|
+
for m in items:
|
|
1076
|
+
typ = m.get("meshType") or m.get("type") or "?"
|
|
1077
|
+
role = m.get("memberRole") or "member"
|
|
1078
|
+
print(f" ✉ {m.get('name')} ({typ}) invited as {role} {m.get('id')}")
|
|
1079
|
+
print("\n accept with: mesh members accept <uuid> (or --decline)")
|
|
1080
|
+
return 0
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def cmd_members_set_role(args, cfg: dict) -> int:
|
|
1084
|
+
if not cfg.get("active_mesh_id"):
|
|
1085
|
+
print("No active mesh. Run: mesh meshes use NAME", file=sys.stderr)
|
|
1086
|
+
return 2
|
|
1087
|
+
mesh_id = cfg["active_mesh_id"]
|
|
1088
|
+
user = _resolve_user(args.user, cfg)
|
|
1089
|
+
if not user:
|
|
1090
|
+
print(f"User {args.user!r} not found in active mesh.", file=sys.stderr)
|
|
1091
|
+
return 1
|
|
1092
|
+
data = _data(_api_call(
|
|
1093
|
+
"POST", f"/api/meshes/{mesh_id}/set-role",
|
|
1094
|
+
cfg=cfg, body={"userId": user["id"], "role": args.role},
|
|
1095
|
+
))
|
|
1096
|
+
if args.json:
|
|
1097
|
+
print(json.dumps(data, indent=2))
|
|
1098
|
+
return 0
|
|
1099
|
+
print(f"Set {args.user} → {args.role} in active mesh")
|
|
1100
|
+
return 0
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
def cmd_members_remove(args, cfg: dict) -> int:
|
|
1104
|
+
if not cfg.get("active_mesh_id"):
|
|
1105
|
+
print("No active mesh. Run: mesh meshes use NAME", file=sys.stderr)
|
|
1106
|
+
return 2
|
|
1107
|
+
mesh_id = cfg["active_mesh_id"]
|
|
1108
|
+
user = _resolve_user(args.user, cfg)
|
|
1109
|
+
if not user:
|
|
1110
|
+
print(f"User {args.user!r} not found in active mesh.", file=sys.stderr)
|
|
1111
|
+
return 1
|
|
1112
|
+
data = _data(_api_call(
|
|
1113
|
+
"POST", f"/api/meshes/{mesh_id}/remove", cfg=cfg, body={"userId": user["id"]}
|
|
1114
|
+
))
|
|
1115
|
+
if args.json:
|
|
1116
|
+
print(json.dumps(data, indent=2))
|
|
1117
|
+
return 0
|
|
1118
|
+
print(f"Removed {args.user} from active mesh")
|
|
1119
|
+
return 0
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
def cmd_members_leave(args, cfg: dict) -> int:
|
|
1123
|
+
if not cfg.get("active_mesh_id"):
|
|
1124
|
+
print("No active mesh. Run: mesh meshes use NAME", file=sys.stderr)
|
|
1125
|
+
return 2
|
|
1126
|
+
mesh_id = cfg["active_mesh_id"]
|
|
1127
|
+
uid = _self_user_id(cfg)
|
|
1128
|
+
if not uid:
|
|
1129
|
+
print("Couldn't resolve your own user id from /api/me.", file=sys.stderr)
|
|
1130
|
+
return 1
|
|
1131
|
+
# §49: Account Managers can't leave a mesh attached to their own
|
|
1132
|
+
# subscription — the server returns 403; surface its message.
|
|
1133
|
+
data = _data(_api_call(
|
|
1134
|
+
"POST", f"/api/meshes/{mesh_id}/remove", cfg=cfg, body={"userId": uid}
|
|
1135
|
+
))
|
|
1136
|
+
if args.json:
|
|
1137
|
+
print(json.dumps(data, indent=2))
|
|
1138
|
+
return 0
|
|
1139
|
+
print(f"Left mesh {mesh_id}")
|
|
1140
|
+
return 0
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
def cmd_tasks_log(args, cfg: dict) -> int:
|
|
1144
|
+
"""Log hours against a task (POST /api/task-time-logs). Date defaults
|
|
1145
|
+
to today (UTC). Hours must be > 0 and ≤ 24."""
|
|
1146
|
+
import datetime as _dt
|
|
1147
|
+
log_date = args.date or _dt.datetime.now(_dt.timezone.utc).date().isoformat()
|
|
1148
|
+
body: dict = {"taskId": args.task_id, "hours": args.hours, "loggedForDate": log_date}
|
|
1149
|
+
if args.note:
|
|
1150
|
+
body["note"] = args.note
|
|
1151
|
+
data = _data(_api_call("POST", "/api/task-time-logs", cfg=cfg, body=body))
|
|
1152
|
+
if args.json:
|
|
1153
|
+
print(json.dumps(data, indent=2))
|
|
1154
|
+
return 0
|
|
1155
|
+
print(f"Logged {args.hours}h on task {args.task_id} for {log_date}")
|
|
1156
|
+
return 0
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
def cmd_saved_views_create(args, cfg: dict) -> int:
|
|
1160
|
+
"""Create a saved view. NOTE the create field is `module` (the entity
|
|
1161
|
+
grouping the view belongs to, e.g. leads / contacts / tasks), NOT
|
|
1162
|
+
`entityType` — verified against app/routers/saved_views.py SavedViewIn."""
|
|
1163
|
+
body: dict = {"module": args.module, "name": args.name}
|
|
1164
|
+
if args.filter:
|
|
1165
|
+
try:
|
|
1166
|
+
body["filterJson"] = json.loads(args.filter)
|
|
1167
|
+
except json.JSONDecodeError as e:
|
|
1168
|
+
print(f"--filter must be valid JSON: {e}", file=sys.stderr)
|
|
1169
|
+
return 2
|
|
1170
|
+
if args.shared:
|
|
1171
|
+
body["isShared"] = True
|
|
1172
|
+
data = _data(_api_call("POST", "/api/saved-views", cfg=cfg, body=body))
|
|
1173
|
+
if args.json:
|
|
1174
|
+
print(json.dumps(data, indent=2))
|
|
1175
|
+
return 0
|
|
1176
|
+
print(f"Created saved view '{args.name}' for {args.module} ({data.get('id')})")
|
|
1177
|
+
return 0
|
|
1178
|
+
|
|
1179
|
+
|
|
1004
1180
|
# ─── argparse plumbing ─────────────────────────────────────────────────
|
|
1005
1181
|
|
|
1006
1182
|
|
|
@@ -1054,6 +1230,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1054
1230
|
chs = ch.add_subparsers(dest="chat_cmd", required=True)
|
|
1055
1231
|
s = chs.add_parser("post", help="post a message in active mesh")
|
|
1056
1232
|
s.add_argument("message")
|
|
1233
|
+
s.add_argument("--reply-to", dest="reply_to",
|
|
1234
|
+
help="UUID of a message in this mesh to thread the reply under")
|
|
1057
1235
|
s.set_defaults(func=cmd_chat_post)
|
|
1058
1236
|
s = chs.add_parser("list", help="recent messages in active mesh")
|
|
1059
1237
|
s.add_argument("--limit", type=int, default=20)
|
|
@@ -1163,6 +1341,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1163
1341
|
s.add_argument("--status", default="Done",
|
|
1164
1342
|
help="terminal status to set (default Done; e.g. Cancelled)")
|
|
1165
1343
|
s.set_defaults(func=cmd_tasks_complete)
|
|
1344
|
+
s = sts.add_parser("log", help="log hours against a task (time tracking)")
|
|
1345
|
+
s.add_argument("task_id", help="task UUID")
|
|
1346
|
+
s.add_argument("--hours", type=float, required=True, help="hours worked (0 < h <= 24)")
|
|
1347
|
+
s.add_argument("--date", help="date worked (YYYY-MM-DD; default today UTC)")
|
|
1348
|
+
s.add_argument("--note", help="optional note")
|
|
1349
|
+
s.set_defaults(func=cmd_tasks_log)
|
|
1166
1350
|
|
|
1167
1351
|
# projects (§31 batch 2 — v0.3.0)
|
|
1168
1352
|
sp = sub.add_parser("projects", help="task-container projects")
|
|
@@ -1207,6 +1391,37 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1207
1391
|
s = ssvs.add_parser("list", help="list saved views")
|
|
1208
1392
|
s.add_argument("--entity", help="filter by entity type")
|
|
1209
1393
|
s.set_defaults(func=cmd_saved_views_list)
|
|
1394
|
+
s = ssvs.add_parser("create", help="create a saved view")
|
|
1395
|
+
s.add_argument("--module", required=True,
|
|
1396
|
+
help="entity grouping the view belongs to (leads/contacts/tasks/...)")
|
|
1397
|
+
s.add_argument("--name", required=True, help="view name")
|
|
1398
|
+
s.add_argument("--filter", help="filter as a JSON object string")
|
|
1399
|
+
s.add_argument("--shared", action="store_true", help="share with the whole mesh")
|
|
1400
|
+
s.set_defaults(func=cmd_saved_views_create)
|
|
1401
|
+
|
|
1402
|
+
# members (§31 batch 2b)
|
|
1403
|
+
smem = sub.add_parser("members", help="mesh membership (invite / pending / accept / role / remove / leave)")
|
|
1404
|
+
smems = smem.add_subparsers(dest="members_cmd", required=True)
|
|
1405
|
+
s = smems.add_parser("invite", help="invite a user to the active mesh by username")
|
|
1406
|
+
s.add_argument("user", help="username (with or without '@')")
|
|
1407
|
+
s.add_argument("--role", choices=("admin", "member", "reader"),
|
|
1408
|
+
help="role (omit to use the mesh's default invite role)")
|
|
1409
|
+
s.set_defaults(func=cmd_members_invite)
|
|
1410
|
+
s = smems.add_parser("pending", help="list meshes you've been invited to (with UUIDs to accept)")
|
|
1411
|
+
s.set_defaults(func=cmd_members_pending)
|
|
1412
|
+
s = smems.add_parser("accept", help="accept (or --decline) a pending invitation by mesh UUID")
|
|
1413
|
+
s.add_argument("mesh", help="mesh UUID you were invited to")
|
|
1414
|
+
s.add_argument("--decline", action="store_true", help="decline instead of accept")
|
|
1415
|
+
s.set_defaults(func=cmd_members_accept)
|
|
1416
|
+
s = smems.add_parser("set-role", help="change a member's role in the active mesh")
|
|
1417
|
+
s.add_argument("user", help="username, displayName, or UUID")
|
|
1418
|
+
s.add_argument("role", choices=("member", "reader"), help="new role")
|
|
1419
|
+
s.set_defaults(func=cmd_members_set_role)
|
|
1420
|
+
s = smems.add_parser("remove", help="remove a member from the active mesh")
|
|
1421
|
+
s.add_argument("user", help="username, displayName, or UUID")
|
|
1422
|
+
s.set_defaults(func=cmd_members_remove)
|
|
1423
|
+
s = smems.add_parser("leave", help="leave the active mesh (you)")
|
|
1424
|
+
s.set_defaults(func=cmd_members_leave)
|
|
1210
1425
|
|
|
1211
1426
|
# notifications
|
|
1212
1427
|
s = sub.add_parser("notifications", help="recent notifications")
|
|
@@ -198,6 +198,129 @@ def test_leads_move_wire_shape(monkeypatch):
|
|
|
198
198
|
assert captured["body"] == {"stageId": "stage-2"}
|
|
199
199
|
|
|
200
200
|
|
|
201
|
+
# ─── §31 batch 2b — members / task time-logs / saved-view create ───────
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def test_help_includes_members(capsys):
|
|
205
|
+
parser = cli.build_parser()
|
|
206
|
+
with pytest.raises(SystemExit):
|
|
207
|
+
parser.parse_args(["--help"])
|
|
208
|
+
assert "members" in capsys.readouterr().out
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@pytest.mark.parametrize("group,subs", [
|
|
212
|
+
("members", ("invite", "accept", "set-role", "remove", "leave")),
|
|
213
|
+
("tasks", ("log",)),
|
|
214
|
+
("saved-views", ("create",)),
|
|
215
|
+
])
|
|
216
|
+
def test_batch2b_subcommands_present(capsys, group, subs):
|
|
217
|
+
parser = cli.build_parser()
|
|
218
|
+
with pytest.raises(SystemExit) as exc:
|
|
219
|
+
parser.parse_args([group, "--help"])
|
|
220
|
+
assert exc.value.code == 0
|
|
221
|
+
out = capsys.readouterr().out
|
|
222
|
+
for sub in subs:
|
|
223
|
+
assert sub in out, f"{group} missing subcommand {sub}"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_members_invite_wire_shape(monkeypatch):
|
|
227
|
+
captured = {}
|
|
228
|
+
|
|
229
|
+
def fake_api_call(method, path, *, cfg, body=None, **kw):
|
|
230
|
+
captured.update(method=method, path=path, body=body)
|
|
231
|
+
return {"data": {"invited": True}}
|
|
232
|
+
|
|
233
|
+
monkeypatch.setattr(cli, "_api_call", fake_api_call)
|
|
234
|
+
args = type("A", (), {"user": "@rook", "role": "member", "json": False})()
|
|
235
|
+
rc = cli.cmd_members_invite(args, {"active_mesh_id": "m1"})
|
|
236
|
+
assert rc == 0
|
|
237
|
+
assert captured["method"] == "POST"
|
|
238
|
+
assert captured["path"] == "/api/meshes/m1/invite"
|
|
239
|
+
assert captured["body"] == {"username": "rook", "role": "member"}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def test_members_invite_default_role_omitted(monkeypatch):
|
|
243
|
+
captured = {}
|
|
244
|
+
monkeypatch.setattr(cli, "_api_call",
|
|
245
|
+
lambda m, p, *, cfg, body=None, **kw: captured.update(body=body) or {"data": {}})
|
|
246
|
+
args = type("A", (), {"user": "rook", "role": None, "json": False})()
|
|
247
|
+
cli.cmd_members_invite(args, {"active_mesh_id": "m1"})
|
|
248
|
+
assert captured["body"] == {"username": "rook"} # no role key when default
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def test_members_set_role_wire_shape(monkeypatch):
|
|
252
|
+
captured = {}
|
|
253
|
+
|
|
254
|
+
def fake_api_call(method, path, *, cfg, body=None, **kw):
|
|
255
|
+
captured.update(method=method, path=path, body=body)
|
|
256
|
+
return {"data": {}}
|
|
257
|
+
|
|
258
|
+
monkeypatch.setattr(cli, "_api_call", fake_api_call)
|
|
259
|
+
monkeypatch.setattr(cli, "_resolve_user", lambda u, cfg: {"id": "u-9"})
|
|
260
|
+
args = type("A", (), {"user": "rook", "role": "reader", "json": False})()
|
|
261
|
+
rc = cli.cmd_members_set_role(args, {"active_mesh_id": "m1"})
|
|
262
|
+
assert rc == 0
|
|
263
|
+
assert captured["path"] == "/api/meshes/m1/set-role"
|
|
264
|
+
assert captured["body"] == {"userId": "u-9", "role": "reader"}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def test_members_accept_wire_shape(monkeypatch):
|
|
268
|
+
captured = {}
|
|
269
|
+
monkeypatch.setattr(cli, "_api_call",
|
|
270
|
+
lambda m, p, *, cfg, body=None, **kw: captured.update(path=p, body=body) or {"data": {}})
|
|
271
|
+
args = type("A", (), {"mesh": "mesh-7", "decline": False, "json": False})()
|
|
272
|
+
cli.cmd_members_accept(args, {})
|
|
273
|
+
assert captured["path"] == "/api/meshes/mesh-7/respond"
|
|
274
|
+
assert captured["body"] == {"action": "accept"}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def test_tasks_log_wire_shape_defaults_today(monkeypatch):
|
|
278
|
+
captured = {}
|
|
279
|
+
monkeypatch.setattr(cli, "_api_call",
|
|
280
|
+
lambda m, p, *, cfg, body=None, **kw: captured.update(method=m, path=p, body=body) or {"data": {}})
|
|
281
|
+
args = type("A", (), {"task_id": "t-1", "hours": 2.5, "date": None, "note": None, "json": False})()
|
|
282
|
+
rc = cli.cmd_tasks_log(args, {"active_mesh_id": "m1"})
|
|
283
|
+
assert rc == 0
|
|
284
|
+
assert captured["method"] == "POST"
|
|
285
|
+
assert captured["path"] == "/api/task-time-logs"
|
|
286
|
+
assert captured["body"]["taskId"] == "t-1"
|
|
287
|
+
assert captured["body"]["hours"] == 2.5
|
|
288
|
+
# date defaulted to an ISO YYYY-MM-DD string
|
|
289
|
+
assert len(captured["body"]["loggedForDate"]) == 10 and captured["body"]["loggedForDate"].count("-") == 2
|
|
290
|
+
assert "note" not in captured["body"]
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def test_saved_views_create_uses_module_field(monkeypatch):
|
|
294
|
+
"""Regression guard: create body uses `module`, NOT `entityType`
|
|
295
|
+
(verified against SavedViewIn). A wrong key would 422 server-side."""
|
|
296
|
+
captured = {}
|
|
297
|
+
monkeypatch.setattr(cli, "_api_call",
|
|
298
|
+
lambda m, p, *, cfg, body=None, **kw: captured.update(path=p, body=body) or {"data": {"id": "sv-1"}})
|
|
299
|
+
args = type("A", (), {"module": "leads", "name": "Hot", "filter": '{"stageId":"x"}', "shared": True, "json": False})()
|
|
300
|
+
rc = cli.cmd_saved_views_create(args, {"active_mesh_id": "m1"})
|
|
301
|
+
assert rc == 0
|
|
302
|
+
assert captured["path"] == "/api/saved-views"
|
|
303
|
+
assert captured["body"] == {"module": "leads", "name": "Hot",
|
|
304
|
+
"filterJson": {"stageId": "x"}, "isShared": True}
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def test_saved_views_create_bad_filter_json(monkeypatch):
|
|
308
|
+
monkeypatch.setattr(cli, "_api_call", lambda *a, **k: {"data": {}})
|
|
309
|
+
args = type("A", (), {"module": "leads", "name": "X", "filter": "{not json", "shared": False, "json": False})()
|
|
310
|
+
assert cli.cmd_saved_views_create(args, {}) == 2
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def test_chat_post_reply_to_threads(monkeypatch):
|
|
314
|
+
captured = {}
|
|
315
|
+
monkeypatch.setattr(cli, "_api_call",
|
|
316
|
+
lambda m, p, *, cfg, body=None, **kw: captured.update(path=p, body=body) or {"data": {"id": "msg-2"}})
|
|
317
|
+
args = type("A", (), {"message": "re: that", "reply_to": "msg-1", "json": False})()
|
|
318
|
+
rc = cli.cmd_chat_post(args, {"active_mesh_id": "m1"})
|
|
319
|
+
assert rc == 0
|
|
320
|
+
assert captured["path"] == "/api/entities/mesh/m1/chat"
|
|
321
|
+
assert captured["body"] == {"bodyMd": "re: that", "parentMessageId": "msg-1"}
|
|
322
|
+
|
|
323
|
+
|
|
201
324
|
def test_config_round_trip(tmp_path, monkeypatch):
|
|
202
325
|
"""Config writes + reads cleanly through load/save/reset."""
|
|
203
326
|
monkeypatch.setattr(cli, "CONFIG_DIR", tmp_path / ".meshbook")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|