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.
@@ -0,0 +1,4 @@
1
+ """HealthCloud CLI: `hc mcp <audience> <command>` generated from the shared MCP tool manifest."""
2
+
3
+ __version__ = "0.1.0"
4
+
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