healthcloud-cli 3.2.3__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.
- {healthcloud_cli-3.2.3 → healthcloud_cli-3.2.5}/PKG-INFO +60 -1
- {healthcloud_cli-3.2.3 → healthcloud_cli-3.2.5}/README.md +59 -0
- healthcloud_cli-3.2.5/healthcloud_cli/ams/commands.py +26 -0
- healthcloud_cli-3.2.5/healthcloud_cli/ams/compatibility.py +264 -0
- healthcloud_cli-3.2.5/healthcloud_cli/ams/context.py +74 -0
- healthcloud_cli-3.2.5/healthcloud_cli/ams/group.py +30 -0
- healthcloud_cli-3.2.5/healthcloud_cli/ams/registry.py +63 -0
- {healthcloud_cli-3.2.3 → healthcloud_cli-3.2.5}/healthcloud_cli/cli.py +10 -9
- {healthcloud_cli-3.2.3 → healthcloud_cli-3.2.5}/healthcloud_cli/contracts/mcp-tools.manifest.json +690 -237
- {healthcloud_cli-3.2.3 → healthcloud_cli-3.2.5}/pyproject.toml +1 -1
- healthcloud_cli-3.2.3/healthcloud_cli/ams/commands.py +0 -242
- {healthcloud_cli-3.2.3 → healthcloud_cli-3.2.5}/.gitignore +0 -0
- {healthcloud_cli-3.2.3 → healthcloud_cli-3.2.5}/hatch_build.py +0 -0
- {healthcloud_cli-3.2.3 → healthcloud_cli-3.2.5}/healthcloud_cli/__init__.py +0 -0
- {healthcloud_cli-3.2.3 → healthcloud_cli-3.2.5}/healthcloud_cli/ams/__init__.py +0 -0
- {healthcloud_cli-3.2.3 → healthcloud_cli-3.2.5}/healthcloud_cli/ams/auth.py +0 -0
- {healthcloud_cli-3.2.3 → healthcloud_cli-3.2.5}/healthcloud_cli/ams/output.py +0 -0
- {healthcloud_cli-3.2.3 → healthcloud_cli-3.2.5}/healthcloud_cli/ams/target.py +0 -0
- {healthcloud_cli-3.2.3 → healthcloud_cli-3.2.5}/healthcloud_cli/ams/tuning.py +0 -0
- {healthcloud_cli-3.2.3 → healthcloud_cli-3.2.5}/healthcloud_cli/ams/ui.py +0 -0
- {healthcloud_cli-3.2.3 → healthcloud_cli-3.2.5}/healthcloud_cli/ams/windows_files.py +0 -0
- {healthcloud_cli-3.2.3 → healthcloud_cli-3.2.5}/healthcloud_cli/context.py +0 -0
- {healthcloud_cli-3.2.3 → healthcloud_cli-3.2.5}/healthcloud_cli/contracts/__init__.py +0 -0
- {healthcloud_cli-3.2.3 → healthcloud_cli-3.2.5}/healthcloud_cli/main.py +0 -0
- {healthcloud_cli-3.2.3 → healthcloud_cli-3.2.5}/healthcloud_cli/manifest.py +0 -0
- {healthcloud_cli-3.2.3 → healthcloud_cli-3.2.5}/healthcloud_cli/output.py +0 -0
- {healthcloud_cli-3.2.3 → healthcloud_cli-3.2.5}/healthcloud_cli/params.py +0 -0
- {healthcloud_cli-3.2.3 → healthcloud_cli-3.2.5}/healthcloud_cli/util.py +0 -0
- {healthcloud_cli-3.2.3 → healthcloud_cli-3.2.5}/scripts/__init__.py +0 -0
- {healthcloud_cli-3.2.3 → 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.
|
|
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.
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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(
|
|
295
|
+
hc.add_command(create_ams_group())
|
|
295
296
|
|
|
296
297
|
|
|
297
298
|
def main() -> None:
|