linc-cli-kit 0.1.1__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.
linc_cli_kit/output.py ADDED
@@ -0,0 +1,404 @@
1
+ """Output contract (spec §5.2): mode resolution, stdout/stderr routing, artifacts, heartbeat."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import contextlib
7
+ import dataclasses
8
+ import datetime
9
+ import hashlib
10
+ import json
11
+ import math
12
+ import os
13
+ import signal
14
+ import sys
15
+ import threading
16
+ import time
17
+ from collections.abc import Callable, Iterator, Mapping
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from linc_cli_kit.errors import Outcome, ToolError, classify, classify_exception, safe_str
22
+
23
+ MODES = ("json", "human", "jsonl")
24
+
25
+
26
+ def resolve_mode(flag: str | None, env: Mapping[str, str] | None = None) -> str:
27
+ if flag in MODES:
28
+ return flag # type: ignore[return-value]
29
+ env = os.environ if env is None else env
30
+ value = env.get("LINC_OUTPUT", "").lower()
31
+ return value if value in MODES else "json"
32
+
33
+
34
+ def _pretty() -> bool:
35
+ return bool(getattr(sys.stdout, "isatty", lambda: False)())
36
+
37
+
38
+ def json_default(obj: Any) -> Any:
39
+ """`json.dumps(default=...)` for tool results: structured types become structured JSON.
40
+
41
+ - dataclass instance -> object of its fields (shallow; nested values recurse through
42
+ this hook, so nothing is deep-copied)
43
+ - pydantic `BaseModel` instance -> `model_dump(mode="json")`
44
+ - set / frozenset -> sorted list (by `repr` when the members are not mutually orderable)
45
+ - bytes / bytearray / memoryview -> base64 string (standard alphabet, padded). Binary
46
+ content large enough to matter should be an artifact (`-o/--output`), not a payload.
47
+ - datetime / date / time -> ISO 8601 (`isoformat()`)
48
+ - anything else -> `str(obj)` (Path, Decimal, UUID, Enum ... as before)
49
+ """
50
+ if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
51
+ return {f.name: getattr(obj, f.name) for f in dataclasses.fields(obj)}
52
+ # A real pydantic model only, never a duck-typed `model_dump` attribute: a MagicMock
53
+ # answers every attribute with a callable returning another mock, which recursed without
54
+ # end. Looked up in sys.modules so the kit never imports pydantic itself.
55
+ pydantic = sys.modules.get("pydantic")
56
+ if pydantic is not None and isinstance(obj, pydantic.BaseModel):
57
+ return obj.model_dump(mode="json")
58
+ if isinstance(obj, (set, frozenset)):
59
+ try:
60
+ return sorted(obj)
61
+ except TypeError:
62
+ return sorted(obj, key=repr)
63
+ if isinstance(obj, (bytes, bytearray, memoryview)):
64
+ return base64.b64encode(bytes(obj)).decode("ascii")
65
+ if isinstance(obj, (datetime.datetime, datetime.date, datetime.time)):
66
+ return obj.isoformat()
67
+ return str(obj)
68
+
69
+
70
+ def to_json(doc: Any, *, indent: int | None = None) -> str:
71
+ """Strict JSON: typed `json_default` and `allow_nan=False`.
72
+
73
+ NaN/Infinity are not JSON (jq and strict parsers reject the whole document), so a
74
+ non-finite float raises ValueError here; `emit` turns that into a NON_JSON_VALUE
75
+ envelope instead of writing invalid output.
76
+ """
77
+ return json.dumps(doc, default=json_default, allow_nan=False, indent=indent)
78
+
79
+
80
+ def _non_json(exc: Exception) -> ToolError:
81
+ return ToolError(
82
+ f"result is not representable as JSON: {exc}",
83
+ code="NON_JSON_VALUE",
84
+ hint="return JSON-compatible values: None instead of NaN/Infinity, string dict keys",
85
+ category="internal",
86
+ )
87
+
88
+
89
+ def _finite(doc: Any, _seen: frozenset[int] = frozenset()) -> Any:
90
+ """Make envelope details (tool data) always encodable: non-finite floats become strings
91
+ ("nan"), other values go through `json_default` and are recursed into, non-JSON dict
92
+ keys become `str(key)`, and a reference cycle becomes "<cycle>". The failure path must
93
+ never fail, nor drop good fields because one nested value is bad."""
94
+ if isinstance(doc, float):
95
+ return doc if math.isfinite(doc) else repr(doc)
96
+ if doc is None or isinstance(doc, (str, int, bool)):
97
+ return doc
98
+ if id(doc) in _seen:
99
+ return "<cycle>"
100
+ seen = _seen | {id(doc)}
101
+ if isinstance(doc, dict):
102
+ out: dict[Any, Any] = {}
103
+ for k, v in doc.items():
104
+ key = _finite_key(k)
105
+ if key in out: # only via conversion, e.g. a NaN key vs a real "nan" key
106
+ n = 2
107
+ while f"{key}#{n}" in out:
108
+ n += 1
109
+ key = f"{key}#{n}"
110
+ out[key] = _finite(v, seen)
111
+ return out
112
+ if isinstance(doc, (list, tuple)):
113
+ return [_finite(v, seen) for v in doc]
114
+ try:
115
+ converted = json_default(doc)
116
+ except Exception: # a hostile __str__, an unset dataclass field ...
117
+ return f"<unencodable {type(doc).__name__}>"
118
+ return converted if isinstance(converted, str) else _finite(converted, seen)
119
+
120
+
121
+ def _finite_key(key: Any) -> str:
122
+ """The key exactly as it will be spelled in the JSON text, so collisions are visible
123
+ here: json.dumps itself writes 1 / None / True keys as "1" / "null" / "true"."""
124
+ if isinstance(key, str):
125
+ return key
126
+ if key is None or isinstance(key, bool):
127
+ return json.dumps(key)
128
+ if isinstance(key, float) and not math.isfinite(key):
129
+ return safe_str(float(key)) # "nan" / "inf", even for a float subclass
130
+ if isinstance(key, (int, float)):
131
+ return json.dumps(key)
132
+ return safe_str(key)
133
+
134
+
135
+ def _dumps(doc: Any, *, force_pretty: bool = False) -> str:
136
+ indent = 2 if (force_pretty or _pretty()) else None
137
+ return to_json(doc, indent=indent)
138
+
139
+
140
+ def _write_envelope(envelope: dict[str, Any], mode: str) -> None:
141
+ if mode == "human":
142
+ err = envelope["error"]
143
+ sys.stderr.write(f"Error: {err['message']}\n")
144
+ if err.get("hint"):
145
+ sys.stderr.write(f"Hint: {err['hint']}\n")
146
+ else:
147
+ try:
148
+ line = to_json(_finite(envelope))
149
+ except Exception: # never let the failure path fail; keep the error itself
150
+ err = dict(envelope.get("error", {}))
151
+ err["details"] = {}
152
+ line = to_json(_finite({"error": err}))
153
+ sys.stderr.write(line + "\n")
154
+ sys.stderr.flush()
155
+
156
+
157
+ def _artifact_reference(
158
+ image: Any, output_path: str | None, tool: str
159
+ ) -> dict[str, Any]:
160
+ content = image.to_image_content()
161
+ raw = base64.b64decode(content.data)
162
+ mime = content.mimeType
163
+ ext = mime.split("/")[-1]
164
+ if output_path is None:
165
+ stamp = time.strftime("%Y%m%d-%H%M%S")
166
+ output_path = str(Path.home() / ".linc" / "artifacts" / tool / f"{stamp}.{ext}")
167
+ path = Path(output_path)
168
+ path.parent.mkdir(parents=True, exist_ok=True)
169
+ path.write_bytes(raw)
170
+ return {
171
+ "type": mime,
172
+ "path": str(path),
173
+ "bytes": len(raw),
174
+ "sha256": hashlib.sha256(raw).hexdigest(),
175
+ }
176
+
177
+
178
+ def _normalize(result: Any, output_path: str | None, tool: str) -> Any:
179
+ """Turn a FastMCP content list [dict, Image] into {"result":..., "artifact":...}."""
180
+ if isinstance(result, list) and any(
181
+ hasattr(item, "to_image_content") for item in result
182
+ ):
183
+ data = next((item for item in result if isinstance(item, dict)), {})
184
+ image = next(item for item in result if hasattr(item, "to_image_content"))
185
+ return {
186
+ "result": data,
187
+ "artifact": _artifact_reference(image, output_path, tool),
188
+ }
189
+ return result
190
+
191
+
192
+ def emit(
193
+ result: Any,
194
+ *,
195
+ mode: str,
196
+ output_path: str | None = None,
197
+ renderer: Callable[[Any], str] | None = None,
198
+ tool: str = "tool",
199
+ ) -> int:
200
+ """Write result per the contract and return the exit code. Never raises."""
201
+ if mode == "jsonl" and isinstance(result, Iterator):
202
+ count = 0
203
+ for record in result:
204
+ try:
205
+ line = to_json(record)
206
+ except Exception as exc: # NaN, bad keys, a hostile __str__, absurd nesting
207
+ _write_envelope(_non_json(exc).envelope(), mode)
208
+ return 1
209
+ sys.stdout.write(line + "\n")
210
+ sys.stdout.flush()
211
+ count += 1
212
+ sys.stdout.write(
213
+ json.dumps({"event": "summary", "records": count, "exit_code": 0}) + "\n"
214
+ )
215
+ sys.stdout.flush()
216
+ return 0
217
+ if isinstance(result, Iterator) and mode != "jsonl":
218
+ result = list(result)
219
+ try:
220
+ normalized = _normalize(result, output_path, tool)
221
+ except OSError as exc:
222
+ err = ToolError(
223
+ f"could not write artifact: {exc}",
224
+ code="ARTIFACT_WRITE_FAILED",
225
+ category="internal",
226
+ )
227
+ _write_envelope(err.envelope(), mode)
228
+ return 1
229
+ outcome: Outcome = classify(normalized)
230
+ if outcome.exit_code != 0:
231
+ _write_envelope(outcome.envelope or {}, mode)
232
+ return outcome.exit_code
233
+ try:
234
+ if mode == "human":
235
+ # Human output is not the JSON contract: NaN reads fine there.
236
+ text = (
237
+ renderer(outcome.payload)
238
+ if renderer
239
+ else json.dumps(outcome.payload, default=json_default, indent=2)
240
+ )
241
+ text = text.rstrip("\n")
242
+ elif mode == "jsonl":
243
+ text = to_json(outcome.payload)
244
+ else:
245
+ text = _dumps(outcome.payload)
246
+ except Exception as exc: # NaN, bad keys, a hostile __str__, absurd nesting
247
+ if mode == "human" and renderer is not None:
248
+ raise # a renderer bug, not an encoding problem: mount's handler envelopes it
249
+ _write_envelope(_non_json(exc).envelope(), mode)
250
+ return 1
251
+ sys.stdout.write(text + "\n")
252
+ sys.stdout.flush()
253
+ return 0
254
+
255
+
256
+ def emit_exception(exc: BaseException, *, mode: str) -> int:
257
+ outcome = classify_exception(exc)
258
+ _write_envelope(outcome.envelope or {}, mode)
259
+ return outcome.exit_code
260
+
261
+
262
+ @contextlib.contextmanager
263
+ def heartbeat(
264
+ interval: float = 10.0, label: str = "working", *, mode: str | None = None
265
+ ) -> Iterator[None]:
266
+ """Emit a stderr line every `interval` seconds so harness dead-man timers see activity.
267
+
268
+ Outside `--human` mode each beat is one JSON object
269
+ (`{"event": "heartbeat", "label": ..., "elapsed_s": ...}`), so every stderr line a
270
+ command writes parses on its own, and the error envelope stays the LAST line (see
271
+ `parse_error_envelope`). `mode=None` follows the invocation's `--human`/`--json` flag,
272
+ then `LINC_OUTPUT`, like the rest of the contract. The thread is joined on exit, so no
273
+ beat can land after the envelope.
274
+ """
275
+ if mode is None:
276
+ # Call-time import: mount imports this module. current_mode() reads the invocation's
277
+ # --human/--json flag first and falls back to LINC_OUTPUT outside a Click context.
278
+ from linc_cli_kit.mount import current_mode
279
+
280
+ mode = current_mode()
281
+ as_json = resolve_mode(mode) != "human"
282
+ stop = threading.Event()
283
+
284
+ def _beat() -> None:
285
+ started = time.monotonic()
286
+ while not stop.wait(interval):
287
+ elapsed = int(time.monotonic() - started)
288
+ if as_json:
289
+ line = json.dumps({"event": "heartbeat", "label": label, "elapsed_s": elapsed})
290
+ else:
291
+ line = f"[{label}] {elapsed}s elapsed"
292
+ sys.stderr.write(line + "\n")
293
+ sys.stderr.flush()
294
+
295
+ thread = threading.Thread(target=_beat, name=f"linc-heartbeat-{label}", daemon=True)
296
+ thread.start()
297
+ try:
298
+ yield
299
+ finally:
300
+ stop.set()
301
+ # Bounded: a beat is only ever a wait() plus one write, so this returns at once
302
+ # unless stderr itself is blocked (and then the envelope would block too).
303
+ thread.join(timeout=1.0)
304
+
305
+
306
+ _SIGNAL_GRACE_S = 10.0
307
+ _REPEAT_WINDOW_S = 0.5
308
+
309
+ # SIGTERM watchdog. ONE daemon thread per process, started from harden_streams() (never
310
+ # from the handler: Thread.start() takes threading's internal locks, which the interrupted
311
+ # main thread may already hold). The handler only records a deadline and notifies, under an
312
+ # RLock so a signal landing while the main thread holds it cannot self-deadlock.
313
+ _watchdog_cond = threading.Condition(threading.RLock())
314
+ _signal_state: dict[str, Any] = {"thread": None, "term_at": None, "deadline": None}
315
+
316
+
317
+ def _signal_grace() -> float:
318
+ """LINC_SIGNAL_GRACE seconds, clamped to [0, threading.TIMEOUT_MAX]: a larger value (or
319
+ `inf`, "never force") would make Condition.wait raise OverflowError in the watchdog."""
320
+ try:
321
+ grace = float(os.environ.get("LINC_SIGNAL_GRACE", _SIGNAL_GRACE_S))
322
+ except ValueError:
323
+ return _SIGNAL_GRACE_S
324
+ if math.isnan(grace):
325
+ return _SIGNAL_GRACE_S
326
+ return min(max(0.0, grace), threading.TIMEOUT_MAX)
327
+
328
+
329
+ def _watchdog_loop() -> None:
330
+ while True:
331
+ with _watchdog_cond:
332
+ deadline = _signal_state["deadline"]
333
+ if deadline is None:
334
+ _watchdog_cond.wait()
335
+ continue
336
+ remaining = deadline - time.monotonic()
337
+ if remaining > 0:
338
+ _watchdog_cond.wait(min(remaining, threading.TIMEOUT_MAX))
339
+ continue
340
+ # Outside the lock, and no stderr flush first: a stuck stderr pipe (the main thread
341
+ # blocked mid-write holding its buffer lock) is exactly the stall this breaks.
342
+ os._exit(143)
343
+
344
+
345
+ def _ensure_watchdog() -> None:
346
+ thread = _signal_state["thread"]
347
+ if thread is None or not thread.is_alive():
348
+ thread = threading.Thread(target=_watchdog_loop, name="linc-signal-watchdog", daemon=True)
349
+ thread.start()
350
+ _signal_state["thread"] = thread
351
+
352
+
353
+ def _reset_signal_state() -> None:
354
+ """Disarm the SIGTERM watchdog and repeat latch (each invocation, and tests)."""
355
+ with _watchdog_cond:
356
+ _signal_state.update(term_at=None, deadline=None)
357
+ _watchdog_cond.notify_all()
358
+
359
+
360
+ def _on_sigterm(_signum: int, _frame: Any) -> None:
361
+ """Raise SystemExit(143) so `finally` blocks, context managers and atexit hooks run on
362
+ the way out (nerve releases its device lease there) — but termination is not optional:
363
+ the watchdog hard-exits 143 if the unwind has not finished after LINC_SIGNAL_GRACE
364
+ seconds (default 10; blocking cleanup, an event-loop worker thread, code that swallows
365
+ SystemExit), and a repeat SIGTERM hard-exits at once. A repeat inside 0.5s is taken as
366
+ the same signal fanned out by a wrapper in the process group and ignored."""
367
+ now = time.monotonic()
368
+ with _watchdog_cond:
369
+ first = _signal_state["term_at"]
370
+ if first is None:
371
+ _signal_state.update(term_at=now, deadline=now + _signal_grace())
372
+ _watchdog_cond.notify_all()
373
+ elif now - first < _REPEAT_WINDOW_S:
374
+ return
375
+ if first is not None:
376
+ os._exit(143) # outside the lock, no flush (see _watchdog_loop)
377
+ raise SystemExit(143)
378
+
379
+
380
+ def _on_sigint(_signum: int, _frame: Any) -> None:
381
+ # Python's own semantics: every Ctrl-C raises KeyboardInterrupt (a command may catch it
382
+ # and carry on, e.g. `index watch`). Click turns it into Abort; install_globals' main
383
+ # maps that to the INTERRUPTED envelope and exit 130.
384
+ raise KeyboardInterrupt
385
+
386
+
387
+ def harden_streams() -> None:
388
+ """Line-buffer stdout/stderr; make SIGTERM/SIGINT unwind the stack (exit 143/130).
389
+
390
+ The handlers raise `SystemExit(143)` / `KeyboardInterrupt` rather than calling
391
+ `os._exit`, so cleanup code runs; for SIGTERM a watchdog and a repeat signal still
392
+ guarantee the process ends (see `_on_sigterm`). A tool that must not be interrupted
393
+ mid-operation should shield that section itself.
394
+ """
395
+ _reset_signal_state()
396
+ for stream in (sys.stdout, sys.stderr):
397
+ if hasattr(stream, "reconfigure"):
398
+ with contextlib.suppress(Exception):
399
+ stream.reconfigure(line_buffering=True)
400
+
401
+ with contextlib.suppress(ValueError): # not in main thread (CliRunner)
402
+ signal.signal(signal.SIGTERM, _on_sigterm)
403
+ signal.signal(signal.SIGINT, _on_sigint)
404
+ _ensure_watchdog()
@@ -0,0 +1,48 @@
1
+ """The only module allowed to touch FastMCP's private tool manager.
2
+
3
+ `_tool_manager` is private SDK API (renamed in the SDK's v2 line). Everything
4
+ else in the kit consumes ToolSpec, so an SDK change is fixed in one place.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Callable
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class ToolSpec:
16
+ name: str
17
+ description: str
18
+ parameters: dict[str, Any]
19
+ fn: Callable[..., Any]
20
+ is_async: bool
21
+
22
+
23
+ def registry_of(server: Any) -> list[ToolSpec]:
24
+ """Return every tool registered on a FastMCP server as a ToolSpec."""
25
+ manager = getattr(server, "_tool_manager", None)
26
+ list_tools = getattr(manager, "list_tools", None)
27
+ if manager is None or list_tools is None:
28
+ raise TypeError(
29
+ "registry_of() expects a FastMCP server with a FastMCP tool registry "
30
+ f"(_tool_manager.list_tools); got {type(server).__name__}"
31
+ )
32
+ specs: list[ToolSpec] = []
33
+ for tool in list_tools():
34
+ for attr in ("name", "fn", "parameters", "is_async"):
35
+ if not hasattr(tool, attr):
36
+ raise TypeError(
37
+ f"FastMCP Tool lacks '{attr}' — SDK shape changed; update registry.py"
38
+ )
39
+ specs.append(
40
+ ToolSpec(
41
+ name=tool.name,
42
+ description=(tool.description or "").strip(),
43
+ parameters=dict(tool.parameters or {}),
44
+ fn=tool.fn,
45
+ is_async=bool(tool.is_async),
46
+ )
47
+ )
48
+ return specs
linc_cli_kit/schema.py ADDED
@@ -0,0 +1,143 @@
1
+ """JSON-schema property → click.Option (spec §5.3)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ import click
9
+
10
+ RESERVED = frozenset({"json", "human", "stream", "output", "timeout", "log_level", "help", "yes"})
11
+ _SCALARS = {"integer": click.INT, "number": click.FLOAT, "string": click.STRING}
12
+
13
+
14
+ class JsonType(click.ParamType):
15
+ name = "json"
16
+
17
+ def convert(self, value: Any, param: Any, ctx: Any) -> Any:
18
+ if value is None or isinstance(value, (dict, list)):
19
+ return value
20
+ try:
21
+ return json.loads(value)
22
+ except json.JSONDecodeError as exc:
23
+ self.fail(f"must be valid JSON: {exc}", param, ctx)
24
+
25
+
26
+ class CoerceType(click.ParamType):
27
+ """Try each member type of a scalar union in canonical order."""
28
+
29
+ name = "value"
30
+
31
+ def __init__(self, members: list[dict[str, Any]]) -> None:
32
+ canonical = ("integer", "number", "boolean", "object", "array", "string")
33
+
34
+ def sort_key(m: dict[str, Any]) -> int:
35
+ mtype = m.get("type")
36
+ return canonical.index(mtype) if mtype in canonical else len(canonical)
37
+
38
+ self.members = sorted(members, key=sort_key)
39
+
40
+ def convert(self, value: Any, param: Any, ctx: Any) -> Any:
41
+ for member in self.members:
42
+ kind = member.get("type")
43
+ try:
44
+ if kind == "integer":
45
+ return int(value)
46
+ if kind == "number":
47
+ return float(value)
48
+ if kind == "boolean":
49
+ return click.BOOL.convert(value, param, ctx)
50
+ if kind in ("object", "array"):
51
+ return json.loads(value)
52
+ if kind == "string":
53
+ return str(value)
54
+ except (ValueError, TypeError, json.JSONDecodeError, click.BadParameter):
55
+ continue
56
+ self.fail(
57
+ f"could not coerce {value!r} to any of {[m.get('type') for m in self.members]}",
58
+ param,
59
+ ctx,
60
+ )
61
+
62
+
63
+ def cli_name(tool_name: str, prefix: str) -> str:
64
+ name = tool_name[len(prefix):] if prefix and tool_name.startswith(prefix) else tool_name
65
+ return name.replace("_", "-")
66
+
67
+
68
+ def _flag(name: str) -> str:
69
+ base = name.replace("_", "-")
70
+ return f"--arg-{base}" if name in RESERVED else f"--{base}"
71
+
72
+
73
+ def _members(prop: dict[str, Any]) -> list[dict[str, Any]]:
74
+ return [m for m in (prop.get("anyOf") or prop.get("oneOf") or []) if m.get("type") != "null"]
75
+
76
+
77
+ def option_for(name: str, prop: dict[str, Any], required: bool) -> click.Option:
78
+ help_text = (prop.get("description") or "").strip()
79
+ default = prop.get("default")
80
+ kind = prop.get("type")
81
+ members = _members(prop)
82
+ flag = _flag(name)
83
+
84
+ def build_kwargs() -> dict[str, Any]:
85
+ kwargs: dict[str, Any] = {"required": required, "help": help_text}
86
+ if not required and default is not None:
87
+ kwargs["default"] = default
88
+ kwargs["show_default"] = True
89
+ return kwargs
90
+
91
+ if "enum" in prop:
92
+ kwargs = build_kwargs()
93
+ kwargs["type"] = click.Choice([str(c) for c in prop["enum"]])
94
+ return click.Option([flag, name], **kwargs)
95
+ if kind == "boolean":
96
+ kwargs = build_kwargs()
97
+ kwargs["is_flag"] = True
98
+ if not required:
99
+ kwargs["default"] = bool(default) if default is not None else False
100
+ return click.Option([f"{flag}/--no-{flag.lstrip('-')}", name], **kwargs)
101
+ if kind in _SCALARS:
102
+ kwargs = build_kwargs()
103
+ kwargs["type"] = _SCALARS[kind]
104
+ if not required:
105
+ kwargs["show_default"] = default is not None
106
+ return click.Option([flag, name], **kwargs)
107
+ if kind == "array":
108
+ item_kind = (prop.get("items") or {}).get("type")
109
+ if item_kind in _SCALARS:
110
+ kwargs = build_kwargs()
111
+ kwargs["type"] = _SCALARS[item_kind]
112
+ kwargs["multiple"] = True
113
+ if not required and isinstance(default, list):
114
+ kwargs["default"] = tuple(default)
115
+ return click.Option([flag, name], **kwargs)
116
+ kwargs = build_kwargs()
117
+ kwargs["type"] = JsonType()
118
+ if not required:
119
+ kwargs["default"] = default
120
+ return click.Option([f"{flag}-json", name], **kwargs)
121
+ if kind == "object" or any(m.get("type") in ("object", "array") for m in members):
122
+ kwargs = build_kwargs()
123
+ kwargs["type"] = JsonType()
124
+ if not required:
125
+ kwargs["default"] = default
126
+ return click.Option([f"{flag}-json", name], **kwargs)
127
+ if members:
128
+ kwargs = build_kwargs()
129
+ kwargs["type"] = CoerceType(members)
130
+ if not required:
131
+ kwargs["default"] = default
132
+ return click.Option([flag, name], **kwargs)
133
+ kwargs = build_kwargs()
134
+ kwargs["type"] = click.STRING
135
+ if not required:
136
+ kwargs["show_default"] = default is not None
137
+ return click.Option([flag, name], **kwargs)
138
+
139
+
140
+ def options_for(parameters: dict[str, Any]) -> list[click.Option]:
141
+ required = set(parameters.get("required", []))
142
+ props = parameters.get("properties") or {}
143
+ return [option_for(n, p, n in required) for n, p in props.items()]