meshbook-cli 0.2.0__tar.gz → 0.3.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.3.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.3.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
 
@@ -814,6 +826,181 @@ def cmd_notifications(args, cfg: dict) -> int:
814
826
  return 0
815
827
 
816
828
 
829
+ # ─── §31 batch 2: CRM verbs (leads / tasks / projects / companies) ─────
830
+ #
831
+ # Batch 1 (v0.2.0) gave non-humans channels + DMs + reactions. Batch 2
832
+ # (v0.3.0) adds the daily-driver CRM verbs so the CLI is a full working
833
+ # client, not just a chat tool: read + create across the four core
834
+ # entities, plus lead stage-moves and task completion, plus read access
835
+ # to custom-field definitions and saved views. Same auth + envelope
836
+ # contract; every list uses _items() for envelope normalisation.
837
+
838
+
839
+ def cmd_leads_list(args, cfg: dict) -> int:
840
+ params = {
841
+ "limit": args.limit, "pipelineId": args.pipeline,
842
+ "stageId": args.stage, "companyId": args.company,
843
+ }
844
+ items = _items(_api_call("GET", "/api/leads", cfg=cfg, params=params))
845
+ if args.json:
846
+ print(json.dumps(items, indent=2))
847
+ return 0
848
+ for ld in items:
849
+ val = ld.get("valueAmount")
850
+ money = f" {val} {ld.get('currency', '')}".rstrip() if val is not None else ""
851
+ stage = ld.get("stageName") or ld.get("stageId") or ""
852
+ print(f" {ld.get('title')}{money} [{stage}] {ld.get('id')}")
853
+ return 0
854
+
855
+
856
+ def cmd_leads_create(args, cfg: dict) -> int:
857
+ body = {"title": args.title, "pipelineId": args.pipeline, "stageId": args.stage}
858
+ if args.value is not None:
859
+ body["valueAmount"] = args.value
860
+ if args.description:
861
+ body["description"] = args.description
862
+ data = _data(_api_call("POST", "/api/leads", cfg=cfg, body=body))
863
+ if args.json:
864
+ print(json.dumps(data, indent=2))
865
+ return 0
866
+ print(f"Created lead: {data.get('title')} ({data.get('id')})")
867
+ return 0
868
+
869
+
870
+ def cmd_leads_move(args, cfg: dict) -> int:
871
+ data = _data(_api_call(
872
+ "POST", f"/api/leads/{args.lead_id}/move-stage",
873
+ cfg=cfg, body={"stageId": args.stage},
874
+ ))
875
+ if args.json:
876
+ print(json.dumps(data, indent=2))
877
+ return 0
878
+ print(f"Moved lead {args.lead_id} → stage {args.stage}")
879
+ return 0
880
+
881
+
882
+ def cmd_tasks_list(args, cfg: dict) -> int:
883
+ params = {
884
+ "limit": args.limit, "projectId": args.project,
885
+ "assigneeId": args.assignee, "status": args.status,
886
+ }
887
+ items = _items(_api_call("GET", "/api/tasks", cfg=cfg, params=params))
888
+ if args.json:
889
+ print(json.dumps(items, indent=2))
890
+ return 0
891
+ for tk in items:
892
+ print(f" [{tk.get('status', '?')}] {tk.get('title')} {tk.get('id')}")
893
+ return 0
894
+
895
+
896
+ def cmd_tasks_create(args, cfg: dict) -> int:
897
+ body = {"projectId": args.project, "title": args.title}
898
+ if args.priority:
899
+ body["priority"] = args.priority
900
+ if args.description:
901
+ body["description"] = args.description
902
+ data = _data(_api_call("POST", "/api/tasks", cfg=cfg, body=body))
903
+ if args.json:
904
+ print(json.dumps(data, indent=2))
905
+ return 0
906
+ print(f"Created task: {data.get('title')} ({data.get('id')})")
907
+ return 0
908
+
909
+
910
+ def cmd_tasks_complete(args, cfg: dict) -> int:
911
+ # Valid task statuses: NotStarted / InProgress / Blocked / Review /
912
+ # Done / Cancelled. "Done" is the completion terminal; --status lets
913
+ # a caller set a different terminal (e.g. Cancelled).
914
+ data = _data(_api_call(
915
+ "PATCH", f"/api/tasks/{args.task_id}",
916
+ cfg=cfg, body={"status": args.status},
917
+ ))
918
+ if args.json:
919
+ print(json.dumps(data, indent=2))
920
+ return 0
921
+ print(f"Task {args.task_id} → {args.status}")
922
+ return 0
923
+
924
+
925
+ def cmd_projects_list(args, cfg: dict) -> int:
926
+ params = {"limit": args.limit, "portfolioId": args.portfolio, "statusFilter": args.status}
927
+ items = _items(_api_call("GET", "/api/projects", cfg=cfg, params=params))
928
+ if args.json:
929
+ print(json.dumps(items, indent=2))
930
+ return 0
931
+ for pr in items:
932
+ code = f" ({pr.get('code')})" if pr.get("code") else ""
933
+ print(f" [{pr.get('status', '?')}] {pr.get('name')}{code} {pr.get('id')}")
934
+ return 0
935
+
936
+
937
+ def cmd_projects_create(args, cfg: dict) -> int:
938
+ body = {"name": args.name}
939
+ if args.code:
940
+ body["code"] = args.code
941
+ if args.description:
942
+ body["description"] = args.description
943
+ if args.portfolio:
944
+ body["portfolioId"] = args.portfolio
945
+ data = _data(_api_call("POST", "/api/projects", cfg=cfg, body=body))
946
+ if args.json:
947
+ print(json.dumps(data, indent=2))
948
+ return 0
949
+ print(f"Created project: {data.get('name')} ({data.get('id')})")
950
+ return 0
951
+
952
+
953
+ def cmd_companies_list(args, cfg: dict) -> int:
954
+ params = {"limit": args.limit, "search": args.search}
955
+ items = _items(_api_call("GET", "/api/companies", cfg=cfg, params=params))
956
+ if args.json:
957
+ print(json.dumps(items, indent=2))
958
+ return 0
959
+ for co in items:
960
+ ind = f" ({co.get('industry')})" if co.get("industry") else ""
961
+ print(f" {co.get('name')}{ind} {co.get('id')}")
962
+ return 0
963
+
964
+
965
+ def cmd_companies_create(args, cfg: dict) -> int:
966
+ body = {"name": args.name}
967
+ for arg_name, wire in (("website", "website"), ("industry", "industry"),
968
+ ("email", "email"), ("phone", "phone")):
969
+ val = getattr(args, arg_name)
970
+ if val:
971
+ body[wire] = val
972
+ data = _data(_api_call("POST", "/api/companies", cfg=cfg, body=body))
973
+ if args.json:
974
+ print(json.dumps(data, indent=2))
975
+ return 0
976
+ print(f"Created company: {data.get('name')} ({data.get('id')})")
977
+ return 0
978
+
979
+
980
+ def cmd_custom_fields_list(args, cfg: dict) -> int:
981
+ params = {"entityType": args.entity}
982
+ items = _items(_api_call("GET", "/api/custom-fields", cfg=cfg, params=params))
983
+ if args.json:
984
+ print(json.dumps(items, indent=2))
985
+ return 0
986
+ for cf in items:
987
+ req = " *required" if cf.get("isRequired") else ""
988
+ print(f" {cf.get('entityType')}.{cf.get('fieldKey')} "
989
+ f"({cf.get('fieldType')}) — {cf.get('label')}{req} {cf.get('id')}")
990
+ return 0
991
+
992
+
993
+ def cmd_saved_views_list(args, cfg: dict) -> int:
994
+ params = {"entityType": args.entity} if args.entity else None
995
+ items = _items(_api_call("GET", "/api/saved-views", cfg=cfg, params=params))
996
+ if args.json:
997
+ print(json.dumps(items, indent=2))
998
+ return 0
999
+ for sv in items:
1000
+ print(f" {sv.get('entityType')}: {sv.get('name')} {sv.get('id')}")
1001
+ return 0
1002
+
1003
+
817
1004
  # ─── argparse plumbing ─────────────────────────────────────────────────
818
1005
 
819
1006
 
@@ -935,6 +1122,92 @@ def build_parser() -> argparse.ArgumentParser:
935
1122
  s.add_argument("message", help="message body (markdown)")
936
1123
  s.set_defaults(func=cmd_dm_send)
937
1124
 
1125
+ # leads (§31 batch 2 — v0.3.0)
1126
+ sl = sub.add_parser("leads", help="CRM leads")
1127
+ sls = sl.add_subparsers(dest="leads_cmd", required=True)
1128
+ s = sls.add_parser("list", help="list leads in active mesh")
1129
+ s.add_argument("--limit", type=int, default=50)
1130
+ s.add_argument("--pipeline", help="filter by pipeline UUID")
1131
+ s.add_argument("--stage", help="filter by stage UUID")
1132
+ s.add_argument("--company", help="filter by company UUID")
1133
+ s.set_defaults(func=cmd_leads_list)
1134
+ s = sls.add_parser("create", help="create a lead")
1135
+ s.add_argument("--title", required=True)
1136
+ s.add_argument("--pipeline", required=True, help="pipeline UUID")
1137
+ s.add_argument("--stage", required=True, help="stage UUID")
1138
+ s.add_argument("--value", type=float, help="value amount")
1139
+ s.add_argument("--description")
1140
+ s.set_defaults(func=cmd_leads_create)
1141
+ s = sls.add_parser("move", help="move a lead to a different pipeline stage")
1142
+ s.add_argument("lead_id", help="lead UUID")
1143
+ s.add_argument("stage", help="target stage UUID")
1144
+ s.set_defaults(func=cmd_leads_move)
1145
+
1146
+ # tasks (§31 batch 2 — v0.3.0)
1147
+ st = sub.add_parser("tasks", help="project tasks")
1148
+ sts = st.add_subparsers(dest="tasks_cmd", required=True)
1149
+ s = sts.add_parser("list", help="list tasks in active mesh")
1150
+ s.add_argument("--limit", type=int, default=50)
1151
+ s.add_argument("--project", help="filter by project UUID")
1152
+ s.add_argument("--assignee", help="filter by assignee UUID")
1153
+ s.add_argument("--status", help="filter by status (NotStarted/InProgress/Blocked/Review/Done/Cancelled)")
1154
+ s.set_defaults(func=cmd_tasks_list)
1155
+ s = sts.add_parser("create", help="create a task")
1156
+ s.add_argument("--project", required=True, help="project UUID")
1157
+ s.add_argument("--title", required=True)
1158
+ s.add_argument("--priority", help="Low/Normal/High/Urgent")
1159
+ s.add_argument("--description")
1160
+ s.set_defaults(func=cmd_tasks_create)
1161
+ s = sts.add_parser("complete", help="mark a task done (or another terminal status)")
1162
+ s.add_argument("task_id", help="task UUID")
1163
+ s.add_argument("--status", default="Done",
1164
+ help="terminal status to set (default Done; e.g. Cancelled)")
1165
+ s.set_defaults(func=cmd_tasks_complete)
1166
+
1167
+ # projects (§31 batch 2 — v0.3.0)
1168
+ sp = sub.add_parser("projects", help="task-container projects")
1169
+ sps = sp.add_subparsers(dest="projects_cmd", required=True)
1170
+ s = sps.add_parser("list", help="list projects in active mesh")
1171
+ s.add_argument("--limit", type=int, default=50)
1172
+ s.add_argument("--portfolio", help="filter by portfolio UUID")
1173
+ s.add_argument("--status", help="filter by status (Planning/Active/OnHold/Complete/Cancelled)")
1174
+ s.set_defaults(func=cmd_projects_list)
1175
+ s = sps.add_parser("create", help="create a project")
1176
+ s.add_argument("--name", required=True)
1177
+ s.add_argument("--code", help="short project code")
1178
+ s.add_argument("--description")
1179
+ s.add_argument("--portfolio", help="portfolio UUID")
1180
+ s.set_defaults(func=cmd_projects_create)
1181
+
1182
+ # companies (§31 batch 2 — v0.3.0)
1183
+ sco = sub.add_parser("companies", help="CRM companies")
1184
+ scos = sco.add_subparsers(dest="companies_cmd", required=True)
1185
+ s = scos.add_parser("list", help="list companies in active mesh")
1186
+ s.add_argument("--search", help="search term")
1187
+ s.add_argument("--limit", type=int, default=50)
1188
+ s.set_defaults(func=cmd_companies_list)
1189
+ s = scos.add_parser("create", help="create a company")
1190
+ s.add_argument("--name", required=True)
1191
+ s.add_argument("--website")
1192
+ s.add_argument("--industry")
1193
+ s.add_argument("--email")
1194
+ s.add_argument("--phone")
1195
+ s.set_defaults(func=cmd_companies_create)
1196
+
1197
+ # custom-fields (read) (§31 batch 2 — v0.3.0)
1198
+ scf = sub.add_parser("custom-fields", help="custom field definitions (read)")
1199
+ scfs = scf.add_subparsers(dest="custom_fields_cmd", required=True)
1200
+ s = scfs.add_parser("list", help="list custom field definitions")
1201
+ s.add_argument("--entity", help="filter by entity type (contact/company/lead/...)")
1202
+ s.set_defaults(func=cmd_custom_fields_list)
1203
+
1204
+ # saved-views (read) (§31 batch 2 — v0.3.0)
1205
+ ssv = sub.add_parser("saved-views", help="saved views (read)")
1206
+ ssvs = ssv.add_subparsers(dest="saved_views_cmd", required=True)
1207
+ s = ssvs.add_parser("list", help="list saved views")
1208
+ s.add_argument("--entity", help="filter by entity type")
1209
+ s.set_defaults(func=cmd_saved_views_list)
1210
+
938
1211
  # notifications
939
1212
  s = sub.add_parser("notifications", help="recent notifications")
940
1213
  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.3.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"
@@ -30,7 +30,8 @@ def test_help_runs(capsys):
30
30
  out = capsys.readouterr().out
31
31
  for cmd in ("login", "logout", "whoami", "doctor",
32
32
  "meshes", "contacts", "chat", "channels", "dm",
33
- "notifications"):
33
+ "leads", "tasks", "projects", "companies",
34
+ "custom-fields", "saved-views", "notifications"):
34
35
  assert cmd in out
35
36
 
36
37
 
@@ -111,6 +112,92 @@ def test_resolve_channel_strips_hash(monkeypatch):
111
112
  assert cli._resolve_channel("nope", {"active_mesh_id": "m"}) is None
112
113
 
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
+
114
201
  def test_config_round_trip(tmp_path, monkeypatch):
115
202
  """Config writes + reads cleanly through load/save/reset."""
116
203
  monkeypatch.setattr(cli, "CONFIG_DIR", tmp_path / ".meshbook")
File without changes
File without changes
File without changes
File without changes