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/__init__.py +65 -0
- linc_cli_kit/errors.py +136 -0
- linc_cli_kit/logging.py +34 -0
- linc_cli_kit/mount.py +287 -0
- linc_cli_kit/output.py +404 -0
- linc_cli_kit/registry.py +48 -0
- linc_cli_kit/schema.py +143 -0
- linc_cli_kit/testing.py +459 -0
- linc_cli_kit/transport.py +142 -0
- linc_cli_kit-0.1.1.dist-info/METADATA +201 -0
- linc_cli_kit-0.1.1.dist-info/RECORD +14 -0
- linc_cli_kit-0.1.1.dist-info/WHEEL +4 -0
- linc_cli_kit-0.1.1.dist-info/licenses/LICENSE +202 -0
- linc_cli_kit-0.1.1.dist-info/licenses/NOTICE +2 -0
linc_cli_kit/__init__.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""linc-cli-kit — FastMCP registry → Click commands under one output contract.
|
|
2
|
+
|
|
3
|
+
Import cost is paid by every adopter CLI invocation, `--help` included, so the conformance
|
|
4
|
+
suite (`assert_cli_parity`, `ParityReport`: click.testing + unittest.mock) loads on first
|
|
5
|
+
attribute access (PEP 562), and `transport` imports httpx/anyio only when a call needs them.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
9
|
+
|
|
10
|
+
from linc_cli_kit.errors import ToolError, UsageError, parse_error_envelope
|
|
11
|
+
from linc_cli_kit.logging import configure_stderr_logging, configure_structlog_stderr
|
|
12
|
+
from linc_cli_kit.mount import (
|
|
13
|
+
current_mode,
|
|
14
|
+
current_output,
|
|
15
|
+
current_timeout,
|
|
16
|
+
install_globals,
|
|
17
|
+
mount_tools,
|
|
18
|
+
)
|
|
19
|
+
from linc_cli_kit.output import emit, emit_exception, heartbeat, json_default, to_json
|
|
20
|
+
from linc_cli_kit.registry import ToolSpec, registry_of
|
|
21
|
+
from linc_cli_kit.transport import HttpDaemonTransport, LocalTransport
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from linc_cli_kit.testing import ParityReport, assert_cli_parity
|
|
25
|
+
|
|
26
|
+
_LAZY = {"ParityReport": "linc_cli_kit.testing", "assert_cli_parity": "linc_cli_kit.testing"}
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"HttpDaemonTransport",
|
|
30
|
+
"LocalTransport",
|
|
31
|
+
"ParityReport",
|
|
32
|
+
"ToolError",
|
|
33
|
+
"ToolSpec",
|
|
34
|
+
"UsageError",
|
|
35
|
+
"assert_cli_parity",
|
|
36
|
+
"configure_stderr_logging",
|
|
37
|
+
"configure_structlog_stderr",
|
|
38
|
+
"current_mode",
|
|
39
|
+
"current_output",
|
|
40
|
+
"current_timeout",
|
|
41
|
+
"emit",
|
|
42
|
+
"emit_exception",
|
|
43
|
+
"heartbeat",
|
|
44
|
+
"install_globals",
|
|
45
|
+
"json_default",
|
|
46
|
+
"mount_tools",
|
|
47
|
+
"parse_error_envelope",
|
|
48
|
+
"registry_of",
|
|
49
|
+
"to_json",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def __getattr__(name: str) -> Any:
|
|
54
|
+
module = _LAZY.get(name)
|
|
55
|
+
if module is None:
|
|
56
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
57
|
+
import importlib
|
|
58
|
+
|
|
59
|
+
value = getattr(importlib.import_module(module), name)
|
|
60
|
+
globals()[name] = value # cache: later lookups skip __getattr__
|
|
61
|
+
return value
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def __dir__() -> list[str]:
|
|
65
|
+
return sorted(set(globals()) | set(__all__))
|
linc_cli_kit/errors.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Error envelope + exit-code classification (spec §5.2)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
CATEGORIES = (
|
|
11
|
+
"validation",
|
|
12
|
+
"not_found",
|
|
13
|
+
"permission",
|
|
14
|
+
"unavailable",
|
|
15
|
+
"upstream_timeout",
|
|
16
|
+
"internal",
|
|
17
|
+
)
|
|
18
|
+
_RETRYABLE = {"unavailable", "upstream_timeout"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ToolError(Exception):
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
message: str,
|
|
25
|
+
*,
|
|
26
|
+
code: str = "TOOL_ERROR",
|
|
27
|
+
hint: str = "",
|
|
28
|
+
category: str = "internal",
|
|
29
|
+
retryable: bool | None = None,
|
|
30
|
+
details: dict[str, Any] | None = None,
|
|
31
|
+
exit_code: int = 1,
|
|
32
|
+
) -> None:
|
|
33
|
+
super().__init__(message)
|
|
34
|
+
self.message = message
|
|
35
|
+
self.code = code
|
|
36
|
+
self.hint = hint
|
|
37
|
+
self.category = category if category in CATEGORIES else "internal"
|
|
38
|
+
self.retryable = (self.category in _RETRYABLE) if retryable is None else retryable
|
|
39
|
+
self.details = details or {}
|
|
40
|
+
self.exit_code = exit_code
|
|
41
|
+
|
|
42
|
+
def envelope(self) -> dict[str, Any]:
|
|
43
|
+
return {"error": {
|
|
44
|
+
"code": self.code, "message": self.message, "hint": self.hint,
|
|
45
|
+
"category": self.category, "retryable": self.retryable, "details": self.details,
|
|
46
|
+
}}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class UsageError(ToolError):
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
message: str,
|
|
53
|
+
*,
|
|
54
|
+
hint: str = "",
|
|
55
|
+
details: dict[str, Any] | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
super().__init__(
|
|
58
|
+
message,
|
|
59
|
+
code="USAGE_ERROR",
|
|
60
|
+
hint=hint,
|
|
61
|
+
category="validation",
|
|
62
|
+
retryable=False,
|
|
63
|
+
details=details,
|
|
64
|
+
exit_code=2,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class Outcome:
|
|
70
|
+
exit_code: int
|
|
71
|
+
payload: Any
|
|
72
|
+
envelope: dict[str, Any] | None = field(default=None)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def safe_str(value: Any) -> str:
|
|
76
|
+
"""str() that cannot raise: the error path must not fail on a hostile __str__."""
|
|
77
|
+
try:
|
|
78
|
+
return str(value)
|
|
79
|
+
except Exception:
|
|
80
|
+
try:
|
|
81
|
+
return repr(value)
|
|
82
|
+
except Exception:
|
|
83
|
+
return f"<unprintable {type(value).__name__}>"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _upper_snake(name: str) -> str:
|
|
87
|
+
return re.sub(r"(?<!^)(?=[A-Z])", "_", name).upper()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def classify(result: Any) -> Outcome:
|
|
91
|
+
"""Map a tool's return value to an Outcome using the fleet's dict conventions."""
|
|
92
|
+
if result is None:
|
|
93
|
+
return Outcome(0, {}, None)
|
|
94
|
+
# Truthiness, not key presence: tools that always include an "error" key and set it to
|
|
95
|
+
# None/"" on success must classify as success (fleet convention).
|
|
96
|
+
if isinstance(result, dict) and result.get("error"):
|
|
97
|
+
validation = bool(result.get("validation_error"))
|
|
98
|
+
err = ToolError(
|
|
99
|
+
safe_str(result["error"]),
|
|
100
|
+
code="VALIDATION_ERROR" if validation else "TOOL_ERROR",
|
|
101
|
+
category="validation" if validation else "internal",
|
|
102
|
+
details={k: v for k, v in result.items() if k not in ("error", "validation_error")},
|
|
103
|
+
exit_code=2 if validation else 1,
|
|
104
|
+
)
|
|
105
|
+
return Outcome(err.exit_code, None, err.envelope())
|
|
106
|
+
return Outcome(0, result, None)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def parse_error_envelope(stderr: str) -> dict[str, Any] | None:
|
|
110
|
+
"""Return the error envelope from a command's stderr, or None if it wrote none.
|
|
111
|
+
|
|
112
|
+
The contract: on failure the envelope is the LAST line of stderr. Lines before it may be
|
|
113
|
+
JSON heartbeats (`{"event": "heartbeat", ...}`) or stderr logging, so never
|
|
114
|
+
`json.loads` the whole stream — take the last line that parses as an object with an
|
|
115
|
+
"error" key, which is what this does.
|
|
116
|
+
"""
|
|
117
|
+
for line in reversed(stderr.splitlines()):
|
|
118
|
+
line = line.strip()
|
|
119
|
+
if not line.startswith("{"):
|
|
120
|
+
continue
|
|
121
|
+
try:
|
|
122
|
+
doc = json.loads(line)
|
|
123
|
+
except ValueError:
|
|
124
|
+
continue
|
|
125
|
+
if isinstance(doc, dict) and isinstance(doc.get("error"), dict):
|
|
126
|
+
return doc
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def classify_exception(exc: BaseException) -> Outcome:
|
|
131
|
+
if isinstance(exc, ToolError):
|
|
132
|
+
return Outcome(exc.exit_code, None, exc.envelope())
|
|
133
|
+
err = ToolError(
|
|
134
|
+
safe_str(exc) or exc.__class__.__name__, code=_upper_snake(exc.__class__.__name__)
|
|
135
|
+
)
|
|
136
|
+
return Outcome(1, None, err.envelope())
|
linc_cli_kit/logging.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""stderr-only logging, the sentinel pattern."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def configure_stderr_logging(level: str | None = None) -> None:
|
|
11
|
+
name = (level or os.environ.get("LINC_LOG_LEVEL") or "WARNING").upper()
|
|
12
|
+
root = logging.getLogger()
|
|
13
|
+
for handler in list(root.handlers):
|
|
14
|
+
root.removeHandler(handler)
|
|
15
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
16
|
+
handler.setFormatter(logging.Formatter("%(levelname)s %(name)s: %(message)s"))
|
|
17
|
+
root.addHandler(handler)
|
|
18
|
+
root.setLevel(getattr(logging, name, logging.WARNING))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def configure_structlog_stderr() -> bool:
|
|
22
|
+
"""Point structlog's default logger factory at stderr; no-op if structlog is absent.
|
|
23
|
+
|
|
24
|
+
Returns True if structlog was configured, False if it is not installed. The kit has no
|
|
25
|
+
hard dependency on structlog — the import happens inside the function so adopters that
|
|
26
|
+
do not use it pay nothing. Call this ABOVE your package imports: modules bind their
|
|
27
|
+
logger at import time, so a later call leaves already-bound loggers on stdout.
|
|
28
|
+
"""
|
|
29
|
+
try:
|
|
30
|
+
import structlog
|
|
31
|
+
except ImportError:
|
|
32
|
+
return False
|
|
33
|
+
structlog.configure(logger_factory=structlog.PrintLoggerFactory(file=sys.stderr))
|
|
34
|
+
return True
|
linc_cli_kit/mount.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Mount a FastMCP registry onto a Click group (spec §5.1, §5.3, §5.5)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
from linc_cli_kit.errors import ToolError, UsageError
|
|
13
|
+
from linc_cli_kit.logging import configure_stderr_logging
|
|
14
|
+
from linc_cli_kit.output import (
|
|
15
|
+
emit,
|
|
16
|
+
emit_exception,
|
|
17
|
+
harden_streams,
|
|
18
|
+
heartbeat,
|
|
19
|
+
resolve_mode,
|
|
20
|
+
to_json,
|
|
21
|
+
)
|
|
22
|
+
from linc_cli_kit.registry import ToolSpec, registry_of
|
|
23
|
+
from linc_cli_kit.schema import cli_name, options_for
|
|
24
|
+
from linc_cli_kit.transport import LocalTransport, Transport
|
|
25
|
+
|
|
26
|
+
_META = "linc_cli_kit."
|
|
27
|
+
|
|
28
|
+
# click.ParamType.name -> the schema doc's normalized "type" string (ruling 4).
|
|
29
|
+
_TYPE_NAMES = {
|
|
30
|
+
"text": "STRING",
|
|
31
|
+
"integer": "INT",
|
|
32
|
+
"float": "FLOAT",
|
|
33
|
+
"boolean": "BOOL",
|
|
34
|
+
"choice": "CHOICE",
|
|
35
|
+
"json": "JSON",
|
|
36
|
+
"value": "VALUE",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def current_mode() -> str:
|
|
41
|
+
ctx = click.get_current_context(silent=True)
|
|
42
|
+
if ctx is not None:
|
|
43
|
+
mode = ctx.find_root().meta.get(_META + "mode")
|
|
44
|
+
if mode is not None:
|
|
45
|
+
return mode
|
|
46
|
+
return resolve_mode(None)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def current_output() -> str | None:
|
|
50
|
+
ctx = click.get_current_context(silent=True)
|
|
51
|
+
return ctx.find_root().meta.get(_META + "output") if ctx is not None else None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def current_timeout() -> float | None:
|
|
55
|
+
ctx = click.get_current_context(silent=True)
|
|
56
|
+
return ctx.find_root().meta.get(_META + "timeout") if ctx is not None else None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def install_globals(group: click.Group) -> click.Group:
|
|
60
|
+
"""Add the kit's global options and stream/logging hardening to an existing group."""
|
|
61
|
+
if any(getattr(p, "name", None) == "linc_mode" for p in group.params):
|
|
62
|
+
return group
|
|
63
|
+
|
|
64
|
+
# Built fresh per call (ruling 1) — Option instances must not be shared across groups.
|
|
65
|
+
global_options: list[click.Option] = [
|
|
66
|
+
click.Option(
|
|
67
|
+
["--json", "linc_mode"], flag_value="json", default=None,
|
|
68
|
+
help="JSON on stdout (default).",
|
|
69
|
+
),
|
|
70
|
+
click.Option(["--human", "linc_mode"], flag_value="human", help="Human rendering."),
|
|
71
|
+
click.Option(
|
|
72
|
+
["--stream", "linc_mode"], flag_value="jsonl",
|
|
73
|
+
help="JSON Lines, one record per line.",
|
|
74
|
+
),
|
|
75
|
+
click.Option(
|
|
76
|
+
["--output", "-o", "linc_output"], default=None, help="Path for binary artifacts.",
|
|
77
|
+
),
|
|
78
|
+
click.Option(
|
|
79
|
+
["--timeout", "linc_timeout"], type=float, default=None,
|
|
80
|
+
help="Per-call timeout seconds.",
|
|
81
|
+
),
|
|
82
|
+
click.Option(
|
|
83
|
+
["--log-level", "linc_log_level"], default=None, help="LINC_LOG_LEVEL override.",
|
|
84
|
+
),
|
|
85
|
+
]
|
|
86
|
+
group.params.extend(global_options)
|
|
87
|
+
|
|
88
|
+
# The invocation's resolved mode, kept past the context's teardown so an exception that
|
|
89
|
+
# escapes to main() below is rendered the way the caller asked (--human vs JSON).
|
|
90
|
+
invocation: dict[str, str | None] = {"mode": None}
|
|
91
|
+
|
|
92
|
+
original_callback = group.callback
|
|
93
|
+
|
|
94
|
+
def callback(**kwargs: Any) -> Any:
|
|
95
|
+
ctx = click.get_current_context()
|
|
96
|
+
ctx.meta[_META + "mode"] = resolve_mode(kwargs.pop("linc_mode", None))
|
|
97
|
+
invocation["mode"] = ctx.meta[_META + "mode"]
|
|
98
|
+
ctx.meta[_META + "output"] = kwargs.pop("linc_output", None)
|
|
99
|
+
ctx.meta[_META + "timeout"] = kwargs.pop("linc_timeout", None)
|
|
100
|
+
configure_stderr_logging(kwargs.pop("linc_log_level", None))
|
|
101
|
+
harden_streams()
|
|
102
|
+
return original_callback(**kwargs) if original_callback else None
|
|
103
|
+
|
|
104
|
+
if original_callback is not None:
|
|
105
|
+
callback = functools.wraps(original_callback)(callback)
|
|
106
|
+
group.callback = callback
|
|
107
|
+
|
|
108
|
+
original_main = group.main
|
|
109
|
+
|
|
110
|
+
def main(*args: Any, **kwargs: Any) -> Any:
|
|
111
|
+
# Hard-assigned, not setdefault: a caller passing standalone_mode=True (e.g. a
|
|
112
|
+
# CliRunner.invoke(..., standalone_mode=True) test) must not be able to bypass the
|
|
113
|
+
# envelope contract (ruled fix, finding 2).
|
|
114
|
+
kwargs["standalone_mode"] = False
|
|
115
|
+
invocation["mode"] = None
|
|
116
|
+
try:
|
|
117
|
+
rv = original_main(*args, **kwargs)
|
|
118
|
+
except click.UsageError as exc:
|
|
119
|
+
emit_exception(UsageError(exc.format_message()), mode=_mode())
|
|
120
|
+
sys.exit(2)
|
|
121
|
+
except click.exceptions.Exit as exc:
|
|
122
|
+
sys.exit(exc.exit_code)
|
|
123
|
+
except click.exceptions.Abort as exc:
|
|
124
|
+
# Click converts KeyboardInterrupt (our SIGINT handler) into Abort.
|
|
125
|
+
interrupted = isinstance(exc.__cause__ or exc.__context__, KeyboardInterrupt)
|
|
126
|
+
err = ToolError(
|
|
127
|
+
"interrupted" if interrupted else "aborted",
|
|
128
|
+
code="INTERRUPTED" if interrupted else "ABORTED",
|
|
129
|
+
)
|
|
130
|
+
emit_exception(err, mode=_mode())
|
|
131
|
+
sys.exit(130 if interrupted else 1)
|
|
132
|
+
except Exception as exc:
|
|
133
|
+
# A hand-written override or group callback that raises must still honour the
|
|
134
|
+
# contract: envelope on stderr, empty stdout, no traceback. Generated commands
|
|
135
|
+
# already catch inside their callback; this is the net for everything else.
|
|
136
|
+
# SystemExit (e.g. our SIGTERM handler) is a BaseException and passes through.
|
|
137
|
+
sys.exit(emit_exception(exc, mode=_mode()))
|
|
138
|
+
else:
|
|
139
|
+
# type(rv) is int, not isinstance: bool is an int subclass, and a callback that
|
|
140
|
+
# happens to return True/False must not be mistaken for an exit code (minor 7).
|
|
141
|
+
sys.exit(rv if type(rv) is int else 0)
|
|
142
|
+
|
|
143
|
+
def _mode() -> str:
|
|
144
|
+
return invocation["mode"] or resolve_mode(None)
|
|
145
|
+
|
|
146
|
+
group.main = main
|
|
147
|
+
return group
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _example_line(description: str) -> str | None:
|
|
151
|
+
for line in description.splitlines():
|
|
152
|
+
idx = line.find("Example:")
|
|
153
|
+
if idx != -1:
|
|
154
|
+
return line[idx:].strip()
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _build_command(
|
|
159
|
+
spec: ToolSpec, name: str, transport: Transport, renderer: Callable[[Any], str] | None
|
|
160
|
+
) -> click.Command:
|
|
161
|
+
options = options_for(spec.parameters) # never mutates spec.parameters (ruling 9)
|
|
162
|
+
options.sort(key=lambda o: not o.required) # required before optional
|
|
163
|
+
|
|
164
|
+
def callback(**kwargs: Any) -> None:
|
|
165
|
+
ctx = click.get_current_context()
|
|
166
|
+
payload = {k: v for k, v in kwargs.items() if v is not None and v != ()}
|
|
167
|
+
mode = current_mode()
|
|
168
|
+
try:
|
|
169
|
+
with heartbeat(label=name, mode=mode):
|
|
170
|
+
result = transport.call(spec, payload, timeout=current_timeout())
|
|
171
|
+
# emit() runs INSIDE the try (finding 1): a renderer (--human) or a mid-stream
|
|
172
|
+
# generator (--stream) that raises must still produce an envelope, not a bare
|
|
173
|
+
# traceback with empty stdout/stderr.
|
|
174
|
+
ctx.exit(
|
|
175
|
+
emit(result, mode=mode, output_path=current_output(), renderer=renderer,
|
|
176
|
+
tool=name)
|
|
177
|
+
)
|
|
178
|
+
except click.exceptions.Exit:
|
|
179
|
+
# ctx.exit() above raises this on the success path — it is not an error to wrap.
|
|
180
|
+
raise
|
|
181
|
+
except click.UsageError as exc: # narrowed from ClickException (minor 8): a
|
|
182
|
+
# click.FileError etc. is not a usage error and should fall through below.
|
|
183
|
+
ctx.exit(emit_exception(UsageError(exc.format_message()), mode=mode))
|
|
184
|
+
except Exception as exc: # contract: never a raw traceback on stdout
|
|
185
|
+
ctx.exit(emit_exception(exc, mode=mode))
|
|
186
|
+
|
|
187
|
+
example = _example_line(spec.description)
|
|
188
|
+
help_text = spec.description.split("\n\n")[0]
|
|
189
|
+
cmd = click.Command(
|
|
190
|
+
name, params=list(options), callback=callback, help=help_text, epilog=example,
|
|
191
|
+
)
|
|
192
|
+
cmd.linc_transport = transport # ruling 7
|
|
193
|
+
# Marks the command as kit-generated. The conformance suite classifies on THIS attribute,
|
|
194
|
+
# not on callback.__module__ — an adopter is free to wrap or replace the callback.
|
|
195
|
+
cmd.linc_kit_generated = True
|
|
196
|
+
return cmd
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _normalized_type(ptype: click.ParamType) -> str:
|
|
200
|
+
return _TYPE_NAMES.get(ptype.name, ptype.name.upper())
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _opt_doc(o: click.Parameter) -> dict[str, Any]:
|
|
204
|
+
choices = list(o.type.choices) if isinstance(o.type, click.Choice) else None
|
|
205
|
+
default = o.default
|
|
206
|
+
if default == () or default is getattr(click.core, "UNSET", object()):
|
|
207
|
+
default = None
|
|
208
|
+
return {
|
|
209
|
+
"flag": (o.opts or [o.name])[0],
|
|
210
|
+
"type": _normalized_type(o.type),
|
|
211
|
+
"required": bool(o.required),
|
|
212
|
+
"default": default,
|
|
213
|
+
"choices": choices,
|
|
214
|
+
"help": (getattr(o, "help", "") or ""),
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _schema_command(group: click.Group) -> click.Command:
|
|
219
|
+
@click.command("schema", help="Emit the whole command tree as JSON (no --help crawl).")
|
|
220
|
+
@click.option("--json", "as_json", is_flag=True, default=True, help="Always JSON.")
|
|
221
|
+
def schema(as_json: bool) -> None:
|
|
222
|
+
ctx = click.get_current_context(silent=True)
|
|
223
|
+
root_name = ctx.find_root().info_name if ctx is not None else None
|
|
224
|
+
doc = {
|
|
225
|
+
"name": root_name or group.name,
|
|
226
|
+
"globals": {
|
|
227
|
+
(o.opts or [o.name])[0]: (o.help or "")
|
|
228
|
+
for o in group.params
|
|
229
|
+
if isinstance(o, click.Option)
|
|
230
|
+
},
|
|
231
|
+
"commands": {
|
|
232
|
+
cname: {
|
|
233
|
+
"help": (cmd.help or "").split("\n")[0],
|
|
234
|
+
"options": {
|
|
235
|
+
p.name: _opt_doc(p) for p in cmd.params if isinstance(p, click.Option)
|
|
236
|
+
},
|
|
237
|
+
}
|
|
238
|
+
for cname, cmd in sorted(group.commands.items())
|
|
239
|
+
},
|
|
240
|
+
}
|
|
241
|
+
click.echo(to_json(doc))
|
|
242
|
+
|
|
243
|
+
schema.linc_kit_generated = True # marks "ours" so a re-mount on the same group won't collide
|
|
244
|
+
return schema
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def mount_tools(
|
|
248
|
+
group: click.Group,
|
|
249
|
+
server: Any,
|
|
250
|
+
*,
|
|
251
|
+
prefix: str = "",
|
|
252
|
+
transport: Transport | None = None,
|
|
253
|
+
skip: dict[str, str] | None = None,
|
|
254
|
+
renderers: dict[str, Callable[[Any], str]] | None = None,
|
|
255
|
+
) -> list[str]:
|
|
256
|
+
"""Reflect every registered tool into `group`. Hand-written commands win on name collision.
|
|
257
|
+
|
|
258
|
+
Daemon-only marking is NOT a `mount_tools` concern (finding 3, spec §5.4) — it belongs to
|
|
259
|
+
the `HttpDaemonTransport` a caller passes in via `transport=`, which already implements it.
|
|
260
|
+
"""
|
|
261
|
+
if not any(getattr(p, "name", None) == "linc_mode" for p in group.params):
|
|
262
|
+
raise RuntimeError("call install_globals(group) before mount_tools")
|
|
263
|
+
transport = transport or LocalTransport()
|
|
264
|
+
skip = skip or {}
|
|
265
|
+
renderers = renderers or {}
|
|
266
|
+
existing_schema = group.commands.get("schema")
|
|
267
|
+
if existing_schema is not None and not getattr(existing_schema, "linc_kit_generated", False):
|
|
268
|
+
raise ValueError("group already defines 'schema'; the kit reserves that command name")
|
|
269
|
+
for tool_name, why in skip.items():
|
|
270
|
+
if not why.strip():
|
|
271
|
+
raise ValueError(f"skip['{tool_name}'] needs a justification string")
|
|
272
|
+
specs = registry_of(server)
|
|
273
|
+
unknown_skips = sorted(set(skip) - {spec.name for spec in specs})
|
|
274
|
+
if unknown_skips:
|
|
275
|
+
raise ValueError(f"skip references unknown tool(s): {unknown_skips}")
|
|
276
|
+
mounted: list[str] = []
|
|
277
|
+
for spec in specs:
|
|
278
|
+
if spec.name in skip:
|
|
279
|
+
continue
|
|
280
|
+
name = cli_name(spec.name, prefix)
|
|
281
|
+
if name in group.commands:
|
|
282
|
+
continue
|
|
283
|
+
group.add_command(_build_command(spec, name, transport, renderers.get(name)), name)
|
|
284
|
+
mounted.append(name)
|
|
285
|
+
group.add_command(_schema_command(group), "schema")
|
|
286
|
+
group.linc_transport = transport # ruling 7
|
|
287
|
+
return mounted
|