Skip to content

Commit e93e7ad

Browse files
tylnexttimeclaude
andcommitted
v0.3.0 — §31 parity sweep batch 2-core: CRM verbs
Adds the daily-driver CRM verbs so the CLI is a full working client: - leads: list / create / move-stage - tasks: list / create / complete - projects: list / create - companies: list / create - custom-fields: list (read) - saved-views: list (read) Plus an _items() envelope-normalisation helper. Covers §31 priority groups 1/2/3/5/9/10. Batch 2b (files, api-tokens, mesh-management, saved-view apply/create, task time-log) remains. 23 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9074897 commit e93e7ad

3 files changed

Lines changed: 363 additions & 3 deletions

File tree

mesh/cli.py

Lines changed: 274 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.2.0"
52+
VERSION = "0.3.0"
5353
DEFAULT_BASE = os.environ.get("MESHBOOK_BASE", "https://meshbook.org")
5454

5555

@@ -165,6 +165,18 @@ def _data(payload: dict) -> object:
165165
return payload
166166

167167

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+
168180
# ─── command implementations ───────────────────────────────────────────
169181

170182

@@ -814,6 +826,181 @@ def cmd_notifications(args, cfg: dict) -> int:
814826
return 0
815827

816828

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+
8171004
# ─── argparse plumbing ─────────────────────────────────────────────────
8181005

8191006

@@ -935,6 +1122,92 @@ def build_parser() -> argparse.ArgumentParser:
9351122
s.add_argument("message", help="message body (markdown)")
9361123
s.set_defaults(func=cmd_dm_send)
9371124

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+
9381211
# notifications
9391212
s = sub.add_parser("notifications", help="recent notifications")
9401213
s.set_defaults(func=cmd_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.2.0"
7+
version = "0.3.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"

0 commit comments

Comments
 (0)