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