cli-anything-g32 0.1.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.
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: cli-anything-g32
3
+ Version: 0.1.0
4
+ Summary: CLI-Anything harness for the G32 Nexus FastAPI backend
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: click>=8.0
7
+ Requires-Dist: requests>=2.28
8
+ Dynamic: requires-dist
9
+ Dynamic: requires-python
10
+ Dynamic: summary
@@ -0,0 +1,4 @@
1
+ from cli_anything.g32.g32_cli import cli
2
+
3
+ if __name__ == "__main__":
4
+ cli()
@@ -0,0 +1,27 @@
1
+ """Case notes domain operations."""
2
+
3
+ from typing import Any
4
+ from cli_anything.g32.core.client import G32Client
5
+
6
+
7
+ def list_case_notes(client: G32Client, patient_uuid: str) -> Any:
8
+ return client.get(f"/patient/case_notes/v2/{patient_uuid}")
9
+
10
+
11
+ def get_case_note(client: G32Client, note_uuid: str) -> Any:
12
+ return client.get(f"/patient/case_notes/v2/note/{note_uuid}")
13
+
14
+
15
+ def generate_case_note_report(client: G32Client, patient_id: str, design_type: str = "new") -> Any:
16
+ return client.post(
17
+ f"/patient/case_notes/v2/report/{patient_id}",
18
+ data={"designtype": design_type},
19
+ )
20
+
21
+
22
+ def list_case_note_categories(client: G32Client) -> Any:
23
+ return client.get("/patient/case_notes/v2/categories")
24
+
25
+
26
+ def list_case_note_templates(client: G32Client) -> Any:
27
+ return client.get("/patient/case_notes/v2/templates")
@@ -0,0 +1,45 @@
1
+ """HTTP client wrapper for the G32 Nexus FastAPI backend."""
2
+
3
+ import json
4
+ from typing import Any, Optional
5
+ import requests
6
+ from cli_anything.g32.utils.session import require_token
7
+
8
+
9
+ class G32Client:
10
+ def __init__(self, base_url: Optional[str] = None, token: Optional[str] = None):
11
+ if base_url and token:
12
+ self.base_url = base_url.rstrip("/")
13
+ self.token = token
14
+ else:
15
+ self.base_url, self.token = require_token()
16
+
17
+ def _headers(self) -> dict:
18
+ return {"Authorization": f"Bearer {self.token}"}
19
+
20
+ def get(self, path: str, params: Optional[dict] = None) -> Any:
21
+ url = f"{self.base_url}{path}"
22
+ r = requests.get(url, headers=self._headers(), params=params, timeout=30)
23
+ r.raise_for_status()
24
+ return r.json()
25
+
26
+ def post(self, path: str, data: Any = None, files: Optional[dict] = None) -> Any:
27
+ url = f"{self.base_url}{path}"
28
+ if files:
29
+ r = requests.post(url, headers=self._headers(), data=data, files=files, timeout=60)
30
+ else:
31
+ r = requests.post(url, headers=self._headers(), json=data, timeout=30)
32
+ r.raise_for_status()
33
+ return r.json()
34
+
35
+ def put(self, path: str, data: Any = None) -> Any:
36
+ url = f"{self.base_url}{path}"
37
+ r = requests.put(url, headers=self._headers(), json=data, timeout=30)
38
+ r.raise_for_status()
39
+ return r.json()
40
+
41
+ def delete(self, path: str) -> Any:
42
+ url = f"{self.base_url}{path}"
43
+ r = requests.delete(url, headers=self._headers(), timeout=30)
44
+ r.raise_for_status()
45
+ return r.json()
@@ -0,0 +1,16 @@
1
+ """Lab report domain operations."""
2
+
3
+ from typing import Any
4
+ from cli_anything.g32.core.client import G32Client
5
+
6
+
7
+ def get_lab_reports(client: G32Client, patient_uuid: str) -> Any:
8
+ return client.get(f"/health_info/lab_report/patient/{patient_uuid}")
9
+
10
+
11
+ def get_lab_report(client: G32Client, report_uuid: str) -> Any:
12
+ return client.get(f"/health_info/lab_report/get/{report_uuid}")
13
+
14
+
15
+ def get_lab_trends(client: G32Client, patient_uuid: str) -> Any:
16
+ return client.get(f"/health_info/lab_report/patient/{patient_uuid}/trends")
@@ -0,0 +1,20 @@
1
+ """Medication domain operations."""
2
+
3
+ from typing import Any
4
+ from cli_anything.g32.core.client import G32Client
5
+
6
+
7
+ def get_medications(client: G32Client, patient_id: str) -> Any:
8
+ return client.get(f"/patient/v3/medication/{patient_id}")
9
+
10
+
11
+ def get_daily_medication(client: G32Client, patient_id: str) -> Any:
12
+ return client.get(f"/daily-medication/{patient_id}")
13
+
14
+
15
+ def get_medication_monitoring(client: G32Client, patient_id: str) -> Any:
16
+ return client.get(f"/medication-monitoring/{patient_id}")
17
+
18
+
19
+ def get_health_summary(client: G32Client, patient_id: str) -> Any:
20
+ return client.get(f"/patient/v5/health-summary/{patient_id}")
@@ -0,0 +1,20 @@
1
+ """Patient domain operations."""
2
+
3
+ from typing import Any, Optional
4
+ from cli_anything.g32.core.client import G32Client
5
+
6
+
7
+ def search_patients(client: G32Client, term: str) -> Any:
8
+ return client.get(f"/patient/v2/search/{term}")
9
+
10
+
11
+ def list_patients(client: G32Client, page: int = 1) -> Any:
12
+ return client.get(f"/patient/v2/all/{page}")
13
+
14
+
15
+ def get_patient(client: G32Client, patient_id: str) -> Any:
16
+ return client.get(f"/patient/v2/get/{patient_id}")
17
+
18
+
19
+ def list_patients_by_condition(client: G32Client, condition: str, page: int = 1) -> Any:
20
+ return client.get("/patient-list-by-conditions/by-condition", params={"condition": condition, "page": page})
@@ -0,0 +1,12 @@
1
+ """Staff/organisation domain operations."""
2
+
3
+ from typing import Any
4
+ from cli_anything.g32.core.client import G32Client
5
+
6
+
7
+ def list_staff(client: G32Client) -> Any:
8
+ return client.get("/admin/staffs")
9
+
10
+
11
+ def get_staff(client: G32Client, firebase_uid: str) -> Any:
12
+ return client.get(f"/admin/staff/{firebase_uid}")
@@ -0,0 +1,40 @@
1
+ """Organisation statistics — action logs, health conditions, CPD analytics."""
2
+
3
+ from typing import Any, List, Optional
4
+ from cli_anything.g32.core.client import G32Client
5
+
6
+
7
+ def get_action_stats(client: G32Client, start_date: Optional[str] = None, end_date: Optional[str] = None) -> Any:
8
+ params = {}
9
+ if start_date:
10
+ params["start_date"] = start_date
11
+ if end_date:
12
+ params["end_date"] = end_date
13
+ return client.get("/admin/action_stats_v2", params=params or None)
14
+
15
+
16
+ def get_condition_stats(client: G32Client, start_date: Optional[str] = None, end_date: Optional[str] = None) -> Any:
17
+ params = {}
18
+ if start_date:
19
+ params["start_date"] = start_date
20
+ if end_date:
21
+ params["end_date"] = end_date
22
+ return client.get("/admin/condition_stats", params=params or None)
23
+
24
+
25
+ def get_cpd_analytics(
26
+ client: G32Client,
27
+ start_date: Optional[str] = None,
28
+ end_date: Optional[str] = None,
29
+ premise_ids: Optional[List[str]] = None,
30
+ include_inactive: bool = False,
31
+ top_n: int = 100,
32
+ ) -> Any:
33
+ params: dict = {"top_n": top_n, "include_inactive_staff": str(include_inactive).lower()}
34
+ if start_date:
35
+ params["start_date"] = start_date
36
+ if end_date:
37
+ params["end_date"] = end_date
38
+ if premise_ids:
39
+ params["premise_ids"] = premise_ids
40
+ return client.get("/admin/cpd_analytics", params=params)
@@ -0,0 +1,447 @@
1
+ """G32 Nexus CLI — patient data, KPI dashboard, and leave history."""
2
+
3
+ import json
4
+ import sys
5
+ from typing import Optional
6
+
7
+ import click
8
+ import requests
9
+
10
+ from cli_anything.g32.core.client import G32Client
11
+ from cli_anything.g32.core import patients, lab_reports, case_notes, medication, staff, stats
12
+ from cli_anything.g32.g32_feature import kpi, leave
13
+ from cli_anything.g32.utils.session import save_token, save_session, clear_session, get_session
14
+ from cli_anything.g32.utils import auth as _auth
15
+ from cli_anything.g32.utils.repl_skin import ReplSkin
16
+
17
+
18
+ # ── Helpers ───────────────────────────────────────────────────────────────────
19
+
20
+ def _client(ctx: click.Context) -> G32Client:
21
+ return G32Client()
22
+
23
+
24
+ def _out(ctx: click.Context, data) -> None:
25
+ if ctx.obj.get("json"):
26
+ click.echo(json.dumps(data, indent=2, default=str))
27
+ else:
28
+ if isinstance(data, (dict, list)):
29
+ click.echo(json.dumps(data, indent=2, default=str))
30
+ else:
31
+ click.echo(str(data))
32
+
33
+
34
+ def _handle_error(e: Exception) -> None:
35
+ if isinstance(e, requests.HTTPError):
36
+ try:
37
+ detail = e.response.json().get("detail", str(e))
38
+ except Exception:
39
+ detail = str(e)
40
+ click.echo(f"API error {e.response.status_code}: {detail}", err=True)
41
+ else:
42
+ click.echo(f"Error: {e}", err=True)
43
+ sys.exit(1)
44
+
45
+
46
+ # ── Root group ────────────────────────────────────────────────────────────────
47
+
48
+ @click.group()
49
+ @click.option("--json", "json_mode", is_flag=True, default=False, help="Output raw JSON.")
50
+ @click.pass_context
51
+ def cli(ctx: click.Context, json_mode: bool) -> None:
52
+ """G32 Nexus CLI — KPI dashboard, leave history, and patient data."""
53
+ ctx.ensure_object(dict)
54
+ ctx.obj["json"] = json_mode
55
+
56
+
57
+ # ── Auth ──────────────────────────────────────────────────────────────────────
58
+
59
+ @cli.command()
60
+ @click.option("--url", default="https://g32.aipharm.xyz", show_default=True, help="G32 API base URL.")
61
+ @click.option("--token", default=None, hidden=True, help="Pre-issued token (CI/agent use only).")
62
+ def login(url: str, token: str) -> None:
63
+ """Log in to G32 Nexus. Prompts for email and password."""
64
+ if token:
65
+ save_token(url, token)
66
+ click.echo(f"Session saved (token mode). Base URL: {url}")
67
+ return
68
+
69
+ email = click.prompt("Email")
70
+ password = click.prompt("Password", hide_input=True)
71
+
72
+ click.echo("Authenticating…")
73
+ try:
74
+ resp = _auth.login(url, email, password)
75
+ except ValueError as e:
76
+ click.echo(str(e), err=True)
77
+ sys.exit(1)
78
+
79
+ id_token = resp["firebase_token"]
80
+ local_id = resp["user_id"]
81
+
82
+ save_session(url, id_token, resp["refresh_token"], local_id, email)
83
+ click.echo(f"Logged in as {email}")
84
+
85
+
86
+ @cli.command()
87
+ def logout() -> None:
88
+ """Log out and revoke tokens server-side."""
89
+ data = get_session()
90
+ if data.get("token") and data.get("base_url"):
91
+ _auth.logout_server(data["base_url"], data["token"])
92
+ clear_session()
93
+ click.echo("Logged out.")
94
+
95
+
96
+ @cli.command()
97
+ def whoami() -> None:
98
+ """Show current session info."""
99
+ data = get_session()
100
+ if not data or not data.get("token"):
101
+ click.echo("Not logged in.")
102
+ return
103
+ click.echo(f"URL : {data.get('base_url', 'n/a')}")
104
+ click.echo(f"Email: {data.get('email', '(token mode)')}")
105
+ issued = data.get("issued_at")
106
+ if issued:
107
+ import time
108
+ age_min = int((time.time() - issued) / 60)
109
+ click.echo(f"Token: {age_min} min old (auto-refreshes at 55 min)")
110
+ else:
111
+ click.echo(f"Token: {data.get('token', '')[:24]}… (manual token mode)")
112
+
113
+
114
+ # ── Patient commands ──────────────────────────────────────────────────────────
115
+
116
+ @cli.group()
117
+ def patient() -> None:
118
+ """Patient search and lookup."""
119
+
120
+
121
+ @patient.command("search")
122
+ @click.argument("term")
123
+ @click.pass_context
124
+ def patient_search(ctx, term: str) -> None:
125
+ """Search patients by name, IC, or ref ID."""
126
+ try:
127
+ _out(ctx, patients.search_patients(_client(ctx), term))
128
+ except Exception as e:
129
+ _handle_error(e)
130
+
131
+
132
+ @patient.command("list")
133
+ @click.option("--page", default=1, show_default=True)
134
+ @click.pass_context
135
+ def patient_list(ctx, page: int) -> None:
136
+ """List patients (paginated)."""
137
+ try:
138
+ _out(ctx, patients.list_patients(_client(ctx), page))
139
+ except Exception as e:
140
+ _handle_error(e)
141
+
142
+
143
+ @patient.command("get")
144
+ @click.argument("patient_id")
145
+ @click.pass_context
146
+ def patient_get(ctx, patient_id: str) -> None:
147
+ """Get a patient by ID."""
148
+ try:
149
+ _out(ctx, patients.get_patient(_client(ctx), patient_id))
150
+ except Exception as e:
151
+ _handle_error(e)
152
+
153
+
154
+ @patient.command("by-condition")
155
+ @click.argument("condition")
156
+ @click.option("--page", default=1, show_default=True)
157
+ @click.pass_context
158
+ def patient_by_condition(ctx, condition: str, page: int) -> None:
159
+ """List patients by medical condition tag."""
160
+ try:
161
+ _out(ctx, patients.list_patients_by_condition(_client(ctx), condition, page))
162
+ except Exception as e:
163
+ _handle_error(e)
164
+
165
+
166
+ # ── Lab report commands ───────────────────────────────────────────────────────
167
+
168
+ @cli.group()
169
+ def lab() -> None:
170
+ """Lab report data and trends."""
171
+
172
+
173
+ @lab.command("list")
174
+ @click.argument("patient_uuid")
175
+ @click.pass_context
176
+ def lab_list(ctx, patient_uuid: str) -> None:
177
+ """List all lab reports for a patient."""
178
+ try:
179
+ _out(ctx, lab_reports.get_lab_reports(_client(ctx), patient_uuid))
180
+ except Exception as e:
181
+ _handle_error(e)
182
+
183
+
184
+ @lab.command("get")
185
+ @click.argument("report_uuid")
186
+ @click.pass_context
187
+ def lab_get(ctx, report_uuid: str) -> None:
188
+ """Get a single lab report with all results."""
189
+ try:
190
+ _out(ctx, lab_reports.get_lab_report(_client(ctx), report_uuid))
191
+ except Exception as e:
192
+ _handle_error(e)
193
+
194
+
195
+ @lab.command("trends")
196
+ @click.argument("patient_uuid")
197
+ @click.pass_context
198
+ def lab_trends(ctx, patient_uuid: str) -> None:
199
+ """Show lab result trends for a patient."""
200
+ try:
201
+ _out(ctx, lab_reports.get_lab_trends(_client(ctx), patient_uuid))
202
+ except Exception as e:
203
+ _handle_error(e)
204
+
205
+
206
+ # ── Case note commands ────────────────────────────────────────────────────────
207
+
208
+ @cli.group()
209
+ def note() -> None:
210
+ """Case note lookup and reference data."""
211
+
212
+
213
+ @note.command("list")
214
+ @click.argument("patient_uuid")
215
+ @click.pass_context
216
+ def note_list(ctx, patient_uuid: str) -> None:
217
+ """List all case notes for a patient."""
218
+ try:
219
+ _out(ctx, case_notes.list_case_notes(_client(ctx), patient_uuid))
220
+ except Exception as e:
221
+ _handle_error(e)
222
+
223
+
224
+ @note.command("get")
225
+ @click.argument("note_uuid")
226
+ @click.pass_context
227
+ def note_get(ctx, note_uuid: str) -> None:
228
+ """Get a single case note."""
229
+ try:
230
+ _out(ctx, case_notes.get_case_note(_client(ctx), note_uuid))
231
+ except Exception as e:
232
+ _handle_error(e)
233
+
234
+
235
+ @note.command("report")
236
+ @click.argument("patient_id")
237
+ @click.option("--design", default="new", type=click.Choice(["old", "new", "markdown"]), show_default=True)
238
+ @click.pass_context
239
+ def note_report(ctx, patient_id: str, design: str) -> None:
240
+ """Generate a PDF case note report for a patient."""
241
+ try:
242
+ _out(ctx, case_notes.generate_case_note_report(_client(ctx), patient_id, design))
243
+ except Exception as e:
244
+ _handle_error(e)
245
+
246
+
247
+ # ── Medication commands ───────────────────────────────────────────────────────
248
+
249
+ @cli.group()
250
+ def med() -> None:
251
+ """Medication and health summary."""
252
+
253
+
254
+ @med.command("list")
255
+ @click.argument("patient_id")
256
+ @click.pass_context
257
+ def med_list(ctx, patient_id: str) -> None:
258
+ """List medications for a patient."""
259
+ try:
260
+ _out(ctx, medication.get_medications(_client(ctx), patient_id))
261
+ except Exception as e:
262
+ _handle_error(e)
263
+
264
+
265
+ @med.command("daily")
266
+ @click.argument("patient_id")
267
+ @click.pass_context
268
+ def med_daily(ctx, patient_id: str) -> None:
269
+ """Show daily medication schedule for a patient."""
270
+ try:
271
+ _out(ctx, medication.get_daily_medication(_client(ctx), patient_id))
272
+ except Exception as e:
273
+ _handle_error(e)
274
+
275
+
276
+ @med.command("summary")
277
+ @click.argument("patient_id")
278
+ @click.pass_context
279
+ def med_summary(ctx, patient_id: str) -> None:
280
+ """Show health summary for a patient."""
281
+ try:
282
+ _out(ctx, medication.get_health_summary(_client(ctx), patient_id))
283
+ except Exception as e:
284
+ _handle_error(e)
285
+
286
+
287
+ # ── Staff commands ────────────────────────────────────────────────────────────
288
+
289
+ @cli.group("staff")
290
+ def staff_cmd() -> None:
291
+ """Staff and organisation lookup."""
292
+
293
+
294
+ @staff_cmd.command("list")
295
+ @click.pass_context
296
+ def staff_list(ctx) -> None:
297
+ """List all staff in the organisation."""
298
+ try:
299
+ _out(ctx, staff.list_staff(_client(ctx)))
300
+ except Exception as e:
301
+ _handle_error(e)
302
+
303
+
304
+ @staff_cmd.command("get")
305
+ @click.argument("firebase_uid")
306
+ @click.pass_context
307
+ def staff_get(ctx, firebase_uid: str) -> None:
308
+ """Get a staff member by Firebase UID."""
309
+ try:
310
+ _out(ctx, staff.get_staff(_client(ctx), firebase_uid))
311
+ except Exception as e:
312
+ _handle_error(e)
313
+
314
+
315
+ # ── Stats commands ────────────────────────────────────────────────────────────
316
+
317
+ @cli.group("stats")
318
+ def stats_group() -> None:
319
+ """Organisation statistics (admin / branch manager only)."""
320
+
321
+
322
+ @stats_group.command("actions")
323
+ @click.option("--start", default=None, help="Start date ISO-8601 (default: 7 days ago).")
324
+ @click.option("--end", default=None, help="End date ISO-8601 (default: now).")
325
+ @click.pass_context
326
+ def stats_actions(ctx, start: str, end: str) -> None:
327
+ """Action-log analytics: staff performance, branch activeness, behaviour insights."""
328
+ try:
329
+ _out(ctx, stats.get_action_stats(_client(ctx), start, end))
330
+ except Exception as e:
331
+ _handle_error(e)
332
+
333
+
334
+ @stats_group.command("conditions")
335
+ @click.option("--start", default=None, help="Start date ISO-8601 (default: 7 days ago).")
336
+ @click.option("--end", default=None, help="End date ISO-8601 (default: now).")
337
+ @click.pass_context
338
+ def stats_conditions(ctx, start: str, end: str) -> None:
339
+ """Health condition frequency: counts by condition, branch, age group, and gender."""
340
+ try:
341
+ _out(ctx, stats.get_condition_stats(_client(ctx), start, end))
342
+ except Exception as e:
343
+ _handle_error(e)
344
+
345
+
346
+ @stats_group.command("cpd")
347
+ @click.option("--start", default=None, help="Start date YYYY-MM-DD (default: Jan 1 of current year).")
348
+ @click.option("--end", default=None, help="End date YYYY-MM-DD (default: today).")
349
+ @click.option("--premise", "premise_ids", multiple=True, help="Filter by premise ID (repeatable).")
350
+ @click.option("--include-inactive", is_flag=True, default=False)
351
+ @click.option("--top-n", default=100, show_default=True)
352
+ @click.pass_context
353
+ def stats_cpd(ctx, start, end, premise_ids, include_inactive, top_n) -> None:
354
+ """CPD analytics: enrolments, completions, leaderboard, at-risk cohort."""
355
+ try:
356
+ _out(ctx, stats.get_cpd_analytics(_client(ctx), start, end, list(premise_ids) or None, include_inactive, top_n))
357
+ except Exception as e:
358
+ _handle_error(e)
359
+
360
+
361
+ # ── G32 KPI commands ──────────────────────────────────────────────────────────
362
+
363
+ @cli.group("kpi")
364
+ def kpi_group() -> None:
365
+ """G32 KPI dashboard — roles, years, details, summary."""
366
+
367
+
368
+ @kpi_group.command("roles")
369
+ @click.pass_context
370
+ def kpi_roles(ctx) -> None:
371
+ """List valid job_role values for the authenticated staff member."""
372
+ try:
373
+ _out(ctx, kpi.get_roles(_client(ctx)))
374
+ except Exception as e:
375
+ _handle_error(e)
376
+
377
+
378
+ @kpi_group.command("years")
379
+ @click.pass_context
380
+ def kpi_years(ctx) -> None:
381
+ """List years with KPI data for the authenticated staff member."""
382
+ try:
383
+ _out(ctx, kpi.get_years(_client(ctx)))
384
+ except Exception as e:
385
+ _handle_error(e)
386
+
387
+
388
+ @kpi_group.command("details")
389
+ @click.option("--year", required=True, type=int, help="KPI year.")
390
+ @click.option("--role", required=True, help="Job role (from 'kpi roles').")
391
+ @click.option("--month", default=None, type=int, help="Filter to a specific month (1-12).")
392
+ @click.pass_context
393
+ def kpi_details(ctx, year: int, role: str, month) -> None:
394
+ """Show per-KPI detail rows for a given year and role."""
395
+ try:
396
+ _out(ctx, kpi.get_details(_client(ctx), year, role, month))
397
+ except Exception as e:
398
+ _handle_error(e)
399
+
400
+
401
+ @kpi_group.command("summary")
402
+ @click.option("--year", required=True, type=int, help="KPI year.")
403
+ @click.option("--role", required=True, help="Job role (from 'kpi roles').")
404
+ @click.pass_context
405
+ def kpi_summary(ctx, year: int, role: str) -> None:
406
+ """Show monthly KPI summary (total marks, grade) for a year and role."""
407
+ try:
408
+ _out(ctx, kpi.get_summary(_client(ctx), year, role))
409
+ except Exception as e:
410
+ _handle_error(e)
411
+
412
+
413
+ # ── G32 Leave commands ────────────────────────────────────────────────────────
414
+
415
+ @cli.group("leave")
416
+ def leave_group() -> None:
417
+ """Worksy leave history."""
418
+
419
+
420
+ @leave_group.command("list")
421
+ @click.option("--uid", default=None, help="Staff Firebase UID (default: self).")
422
+ @click.option("--premise", default=None, help="Filter by premise UUID.")
423
+ @click.option("--status", default=None, help="Filter by status (e.g. approved, pending).")
424
+ @click.option("--type", "leave_type", default=None, help="Filter by leave type.")
425
+ @click.option("--month", default=None, help="Filter by month YYYY-MM.")
426
+ @click.option("--start", default=None, help="Start date YYYY-MM-DD.")
427
+ @click.option("--end", default=None, help="End date YYYY-MM-DD.")
428
+ @click.option("--page", default=1, show_default=True)
429
+ @click.option("--limit", default=100, show_default=True)
430
+ @click.pass_context
431
+ def leave_list(ctx, uid, premise, status, leave_type, month, start, end, page, limit) -> None:
432
+ """List Worksy leave records with summary statistics."""
433
+ try:
434
+ _out(ctx, leave.get_leave_history(_client(ctx), uid, premise, status, leave_type, month, start, end, page, limit))
435
+ except Exception as e:
436
+ _handle_error(e)
437
+
438
+
439
+ # ── REPL ──────────────────────────────────────────────────────────────────────
440
+
441
+ @cli.command("repl")
442
+ @click.option("--json", "json_mode", is_flag=True, default=False)
443
+ @click.pass_context
444
+ def repl(ctx, json_mode: bool) -> None:
445
+ """Start interactive REPL session."""
446
+ skin = ReplSkin(cli, json_mode=json_mode or ctx.obj.get("json", False))
447
+ skin.run()
@@ -0,0 +1,27 @@
1
+ """G32 KPI domain operations."""
2
+
3
+ from typing import Any, Optional
4
+ from cli_anything.g32.core.client import G32Client
5
+
6
+
7
+ def get_roles(client: G32Client) -> Any:
8
+ """GET /g32/kpi/roles — distinct job_role values for the authenticated staff."""
9
+ return client.get("/g32/kpi/roles")
10
+
11
+
12
+ def get_years(client: G32Client) -> Any:
13
+ """GET /g32/kpi/years — distinct years with KPI data for the authenticated staff."""
14
+ return client.get("/g32/kpi/years")
15
+
16
+
17
+ def get_details(client: G32Client, year: int, role: str, month: Optional[int] = None) -> Any:
18
+ """GET /g32/kpi/details — per-KPI detail rows for a given year/role/month."""
19
+ params: dict = {"year": year, "role": role}
20
+ if month is not None:
21
+ params["month"] = month
22
+ return client.get("/g32/kpi/details", params=params)
23
+
24
+
25
+ def get_summary(client: G32Client, year: int, role: str) -> Any:
26
+ """GET /g32/kpi/summary — monthly summary rows (total_marks, grade) for a year/role."""
27
+ return client.get("/g32/kpi/summary", params={"year": year, "role": role})
@@ -0,0 +1,35 @@
1
+ """G32 leave history domain operations."""
2
+
3
+ from typing import Any, Optional
4
+ from cli_anything.g32.core.client import G32Client
5
+
6
+
7
+ def get_leave_history(
8
+ client: G32Client,
9
+ staff_uid: Optional[str] = None,
10
+ premise_uuid: Optional[str] = None,
11
+ status: Optional[str] = None,
12
+ leave_type: Optional[str] = None,
13
+ month: Optional[str] = None,
14
+ start_date: Optional[str] = None,
15
+ end_date: Optional[str] = None,
16
+ page: int = 1,
17
+ limit: int = 100,
18
+ ) -> Any:
19
+ """GET /g32/personal/worksy-leave-history — paginated leave records with summary."""
20
+ params: dict = {"page": page, "limit": limit}
21
+ if staff_uid:
22
+ params["staff_firebase_uid"] = staff_uid
23
+ if premise_uuid:
24
+ params["premise_uuid"] = premise_uuid
25
+ if status:
26
+ params["status"] = status
27
+ if leave_type:
28
+ params["leave_type"] = leave_type
29
+ if month:
30
+ params["month"] = month
31
+ if start_date:
32
+ params["start_date"] = start_date
33
+ if end_date:
34
+ params["end_date"] = end_date
35
+ return client.get("/g32/personal/worksy-leave-history", params=params)
@@ -0,0 +1,42 @@
1
+ """Backend auth — calls /v1/auth/* endpoints instead of Firebase directly."""
2
+
3
+ import requests
4
+
5
+
6
+ def login(base_url: str, email: str, password: str) -> dict:
7
+ """POST /v1/auth/login → {firebase_token, refresh_token, user_id, email, display_name}"""
8
+ resp = requests.post(
9
+ f"{base_url}/v1/auth/login",
10
+ json={"email": email, "password": password, "origin": "g32-cli"},
11
+ timeout=15,
12
+ )
13
+ if resp.status_code == 401:
14
+ detail = resp.json().get("detail", "Authentication failed.")
15
+ raise ValueError(detail)
16
+ resp.raise_for_status()
17
+ return resp.json()
18
+
19
+
20
+ def refresh(base_url: str, refresh_token: str) -> dict:
21
+ """POST /v1/auth/refresh → {firebase_token, refresh_token}"""
22
+ resp = requests.post(
23
+ f"{base_url}/v1/auth/refresh",
24
+ json={"refresh_token": refresh_token},
25
+ timeout=15,
26
+ )
27
+ if resp.status_code in (400, 401):
28
+ raise ValueError("Refresh token expired — please log in again.")
29
+ resp.raise_for_status()
30
+ return resp.json()
31
+
32
+
33
+ def logout_server(base_url: str, token: str) -> None:
34
+ """POST /v1/auth/logout — revokes all refresh tokens server-side."""
35
+ try:
36
+ requests.post(
37
+ f"{base_url}/v1/auth/logout",
38
+ headers={"Authorization": f"Bearer {token}"},
39
+ timeout=10,
40
+ )
41
+ except Exception:
42
+ pass
@@ -0,0 +1,68 @@
1
+ """Unified REPL skin for the G32 CLI harness."""
2
+
3
+ from typing import Optional
4
+ import click
5
+
6
+
7
+ BANNER = """\
8
+ ╔══════════════════════════════════════════╗
9
+ ║ G32 Nexus CLI • type 'help' ║
10
+ ╚══════════════════════════════════════════╝
11
+ """
12
+
13
+ PROMPT = "g32> "
14
+
15
+
16
+ class ReplSkin:
17
+ """Interactive REPL that delegates lines to a Click group."""
18
+
19
+ def __init__(self, cli_group: click.Group, json_mode: bool = False):
20
+ self.cli = cli_group
21
+ self.json_mode = json_mode
22
+ self._history: list[str] = []
23
+
24
+ def push_history(self, line: str) -> None:
25
+ self._history.append(line)
26
+
27
+ def undo(self) -> Optional[str]:
28
+ return self._history.pop() if self._history else None
29
+
30
+ def _dispatch(self, line: str) -> None:
31
+ args = line.strip().split()
32
+ if not args:
33
+ return
34
+ if self.json_mode and "--json" not in args:
35
+ args.append("--json")
36
+ try:
37
+ self.cli.main(args=args, standalone_mode=False)
38
+ except click.UsageError as e:
39
+ click.echo(f"Error: {e}", err=True)
40
+ except SystemExit:
41
+ pass
42
+ except Exception as e:
43
+ click.echo(f"Error: {e}", err=True)
44
+
45
+ def run(self) -> None:
46
+ click.echo(BANNER)
47
+ while True:
48
+ try:
49
+ line = input(PROMPT).strip()
50
+ except (EOFError, KeyboardInterrupt):
51
+ click.echo("\nBye.")
52
+ break
53
+
54
+ if not line:
55
+ continue
56
+ if line in ("exit", "quit", "q"):
57
+ click.echo("Bye.")
58
+ break
59
+ if line == "undo":
60
+ last = self.undo()
61
+ click.echo(f"Removed from history: {last}" if last else "Nothing to undo.")
62
+ continue
63
+ if line in ("help", "?"):
64
+ self._dispatch("--help")
65
+ continue
66
+
67
+ self.push_history(line)
68
+ self._dispatch(line)
@@ -0,0 +1,108 @@
1
+ """
2
+ Session management: persist credentials to ~/.g32_session.json.
3
+ Locked writes prevent concurrent JSON corruption.
4
+ Auto-refreshes the token when it is older than 55 minutes
5
+ by calling the backend /v1/auth/refresh endpoint.
6
+ """
7
+
8
+ import json
9
+ import fcntl
10
+ import os
11
+ import time
12
+ from pathlib import Path
13
+
14
+ SESSION_FILE = Path.home() / ".g32_session.json"
15
+
16
+ _TOKEN_TTL = 55 * 60 # refresh 5 min before Firebase's 1-hour expiry
17
+
18
+
19
+ def _load_raw() -> dict:
20
+ if not SESSION_FILE.exists():
21
+ return {}
22
+ try:
23
+ with open(SESSION_FILE, "r") as f:
24
+ return json.load(f)
25
+ except (json.JSONDecodeError, OSError):
26
+ return {}
27
+
28
+
29
+ def _save_raw(data: dict) -> None:
30
+ tmp = SESSION_FILE.with_suffix(".tmp")
31
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
32
+ with os.fdopen(fd, "w") as f:
33
+ fcntl.flock(f, fcntl.LOCK_EX)
34
+ json.dump(data, f, indent=2)
35
+ fcntl.flock(f, fcntl.LOCK_UN)
36
+ tmp.replace(SESSION_FILE)
37
+
38
+
39
+ def get_session() -> dict:
40
+ return _load_raw()
41
+
42
+
43
+ def save_session(
44
+ base_url: str,
45
+ token: str,
46
+ refresh_token: str,
47
+ local_id: str,
48
+ email: str,
49
+ ) -> None:
50
+ data = _load_raw()
51
+ data.update(
52
+ base_url=base_url.rstrip("/"),
53
+ token=token,
54
+ refresh_token=refresh_token,
55
+ local_id=local_id,
56
+ email=email,
57
+ issued_at=time.time(),
58
+ )
59
+ _save_raw(data)
60
+
61
+
62
+ def save_token(base_url: str, token: str) -> None:
63
+ """Agent / CI mode: store only base_url + token, no auto-refresh."""
64
+ data = _load_raw()
65
+ data["base_url"] = base_url.rstrip("/")
66
+ data["token"] = token
67
+ data.pop("issued_at", None)
68
+ _save_raw(data)
69
+
70
+
71
+ def clear_session() -> None:
72
+ if SESSION_FILE.exists():
73
+ SESSION_FILE.unlink()
74
+
75
+
76
+ def require_token() -> tuple[str, str]:
77
+ """Return (base_url, token). Auto-refreshes via backend if token is near expiry."""
78
+ data = _load_raw()
79
+ base_url = data.get("base_url")
80
+ token = data.get("token")
81
+
82
+ if not base_url or not token:
83
+ raise RuntimeError("Not logged in. Run: g32-agent login")
84
+
85
+ issued_at = data.get("issued_at")
86
+ refresh_token = data.get("refresh_token")
87
+
88
+ if issued_at and refresh_token and (time.time() - issued_at) > _TOKEN_TTL:
89
+ try:
90
+ from cli_anything.g32.utils.auth import refresh as backend_refresh
91
+ new = backend_refresh(base_url, refresh_token)
92
+ token = new["firebase_token"]
93
+ data["token"] = token
94
+ data["refresh_token"] = new["refresh_token"]
95
+ data["issued_at"] = time.time()
96
+ _save_raw(data)
97
+ except Exception:
98
+ raise RuntimeError(
99
+ "Session expired and token refresh failed. Run: g32-agent login"
100
+ )
101
+
102
+ return base_url, token
103
+
104
+
105
+ def update_session_key(key: str, value) -> None:
106
+ data = _load_raw()
107
+ data[key] = value
108
+ _save_raw(data)
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: cli-anything-g32
3
+ Version: 0.1.0
4
+ Summary: CLI-Anything harness for the G32 Nexus FastAPI backend
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: click>=8.0
7
+ Requires-Dist: requests>=2.28
8
+ Dynamic: requires-dist
9
+ Dynamic: requires-python
10
+ Dynamic: summary
@@ -0,0 +1,21 @@
1
+ setup.py
2
+ cli_anything/g32/__main__.py
3
+ cli_anything/g32/g32_cli.py
4
+ cli_anything/g32/core/case_notes.py
5
+ cli_anything/g32/core/client.py
6
+ cli_anything/g32/core/lab_reports.py
7
+ cli_anything/g32/core/medication.py
8
+ cli_anything/g32/core/patients.py
9
+ cli_anything/g32/core/staff.py
10
+ cli_anything/g32/core/stats.py
11
+ cli_anything/g32/g32_feature/kpi.py
12
+ cli_anything/g32/g32_feature/leave.py
13
+ cli_anything/g32/utils/auth.py
14
+ cli_anything/g32/utils/repl_skin.py
15
+ cli_anything/g32/utils/session.py
16
+ cli_anything_g32.egg-info/PKG-INFO
17
+ cli_anything_g32.egg-info/SOURCES.txt
18
+ cli_anything_g32.egg-info/dependency_links.txt
19
+ cli_anything_g32.egg-info/entry_points.txt
20
+ cli_anything_g32.egg-info/requires.txt
21
+ cli_anything_g32.egg-info/top_level.txt
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ g32 = cli_anything.g32.g32_cli:cli
3
+ g32-agent = cli_anything.g32.g32_cli:cli
@@ -0,0 +1,2 @@
1
+ click>=8.0
2
+ requests>=2.28
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,19 @@
1
+ from setuptools import setup, find_namespace_packages
2
+
3
+ setup(
4
+ name="cli-anything-g32",
5
+ version="0.1.0",
6
+ description="CLI-Anything harness for the G32 Nexus FastAPI backend",
7
+ packages=find_namespace_packages(include=["cli_anything.*"]),
8
+ install_requires=[
9
+ "click>=8.0",
10
+ "requests>=2.28",
11
+ ],
12
+ entry_points={
13
+ "console_scripts": [
14
+ "g32-agent=cli_anything.g32.g32_cli:cli",
15
+ "g32=cli_anything.g32.g32_cli:cli",
16
+ ]
17
+ },
18
+ python_requires=">=3.10",
19
+ )