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.
@@ -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.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.3.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")
@@ -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.5.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