meshbook-cli 0.2.0__tar.gz → 0.4.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshbook-cli
3
- Version: 0.2.0
3
+ Version: 0.4.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.2.0"
52
+ VERSION = "0.4.0"
53
53
  DEFAULT_BASE = os.environ.get("MESHBOOK_BASE", "https://meshbook.org")
54
54
 
55
55
 
@@ -165,6 +165,18 @@ def _data(payload: dict) -> object:
165
165
  return payload
166
166
 
167
167
 
168
+ def _items(payload: object) -> list:
169
+ """Normalise a list response through the envelope to a plain list.
170
+
171
+ Handles all three shapes the API emits: a bare list, {data: [...]},
172
+ and the ok_list paginated shape {data: {items: [...], total}}.
173
+ """
174
+ data = payload.get("data", payload) if isinstance(payload, dict) else payload
175
+ if isinstance(data, dict) and "items" in data:
176
+ data = data["items"]
177
+ return data or []
178
+
179
+
168
180
  # ─── command implementations ───────────────────────────────────────────
169
181
 
170
182
 
@@ -374,6 +386,10 @@ def cmd_chat_post(args, cfg: dict) -> int:
374
386
  return 2
375
387
  mesh_id = cfg["active_mesh_id"]
376
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
377
393
  payload = _api_call(
378
394
  "POST", f"/api/entities/mesh/{mesh_id}/chat", cfg=cfg, body=body
379
395
  )
@@ -814,6 +830,333 @@ def cmd_notifications(args, cfg: dict) -> int:
814
830
  return 0
815
831
 
816
832
 
833
+ # ─── §31 batch 2: CRM verbs (leads / tasks / projects / companies) ─────
834
+ #
835
+ # Batch 1 (v0.2.0) gave non-humans channels + DMs + reactions. Batch 2
836
+ # (v0.3.0) adds the daily-driver CRM verbs so the CLI is a full working
837
+ # client, not just a chat tool: read + create across the four core
838
+ # entities, plus lead stage-moves and task completion, plus read access
839
+ # to custom-field definitions and saved views. Same auth + envelope
840
+ # contract; every list uses _items() for envelope normalisation.
841
+
842
+
843
+ def cmd_leads_list(args, cfg: dict) -> int:
844
+ params = {
845
+ "limit": args.limit, "pipelineId": args.pipeline,
846
+ "stageId": args.stage, "companyId": args.company,
847
+ }
848
+ items = _items(_api_call("GET", "/api/leads", cfg=cfg, params=params))
849
+ if args.json:
850
+ print(json.dumps(items, indent=2))
851
+ return 0
852
+ for ld in items:
853
+ val = ld.get("valueAmount")
854
+ money = f" {val} {ld.get('currency', '')}".rstrip() if val is not None else ""
855
+ stage = ld.get("stageName") or ld.get("stageId") or ""
856
+ print(f" {ld.get('title')}{money} [{stage}] {ld.get('id')}")
857
+ return 0
858
+
859
+
860
+ def cmd_leads_create(args, cfg: dict) -> int:
861
+ body = {"title": args.title, "pipelineId": args.pipeline, "stageId": args.stage}
862
+ if args.value is not None:
863
+ body["valueAmount"] = args.value
864
+ if args.description:
865
+ body["description"] = args.description
866
+ data = _data(_api_call("POST", "/api/leads", cfg=cfg, body=body))
867
+ if args.json:
868
+ print(json.dumps(data, indent=2))
869
+ return 0
870
+ print(f"Created lead: {data.get('title')} ({data.get('id')})")
871
+ return 0
872
+
873
+
874
+ def cmd_leads_move(args, cfg: dict) -> int:
875
+ data = _data(_api_call(
876
+ "POST", f"/api/leads/{args.lead_id}/move-stage",
877
+ cfg=cfg, body={"stageId": args.stage},
878
+ ))
879
+ if args.json:
880
+ print(json.dumps(data, indent=2))
881
+ return 0
882
+ print(f"Moved lead {args.lead_id} → stage {args.stage}")
883
+ return 0
884
+
885
+
886
+ def cmd_tasks_list(args, cfg: dict) -> int:
887
+ params = {
888
+ "limit": args.limit, "projectId": args.project,
889
+ "assigneeId": args.assignee, "status": args.status,
890
+ }
891
+ items = _items(_api_call("GET", "/api/tasks", cfg=cfg, params=params))
892
+ if args.json:
893
+ print(json.dumps(items, indent=2))
894
+ return 0
895
+ for tk in items:
896
+ print(f" [{tk.get('status', '?')}] {tk.get('title')} {tk.get('id')}")
897
+ return 0
898
+
899
+
900
+ def cmd_tasks_create(args, cfg: dict) -> int:
901
+ body = {"projectId": args.project, "title": args.title}
902
+ if args.priority:
903
+ body["priority"] = args.priority
904
+ if args.description:
905
+ body["description"] = args.description
906
+ data = _data(_api_call("POST", "/api/tasks", cfg=cfg, body=body))
907
+ if args.json:
908
+ print(json.dumps(data, indent=2))
909
+ return 0
910
+ print(f"Created task: {data.get('title')} ({data.get('id')})")
911
+ return 0
912
+
913
+
914
+ def cmd_tasks_complete(args, cfg: dict) -> int:
915
+ # Valid task statuses: NotStarted / InProgress / Blocked / Review /
916
+ # Done / Cancelled. "Done" is the completion terminal; --status lets
917
+ # a caller set a different terminal (e.g. Cancelled).
918
+ data = _data(_api_call(
919
+ "PATCH", f"/api/tasks/{args.task_id}",
920
+ cfg=cfg, body={"status": args.status},
921
+ ))
922
+ if args.json:
923
+ print(json.dumps(data, indent=2))
924
+ return 0
925
+ print(f"Task {args.task_id} → {args.status}")
926
+ return 0
927
+
928
+
929
+ def cmd_projects_list(args, cfg: dict) -> int:
930
+ params = {"limit": args.limit, "portfolioId": args.portfolio, "statusFilter": args.status}
931
+ items = _items(_api_call("GET", "/api/projects", cfg=cfg, params=params))
932
+ if args.json:
933
+ print(json.dumps(items, indent=2))
934
+ return 0
935
+ for pr in items:
936
+ code = f" ({pr.get('code')})" if pr.get("code") else ""
937
+ print(f" [{pr.get('status', '?')}] {pr.get('name')}{code} {pr.get('id')}")
938
+ return 0
939
+
940
+
941
+ def cmd_projects_create(args, cfg: dict) -> int:
942
+ body = {"name": args.name}
943
+ if args.code:
944
+ body["code"] = args.code
945
+ if args.description:
946
+ body["description"] = args.description
947
+ if args.portfolio:
948
+ body["portfolioId"] = args.portfolio
949
+ data = _data(_api_call("POST", "/api/projects", cfg=cfg, body=body))
950
+ if args.json:
951
+ print(json.dumps(data, indent=2))
952
+ return 0
953
+ print(f"Created project: {data.get('name')} ({data.get('id')})")
954
+ return 0
955
+
956
+
957
+ def cmd_companies_list(args, cfg: dict) -> int:
958
+ params = {"limit": args.limit, "search": args.search}
959
+ items = _items(_api_call("GET", "/api/companies", cfg=cfg, params=params))
960
+ if args.json:
961
+ print(json.dumps(items, indent=2))
962
+ return 0
963
+ for co in items:
964
+ ind = f" ({co.get('industry')})" if co.get("industry") else ""
965
+ print(f" {co.get('name')}{ind} {co.get('id')}")
966
+ return 0
967
+
968
+
969
+ def cmd_companies_create(args, cfg: dict) -> int:
970
+ body = {"name": args.name}
971
+ for arg_name, wire in (("website", "website"), ("industry", "industry"),
972
+ ("email", "email"), ("phone", "phone")):
973
+ val = getattr(args, arg_name)
974
+ if val:
975
+ body[wire] = val
976
+ data = _data(_api_call("POST", "/api/companies", cfg=cfg, body=body))
977
+ if args.json:
978
+ print(json.dumps(data, indent=2))
979
+ return 0
980
+ print(f"Created company: {data.get('name')} ({data.get('id')})")
981
+ return 0
982
+
983
+
984
+ def cmd_custom_fields_list(args, cfg: dict) -> int:
985
+ params = {"entityType": args.entity}
986
+ items = _items(_api_call("GET", "/api/custom-fields", cfg=cfg, params=params))
987
+ if args.json:
988
+ print(json.dumps(items, indent=2))
989
+ return 0
990
+ for cf in items:
991
+ req = " *required" if cf.get("isRequired") else ""
992
+ print(f" {cf.get('entityType')}.{cf.get('fieldKey')} "
993
+ f"({cf.get('fieldType')}) — {cf.get('label')}{req} {cf.get('id')}")
994
+ return 0
995
+
996
+
997
+ def cmd_saved_views_list(args, cfg: dict) -> int:
998
+ params = {"entityType": args.entity} if args.entity else None
999
+ items = _items(_api_call("GET", "/api/saved-views", cfg=cfg, params=params))
1000
+ if args.json:
1001
+ print(json.dumps(items, indent=2))
1002
+ return 0
1003
+ for sv in items:
1004
+ print(f" {sv.get('entityType')}: {sv.get('name')} {sv.get('id')}")
1005
+ return 0
1006
+
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_set_role(args, cfg: dict) -> int:
1064
+ if not cfg.get("active_mesh_id"):
1065
+ print("No active mesh. Run: mesh meshes use NAME", file=sys.stderr)
1066
+ return 2
1067
+ mesh_id = cfg["active_mesh_id"]
1068
+ user = _resolve_user(args.user, cfg)
1069
+ if not user:
1070
+ print(f"User {args.user!r} not found in active mesh.", file=sys.stderr)
1071
+ return 1
1072
+ data = _data(_api_call(
1073
+ "POST", f"/api/meshes/{mesh_id}/set-role",
1074
+ cfg=cfg, body={"userId": user["id"], "role": args.role},
1075
+ ))
1076
+ if args.json:
1077
+ print(json.dumps(data, indent=2))
1078
+ return 0
1079
+ print(f"Set {args.user} → {args.role} in active mesh")
1080
+ return 0
1081
+
1082
+
1083
+ def cmd_members_remove(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}/remove", cfg=cfg, body={"userId": user["id"]}
1094
+ ))
1095
+ if args.json:
1096
+ print(json.dumps(data, indent=2))
1097
+ return 0
1098
+ print(f"Removed {args.user} from active mesh")
1099
+ return 0
1100
+
1101
+
1102
+ def cmd_members_leave(args, cfg: dict) -> int:
1103
+ if not cfg.get("active_mesh_id"):
1104
+ print("No active mesh. Run: mesh meshes use NAME", file=sys.stderr)
1105
+ return 2
1106
+ mesh_id = cfg["active_mesh_id"]
1107
+ uid = _self_user_id(cfg)
1108
+ if not uid:
1109
+ print("Couldn't resolve your own user id from /api/me.", file=sys.stderr)
1110
+ return 1
1111
+ # §49: Account Managers can't leave a mesh attached to their own
1112
+ # subscription — the server returns 403; surface its message.
1113
+ data = _data(_api_call(
1114
+ "POST", f"/api/meshes/{mesh_id}/remove", cfg=cfg, body={"userId": uid}
1115
+ ))
1116
+ if args.json:
1117
+ print(json.dumps(data, indent=2))
1118
+ return 0
1119
+ print(f"Left mesh {mesh_id}")
1120
+ return 0
1121
+
1122
+
1123
+ def cmd_tasks_log(args, cfg: dict) -> int:
1124
+ """Log hours against a task (POST /api/task-time-logs). Date defaults
1125
+ to today (UTC). Hours must be > 0 and ≤ 24."""
1126
+ import datetime as _dt
1127
+ log_date = args.date or _dt.datetime.now(_dt.timezone.utc).date().isoformat()
1128
+ body: dict = {"taskId": args.task_id, "hours": args.hours, "loggedForDate": log_date}
1129
+ if args.note:
1130
+ body["note"] = args.note
1131
+ data = _data(_api_call("POST", "/api/task-time-logs", cfg=cfg, body=body))
1132
+ if args.json:
1133
+ print(json.dumps(data, indent=2))
1134
+ return 0
1135
+ print(f"Logged {args.hours}h on task {args.task_id} for {log_date}")
1136
+ return 0
1137
+
1138
+
1139
+ def cmd_saved_views_create(args, cfg: dict) -> int:
1140
+ """Create a saved view. NOTE the create field is `module` (the entity
1141
+ grouping the view belongs to, e.g. leads / contacts / tasks), NOT
1142
+ `entityType` — verified against app/routers/saved_views.py SavedViewIn."""
1143
+ body: dict = {"module": args.module, "name": args.name}
1144
+ if args.filter:
1145
+ try:
1146
+ body["filterJson"] = json.loads(args.filter)
1147
+ except json.JSONDecodeError as e:
1148
+ print(f"--filter must be valid JSON: {e}", file=sys.stderr)
1149
+ return 2
1150
+ if args.shared:
1151
+ body["isShared"] = True
1152
+ data = _data(_api_call("POST", "/api/saved-views", cfg=cfg, body=body))
1153
+ if args.json:
1154
+ print(json.dumps(data, indent=2))
1155
+ return 0
1156
+ print(f"Created saved view '{args.name}' for {args.module} ({data.get('id')})")
1157
+ return 0
1158
+
1159
+
817
1160
  # ─── argparse plumbing ─────────────────────────────────────────────────
818
1161
 
819
1162
 
@@ -867,6 +1210,8 @@ def build_parser() -> argparse.ArgumentParser:
867
1210
  chs = ch.add_subparsers(dest="chat_cmd", required=True)
868
1211
  s = chs.add_parser("post", help="post a message in active mesh")
869
1212
  s.add_argument("message")
1213
+ s.add_argument("--reply-to", dest="reply_to",
1214
+ help="UUID of a message in this mesh to thread the reply under")
870
1215
  s.set_defaults(func=cmd_chat_post)
871
1216
  s = chs.add_parser("list", help="recent messages in active mesh")
872
1217
  s.add_argument("--limit", type=int, default=20)
@@ -935,6 +1280,127 @@ def build_parser() -> argparse.ArgumentParser:
935
1280
  s.add_argument("message", help="message body (markdown)")
936
1281
  s.set_defaults(func=cmd_dm_send)
937
1282
 
1283
+ # leads (§31 batch 2 — v0.3.0)
1284
+ sl = sub.add_parser("leads", help="CRM leads")
1285
+ sls = sl.add_subparsers(dest="leads_cmd", required=True)
1286
+ s = sls.add_parser("list", help="list leads in active mesh")
1287
+ s.add_argument("--limit", type=int, default=50)
1288
+ s.add_argument("--pipeline", help="filter by pipeline UUID")
1289
+ s.add_argument("--stage", help="filter by stage UUID")
1290
+ s.add_argument("--company", help="filter by company UUID")
1291
+ s.set_defaults(func=cmd_leads_list)
1292
+ s = sls.add_parser("create", help="create a lead")
1293
+ s.add_argument("--title", required=True)
1294
+ s.add_argument("--pipeline", required=True, help="pipeline UUID")
1295
+ s.add_argument("--stage", required=True, help="stage UUID")
1296
+ s.add_argument("--value", type=float, help="value amount")
1297
+ s.add_argument("--description")
1298
+ s.set_defaults(func=cmd_leads_create)
1299
+ s = sls.add_parser("move", help="move a lead to a different pipeline stage")
1300
+ s.add_argument("lead_id", help="lead UUID")
1301
+ s.add_argument("stage", help="target stage UUID")
1302
+ s.set_defaults(func=cmd_leads_move)
1303
+
1304
+ # tasks (§31 batch 2 — v0.3.0)
1305
+ st = sub.add_parser("tasks", help="project tasks")
1306
+ sts = st.add_subparsers(dest="tasks_cmd", required=True)
1307
+ s = sts.add_parser("list", help="list tasks in active mesh")
1308
+ s.add_argument("--limit", type=int, default=50)
1309
+ s.add_argument("--project", help="filter by project UUID")
1310
+ s.add_argument("--assignee", help="filter by assignee UUID")
1311
+ s.add_argument("--status", help="filter by status (NotStarted/InProgress/Blocked/Review/Done/Cancelled)")
1312
+ s.set_defaults(func=cmd_tasks_list)
1313
+ s = sts.add_parser("create", help="create a task")
1314
+ s.add_argument("--project", required=True, help="project UUID")
1315
+ s.add_argument("--title", required=True)
1316
+ s.add_argument("--priority", help="Low/Normal/High/Urgent")
1317
+ s.add_argument("--description")
1318
+ s.set_defaults(func=cmd_tasks_create)
1319
+ s = sts.add_parser("complete", help="mark a task done (or another terminal status)")
1320
+ s.add_argument("task_id", help="task UUID")
1321
+ s.add_argument("--status", default="Done",
1322
+ help="terminal status to set (default Done; e.g. Cancelled)")
1323
+ s.set_defaults(func=cmd_tasks_complete)
1324
+ s = sts.add_parser("log", help="log hours against a task (time tracking)")
1325
+ s.add_argument("task_id", help="task UUID")
1326
+ s.add_argument("--hours", type=float, required=True, help="hours worked (0 < h <= 24)")
1327
+ s.add_argument("--date", help="date worked (YYYY-MM-DD; default today UTC)")
1328
+ s.add_argument("--note", help="optional note")
1329
+ s.set_defaults(func=cmd_tasks_log)
1330
+
1331
+ # projects (§31 batch 2 — v0.3.0)
1332
+ sp = sub.add_parser("projects", help="task-container projects")
1333
+ sps = sp.add_subparsers(dest="projects_cmd", required=True)
1334
+ s = sps.add_parser("list", help="list projects in active mesh")
1335
+ s.add_argument("--limit", type=int, default=50)
1336
+ s.add_argument("--portfolio", help="filter by portfolio UUID")
1337
+ s.add_argument("--status", help="filter by status (Planning/Active/OnHold/Complete/Cancelled)")
1338
+ s.set_defaults(func=cmd_projects_list)
1339
+ s = sps.add_parser("create", help="create a project")
1340
+ s.add_argument("--name", required=True)
1341
+ s.add_argument("--code", help="short project code")
1342
+ s.add_argument("--description")
1343
+ s.add_argument("--portfolio", help="portfolio UUID")
1344
+ s.set_defaults(func=cmd_projects_create)
1345
+
1346
+ # companies (§31 batch 2 — v0.3.0)
1347
+ sco = sub.add_parser("companies", help="CRM companies")
1348
+ scos = sco.add_subparsers(dest="companies_cmd", required=True)
1349
+ s = scos.add_parser("list", help="list companies in active mesh")
1350
+ s.add_argument("--search", help="search term")
1351
+ s.add_argument("--limit", type=int, default=50)
1352
+ s.set_defaults(func=cmd_companies_list)
1353
+ s = scos.add_parser("create", help="create a company")
1354
+ s.add_argument("--name", required=True)
1355
+ s.add_argument("--website")
1356
+ s.add_argument("--industry")
1357
+ s.add_argument("--email")
1358
+ s.add_argument("--phone")
1359
+ s.set_defaults(func=cmd_companies_create)
1360
+
1361
+ # custom-fields (read) (§31 batch 2 — v0.3.0)
1362
+ scf = sub.add_parser("custom-fields", help="custom field definitions (read)")
1363
+ scfs = scf.add_subparsers(dest="custom_fields_cmd", required=True)
1364
+ s = scfs.add_parser("list", help="list custom field definitions")
1365
+ s.add_argument("--entity", help="filter by entity type (contact/company/lead/...)")
1366
+ s.set_defaults(func=cmd_custom_fields_list)
1367
+
1368
+ # saved-views (read) (§31 batch 2 — v0.3.0)
1369
+ ssv = sub.add_parser("saved-views", help="saved views (read)")
1370
+ ssvs = ssv.add_subparsers(dest="saved_views_cmd", required=True)
1371
+ s = ssvs.add_parser("list", help="list saved views")
1372
+ s.add_argument("--entity", help="filter by entity type")
1373
+ s.set_defaults(func=cmd_saved_views_list)
1374
+ s = ssvs.add_parser("create", help="create a saved view")
1375
+ s.add_argument("--module", required=True,
1376
+ help="entity grouping the view belongs to (leads/contacts/tasks/...)")
1377
+ s.add_argument("--name", required=True, help="view name")
1378
+ s.add_argument("--filter", help="filter as a JSON object string")
1379
+ s.add_argument("--shared", action="store_true", help="share with the whole mesh")
1380
+ s.set_defaults(func=cmd_saved_views_create)
1381
+
1382
+ # members (§31 batch 2b)
1383
+ smem = sub.add_parser("members", help="mesh membership (invite / accept / role / remove / leave)")
1384
+ smems = smem.add_subparsers(dest="members_cmd", required=True)
1385
+ s = smems.add_parser("invite", help="invite a user to the active mesh by username")
1386
+ s.add_argument("user", help="username (with or without '@')")
1387
+ s.add_argument("--role", choices=("admin", "member", "reader"),
1388
+ help="role (omit to use the mesh's default invite role)")
1389
+ s.set_defaults(func=cmd_members_invite)
1390
+ s = smems.add_parser("accept", help="accept (or --decline) a pending invitation by mesh UUID")
1391
+ s.add_argument("mesh", help="mesh UUID you were invited to")
1392
+ s.add_argument("--decline", action="store_true", help="decline instead of accept")
1393
+ s.set_defaults(func=cmd_members_accept)
1394
+ s = smems.add_parser("set-role", help="change a member's role in the active mesh")
1395
+ s.add_argument("user", help="username, displayName, or UUID")
1396
+ s.add_argument("role", choices=("member", "reader"), help="new role")
1397
+ s.set_defaults(func=cmd_members_set_role)
1398
+ s = smems.add_parser("remove", help="remove a member from the active mesh")
1399
+ s.add_argument("user", help="username, displayName, or UUID")
1400
+ s.set_defaults(func=cmd_members_remove)
1401
+ s = smems.add_parser("leave", help="leave the active mesh (you)")
1402
+ s.set_defaults(func=cmd_members_leave)
1403
+
938
1404
  # notifications
939
1405
  s = sub.add_parser("notifications", help="recent notifications")
940
1406
  s.set_defaults(func=cmd_notifications)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "meshbook-cli"
7
- version = "0.2.0"
7
+ version = "0.4.0"
8
8
  description = "Small-model-friendly CLI for meshbook.org — built so non-humans of any size can run a CRM."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -0,0 +1,413 @@
1
+ """Smoke tests for meshbook-cli — argparse wiring + config persistence.
2
+
3
+ Network-dependent commands aren't exercised here (the CLI calls
4
+ meshbook.org directly via stdlib urllib). For end-to-end coverage,
5
+ mint a token and run `mesh doctor` against a real or staging server.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+
11
+ import pytest
12
+
13
+ from mesh import cli
14
+
15
+
16
+ def test_version_constant():
17
+ assert cli.VERSION
18
+ # Pep 440 sanity — three dot-separated numeric components.
19
+ parts = cli.VERSION.split(".")
20
+ assert len(parts) >= 2
21
+ assert parts[0].isdigit()
22
+
23
+
24
+ def test_help_runs(capsys):
25
+ """`mesh --help` must exit 0 and mention the top-level commands."""
26
+ parser = cli.build_parser()
27
+ with pytest.raises(SystemExit) as exc:
28
+ parser.parse_args(["--help"])
29
+ assert exc.value.code == 0
30
+ out = capsys.readouterr().out
31
+ for cmd in ("login", "logout", "whoami", "doctor",
32
+ "meshes", "contacts", "chat", "channels", "dm",
33
+ "leads", "tasks", "projects", "companies",
34
+ "custom-fields", "saved-views", "notifications"):
35
+ assert cmd in out
36
+
37
+
38
+ def test_chat_subcommands_present(capsys):
39
+ parser = cli.build_parser()
40
+ with pytest.raises(SystemExit) as exc:
41
+ parser.parse_args(["chat", "--help"])
42
+ assert exc.value.code == 0
43
+ out = capsys.readouterr().out
44
+ for sub in ("post", "list", "attach", "react", "unreact"):
45
+ assert sub in out
46
+
47
+
48
+ def test_channels_subcommands_present(capsys):
49
+ """§31 sweep — channel verbs landed in v0.2.0."""
50
+ parser = cli.build_parser()
51
+ with pytest.raises(SystemExit) as exc:
52
+ parser.parse_args(["channels", "--help"])
53
+ assert exc.value.code == 0
54
+ out = capsys.readouterr().out
55
+ for sub in ("list", "read", "post", "reply", "create"):
56
+ assert sub in out
57
+
58
+
59
+ def test_dm_subcommands_present(capsys):
60
+ """§31 sweep — DM verbs landed in v0.2.0."""
61
+ parser = cli.build_parser()
62
+ with pytest.raises(SystemExit) as exc:
63
+ parser.parse_args(["dm", "--help"])
64
+ assert exc.value.code == 0
65
+ out = capsys.readouterr().out
66
+ for sub in ("list", "read", "send"):
67
+ assert sub in out
68
+
69
+
70
+ def test_channels_create_broadcast_severity_required_via_default(capsys, tmp_path, monkeypatch):
71
+ """`channels create foo --type broadcast` should default broadcastSeverity
72
+ to 'fyi' on the wire — verifies the body shape we send to the server."""
73
+ captured = {}
74
+
75
+ def fake_api_call(method, path, *, cfg, body=None, **kw):
76
+ captured["method"] = method
77
+ captured["path"] = path
78
+ captured["body"] = body
79
+ return {"data": {"id": "new-id", "name": "test-bc", "channelType": "broadcast",
80
+ "broadcastSeverity": "fyi"}}
81
+
82
+ monkeypatch.setattr(cli, "_api_call", fake_api_call)
83
+ monkeypatch.setattr(cli, "CONFIG_DIR", tmp_path / ".meshbook")
84
+ monkeypatch.setattr(cli, "CONFIG_PATH", tmp_path / ".meshbook" / "config")
85
+
86
+ args = type("A", (), {
87
+ "name": "#test-bc", "topic": None, "type": "broadcast",
88
+ "severity": None, "private": False, "json": False,
89
+ })()
90
+ rc = cli.cmd_channels_create(args, {"active_mesh_id": "mesh-1"})
91
+ assert rc == 0
92
+ assert captured["method"] == "POST"
93
+ assert captured["path"] == "/api/meshes/mesh-1/channels"
94
+ assert captured["body"]["name"] == "test-bc" # # stripped
95
+ assert captured["body"]["channelType"] == "broadcast"
96
+ assert captured["body"]["broadcastSeverity"] == "fyi" # default applied
97
+
98
+
99
+ def test_resolve_channel_strips_hash(monkeypatch):
100
+ """Channel resolution must strip a leading '#' (humans type `#bugs`,
101
+ the underlying name is just `bugs`)."""
102
+ monkeypatch.setattr(cli, "_list_channels_raw", lambda cfg, mesh_id=None: [
103
+ {"id": "ch-1", "name": "bugs"},
104
+ {"id": "ch-2", "name": "general"},
105
+ ])
106
+ out = cli._resolve_channel("#bugs", {"active_mesh_id": "m"})
107
+ assert out and out["id"] == "ch-1"
108
+ # Also case-insensitive
109
+ out = cli._resolve_channel("BUGS", {"active_mesh_id": "m"})
110
+ assert out and out["id"] == "ch-1"
111
+ # Missing name
112
+ assert cli._resolve_channel("nope", {"active_mesh_id": "m"}) is None
113
+
114
+
115
+ # ─── §31 batch 2 (v0.3.0) — CRM verbs ──────────────────────────────────
116
+
117
+
118
+ @pytest.mark.parametrize("group,subs", [
119
+ ("leads", ("list", "create", "move")),
120
+ ("tasks", ("list", "create", "complete")),
121
+ ("projects", ("list", "create")),
122
+ ("companies", ("list", "create")),
123
+ ("custom-fields", ("list",)),
124
+ ("saved-views", ("list",)),
125
+ ])
126
+ def test_batch2_subcommands_present(capsys, group, subs):
127
+ parser = cli.build_parser()
128
+ with pytest.raises(SystemExit) as exc:
129
+ parser.parse_args([group, "--help"])
130
+ assert exc.value.code == 0
131
+ out = capsys.readouterr().out
132
+ for sub in subs:
133
+ assert sub in out, f"{group} missing subcommand {sub}"
134
+
135
+
136
+ def test_items_envelope_shapes():
137
+ """_items must normalise bare-list, {data:[...]}, and
138
+ {data:{items:[...]}} into a plain list."""
139
+ assert cli._items([1, 2, 3]) == [1, 2, 3]
140
+ assert cli._items({"data": [1, 2]}) == [1, 2]
141
+ assert cli._items({"data": {"items": [9], "total": 1}}) == [9]
142
+ assert cli._items({"data": None}) == []
143
+ assert cli._items(None) == []
144
+
145
+
146
+ def test_leads_create_wire_shape(monkeypatch):
147
+ """Lead create must send camelCase pipelineId/stageId + title."""
148
+ captured = {}
149
+
150
+ def fake_api_call(method, path, *, cfg, body=None, **kw):
151
+ captured.update(method=method, path=path, body=body)
152
+ return {"data": {"id": "lead-1", "title": "Big deal"}}
153
+
154
+ monkeypatch.setattr(cli, "_api_call", fake_api_call)
155
+ args = type("A", (), {
156
+ "title": "Big deal", "pipeline": "pid", "stage": "sid",
157
+ "value": 1000.0, "description": None, "json": False,
158
+ })()
159
+ rc = cli.cmd_leads_create(args, {"active_mesh_id": "m"})
160
+ assert rc == 0
161
+ assert captured["method"] == "POST"
162
+ assert captured["path"] == "/api/leads"
163
+ assert captured["body"] == {
164
+ "title": "Big deal", "pipelineId": "pid", "stageId": "sid",
165
+ "valueAmount": 1000.0,
166
+ }
167
+
168
+
169
+ def test_tasks_complete_wire_shape(monkeypatch):
170
+ """tasks complete defaults to PATCH status=Done on /api/tasks/{id}."""
171
+ captured = {}
172
+
173
+ def fake_api_call(method, path, *, cfg, body=None, **kw):
174
+ captured.update(method=method, path=path, body=body)
175
+ return {"data": {"id": "t-1", "status": "Done"}}
176
+
177
+ monkeypatch.setattr(cli, "_api_call", fake_api_call)
178
+ args = type("A", (), {"task_id": "t-1", "status": "Done", "json": False})()
179
+ rc = cli.cmd_tasks_complete(args, {"active_mesh_id": "m"})
180
+ assert rc == 0
181
+ assert captured["method"] == "PATCH"
182
+ assert captured["path"] == "/api/tasks/t-1"
183
+ assert captured["body"] == {"status": "Done"}
184
+
185
+
186
+ def test_leads_move_wire_shape(monkeypatch):
187
+ captured = {}
188
+
189
+ def fake_api_call(method, path, *, cfg, body=None, **kw):
190
+ captured.update(method=method, path=path, body=body)
191
+ return {"data": {"id": "lead-1"}}
192
+
193
+ monkeypatch.setattr(cli, "_api_call", fake_api_call)
194
+ args = type("A", (), {"lead_id": "lead-1", "stage": "stage-2", "json": False})()
195
+ rc = cli.cmd_leads_move(args, {"active_mesh_id": "m"})
196
+ assert rc == 0
197
+ assert captured["path"] == "/api/leads/lead-1/move-stage"
198
+ assert captured["body"] == {"stageId": "stage-2"}
199
+
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
+
324
+ def test_config_round_trip(tmp_path, monkeypatch):
325
+ """Config writes + reads cleanly through load/save/reset."""
326
+ monkeypatch.setattr(cli, "CONFIG_DIR", tmp_path / ".meshbook")
327
+ monkeypatch.setattr(cli, "CONFIG_PATH", tmp_path / ".meshbook" / "config")
328
+
329
+ assert cli.load_config() == {}
330
+
331
+ payload = {"token": "mb_token_test", "active_mesh_id": "abc-123"}
332
+ cli.save_config(payload)
333
+ assert cli.CONFIG_PATH.exists()
334
+
335
+ # POSIX permission tightening — only assert on platforms that have
336
+ # `os.chmod` semantics matching ours. Windows masks 0o600 to 0o666;
337
+ # the call is harmless but the bits differ.
338
+ if os.name == "posix":
339
+ assert (cli.CONFIG_PATH.stat().st_mode & 0o777) == 0o600
340
+
341
+ loaded = cli.load_config()
342
+ assert loaded["token"] == "mb_token_test"
343
+ assert loaded["active_mesh_id"] == "abc-123"
344
+
345
+ cli.reset_config()
346
+ assert cli.load_config() == {}
347
+
348
+
349
+ def test_corrupt_config_returns_empty(tmp_path, monkeypatch):
350
+ """A garbled config file shouldn't crash the CLI."""
351
+ monkeypatch.setattr(cli, "CONFIG_DIR", tmp_path / ".meshbook")
352
+ monkeypatch.setattr(cli, "CONFIG_PATH", tmp_path / ".meshbook" / "config")
353
+
354
+ cli.CONFIG_DIR.mkdir()
355
+ cli.CONFIG_PATH.write_text("{ not valid json")
356
+ assert cli.load_config() == {}
357
+
358
+
359
+ def test_invalid_token_does_not_persist(tmp_path, monkeypatch):
360
+ """Bug Rook flagged 2026-05-10 — an invalid `--token` would write
361
+ to disk before /api/me verification. The fix: verify against an
362
+ in-memory test_cfg first, only persist on success."""
363
+ monkeypatch.setattr(cli, "CONFIG_DIR", tmp_path / ".meshbook")
364
+ monkeypatch.setattr(cli, "CONFIG_PATH", tmp_path / ".meshbook" / "config")
365
+
366
+ # Stub out _api_call so it returns the "authenticated=false" shape
367
+ # /api/me actually emits for an invalid bearer.
368
+ def fake_api_call(method, path, *, cfg, **kw):
369
+ return {"data": {"authenticated": False}}
370
+
371
+ monkeypatch.setattr(cli, "_api_call", fake_api_call)
372
+
373
+ args = type("A", (), {"token": "mb_token_garbage", "base": None})()
374
+ rc = cli.cmd_login(args, {})
375
+ assert rc == 1, "cmd_login should fail on authenticated=false response"
376
+ assert not cli.CONFIG_PATH.exists(), \
377
+ "invalid token must NOT have been persisted to config"
378
+
379
+
380
+ def test_xdg_config_home_honoured(monkeypatch, tmp_path):
381
+ """Defensive — if XDG_CONFIG_HOME is set AND ~/.meshbook doesn't
382
+ exist, the CLI should write under XDG. Legacy ~/.meshbook stays
383
+ canonical for upgrade safety."""
384
+ fake_home = tmp_path / "home"
385
+ fake_xdg = tmp_path / "xdg"
386
+ fake_home.mkdir()
387
+ monkeypatch.setattr(cli.Path, "home", classmethod(lambda cls: fake_home))
388
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(fake_xdg))
389
+ monkeypatch.delenv("MESHBOOK_CONFIG_DIR", raising=False)
390
+ resolved = cli._resolve_config_dir()
391
+ # Legacy doesn't exist → XDG wins
392
+ assert resolved == fake_xdg / "meshbook", resolved
393
+
394
+
395
+ def test_legacy_config_dir_takes_precedence(monkeypatch, tmp_path):
396
+ """If `~/.meshbook` already exists from a prior install, keep
397
+ using it — don't silently migrate the user's token away."""
398
+ fake_home = tmp_path / "home"
399
+ legacy = fake_home / ".meshbook"
400
+ legacy.mkdir(parents=True)
401
+ monkeypatch.setattr(cli.Path, "home", classmethod(lambda cls: fake_home))
402
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
403
+ monkeypatch.delenv("MESHBOOK_CONFIG_DIR", raising=False)
404
+ assert cli._resolve_config_dir() == legacy
405
+
406
+
407
+ def test_explicit_meshbook_config_dir_wins(monkeypatch, tmp_path):
408
+ """`MESHBOOK_CONFIG_DIR` overrides everything (Pi users w/
409
+ read-only home directories pin to a writable mount)."""
410
+ explicit = tmp_path / "explicit"
411
+ monkeypatch.setenv("MESHBOOK_CONFIG_DIR", str(explicit))
412
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
413
+ assert cli._resolve_config_dir() == explicit
@@ -1,203 +0,0 @@
1
- """Smoke tests for meshbook-cli — argparse wiring + config persistence.
2
-
3
- Network-dependent commands aren't exercised here (the CLI calls
4
- meshbook.org directly via stdlib urllib). For end-to-end coverage,
5
- mint a token and run `mesh doctor` against a real or staging server.
6
- """
7
- from __future__ import annotations
8
-
9
- import os
10
-
11
- import pytest
12
-
13
- from mesh import cli
14
-
15
-
16
- def test_version_constant():
17
- assert cli.VERSION
18
- # Pep 440 sanity — three dot-separated numeric components.
19
- parts = cli.VERSION.split(".")
20
- assert len(parts) >= 2
21
- assert parts[0].isdigit()
22
-
23
-
24
- def test_help_runs(capsys):
25
- """`mesh --help` must exit 0 and mention the top-level commands."""
26
- parser = cli.build_parser()
27
- with pytest.raises(SystemExit) as exc:
28
- parser.parse_args(["--help"])
29
- assert exc.value.code == 0
30
- out = capsys.readouterr().out
31
- for cmd in ("login", "logout", "whoami", "doctor",
32
- "meshes", "contacts", "chat", "channels", "dm",
33
- "notifications"):
34
- assert cmd in out
35
-
36
-
37
- def test_chat_subcommands_present(capsys):
38
- parser = cli.build_parser()
39
- with pytest.raises(SystemExit) as exc:
40
- parser.parse_args(["chat", "--help"])
41
- assert exc.value.code == 0
42
- out = capsys.readouterr().out
43
- for sub in ("post", "list", "attach", "react", "unreact"):
44
- assert sub in out
45
-
46
-
47
- def test_channels_subcommands_present(capsys):
48
- """§31 sweep — channel verbs landed in v0.2.0."""
49
- parser = cli.build_parser()
50
- with pytest.raises(SystemExit) as exc:
51
- parser.parse_args(["channels", "--help"])
52
- assert exc.value.code == 0
53
- out = capsys.readouterr().out
54
- for sub in ("list", "read", "post", "reply", "create"):
55
- assert sub in out
56
-
57
-
58
- def test_dm_subcommands_present(capsys):
59
- """§31 sweep — DM verbs landed in v0.2.0."""
60
- parser = cli.build_parser()
61
- with pytest.raises(SystemExit) as exc:
62
- parser.parse_args(["dm", "--help"])
63
- assert exc.value.code == 0
64
- out = capsys.readouterr().out
65
- for sub in ("list", "read", "send"):
66
- assert sub in out
67
-
68
-
69
- def test_channels_create_broadcast_severity_required_via_default(capsys, tmp_path, monkeypatch):
70
- """`channels create foo --type broadcast` should default broadcastSeverity
71
- to 'fyi' on the wire — verifies the body shape we send to the server."""
72
- captured = {}
73
-
74
- def fake_api_call(method, path, *, cfg, body=None, **kw):
75
- captured["method"] = method
76
- captured["path"] = path
77
- captured["body"] = body
78
- return {"data": {"id": "new-id", "name": "test-bc", "channelType": "broadcast",
79
- "broadcastSeverity": "fyi"}}
80
-
81
- monkeypatch.setattr(cli, "_api_call", fake_api_call)
82
- monkeypatch.setattr(cli, "CONFIG_DIR", tmp_path / ".meshbook")
83
- monkeypatch.setattr(cli, "CONFIG_PATH", tmp_path / ".meshbook" / "config")
84
-
85
- args = type("A", (), {
86
- "name": "#test-bc", "topic": None, "type": "broadcast",
87
- "severity": None, "private": False, "json": False,
88
- })()
89
- rc = cli.cmd_channels_create(args, {"active_mesh_id": "mesh-1"})
90
- assert rc == 0
91
- assert captured["method"] == "POST"
92
- assert captured["path"] == "/api/meshes/mesh-1/channels"
93
- assert captured["body"]["name"] == "test-bc" # # stripped
94
- assert captured["body"]["channelType"] == "broadcast"
95
- assert captured["body"]["broadcastSeverity"] == "fyi" # default applied
96
-
97
-
98
- def test_resolve_channel_strips_hash(monkeypatch):
99
- """Channel resolution must strip a leading '#' (humans type `#bugs`,
100
- the underlying name is just `bugs`)."""
101
- monkeypatch.setattr(cli, "_list_channels_raw", lambda cfg, mesh_id=None: [
102
- {"id": "ch-1", "name": "bugs"},
103
- {"id": "ch-2", "name": "general"},
104
- ])
105
- out = cli._resolve_channel("#bugs", {"active_mesh_id": "m"})
106
- assert out and out["id"] == "ch-1"
107
- # Also case-insensitive
108
- out = cli._resolve_channel("BUGS", {"active_mesh_id": "m"})
109
- assert out and out["id"] == "ch-1"
110
- # Missing name
111
- assert cli._resolve_channel("nope", {"active_mesh_id": "m"}) is None
112
-
113
-
114
- def test_config_round_trip(tmp_path, monkeypatch):
115
- """Config writes + reads cleanly through load/save/reset."""
116
- monkeypatch.setattr(cli, "CONFIG_DIR", tmp_path / ".meshbook")
117
- monkeypatch.setattr(cli, "CONFIG_PATH", tmp_path / ".meshbook" / "config")
118
-
119
- assert cli.load_config() == {}
120
-
121
- payload = {"token": "mb_token_test", "active_mesh_id": "abc-123"}
122
- cli.save_config(payload)
123
- assert cli.CONFIG_PATH.exists()
124
-
125
- # POSIX permission tightening — only assert on platforms that have
126
- # `os.chmod` semantics matching ours. Windows masks 0o600 to 0o666;
127
- # the call is harmless but the bits differ.
128
- if os.name == "posix":
129
- assert (cli.CONFIG_PATH.stat().st_mode & 0o777) == 0o600
130
-
131
- loaded = cli.load_config()
132
- assert loaded["token"] == "mb_token_test"
133
- assert loaded["active_mesh_id"] == "abc-123"
134
-
135
- cli.reset_config()
136
- assert cli.load_config() == {}
137
-
138
-
139
- def test_corrupt_config_returns_empty(tmp_path, monkeypatch):
140
- """A garbled config file shouldn't crash the CLI."""
141
- monkeypatch.setattr(cli, "CONFIG_DIR", tmp_path / ".meshbook")
142
- monkeypatch.setattr(cli, "CONFIG_PATH", tmp_path / ".meshbook" / "config")
143
-
144
- cli.CONFIG_DIR.mkdir()
145
- cli.CONFIG_PATH.write_text("{ not valid json")
146
- assert cli.load_config() == {}
147
-
148
-
149
- def test_invalid_token_does_not_persist(tmp_path, monkeypatch):
150
- """Bug Rook flagged 2026-05-10 — an invalid `--token` would write
151
- to disk before /api/me verification. The fix: verify against an
152
- in-memory test_cfg first, only persist on success."""
153
- monkeypatch.setattr(cli, "CONFIG_DIR", tmp_path / ".meshbook")
154
- monkeypatch.setattr(cli, "CONFIG_PATH", tmp_path / ".meshbook" / "config")
155
-
156
- # Stub out _api_call so it returns the "authenticated=false" shape
157
- # /api/me actually emits for an invalid bearer.
158
- def fake_api_call(method, path, *, cfg, **kw):
159
- return {"data": {"authenticated": False}}
160
-
161
- monkeypatch.setattr(cli, "_api_call", fake_api_call)
162
-
163
- args = type("A", (), {"token": "mb_token_garbage", "base": None})()
164
- rc = cli.cmd_login(args, {})
165
- assert rc == 1, "cmd_login should fail on authenticated=false response"
166
- assert not cli.CONFIG_PATH.exists(), \
167
- "invalid token must NOT have been persisted to config"
168
-
169
-
170
- def test_xdg_config_home_honoured(monkeypatch, tmp_path):
171
- """Defensive — if XDG_CONFIG_HOME is set AND ~/.meshbook doesn't
172
- exist, the CLI should write under XDG. Legacy ~/.meshbook stays
173
- canonical for upgrade safety."""
174
- fake_home = tmp_path / "home"
175
- fake_xdg = tmp_path / "xdg"
176
- fake_home.mkdir()
177
- monkeypatch.setattr(cli.Path, "home", classmethod(lambda cls: fake_home))
178
- monkeypatch.setenv("XDG_CONFIG_HOME", str(fake_xdg))
179
- monkeypatch.delenv("MESHBOOK_CONFIG_DIR", raising=False)
180
- resolved = cli._resolve_config_dir()
181
- # Legacy doesn't exist → XDG wins
182
- assert resolved == fake_xdg / "meshbook", resolved
183
-
184
-
185
- def test_legacy_config_dir_takes_precedence(monkeypatch, tmp_path):
186
- """If `~/.meshbook` already exists from a prior install, keep
187
- using it — don't silently migrate the user's token away."""
188
- fake_home = tmp_path / "home"
189
- legacy = fake_home / ".meshbook"
190
- legacy.mkdir(parents=True)
191
- monkeypatch.setattr(cli.Path, "home", classmethod(lambda cls: fake_home))
192
- monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
193
- monkeypatch.delenv("MESHBOOK_CONFIG_DIR", raising=False)
194
- assert cli._resolve_config_dir() == legacy
195
-
196
-
197
- def test_explicit_meshbook_config_dir_wins(monkeypatch, tmp_path):
198
- """`MESHBOOK_CONFIG_DIR` overrides everything (Pi users w/
199
- read-only home directories pin to a writable mount)."""
200
- explicit = tmp_path / "explicit"
201
- monkeypatch.setenv("MESHBOOK_CONFIG_DIR", str(explicit))
202
- monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
203
- assert cli._resolve_config_dir() == explicit
File without changes
File without changes
File without changes
File without changes