Skip to content

Commit 0786997

Browse files
tylnexttimeclaude
andcommitted
v0.4.0 — §31 parity sweep batch 2b: members / time-logs / saved-view create / chat reply
Adds the remaining daily-driver verbs, each verified against the meshbook routers before wiring: - members invite / accept / set-role / remove / leave - tasks log (task time-logs) - chat post --reply-to (entity-chat threading via parentMessageId) - saved-views create (note: create field is `module`, not `entityType`) Deliberately omits api-tokens management — /api/me/api-tokens refuses bearer (API-token) callers by design, so those verbs would only ever 403 from a token-authed CLI. Token management stays SPA-cookie-only. 12 new tests (presence + wire-shape, incl. a regression guard on the saved-view `module` field and the §49 AM-can't-leave path); 35/35 pass. Fixed a Windows cp1252 crash in `tasks log --help` (a non-ASCII char in help text broke argparse's help print on a cp1252 console). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a8554b0 commit 0786997

3 files changed

Lines changed: 318 additions & 2 deletions

File tree

mesh/cli.py

Lines changed: 194 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
import urllib.request
5050
from pathlib import Path
5151

52-
VERSION = "0.3.0"
52+
VERSION = "0.4.0"
5353
DEFAULT_BASE = os.environ.get("MESHBOOK_BASE", "https://meshbook.org")
5454

5555

@@ -386,6 +386,10 @@ def cmd_chat_post(args, cfg: dict) -> int:
386386
return 2
387387
mesh_id = cfg["active_mesh_id"]
388388
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
389393
payload = _api_call(
390394
"POST", f"/api/entities/mesh/{mesh_id}/chat", cfg=cfg, body=body
391395
)
@@ -1001,6 +1005,158 @@ def cmd_saved_views_list(args, cfg: dict) -> int:
10011005
return 0
10021006

10031007

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+
10041160
# ─── argparse plumbing ─────────────────────────────────────────────────
10051161

10061162

@@ -1054,6 +1210,8 @@ def build_parser() -> argparse.ArgumentParser:
10541210
chs = ch.add_subparsers(dest="chat_cmd", required=True)
10551211
s = chs.add_parser("post", help="post a message in active mesh")
10561212
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")
10571215
s.set_defaults(func=cmd_chat_post)
10581216
s = chs.add_parser("list", help="recent messages in active mesh")
10591217
s.add_argument("--limit", type=int, default=20)
@@ -1163,6 +1321,12 @@ def build_parser() -> argparse.ArgumentParser:
11631321
s.add_argument("--status", default="Done",
11641322
help="terminal status to set (default Done; e.g. Cancelled)")
11651323
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)
11661330

11671331
# projects (§31 batch 2 — v0.3.0)
11681332
sp = sub.add_parser("projects", help="task-container projects")
@@ -1207,6 +1371,35 @@ def build_parser() -> argparse.ArgumentParser:
12071371
s = ssvs.add_parser("list", help="list saved views")
12081372
s.add_argument("--entity", help="filter by entity type")
12091373
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)
12101403

12111404
# notifications
12121405
s = sub.add_parser("notifications", help="recent notifications")

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "meshbook-cli"
7-
version = "0.3.0"
7+
version = "0.4.0"
88
description = "Small-model-friendly CLI for meshbook.org — built so non-humans of any size can run a CRM."
99
readme = "README.md"
1010
license = "MIT"

tests/test_cli_smoke.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,129 @@ def fake_api_call(method, path, *, cfg, body=None, **kw):
198198
assert captured["body"] == {"stageId": "stage-2"}
199199

200200

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+
201324
def test_config_round_trip(tmp_path, monkeypatch):
202325
"""Config writes + reads cleanly through load/save/reset."""
203326
monkeypatch.setattr(cli, "CONFIG_DIR", tmp_path / ".meshbook")

0 commit comments

Comments
 (0)