healthcloud-cli 3.2.4__tar.gz → 3.2.5__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 (30) hide show
  1. {healthcloud_cli-3.2.4 → healthcloud_cli-3.2.5}/PKG-INFO +60 -1
  2. {healthcloud_cli-3.2.4 → healthcloud_cli-3.2.5}/README.md +59 -0
  3. healthcloud_cli-3.2.5/healthcloud_cli/ams/commands.py +26 -0
  4. healthcloud_cli-3.2.5/healthcloud_cli/ams/compatibility.py +264 -0
  5. healthcloud_cli-3.2.5/healthcloud_cli/ams/context.py +74 -0
  6. healthcloud_cli-3.2.5/healthcloud_cli/ams/group.py +30 -0
  7. healthcloud_cli-3.2.5/healthcloud_cli/ams/registry.py +63 -0
  8. {healthcloud_cli-3.2.4 → healthcloud_cli-3.2.5}/healthcloud_cli/cli.py +10 -9
  9. {healthcloud_cli-3.2.4 → healthcloud_cli-3.2.5}/healthcloud_cli/contracts/mcp-tools.manifest.json +690 -237
  10. {healthcloud_cli-3.2.4 → healthcloud_cli-3.2.5}/pyproject.toml +1 -1
  11. healthcloud_cli-3.2.4/healthcloud_cli/ams/commands.py +0 -242
  12. {healthcloud_cli-3.2.4 → healthcloud_cli-3.2.5}/.gitignore +0 -0
  13. {healthcloud_cli-3.2.4 → healthcloud_cli-3.2.5}/hatch_build.py +0 -0
  14. {healthcloud_cli-3.2.4 → healthcloud_cli-3.2.5}/healthcloud_cli/__init__.py +0 -0
  15. {healthcloud_cli-3.2.4 → healthcloud_cli-3.2.5}/healthcloud_cli/ams/__init__.py +0 -0
  16. {healthcloud_cli-3.2.4 → healthcloud_cli-3.2.5}/healthcloud_cli/ams/auth.py +0 -0
  17. {healthcloud_cli-3.2.4 → healthcloud_cli-3.2.5}/healthcloud_cli/ams/output.py +0 -0
  18. {healthcloud_cli-3.2.4 → healthcloud_cli-3.2.5}/healthcloud_cli/ams/target.py +0 -0
  19. {healthcloud_cli-3.2.4 → healthcloud_cli-3.2.5}/healthcloud_cli/ams/tuning.py +0 -0
  20. {healthcloud_cli-3.2.4 → healthcloud_cli-3.2.5}/healthcloud_cli/ams/ui.py +0 -0
  21. {healthcloud_cli-3.2.4 → healthcloud_cli-3.2.5}/healthcloud_cli/ams/windows_files.py +0 -0
  22. {healthcloud_cli-3.2.4 → healthcloud_cli-3.2.5}/healthcloud_cli/context.py +0 -0
  23. {healthcloud_cli-3.2.4 → healthcloud_cli-3.2.5}/healthcloud_cli/contracts/__init__.py +0 -0
  24. {healthcloud_cli-3.2.4 → healthcloud_cli-3.2.5}/healthcloud_cli/main.py +0 -0
  25. {healthcloud_cli-3.2.4 → healthcloud_cli-3.2.5}/healthcloud_cli/manifest.py +0 -0
  26. {healthcloud_cli-3.2.4 → healthcloud_cli-3.2.5}/healthcloud_cli/output.py +0 -0
  27. {healthcloud_cli-3.2.4 → healthcloud_cli-3.2.5}/healthcloud_cli/params.py +0 -0
  28. {healthcloud_cli-3.2.4 → healthcloud_cli-3.2.5}/healthcloud_cli/util.py +0 -0
  29. {healthcloud_cli-3.2.4 → healthcloud_cli-3.2.5}/scripts/__init__.py +0 -0
  30. {healthcloud_cli-3.2.4 → healthcloud_cli-3.2.5}/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.4
3
+ Version: 3.2.5
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
@@ -56,6 +56,24 @@ uv run --frozen hc mcp tools list --audience patient
56
56
  uv run --frozen hc mcp patient get-patient --help
57
57
  ```
58
58
 
59
+ AMS help and shell-completion discovery are credential-free and offline: they
60
+ do not resolve root profiles, read target files, create SDK clients, or log in.
61
+ You can inspect the group, a leaf command, and completion candidates before
62
+ configuring credentials or a target:
63
+
64
+ ```bash
65
+ uv run --frozen hc ams --help
66
+ uv run --frozen hc ams inspect --help
67
+ _HC_COMPLETE=bash_complete COMP_WORDS="hc ams in" COMP_CWORD=2 uv run --frozen hc
68
+ _HC_COMPLETE=bash_complete COMP_WORDS="hc ams inspect --o" COMP_CWORD=3 uv run --frozen hc
69
+ ```
70
+
71
+ Operational validation starts only when Click invokes an AMS leaf callback.
72
+ `discover` then validates its explicit environment and tenant arguments; the
73
+ other leaves require `--target-file` and apply the existing root, target, and
74
+ authentication checks described below. Deferring preflight does not relax
75
+ those safety requirements.
76
+
59
77
  The bundled tool manifest (`healthcloud_cli/contracts/mcp-tools.manifest.json`)
60
78
  is a copy of `packages/cli/contracts/mcp-tools.manifest.json`. Re-sync it
61
79
  after pulling manifest changes with:
@@ -441,3 +459,44 @@ then GET plan and GET agent. Restart the stateful simulation before repeating;
441
459
  stop it with Ctrl+C afterward. This is a source-backed synthetic failure fixture,
442
460
  not the deployed service. The required CI tests exercise both pre-commit and
443
461
  post-commit 503 over loopback HTTP without requiring a Postman login or installation.
462
+
463
+
464
+ ### Installed CLI integration
465
+
466
+ From the repository root, with Python 3.14, uv 0.12.5 and Node.js available:
467
+
468
+ ```bash
469
+ python -m unittest discover -s tooling/cli-integration -p 'test_*.py' -v
470
+ python tooling/cli-integration/run.py --output /tmp/hc-cli-integration
471
+ ```
472
+
473
+ The runner installs the SDK, tenant-admin SDK and CLI from the frozen CLI lock
474
+ with `uv sync --no-editable` into a fresh temporary environment outside the
475
+ checkout. It verifies installed module ownership, version, help for every AMS
476
+ command, native shell completion, discovery and the
477
+ inspect/plan/show/apply/restore journey. Negative
478
+ journeys exercise denied login, identity/qualification/plan rejection, malformed
479
+ responses, failed readback and uncertain writes without mutation replay.
480
+
481
+ The `Installed CLI integration` workflow selects its uv-installed Linux job at
482
+ job level for `packages/cli/**`, `packages/pip/**`,
483
+ `packages/pip-tenantadmin/**`, its own tooling/workflow, docs compiler inputs
484
+ (`tooling/docs-compiler/**`, `docs-src/**`, `docs/sdk/**`), MCP contracts
485
+ (`contracts/mcp/**`), and root package/lock, Python/lock and Ruff inputs. Backend-only changes skip the install job;
486
+ the small `CLI integration` result check still runs and verifies applicability.
487
+ Invalid selection or an applicable failed/cancelled/skipped job fails it.
488
+ The job retains only sanitized consumer/JUnit results for seven days.
489
+
490
+ All scenario records and credentials are synthetic. A test-only Python startup
491
+ adapter preserves the canonical DEV AMS/tenant-admin origins for product
492
+ validation while routing only those exact origins over real HTTP to an owned
493
+ loopback fixture; other origins are refused before network access. Child
494
+ processes use an isolated home and an allowlisted environment. The adapter is
495
+ outside all shipped distributions. This boundary does not verify TLS or deployed
496
+ service conformance. A verified save still exits 1 with `outcome="saved"` and
497
+ `publication="unknown"`; publication and runtime acceptance remain unverified.
498
+
499
+ Required-check activation is tracked separately in
500
+ [services #724](https://github.com/healthcloudservices/healthcloud-services/issues/724).
501
+ Require `CLI integration` only after the workflow is merged and both relevant
502
+ and unrelated-change Actions results and effective settings are verified.
@@ -38,6 +38,24 @@ uv run --frozen hc mcp tools list --audience patient
38
38
  uv run --frozen hc mcp patient get-patient --help
39
39
  ```
40
40
 
41
+ AMS help and shell-completion discovery are credential-free and offline: they
42
+ do not resolve root profiles, read target files, create SDK clients, or log in.
43
+ You can inspect the group, a leaf command, and completion candidates before
44
+ configuring credentials or a target:
45
+
46
+ ```bash
47
+ uv run --frozen hc ams --help
48
+ uv run --frozen hc ams inspect --help
49
+ _HC_COMPLETE=bash_complete COMP_WORDS="hc ams in" COMP_CWORD=2 uv run --frozen hc
50
+ _HC_COMPLETE=bash_complete COMP_WORDS="hc ams inspect --o" COMP_CWORD=3 uv run --frozen hc
51
+ ```
52
+
53
+ Operational validation starts only when Click invokes an AMS leaf callback.
54
+ `discover` then validates its explicit environment and tenant arguments; the
55
+ other leaves require `--target-file` and apply the existing root, target, and
56
+ authentication checks described below. Deferring preflight does not relax
57
+ those safety requirements.
58
+
41
59
  The bundled tool manifest (`healthcloud_cli/contracts/mcp-tools.manifest.json`)
42
60
  is a copy of `packages/cli/contracts/mcp-tools.manifest.json`. Re-sync it
43
61
  after pulling manifest changes with:
@@ -423,3 +441,44 @@ then GET plan and GET agent. Restart the stateful simulation before repeating;
423
441
  stop it with Ctrl+C afterward. This is a source-backed synthetic failure fixture,
424
442
  not the deployed service. The required CI tests exercise both pre-commit and
425
443
  post-commit 503 over loopback HTTP without requiring a Postman login or installation.
444
+
445
+
446
+ ### Installed CLI integration
447
+
448
+ From the repository root, with Python 3.14, uv 0.12.5 and Node.js available:
449
+
450
+ ```bash
451
+ python -m unittest discover -s tooling/cli-integration -p 'test_*.py' -v
452
+ python tooling/cli-integration/run.py --output /tmp/hc-cli-integration
453
+ ```
454
+
455
+ The runner installs the SDK, tenant-admin SDK and CLI from the frozen CLI lock
456
+ with `uv sync --no-editable` into a fresh temporary environment outside the
457
+ checkout. It verifies installed module ownership, version, help for every AMS
458
+ command, native shell completion, discovery and the
459
+ inspect/plan/show/apply/restore journey. Negative
460
+ journeys exercise denied login, identity/qualification/plan rejection, malformed
461
+ responses, failed readback and uncertain writes without mutation replay.
462
+
463
+ The `Installed CLI integration` workflow selects its uv-installed Linux job at
464
+ job level for `packages/cli/**`, `packages/pip/**`,
465
+ `packages/pip-tenantadmin/**`, its own tooling/workflow, docs compiler inputs
466
+ (`tooling/docs-compiler/**`, `docs-src/**`, `docs/sdk/**`), MCP contracts
467
+ (`contracts/mcp/**`), and root package/lock, Python/lock and Ruff inputs. Backend-only changes skip the install job;
468
+ the small `CLI integration` result check still runs and verifies applicability.
469
+ Invalid selection or an applicable failed/cancelled/skipped job fails it.
470
+ The job retains only sanitized consumer/JUnit results for seven days.
471
+
472
+ All scenario records and credentials are synthetic. A test-only Python startup
473
+ adapter preserves the canonical DEV AMS/tenant-admin origins for product
474
+ validation while routing only those exact origins over real HTTP to an owned
475
+ loopback fixture; other origins are refused before network access. Child
476
+ processes use an isolated home and an allowlisted environment. The adapter is
477
+ outside all shipped distributions. This boundary does not verify TLS or deployed
478
+ service conformance. A verified save still exits 1 with `outcome="saved"` and
479
+ `publication="unknown"`; publication and runtime acceptance remain unverified.
480
+
481
+ Required-check activation is tracked separately in
482
+ [services #724](https://github.com/healthcloudservices/healthcloud-services/issues/724).
483
+ Require `CLI integration` only after the workflow is merged and both relevant
484
+ and unrelated-change Actions results and effective settings are verified.
@@ -0,0 +1,26 @@
1
+ """Legacy AMS command imports backed by one compatibility assembly."""
2
+
3
+ from .compatibility import safe_errors
4
+ from .group import create_ams_group
5
+
6
+ ams = create_ams_group()
7
+ discover = ams.commands["discover"]
8
+ inspect = ams.commands["inspect"]
9
+ plan = ams.commands["plan"]
10
+ show = ams.commands["show"]
11
+ apply = ams.commands["apply"]
12
+ restore = ams.commands["restore"]
13
+ ui = ams.commands["ui"]
14
+
15
+ __all__ = [
16
+ "ams",
17
+ "apply",
18
+ "create_ams_group",
19
+ "discover",
20
+ "inspect",
21
+ "plan",
22
+ "restore",
23
+ "safe_errors",
24
+ "show",
25
+ "ui",
26
+ ]
@@ -0,0 +1,264 @@
1
+ """Compatibility command factories for the existing bounded AMS operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import subprocess
7
+ from pathlib import Path
8
+ from typing import Any
9
+ from urllib.parse import quote, urlsplit
10
+
11
+ import click
12
+
13
+ from healthcloud import HCSDK
14
+ from healthcloud.config import resolve_base_url
15
+ from healthcloud.errors import HealthCloudHTTPError, HealthCloudNetworkError
16
+ from healthcloud_tenantadmin.errors import NetworkError, SDKError
17
+
18
+ from .auth import authenticated_sdk, selected_agent
19
+ from .context import AmsDependencies, pass_ams, preflight_discovery, require_target
20
+ from .output import digest, metadata, private_output, read_json
21
+ from .tuning import (
22
+ apply_receipt,
23
+ create_receipt,
24
+ load_receipt,
25
+ plan_metadata,
26
+ reload_bound_plan,
27
+ restore_receipt,
28
+ validate_changes,
29
+ )
30
+ from .ui import build_ui_launch, ui_directory
31
+
32
+
33
+ def safe_errors(function):
34
+ @functools.wraps(function)
35
+ def wrapped(*args, **kwargs):
36
+ try:
37
+ return function(*args, **kwargs)
38
+ except (HealthCloudHTTPError, SDKError) as exc:
39
+ status = getattr(exc, "status_code", None)
40
+ label = (
41
+ "conflict; refresh and review"
42
+ if status == 409
43
+ else "request denied or failed; stopped without identity fallback or replay"
44
+ )
45
+ raise click.ClickException(f"AMS HTTP {status if type(status) is int else 'error'}: {label}.") from None
46
+ except HealthCloudNetworkError, NetworkError:
47
+ raise click.ClickException(
48
+ "AMS transport failure; outcome may be unknown. No mutation replay; inspect before further action."
49
+ ) from None
50
+ except click.ClickException:
51
+ raise
52
+ except Exception:
53
+ raise click.ClickException(
54
+ "AMS operation refused: invalid local input or SDK response. No automatic replay."
55
+ ) from None
56
+
57
+ return wrapped
58
+
59
+
60
+ def _run_process(*args: Any, **kwargs: Any) -> Any:
61
+ return subprocess.run(*args, **kwargs)
62
+
63
+
64
+ def default_dependencies() -> AmsDependencies:
65
+ return AmsDependencies(
66
+ public_sdk_factory=HCSDK,
67
+ authenticated_sdk_factory=authenticated_sdk,
68
+ process_runner=_run_process,
69
+ )
70
+
71
+
72
+ def create_discover_command(dependencies: AmsDependencies) -> click.Command:
73
+ @click.command("discover")
74
+ @click.option("--environment", type=click.Choice(["dev", "uat", "prod"]), required=True)
75
+ @click.option("--tenant-id", required=True)
76
+ @pass_ams
77
+ @safe_errors
78
+ def discover(invocation, environment, tenant_id):
79
+ """Public candidate IDs only; never infer or select an agent."""
80
+ preflight_discovery(invocation)
81
+ if not tenant_id.strip():
82
+ raise click.ClickException("Explicit tenant ID required.")
83
+ with dependencies.public_sdk_factory(environment=environment) as sdk:
84
+ agents = sdk.ams.get_agents_for_tenant(tenant_id)
85
+ if not isinstance(agents, dict) or any(
86
+ not isinstance(v, list) or any(not isinstance(i, str) or not i for i in v) for v in agents.values()
87
+ ):
88
+ raise click.ClickException("Unexpected public discovery response; no candidate selected.")
89
+ origin = urlsplit(resolve_base_url(environment, "ams"))
90
+ metadata(
91
+ dict(
92
+ environment=environment,
93
+ api_origin=f"{origin.scheme}://{origin.netloc}",
94
+ tenant_id=tenant_id,
95
+ candidates={role: agents.get(role, []) for role in ["public", "orchestrator"]},
96
+ selected_agent=None,
97
+ )
98
+ )
99
+
100
+ return discover
101
+
102
+
103
+ def create_inspect_command(dependencies: AmsDependencies) -> click.Command:
104
+ @click.command("inspect")
105
+ @click.option("--out", type=click.Path(path_type=Path, dir_okay=False))
106
+ @pass_ams
107
+ @safe_errors
108
+ def inspect(invocation, out):
109
+ """Inspect identities, version and field hashes; optional private fields."""
110
+ target = require_target(invocation)
111
+
112
+ def read():
113
+ with dependencies.authenticated_sdk_factory(target) as sdk:
114
+ agent = selected_agent(sdk, target)
115
+ values = dict(instructions=agent.instructions, greetings=agent.greetings)
116
+ result = target.binding() | dict(
117
+ config_version=agent.config_version,
118
+ fields=sorted(values),
119
+ field_hashes={k: digest(v) for k, v in values.items()},
120
+ assigned_references={
121
+ k: getattr(agent, k)
122
+ for k in ["tool_ids", "skill_ids", "guardrail_ids", "policy_ids", "data_source_ids"]
123
+ },
124
+ qualification="operator_assertion_only",
125
+ runtime="unverified",
126
+ )
127
+ return result, values
128
+
129
+ if out:
130
+ with private_output(out) as write:
131
+ result, values = read()
132
+ write(result | dict(selected_values=values))
133
+ else:
134
+ result, _ = read()
135
+ metadata(result)
136
+
137
+ return inspect
138
+
139
+
140
+ def create_plan_command(dependencies: AmsDependencies) -> click.Command:
141
+ @click.command("plan")
142
+ @click.option("--changes-file", required=True, type=click.Path(path_type=Path, dir_okay=False))
143
+ @click.option("--out", required=True, type=click.Path(path_type=Path, dir_okay=False))
144
+ @pass_ams
145
+ @safe_errors
146
+ def plan(invocation, changes_file, out):
147
+ """Create an exact reviewed plan from owner-only selected field values."""
148
+ target = require_target(invocation)
149
+ requested = read_json(changes_file, private=True)
150
+ validate_changes(requested)
151
+ with private_output(out) as write, dependencies.authenticated_sdk_factory(target) as sdk:
152
+ receipt = create_receipt(sdk, target, selected_agent(sdk, target), requested)
153
+ write(receipt)
154
+ metadata(
155
+ dict(
156
+ plan_id=receipt["plan_id"],
157
+ plan_hash=receipt["plan_hash"],
158
+ fields=sorted(requested),
159
+ base_config_version=receipt["base_config_version"],
160
+ qualification="operator_assertion_only",
161
+ runtime="unverified",
162
+ )
163
+ )
164
+
165
+ return plan
166
+
167
+
168
+ def create_show_command(dependencies: AmsDependencies) -> click.Command:
169
+ @click.command("show")
170
+ @click.option("--receipt", required=True, type=click.Path(path_type=Path, dir_okay=False))
171
+ @click.option("--out", type=click.Path(path_type=Path, dir_okay=False))
172
+ @pass_ams
173
+ @safe_errors
174
+ def show(invocation, receipt, out):
175
+ """Reload the immutable server plan; free text only in a private diff."""
176
+ target = require_target(invocation)
177
+ record = load_receipt(receipt, target)
178
+
179
+ def read():
180
+ with dependencies.authenticated_sdk_factory(target) as sdk:
181
+ selected_agent(sdk, target)
182
+ return reload_bound_plan(sdk, target, record)
183
+
184
+ if out:
185
+ with private_output(out) as write:
186
+ plan = read()
187
+ write(
188
+ dict(
189
+ plan_id=plan.id,
190
+ plan_hash=plan.plan_hash,
191
+ changes={
192
+ k: {side: v[side] for side in ["before", "after"]}
193
+ for k, v in plan.plan_json["changes"].items()
194
+ },
195
+ )
196
+ )
197
+ else:
198
+ plan = read()
199
+ metadata(plan_metadata(plan))
200
+
201
+ return show
202
+
203
+
204
+ def create_apply_command(dependencies: AmsDependencies) -> click.Command:
205
+ @click.command("apply")
206
+ @click.option("--receipt", required=True, type=click.Path(path_type=Path, dir_okay=False))
207
+ @click.option("--confirm-plan-hash", required=True)
208
+ @pass_ams
209
+ @safe_errors
210
+ def apply(invocation, receipt, confirm_plan_hash):
211
+ """Confirm the immutable plan hash; mutation is never automatically replayed."""
212
+ target = require_target(invocation)
213
+ record = load_receipt(receipt, target)
214
+ with dependencies.authenticated_sdk_factory(target) as sdk:
215
+ apply_receipt(sdk, target, record, confirm_plan_hash)
216
+
217
+ return apply
218
+
219
+
220
+ def create_restore_command(dependencies: AmsDependencies) -> click.Command:
221
+ @click.command("restore")
222
+ @click.option("--receipt", required=True, type=click.Path(path_type=Path, dir_okay=False))
223
+ @click.option("--out", required=True, type=click.Path(path_type=Path, dir_okay=False))
224
+ @pass_ams
225
+ @safe_errors
226
+ def restore(invocation, receipt, out):
227
+ """Create a new reviewed inverse plan; intervening same-field edits block it."""
228
+ target = require_target(invocation)
229
+ record = load_receipt(receipt, target)
230
+ with private_output(out) as write, dependencies.authenticated_sdk_factory(target) as sdk:
231
+ restored = restore_receipt(sdk, target, record)
232
+ if restored["plan_id"] == record["plan_id"] or restored["plan_hash"] == record["plan_hash"]:
233
+ raise click.ClickException("Restore must be a new immutable server plan/hash.")
234
+ write(restored)
235
+ metadata(
236
+ dict(
237
+ plan_id=restored["plan_id"],
238
+ plan_hash=restored["plan_hash"],
239
+ fields=sorted(restored["requested"]),
240
+ base_config_version=restored["base_config_version"],
241
+ runtime="unverified",
242
+ )
243
+ )
244
+
245
+ return restore
246
+
247
+
248
+ def create_ui_command(dependencies: AmsDependencies) -> click.Command:
249
+ @click.command("ui")
250
+ @click.option("--repo", required=True, type=click.Path(path_type=Path, file_okay=False))
251
+ @pass_ams
252
+ @safe_errors
253
+ def ui(invocation, repo):
254
+ """Run the existing local frontend; browser login is separate."""
255
+ target = require_target(invocation)
256
+ argv, env = build_ui_launch(target, repo)
257
+ metadata(
258
+ dict(route=f"/app/agents/{quote(target.agent_id, safe='')}", browser_login="separate", runtime="unverified")
259
+ )
260
+ result = dependencies.process_runner(argv, cwd=ui_directory(repo), env=env, shell=False, check=False)
261
+ if result.returncode:
262
+ raise click.ClickException("Frontend exited unsuccessfully.")
263
+
264
+ return ui
@@ -0,0 +1,74 @@
1
+ """Typed invocation configuration and deferred AMS operational preflight."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from contextlib import AbstractContextManager
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import click
12
+
13
+ from ..context import CliConfig, resolve_config
14
+ from .target import AmsTarget, load_target
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class AmsDependencies:
19
+ """Process-local effect adapters used by one assembled AMS command tree."""
20
+
21
+ public_sdk_factory: Callable[..., Any]
22
+ authenticated_sdk_factory: Callable[[AmsTarget], AbstractContextManager[Any]]
23
+ process_runner: Callable[..., Any]
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class AmsContext:
28
+ """Configuration recorded for one AMS command invocation."""
29
+
30
+ root_config: CliConfig
31
+ target_file: Path | None
32
+ dependencies: AmsDependencies
33
+
34
+
35
+ pass_ams = click.make_pass_decorator(AmsContext, ensure=False)
36
+
37
+
38
+ def _check_root(invocation: AmsContext) -> CliConfig:
39
+ cfg = invocation.root_config
40
+ if cfg.access_token is not None or cfg.base_url is not None:
41
+ raise click.ClickException("AMS refuses root MCP credentials and base URL settings.")
42
+ try:
43
+ resolved = resolve_config(
44
+ cfg.access_token,
45
+ cfg.base_url,
46
+ cfg.profile,
47
+ cfg.output,
48
+ False,
49
+ cfg.verbose,
50
+ )
51
+ except click.UsageError, OSError, ValueError, TypeError:
52
+ raise click.ClickException("AMS root profile is unavailable or invalid; use an explicit target.") from None
53
+ if resolved.access_token is not None or resolved.base_url is not None:
54
+ raise click.ClickException("AMS refuses root MCP credentials and base URL settings.")
55
+ if resolved.output == "raw":
56
+ raise click.ClickException("Raw output is refused for AMS.")
57
+ return resolved
58
+
59
+
60
+ def preflight_discovery(invocation: AmsContext) -> None:
61
+ """Validate root options for discovery without loading a target."""
62
+
63
+ _check_root(invocation)
64
+ if invocation.target_file is not None:
65
+ raise click.ClickException("Discover requires explicit environment/tenant flags, without a target file.")
66
+
67
+
68
+ def require_target(invocation: AmsContext) -> AmsTarget:
69
+ """Validate root options and load the explicitly selected AMS target."""
70
+
71
+ _check_root(invocation)
72
+ if invocation.target_file is None:
73
+ raise click.ClickException("AMS requires an explicit --target-file.")
74
+ return load_target(invocation.target_file)
@@ -0,0 +1,30 @@
1
+ """Fresh AMS Click group assembly."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+ from ..context import CliConfig
10
+ from .compatibility import default_dependencies, safe_errors
11
+ from .context import AmsContext, AmsDependencies
12
+ from .registry import IMPLEMENTED_CHILDREN, register_children
13
+
14
+
15
+ def create_ams_group(dependencies: AmsDependencies | None = None) -> click.Group:
16
+ dependencies = dependencies or default_dependencies()
17
+
18
+ @click.group("ams")
19
+ @click.option("--target-file", type=click.Path(path_type=Path, dir_okay=False), default=None)
20
+ @click.pass_context
21
+ @safe_errors
22
+ def group(ctx: click.Context, target_file: Path | None) -> None:
23
+ """Inspect and tune an explicitly selected deployed AMS agent."""
24
+ cfg = ctx.find_object(CliConfig)
25
+ if cfg is None:
26
+ raise click.ClickException("AMS requires root CLI configuration.")
27
+ ctx.obj = AmsContext(root_config=cfg, target_file=target_file, dependencies=dependencies)
28
+
29
+ register_children(group, dependencies, IMPLEMENTED_CHILDREN)
30
+ return group
@@ -0,0 +1,63 @@
1
+ """Static, atomic child registration for AMS command groups."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Sequence
6
+ from dataclasses import dataclass
7
+
8
+ import click
9
+
10
+ from .compatibility import (
11
+ create_apply_command,
12
+ create_discover_command,
13
+ create_inspect_command,
14
+ create_plan_command,
15
+ create_restore_command,
16
+ create_show_command,
17
+ create_ui_command,
18
+ )
19
+ from .context import AmsDependencies
20
+
21
+ CommandFactory = Callable[[AmsDependencies], click.Command]
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class ChildRegistration:
26
+ name: str
27
+ factory: CommandFactory
28
+
29
+
30
+ IMPLEMENTED_CHILDREN = (
31
+ ChildRegistration("discover", create_discover_command),
32
+ ChildRegistration("inspect", create_inspect_command),
33
+ ChildRegistration("plan", create_plan_command),
34
+ ChildRegistration("show", create_show_command),
35
+ ChildRegistration("apply", create_apply_command),
36
+ ChildRegistration("restore", create_restore_command),
37
+ ChildRegistration("ui", create_ui_command),
38
+ )
39
+
40
+
41
+ def register_children(
42
+ parent: click.Group,
43
+ dependencies: AmsDependencies,
44
+ registrations: Sequence[ChildRegistration],
45
+ ) -> None:
46
+ names = [registration.name for registration in registrations]
47
+ if any(not isinstance(name, str) or not name for name in names):
48
+ raise ValueError("AMS child registration names must be non-empty strings")
49
+ if len(names) != len(set(names)):
50
+ raise ValueError("AMS child registry contains a duplicate name")
51
+ collisions = set(names) & set(parent.commands)
52
+ if collisions:
53
+ raise ValueError(f"AMS child registry would replace existing commands: {sorted(collisions)}")
54
+
55
+ commands = [registration.factory(dependencies) for registration in registrations]
56
+ for registration, command in zip(registrations, commands, strict=True):
57
+ if command.name != registration.name:
58
+ raise ValueError(
59
+ f"AMS child registration expected {registration.name!r}, factory returned {command.name!r}"
60
+ )
61
+
62
+ for registration, command in zip(registrations, commands, strict=True):
63
+ parent.add_command(command, registration.name)
@@ -16,7 +16,7 @@ from typing import Any
16
16
  import click
17
17
 
18
18
  from . import manifest as manifest_module
19
- from .ams.commands import ams
19
+ from .ams.group import create_ams_group
20
20
  from .context import CliConfig, resolve_config
21
21
  from .output import dispatch, dump_rpc_result, read_stdin
22
22
  from .params import ParamSpec, build_param_specs, make_click_option, merge_arguments, validate_final
@@ -256,13 +256,14 @@ def hc(
256
256
  ) -> None:
257
257
  """HealthCloud MCP gateway CLI."""
258
258
  if ctx.invoked_subcommand == "ams":
259
- # Do not let profile parsing errors echo credential-bearing input.
260
- if access_token is not None or base_url is not None:
261
- raise click.ClickException("AMS refuses root MCP credentials and base URL settings.")
262
- try:
263
- ctx.obj = resolve_config(access_token, base_url, profile, output_mode, raw_flag, verbose)
264
- except click.UsageError, OSError, ValueError, TypeError:
265
- raise click.ClickException("AMS root profile is unavailable or invalid; use an explicit target.") from None
259
+ ctx.obj = CliConfig(
260
+ access_token=access_token,
261
+ base_url=base_url,
262
+ profile=profile,
263
+ environment="dev",
264
+ output="raw" if raw_flag else (output_mode or "pretty"),
265
+ verbose=verbose,
266
+ )
266
267
  else:
267
268
  ctx.obj = resolve_config(access_token, base_url, profile, output_mode, raw_flag, verbose)
268
269
 
@@ -291,7 +292,7 @@ def _build_mcp_group() -> click.Group:
291
292
 
292
293
  hc.add_command(_build_mcp_group())
293
294
 
294
- hc.add_command(ams)
295
+ hc.add_command(create_ams_group())
295
296
 
296
297
 
297
298
  def main() -> None: