healthcloud-cli 3.2.2__tar.gz → 3.2.4__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.
Files changed (25) hide show
  1. {healthcloud_cli-3.2.2 → healthcloud_cli-3.2.4}/PKG-INFO +41 -3
  2. {healthcloud_cli-3.2.2 → healthcloud_cli-3.2.4}/README.md +40 -2
  3. {healthcloud_cli-3.2.2 → healthcloud_cli-3.2.4}/healthcloud_cli/ams/output.py +18 -5
  4. {healthcloud_cli-3.2.2 → healthcloud_cli-3.2.4}/healthcloud_cli/ams/tuning.py +6 -4
  5. healthcloud_cli-3.2.4/healthcloud_cli/ams/windows_files.py +162 -0
  6. {healthcloud_cli-3.2.2 → healthcloud_cli-3.2.4}/pyproject.toml +1 -1
  7. {healthcloud_cli-3.2.2 → healthcloud_cli-3.2.4}/.gitignore +0 -0
  8. {healthcloud_cli-3.2.2 → healthcloud_cli-3.2.4}/hatch_build.py +0 -0
  9. {healthcloud_cli-3.2.2 → healthcloud_cli-3.2.4}/healthcloud_cli/__init__.py +0 -0
  10. {healthcloud_cli-3.2.2 → healthcloud_cli-3.2.4}/healthcloud_cli/ams/__init__.py +0 -0
  11. {healthcloud_cli-3.2.2 → healthcloud_cli-3.2.4}/healthcloud_cli/ams/auth.py +0 -0
  12. {healthcloud_cli-3.2.2 → healthcloud_cli-3.2.4}/healthcloud_cli/ams/commands.py +0 -0
  13. {healthcloud_cli-3.2.2 → healthcloud_cli-3.2.4}/healthcloud_cli/ams/target.py +0 -0
  14. {healthcloud_cli-3.2.2 → healthcloud_cli-3.2.4}/healthcloud_cli/ams/ui.py +0 -0
  15. {healthcloud_cli-3.2.2 → healthcloud_cli-3.2.4}/healthcloud_cli/cli.py +0 -0
  16. {healthcloud_cli-3.2.2 → healthcloud_cli-3.2.4}/healthcloud_cli/context.py +0 -0
  17. {healthcloud_cli-3.2.2 → healthcloud_cli-3.2.4}/healthcloud_cli/contracts/__init__.py +0 -0
  18. {healthcloud_cli-3.2.2 → healthcloud_cli-3.2.4}/healthcloud_cli/contracts/mcp-tools.manifest.json +0 -0
  19. {healthcloud_cli-3.2.2 → healthcloud_cli-3.2.4}/healthcloud_cli/main.py +0 -0
  20. {healthcloud_cli-3.2.2 → healthcloud_cli-3.2.4}/healthcloud_cli/manifest.py +0 -0
  21. {healthcloud_cli-3.2.2 → healthcloud_cli-3.2.4}/healthcloud_cli/output.py +0 -0
  22. {healthcloud_cli-3.2.2 → healthcloud_cli-3.2.4}/healthcloud_cli/params.py +0 -0
  23. {healthcloud_cli-3.2.2 → healthcloud_cli-3.2.4}/healthcloud_cli/util.py +0 -0
  24. {healthcloud_cli-3.2.2 → healthcloud_cli-3.2.4}/scripts/__init__.py +0 -0
  25. {healthcloud_cli-3.2.2 → healthcloud_cli-3.2.4}/scripts/bundle_manifest.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: healthcloud-cli
3
- Version: 3.2.2
3
+ Version: 3.2.4
4
4
  Summary: Command-line interface for the HealthCloud MCP gateway, generated from the shared MCP tool manifest.
5
5
  Project-URL: Issues, https://github.com/healthcloudservices/healthcloud-services/issues
6
6
  Project-URL: Repository, https://github.com/healthcloudservices/healthcloud-services
@@ -358,7 +358,8 @@ uv run --frozen hc ams --target-file TARGET.json ui --repo EXISTING_AMS_WORKTREE
358
358
  Changes JSON accepts only nonempty subsets of `instructions` (string) and
359
359
  `greetings` (language-key to string map). Input changes/receipts must be regular,
360
360
  owner-only files (for example create under `umask 077`). Outputs use exclusive
361
- 0600 creation and refuse overwrite/symlinks; failed operations remove unfinished
361
+ 0600 creation on POSIX and an owner-only protected DACL on Windows, and refuse
362
+ overwrite/symlinks; failed operations remove unfinished
362
363
  outputs. No whole-agent snapshot is saved. Free text appears only in explicitly
363
364
  requested private files for human review before any AI use. Default output in
364
365
  pretty/json/verbose modes contains bounded metadata and hashes; AMS refuses raw.
@@ -375,7 +376,7 @@ full-input qualification above. Apply requires the displayed hash, matching
375
376
  version and selected before-values, and sends `confirm=True` once. HTTP 401/403
376
377
  and 409 never trigger identity fallback or mutation replay.
377
378
 
378
- On transport failure, apply records an unknown outcome and reconciles through GET
379
+ On transport failure or HTTP 5xx, apply records an unknown outcome and reconciles through GET
379
380
  plan/agent without retrying the mutation. Confirmed saves require applied status,
380
381
  selected-field readback and exactly the next config version. The current public
381
382
  response omits publication_status: **saved/readback confirmed is reported separately
@@ -403,3 +404,40 @@ operator-controlled.
403
404
  AMS failures exit nonzero with safe categories, never backend exception bodies.
404
405
  In particular, apply may exit nonzero after a confirmed save because publication
405
406
  is unknown: inspect the emitted outcome before any next action.
407
+
408
+ On Windows, `chmod` does not establish file privacy. Create the private-input
409
+ directory with an owner-only ACL before saving change JSON or receipts there:
410
+
411
+ ```powershell
412
+ New-Item -ItemType Directory -Path .\ams-private
413
+ $amsUser = whoami
414
+ icacls .\ams-private /inheritance:r /grant:r "${amsUser}:(OI)(CI)F"
415
+ ```
416
+
417
+ Native file handling checks the opened handle's owner and DACL, rejects grants to
418
+ other principals and reparse points, and creates outputs with their restricted
419
+ DACL already in place. Filesystems without usable ACLs fail closed.
420
+
421
+ The `Python CLI` and `Python CLI (Windows)` CI jobs run the non-live suite on
422
+ Linux and Windows, including native file permissions and apply reconciliation.
423
+ Both feed `SDK package checks`; a job failure or unexpected job skip blocks that aggregate.
424
+ Run locally, sequentially, from `packages/cli/pip` with
425
+ `uv run --frozen --extra dev python -m pytest -q -m 'not live'`.
426
+ These synthetic checks do not establish live CareCo acceptance. That still needs
427
+ an explicitly selected and qualified DEV agent, scoped credentials, and the
428
+ attended before/after/restore procedure above.
429
+
430
+ For an optional real-HTTP reproduction with Postman CLI 1.75.0, start
431
+ `postman simulation run postman/simulations/ams-tuning-failure.sim.yaml --port 4900 --no-history --output ndjson`
432
+ from the repository root. In another terminal, run the same SDK verification
433
+ helper used by the required loopback HTTP tests:
434
+
435
+ ```bash
436
+ uv run --project packages/cli/pip --frozen --extra dev python -c "import sys; sys.path.insert(0, 'packages/cli/pip/tests'); from test_ams_http import verify_uncertain_apply; print(verify_uncertain_apply('http://127.0.0.1:4900/ams/api/ams'))"
437
+ ```
438
+
439
+ The simulation log must show GET plan, GET agent, one POST apply returning 503,
440
+ then GET plan and GET agent. Restart the stateful simulation before repeating;
441
+ stop it with Ctrl+C afterward. This is a source-backed synthetic failure fixture,
442
+ not the deployed service. The required CI tests exercise both pre-commit and
443
+ post-commit 503 over loopback HTTP without requiring a Postman login or installation.
@@ -340,7 +340,8 @@ uv run --frozen hc ams --target-file TARGET.json ui --repo EXISTING_AMS_WORKTREE
340
340
  Changes JSON accepts only nonempty subsets of `instructions` (string) and
341
341
  `greetings` (language-key to string map). Input changes/receipts must be regular,
342
342
  owner-only files (for example create under `umask 077`). Outputs use exclusive
343
- 0600 creation and refuse overwrite/symlinks; failed operations remove unfinished
343
+ 0600 creation on POSIX and an owner-only protected DACL on Windows, and refuse
344
+ overwrite/symlinks; failed operations remove unfinished
344
345
  outputs. No whole-agent snapshot is saved. Free text appears only in explicitly
345
346
  requested private files for human review before any AI use. Default output in
346
347
  pretty/json/verbose modes contains bounded metadata and hashes; AMS refuses raw.
@@ -357,7 +358,7 @@ full-input qualification above. Apply requires the displayed hash, matching
357
358
  version and selected before-values, and sends `confirm=True` once. HTTP 401/403
358
359
  and 409 never trigger identity fallback or mutation replay.
359
360
 
360
- On transport failure, apply records an unknown outcome and reconciles through GET
361
+ On transport failure or HTTP 5xx, apply records an unknown outcome and reconciles through GET
361
362
  plan/agent without retrying the mutation. Confirmed saves require applied status,
362
363
  selected-field readback and exactly the next config version. The current public
363
364
  response omits publication_status: **saved/readback confirmed is reported separately
@@ -385,3 +386,40 @@ operator-controlled.
385
386
  AMS failures exit nonzero with safe categories, never backend exception bodies.
386
387
  In particular, apply may exit nonzero after a confirmed save because publication
387
388
  is unknown: inspect the emitted outcome before any next action.
389
+
390
+ On Windows, `chmod` does not establish file privacy. Create the private-input
391
+ directory with an owner-only ACL before saving change JSON or receipts there:
392
+
393
+ ```powershell
394
+ New-Item -ItemType Directory -Path .\ams-private
395
+ $amsUser = whoami
396
+ icacls .\ams-private /inheritance:r /grant:r "${amsUser}:(OI)(CI)F"
397
+ ```
398
+
399
+ Native file handling checks the opened handle's owner and DACL, rejects grants to
400
+ other principals and reparse points, and creates outputs with their restricted
401
+ DACL already in place. Filesystems without usable ACLs fail closed.
402
+
403
+ The `Python CLI` and `Python CLI (Windows)` CI jobs run the non-live suite on
404
+ Linux and Windows, including native file permissions and apply reconciliation.
405
+ Both feed `SDK package checks`; a job failure or unexpected job skip blocks that aggregate.
406
+ Run locally, sequentially, from `packages/cli/pip` with
407
+ `uv run --frozen --extra dev python -m pytest -q -m 'not live'`.
408
+ These synthetic checks do not establish live CareCo acceptance. That still needs
409
+ an explicitly selected and qualified DEV agent, scoped credentials, and the
410
+ attended before/after/restore procedure above.
411
+
412
+ For an optional real-HTTP reproduction with Postman CLI 1.75.0, start
413
+ `postman simulation run postman/simulations/ams-tuning-failure.sim.yaml --port 4900 --no-history --output ndjson`
414
+ from the repository root. In another terminal, run the same SDK verification
415
+ helper used by the required loopback HTTP tests:
416
+
417
+ ```bash
418
+ uv run --project packages/cli/pip --frozen --extra dev python -c "import sys; sys.path.insert(0, 'packages/cli/pip/tests'); from test_ams_http import verify_uncertain_apply; print(verify_uncertain_apply('http://127.0.0.1:4900/ams/api/ams'))"
419
+ ```
420
+
421
+ The simulation log must show GET plan, GET agent, one POST apply returning 503,
422
+ then GET plan and GET agent. Restart the stateful simulation before repeating;
423
+ stop it with Ctrl+C afterward. This is a source-backed synthetic failure fixture,
424
+ not the deployed service. The required CI tests exercise both pre-commit and
425
+ post-commit 503 over loopback HTTP without requiring a Postman login or installation.
@@ -37,10 +37,17 @@ def _pairs(pairs):
37
37
  def read_json(path: Path, *, private: bool = False) -> dict:
38
38
  """Reject links, non-regular files and ambiguous JSON without quoting input."""
39
39
  try:
40
- fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
40
+ if os.name == "nt":
41
+ from .windows_files import open_file
42
+
43
+ fd = open_file(path, private=private)
44
+ else:
45
+ fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
41
46
  with os.fdopen(fd, encoding="utf-8") as stream:
42
47
  info = os.fstat(stream.fileno())
43
- if not stat.S_ISREG(info.st_mode) or (private and (info.st_mode & 0o077 or info.st_uid != os.getuid())):
48
+ if not stat.S_ISREG(info.st_mode) or (
49
+ private and os.name != "nt" and (info.st_mode & 0o077 or info.st_uid != os.getuid())
50
+ ):
44
51
  raise ValueError("Private input must be an owner-only regular file")
45
52
  value = json.load(
46
53
  stream, object_pairs_hook=_pairs, parse_constant=lambda _: (_ for _ in ()).throw(ValueError())
@@ -58,12 +65,18 @@ def read_json(path: Path, *, private: bool = False) -> dict:
58
65
  def private_output(path: Path):
59
66
  """Reserve output before network calls, never overwrite, clean failed work."""
60
67
  try:
61
- fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
62
- except OSError:
68
+ if os.name == "nt":
69
+ from .windows_files import open_file
70
+
71
+ fd = open_file(path, create=True)
72
+ else:
73
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
74
+ except OSError, ValueError:
63
75
  raise click.ClickException("Cannot create private output; choose a new file, without symlinks.") from None
64
76
  try:
65
77
  with os.fdopen(fd, "w", encoding="utf-8") as stream:
66
- os.fchmod(stream.fileno(), 0o600)
78
+ if os.name != "nt":
79
+ os.fchmod(stream.fileno(), 0o600)
67
80
  yield lambda value: stream.write(canonical(value) + "\n")
68
81
  except BaseException:
69
82
  path.unlink(missing_ok=True)
@@ -9,7 +9,7 @@ from uuid import uuid4
9
9
 
10
10
  import click
11
11
 
12
- from healthcloud.errors import HealthCloudNetworkError
12
+ from healthcloud.errors import HealthCloudHTTPError, HealthCloudNetworkError
13
13
  from healthcloud.models.base import JsonValue
14
14
 
15
15
  from .auth import selected_agent
@@ -199,7 +199,7 @@ def _outcome(sdk, target, receipt, *, unknown: bool = False):
199
199
  metadata(result)
200
200
  if unknown:
201
201
  raise click.ClickException(
202
- "Mutation outcome was unknown after transport failure; GET reconciliation recorded. Review saved state; do not replay."
202
+ "Mutation outcome was unknown after transport or server failure; GET reconciliation recorded. Review saved state; do not replay."
203
203
  )
204
204
  if not saved:
205
205
  raise click.ClickException("Apply status or selected-field readback failed; inspect before further action.")
@@ -226,8 +226,10 @@ def apply_receipt(sdk, target, receipt, confirm_hash: str):
226
226
  result = sdk.ams_admin.apply_tuning_plan(
227
227
  plan.id, plan_hash=plan.plan_hash, expected_config_version=plan.base_config_version, confirm=True
228
228
  )
229
- except HealthCloudNetworkError:
230
- # No POST replay: even a timeout can have committed on the server.
229
+ except (HealthCloudNetworkError, HealthCloudHTTPError) as exc:
230
+ if isinstance(exc, HealthCloudHTTPError) and not 500 <= exc.status_code < 600:
231
+ raise
232
+ # A timeout or server/proxy error can occur after the mutation committed.
231
233
  metadata(dict(plan_id=plan.id, outcome="unknown", mutation_replayed=False))
232
234
  _outcome(sdk, target, receipt, unknown=True)
233
235
  if result is None or result.id != plan.id or result.plan_hash != plan.plan_hash:
@@ -0,0 +1,162 @@
1
+ """Windows handle-based JSON access with an owner-only, protected DACL.
2
+
3
+ Keep this module Windows-only: the public file helpers import it lazily.
4
+ Security is checked on the opened handle, before reading or writing content.
5
+ """
6
+
7
+ import ctypes
8
+ import msvcrt
9
+ import os
10
+ from ctypes import wintypes as w
11
+ from pathlib import Path
12
+
13
+ _kernel = ctypes.WinDLL("kernel32", use_last_error=True)
14
+ _security = ctypes.WinDLL("advapi32", use_last_error=True)
15
+ _P = ctypes.c_void_p
16
+
17
+
18
+ def _bind(library, name, result, *arguments):
19
+ function = getattr(library, name)
20
+ function.restype = result
21
+ function.argtypes = arguments
22
+ return function
23
+
24
+
25
+ _close = _bind(_kernel, "CloseHandle", w.BOOL, w.HANDLE)
26
+ _free = _bind(_kernel, "LocalFree", _P, _P)
27
+ _process = _bind(_kernel, "GetCurrentProcess", w.HANDLE)
28
+ _open_token = _bind(_security, "OpenProcessToken", w.BOOL, w.HANDLE, w.DWORD, ctypes.POINTER(w.HANDLE))
29
+ _token_info = _bind(_security, "GetTokenInformation", w.BOOL, w.HANDLE, w.DWORD, _P, w.DWORD, ctypes.POINTER(w.DWORD))
30
+ _sid_text = _bind(_security, "ConvertSidToStringSidW", w.BOOL, _P, ctypes.POINTER(_P))
31
+ _descriptor = _bind(
32
+ _security,
33
+ "ConvertStringSecurityDescriptorToSecurityDescriptorW",
34
+ w.BOOL,
35
+ w.LPCWSTR,
36
+ w.DWORD,
37
+ ctypes.POINTER(_P),
38
+ _P,
39
+ )
40
+ _get_security = _bind(
41
+ _security,
42
+ "GetSecurityInfo",
43
+ w.DWORD,
44
+ w.HANDLE,
45
+ w.DWORD,
46
+ w.DWORD,
47
+ ctypes.POINTER(_P),
48
+ _P,
49
+ ctypes.POINTER(_P),
50
+ _P,
51
+ ctypes.POINTER(_P),
52
+ )
53
+ _get_ace = _bind(_security, "GetAce", w.BOOL, _P, w.DWORD, ctypes.POINTER(_P))
54
+ _create = _bind(_kernel, "CreateFileW", w.HANDLE, w.LPCWSTR, w.DWORD, w.DWORD, _P, w.DWORD, w.DWORD, w.HANDLE)
55
+ _file_type = _bind(_kernel, "GetFileType", w.DWORD, w.HANDLE)
56
+ _file_info = _bind(_kernel, "GetFileInformationByHandleEx", w.BOOL, w.HANDLE, w.DWORD, _P, w.DWORD)
57
+ _set_file_info = _bind(_kernel, "SetFileInformationByHandle", w.BOOL, w.HANDLE, w.DWORD, _P, w.DWORD)
58
+
59
+
60
+ class _SecurityAttributes(ctypes.Structure):
61
+ _fields_ = [("length", w.DWORD), ("descriptor", _P), ("inherit", w.BOOL)]
62
+
63
+
64
+ class _Acl(ctypes.Structure):
65
+ _fields_ = [("revision", w.BYTE), ("reserved", w.BYTE), ("size", w.WORD), ("count", w.WORD), ("pad", w.WORD)]
66
+
67
+
68
+ def _check(ok):
69
+ if not ok:
70
+ raise ctypes.WinError(ctypes.get_last_error())
71
+
72
+
73
+ def _sid_string(sid) -> str:
74
+ text = _P()
75
+ _check(_sid_text(sid, ctypes.byref(text)))
76
+ try:
77
+ return ctypes.wstring_at(text)
78
+ finally:
79
+ _free(text)
80
+
81
+
82
+ def _user_sid() -> str:
83
+ token = w.HANDLE()
84
+ _check(_open_token(_process(), 0x0008, ctypes.byref(token))) # TOKEN_QUERY
85
+ try:
86
+ size = w.DWORD()
87
+ _token_info(token, 1, None, 0, ctypes.byref(size)) # TokenUser
88
+ if not size.value:
89
+ raise ctypes.WinError(ctypes.get_last_error())
90
+ buffer = ctypes.create_string_buffer(size.value)
91
+ _check(_token_info(token, 1, buffer, size, ctypes.byref(size)))
92
+ return _sid_string(_P.from_buffer(buffer).value) # TOKEN_USER starts with the user SID pointer.
93
+ finally:
94
+ _close(token)
95
+
96
+
97
+ def _require_private(handle, user_sid: str) -> None:
98
+ owner, acl, descriptor = _P(), _P(), _P()
99
+ status = _get_security(
100
+ handle, 1, 0x00000005, ctypes.byref(owner), None, ctypes.byref(acl), None, ctypes.byref(descriptor)
101
+ )
102
+ if status:
103
+ raise ctypes.WinError(status)
104
+ try:
105
+ if not owner or _sid_string(owner) != user_sid or not acl:
106
+ raise ValueError("Owner-only Windows DACL required")
107
+ count = ctypes.cast(acl, ctypes.POINTER(_Acl)).contents.count
108
+ for index in range(count):
109
+ ace = _P()
110
+ _check(_get_ace(acl, index, ctypes.byref(ace)))
111
+ kind, flags = (w.BYTE * 2).from_address(ace.value)
112
+ if flags & 0x08 or kind == 1: # INHERIT_ONLY_ACE or ACCESS_DENIED_ACE grants no file access.
113
+ continue
114
+ # Only ordinary ACCESS_ALLOWED_ACE grants to the current user are accepted.
115
+ if kind != 0 or _sid_string(ace.value + 8) != user_sid:
116
+ raise ValueError("Private Windows files must not grant access to other principals")
117
+ finally:
118
+ _free(descriptor)
119
+
120
+
121
+ def open_file(path: Path, *, private: bool = False, create: bool = False) -> int:
122
+ """Return a CRT fd, transferring ownership only after native checks succeed."""
123
+ user = _user_sid() if private or create else None
124
+ descriptor = _P()
125
+ attributes = None
126
+ if create:
127
+ # Protected DACL: no inherited grants and no public interval before ACL setup.
128
+ _check(_descriptor(f"O:{user}D:P(A;;FA;;;{user})", 1, ctypes.byref(descriptor), None))
129
+ attributes = _SecurityAttributes(ctypes.sizeof(_SecurityAttributes), descriptor, False)
130
+ try:
131
+ handle = _create(
132
+ str(path.absolute()),
133
+ (0x40010000 if create else 0x80000000) | 0x00020000, # GENERIC_WRITE + DELETE / READ + READ_CONTROL
134
+ 0 if create else 1, # No sharing for outputs; share reads only for inputs.
135
+ ctypes.byref(attributes) if attributes else None,
136
+ 1 if create else 3, # CREATE_NEW / OPEN_EXISTING
137
+ 0x00200000 | 0x02000000, # OPEN_REPARSE_POINT | BACKUP_SEMANTICS
138
+ None,
139
+ )
140
+ finally:
141
+ if descriptor:
142
+ _free(descriptor)
143
+ if handle == w.HANDLE(-1).value:
144
+ raise ctypes.WinError(ctypes.get_last_error())
145
+ try:
146
+ info = (w.DWORD * 2)() # FILE_ATTRIBUTE_TAG_INFO
147
+ _check(_file_info(handle, 9, info, ctypes.sizeof(info)))
148
+ if _file_type(handle) != 1 or info[0] & (0x10 | 0x400): # Disk files only; no directories or reparse points.
149
+ raise ValueError("Regular non-reparse Windows file required")
150
+ if user is not None:
151
+ _require_private(handle, user)
152
+ fd = msvcrt.open_osfhandle(handle, (os.O_WRONLY if create else os.O_RDONLY) | os.O_BINARY)
153
+ except BaseException:
154
+ try:
155
+ if create:
156
+ # Delete this exact handle's new file, without a path replacement race.
157
+ delete = w.BOOL(True)
158
+ _check(_set_file_info(handle, 4, ctypes.byref(delete), ctypes.sizeof(delete)))
159
+ finally:
160
+ _close(handle)
161
+ raise
162
+ return fd
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "healthcloud-cli"
7
- version = "3.2.2"
7
+ version = "3.2.4"
8
8
  description = "Command-line interface for the HealthCloud MCP gateway, generated from the shared MCP tool manifest."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14"