meshbook-cli 0.3.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.3.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.3.0"
52
+ VERSION = "0.4.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,158 @@ 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_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
+
1004
1160
  # ─── argparse plumbing ─────────────────────────────────────────────────
1005
1161
 
1006
1162
 
@@ -1054,6 +1210,8 @@ def build_parser() -> argparse.ArgumentParser:
1054
1210
  chs = ch.add_subparsers(dest="chat_cmd", required=True)
1055
1211
  s = chs.add_parser("post", help="post a message in active mesh")
1056
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")
1057
1215
  s.set_defaults(func=cmd_chat_post)
1058
1216
  s = chs.add_parser("list", help="recent messages in active mesh")
1059
1217
  s.add_argument("--limit", type=int, default=20)
@@ -1163,6 +1321,12 @@ def build_parser() -> argparse.ArgumentParser:
1163
1321
  s.add_argument("--status", default="Done",
1164
1322
  help="terminal status to set (default Done; e.g. Cancelled)")
1165
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)
1166
1330
 
1167
1331
  # projects (§31 batch 2 — v0.3.0)
1168
1332
  sp = sub.add_parser("projects", help="task-container projects")
@@ -1207,6 +1371,35 @@ def build_parser() -> argparse.ArgumentParser:
1207
1371
  s = ssvs.add_parser("list", help="list saved views")
1208
1372
  s.add_argument("--entity", help="filter by entity type")
1209
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)
1210
1403
 
1211
1404
  # notifications
1212
1405
  s = sub.add_parser("notifications", help="recent notifications")
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "meshbook-cli"
7
- version = "0.3.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"
@@ -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