|
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") |
|
0 commit comments