healthcloud-cli 0.1.0__py3-none-any.whl
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/__init__.py +4 -0
- healthcloud_cli/cli.py +275 -0
- healthcloud_cli/context.py +80 -0
- healthcloud_cli/contracts/__init__.py +0 -0
- healthcloud_cli/contracts/mcp-tools.manifest.json +33376 -0
- healthcloud_cli/main.py +19 -0
- healthcloud_cli/manifest.py +68 -0
- healthcloud_cli/output.py +128 -0
- healthcloud_cli/params.py +231 -0
- healthcloud_cli/util.py +29 -0
- healthcloud_cli-0.1.0.dist-info/METADATA +236 -0
- healthcloud_cli-0.1.0.dist-info/RECORD +14 -0
- healthcloud_cli-0.1.0.dist-info/WHEEL +4 -0
- healthcloud_cli-0.1.0.dist-info/entry_points.txt +2 -0
healthcloud_cli/cli.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""The ``hc`` command line tool.
|
|
2
|
+
|
|
3
|
+
The entire ``hc mcp <audience> <command>`` tree is built dynamically at
|
|
4
|
+
import time from the bundled MCP tool manifest (see ``manifest.py``) -- there
|
|
5
|
+
is exactly one generated Click command per manifest tool, never hand-written
|
|
6
|
+
per-tool source files.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import functools
|
|
12
|
+
import json
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
import click
|
|
17
|
+
|
|
18
|
+
from . import manifest as manifest_module
|
|
19
|
+
from .context import CliConfig, resolve_config
|
|
20
|
+
from .output import dispatch, dump_rpc_result, read_stdin
|
|
21
|
+
from .params import ParamSpec, build_param_specs, make_click_option, merge_arguments, validate_final
|
|
22
|
+
from .util import parse_json_text
|
|
23
|
+
|
|
24
|
+
EXIT_VALIDATION_ERROR = 2
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# --------------------------------------------------------------------------- #
|
|
28
|
+
# Per-tool command (`hc mcp <audience> <cli-command>`)
|
|
29
|
+
# --------------------------------------------------------------------------- #
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _resolve_base_object(input_file: Optional[str], use_stdin: bool) -> dict[str, Any]:
|
|
33
|
+
if input_file and use_stdin:
|
|
34
|
+
raise click.UsageError("--input and --stdin cannot be used together.")
|
|
35
|
+
if input_file:
|
|
36
|
+
text = Path(input_file).read_text(encoding="utf-8")
|
|
37
|
+
base = parse_json_text(text, source=f"--input file '{input_file}'")
|
|
38
|
+
elif use_stdin:
|
|
39
|
+
base = parse_json_text(read_stdin(), source="stdin")
|
|
40
|
+
else:
|
|
41
|
+
return {}
|
|
42
|
+
if not isinstance(base, dict):
|
|
43
|
+
raise click.UsageError("The --input/--stdin JSON value must be a JSON object.")
|
|
44
|
+
return base
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _tool_callback(
|
|
48
|
+
audience: dict[str, Any], tool: dict[str, Any], param_specs: list[ParamSpec], **kwargs: Any
|
|
49
|
+
) -> None:
|
|
50
|
+
ctx = click.get_current_context()
|
|
51
|
+
cfg: CliConfig = ctx.obj
|
|
52
|
+
|
|
53
|
+
input_file = kwargs.pop("input_file")
|
|
54
|
+
use_stdin = kwargs.pop("use_stdin")
|
|
55
|
+
base = _resolve_base_object(input_file, use_stdin)
|
|
56
|
+
|
|
57
|
+
merged = merge_arguments(tool, param_specs, base, kwargs)
|
|
58
|
+
|
|
59
|
+
errors = validate_final(tool, merged)
|
|
60
|
+
if errors:
|
|
61
|
+
for error in errors:
|
|
62
|
+
click.echo(error, err=True)
|
|
63
|
+
raise SystemExit(EXIT_VALIDATION_ERROR)
|
|
64
|
+
|
|
65
|
+
dispatch(cfg, audience["name"], tool["name"], merged)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def make_tool_command(audience: dict[str, Any], tool: dict[str, Any]) -> click.Command:
|
|
69
|
+
param_specs = build_param_specs(tool)
|
|
70
|
+
params: list[click.Parameter] = [make_click_option(spec) for spec in param_specs]
|
|
71
|
+
params.append(
|
|
72
|
+
click.Option(
|
|
73
|
+
["--input", "input_file"],
|
|
74
|
+
type=click.Path(exists=True, dir_okay=False),
|
|
75
|
+
default=None,
|
|
76
|
+
help="Path to a JSON file with base arguments (overridden by explicit flags).",
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
params.append(
|
|
80
|
+
click.Option(
|
|
81
|
+
["--stdin", "use_stdin"],
|
|
82
|
+
is_flag=True,
|
|
83
|
+
default=False,
|
|
84
|
+
help="Read base arguments as JSON from stdin (overridden by explicit flags).",
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
callback = functools.partial(_tool_callback, audience, tool, param_specs)
|
|
89
|
+
return click.Command(
|
|
90
|
+
name=tool["cliCommand"],
|
|
91
|
+
params=params,
|
|
92
|
+
callback=callback,
|
|
93
|
+
help=tool["description"],
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# --------------------------------------------------------------------------- #
|
|
98
|
+
# `hc mcp tools list` / `hc mcp tools show`
|
|
99
|
+
# --------------------------------------------------------------------------- #
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@click.command("list")
|
|
103
|
+
@click.option("--audience", default=None, help="Filter to a single audience (e.g. patient).")
|
|
104
|
+
@click.option("--remote", is_flag=True, default=False, help="Query the live gateway instead of the bundled manifest.")
|
|
105
|
+
@click.pass_context
|
|
106
|
+
def tools_list(ctx: click.Context, audience: Optional[str], remote: bool) -> None:
|
|
107
|
+
"""List MCP tools. Reads the bundled manifest by default (no network call)."""
|
|
108
|
+
cfg: CliConfig = ctx.obj
|
|
109
|
+
|
|
110
|
+
if audience is not None and manifest_module.get_audience(audience) is None:
|
|
111
|
+
raise click.UsageError(f"Unknown audience: '{audience}'")
|
|
112
|
+
|
|
113
|
+
if remote:
|
|
114
|
+
if not audience:
|
|
115
|
+
raise click.UsageError("--remote requires --audience to be specified.")
|
|
116
|
+
from .output import build_sdk
|
|
117
|
+
from healthcloud.errors import HealthCloudHTTPError, HealthCloudNetworkError
|
|
118
|
+
|
|
119
|
+
sdk = build_sdk(cfg)
|
|
120
|
+
try:
|
|
121
|
+
response = sdk.mcp.list_tools(audience)
|
|
122
|
+
except (HealthCloudHTTPError, HealthCloudNetworkError) as exc:
|
|
123
|
+
click.echo(str(exc), err=True)
|
|
124
|
+
raise SystemExit(3)
|
|
125
|
+
finally:
|
|
126
|
+
sdk.close()
|
|
127
|
+
dump_rpc_result(cfg, response)
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
audiences = manifest_module.audiences()
|
|
131
|
+
if audience is not None:
|
|
132
|
+
audiences = [a for a in audiences if a["name"] == audience]
|
|
133
|
+
|
|
134
|
+
rows = [
|
|
135
|
+
{
|
|
136
|
+
"audience": a["name"],
|
|
137
|
+
"cliCommand": t["cliCommand"],
|
|
138
|
+
"name": t["name"],
|
|
139
|
+
"description": t["description"],
|
|
140
|
+
}
|
|
141
|
+
for a in audiences
|
|
142
|
+
for t in a["tools"]
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
if cfg.output in ("json", "raw"):
|
|
146
|
+
click.echo(json.dumps(rows, indent=2))
|
|
147
|
+
else:
|
|
148
|
+
for row in rows:
|
|
149
|
+
click.echo(f"{row['audience']} :: {row['cliCommand']} :: {row['description']}")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@click.command("show")
|
|
153
|
+
@click.argument("audience")
|
|
154
|
+
@click.argument("tool_name")
|
|
155
|
+
@click.pass_context
|
|
156
|
+
def tools_show(ctx: click.Context, audience: str, tool_name: str) -> None:
|
|
157
|
+
"""Show one tool's full manifest entry (description, inputSchema, method/path)."""
|
|
158
|
+
cfg: CliConfig = ctx.obj
|
|
159
|
+
|
|
160
|
+
tool = manifest_module.get_tool(audience, tool_name)
|
|
161
|
+
if tool is None:
|
|
162
|
+
if manifest_module.get_audience(audience) is None:
|
|
163
|
+
raise click.UsageError(f"Unknown audience: '{audience}'")
|
|
164
|
+
raise click.UsageError(f"Unknown tool '{tool_name}' in audience '{audience}'.")
|
|
165
|
+
|
|
166
|
+
if cfg.output in ("json", "raw"):
|
|
167
|
+
click.echo(json.dumps(tool, indent=2))
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
click.echo(f"{tool['name']} ({tool['cliCommand']}) [{audience}]")
|
|
171
|
+
click.echo(tool["description"])
|
|
172
|
+
click.echo(f"{tool['httpMethod']} {tool['httpPath']}")
|
|
173
|
+
click.echo(json.dumps(tool["inputSchema"], indent=2))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# --------------------------------------------------------------------------- #
|
|
177
|
+
# `hc mcp call <audience> <exact-tool-name>` -- generic escape hatch
|
|
178
|
+
# --------------------------------------------------------------------------- #
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@click.command("call")
|
|
182
|
+
@click.argument("audience")
|
|
183
|
+
@click.argument("tool_name")
|
|
184
|
+
@click.option("--input", "input_json", default=None, help="Inline JSON *value* for arguments (not a file path).")
|
|
185
|
+
@click.option("--input-file", "input_file", type=click.Path(exists=True, dir_okay=False), default=None, help="Path to a JSON file with arguments.")
|
|
186
|
+
@click.option("--stdin", "use_stdin", is_flag=True, default=False, help="Read arguments as JSON from stdin.")
|
|
187
|
+
@click.pass_context
|
|
188
|
+
def call_cmd(
|
|
189
|
+
ctx: click.Context,
|
|
190
|
+
audience: str,
|
|
191
|
+
tool_name: str,
|
|
192
|
+
input_json: Optional[str],
|
|
193
|
+
input_file: Optional[str],
|
|
194
|
+
use_stdin: bool,
|
|
195
|
+
) -> None:
|
|
196
|
+
"""Call any MCP tool by its exact tool name, even one not in the bundled manifest."""
|
|
197
|
+
cfg: CliConfig = ctx.obj
|
|
198
|
+
|
|
199
|
+
provided = [name for name, value in (("--input", input_json), ("--input-file", input_file), ("--stdin", use_stdin)) if value]
|
|
200
|
+
if len(provided) > 1:
|
|
201
|
+
raise click.UsageError(f"Only one of --input, --input-file, --stdin may be given (got {', '.join(provided)}).")
|
|
202
|
+
|
|
203
|
+
if input_json is not None:
|
|
204
|
+
arguments = parse_json_text(input_json, source="--input")
|
|
205
|
+
elif input_file is not None:
|
|
206
|
+
arguments = parse_json_text(Path(input_file).read_text(encoding="utf-8"), source=f"--input-file '{input_file}'")
|
|
207
|
+
elif use_stdin:
|
|
208
|
+
arguments = parse_json_text(read_stdin(), source="stdin")
|
|
209
|
+
else:
|
|
210
|
+
arguments = {}
|
|
211
|
+
|
|
212
|
+
if not isinstance(arguments, dict):
|
|
213
|
+
raise click.UsageError("Arguments must be a JSON object.")
|
|
214
|
+
|
|
215
|
+
dispatch(cfg, audience, tool_name, arguments)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# --------------------------------------------------------------------------- #
|
|
219
|
+
# Root group assembly
|
|
220
|
+
# --------------------------------------------------------------------------- #
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@click.group()
|
|
224
|
+
@click.option("--access-token", default=None, envvar="HC_ACCESS_TOKEN", help="Bearer token for the MCP gateway.")
|
|
225
|
+
@click.option("--base-url", default=None, envvar="HC_MCP_BASE_URL", help="Override the MCP gateway base URL.")
|
|
226
|
+
@click.option("--profile", default=None, envvar="HC_PROFILE", help="Named profile from ~/.healthcloud/cli/profiles.json.")
|
|
227
|
+
@click.option("--output", "output_mode", type=click.Choice(["pretty", "json", "raw"]), default=None, help="Output format (default: pretty).")
|
|
228
|
+
@click.option("--raw", "raw_flag", is_flag=True, default=False, help="Shortcut for --output raw.")
|
|
229
|
+
@click.option("--verbose", is_flag=True, default=False, help="Print diagnostics to stderr.")
|
|
230
|
+
@click.version_option(package_name="healthcloud-cli", prog_name="hc")
|
|
231
|
+
@click.pass_context
|
|
232
|
+
def hc(
|
|
233
|
+
ctx: click.Context,
|
|
234
|
+
access_token: Optional[str],
|
|
235
|
+
base_url: Optional[str],
|
|
236
|
+
profile: Optional[str],
|
|
237
|
+
output_mode: Optional[str],
|
|
238
|
+
raw_flag: bool,
|
|
239
|
+
verbose: bool,
|
|
240
|
+
) -> None:
|
|
241
|
+
"""HealthCloud MCP gateway CLI."""
|
|
242
|
+
ctx.obj = resolve_config(access_token, base_url, profile, output_mode, raw_flag, verbose)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _build_mcp_group() -> click.Group:
|
|
246
|
+
mcp_group = click.Group("mcp", help="Interact with the HealthCloud MCP gateway.")
|
|
247
|
+
|
|
248
|
+
for audience in manifest_module.audiences():
|
|
249
|
+
audience_group = click.Group(
|
|
250
|
+
audience["name"],
|
|
251
|
+
help=f"Tools for the '{audience['name']}' audience ({audience['toolCount']} tools).",
|
|
252
|
+
)
|
|
253
|
+
for tool in audience["tools"]:
|
|
254
|
+
audience_group.add_command(make_tool_command(audience, tool))
|
|
255
|
+
mcp_group.add_command(audience_group)
|
|
256
|
+
|
|
257
|
+
tools_group = click.Group("tools", help="Inspect the bundled MCP tool manifest.")
|
|
258
|
+
tools_group.add_command(tools_list)
|
|
259
|
+
tools_group.add_command(tools_show)
|
|
260
|
+
mcp_group.add_command(tools_group)
|
|
261
|
+
|
|
262
|
+
mcp_group.add_command(call_cmd)
|
|
263
|
+
|
|
264
|
+
return mcp_group
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
hc.add_command(_build_mcp_group())
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def main() -> None:
|
|
271
|
+
hc()
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
if __name__ == "__main__":
|
|
275
|
+
main()
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Global CLI configuration: resolves auth/base-url/output settings.
|
|
2
|
+
|
|
3
|
+
Precedence (highest wins): explicit CLI flag > environment variable >
|
|
4
|
+
profile file value > built-in default.
|
|
5
|
+
|
|
6
|
+
``click``'s ``envvar=`` option support already gives us "CLI flag beats
|
|
7
|
+
environment variable" for free; this module only adds the profile-file layer
|
|
8
|
+
underneath that, plus the final built-in defaults.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Optional
|
|
17
|
+
|
|
18
|
+
import click
|
|
19
|
+
|
|
20
|
+
PROFILES_PATH = Path.home() / ".healthcloud" / "cli" / "profiles.json"
|
|
21
|
+
|
|
22
|
+
OutputMode = str # "pretty" | "json" | "raw"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class CliConfig:
|
|
27
|
+
access_token: Optional[str]
|
|
28
|
+
base_url: Optional[str]
|
|
29
|
+
profile: Optional[str]
|
|
30
|
+
environment: str
|
|
31
|
+
output: OutputMode
|
|
32
|
+
verbose: bool
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_profile(name: Optional[str], path: Path = PROFILES_PATH) -> dict[str, Any]:
|
|
36
|
+
"""Load one named profile from ``~/.healthcloud/cli/profiles.json``.
|
|
37
|
+
|
|
38
|
+
A missing profiles file is not an error as long as no profile was
|
|
39
|
+
requested. If the caller explicitly asked for a profile (``--profile`` /
|
|
40
|
+
``HC_PROFILE``), a missing file or missing entry is a clear usage error
|
|
41
|
+
rather than a silent no-op, since a typo'd profile name should not
|
|
42
|
+
silently fall back to unauthenticated defaults.
|
|
43
|
+
"""
|
|
44
|
+
if not name:
|
|
45
|
+
return {}
|
|
46
|
+
if not path.exists():
|
|
47
|
+
raise click.UsageError(f"--profile '{name}' requested but no profiles file exists at {path}")
|
|
48
|
+
try:
|
|
49
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
50
|
+
except json.JSONDecodeError as exc:
|
|
51
|
+
raise click.UsageError(f"Profiles file {path} is not valid JSON: {exc}") from exc
|
|
52
|
+
if name not in data:
|
|
53
|
+
raise click.UsageError(f"Profile '{name}' not found in {path}")
|
|
54
|
+
profile = data[name]
|
|
55
|
+
if not isinstance(profile, dict):
|
|
56
|
+
raise click.UsageError(f"Profile '{name}' in {path} must be a JSON object")
|
|
57
|
+
return profile
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def resolve_config(
|
|
61
|
+
access_token: Optional[str],
|
|
62
|
+
base_url: Optional[str],
|
|
63
|
+
profile: Optional[str],
|
|
64
|
+
output: Optional[str],
|
|
65
|
+
raw: bool,
|
|
66
|
+
verbose: bool,
|
|
67
|
+
) -> CliConfig:
|
|
68
|
+
profile_data = load_profile(profile, path=PROFILES_PATH)
|
|
69
|
+
resolved_access_token = access_token if access_token is not None else profile_data.get("accessToken")
|
|
70
|
+
resolved_base_url = base_url if base_url is not None else profile_data.get("baseUrl")
|
|
71
|
+
resolved_environment = profile_data.get("environment", "dev")
|
|
72
|
+
resolved_output = "raw" if raw else (output or "pretty")
|
|
73
|
+
return CliConfig(
|
|
74
|
+
access_token=resolved_access_token,
|
|
75
|
+
base_url=resolved_base_url,
|
|
76
|
+
profile=profile,
|
|
77
|
+
environment=resolved_environment,
|
|
78
|
+
output=resolved_output,
|
|
79
|
+
verbose=verbose,
|
|
80
|
+
)
|
|
File without changes
|