toolplane 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.
- toolplane/__init__.py +46 -0
- toolplane/adapters/__init__.py +13 -0
- toolplane/adapters/ambient_cli.py +329 -0
- toolplane/adapters/cli_to_py.py +223 -0
- toolplane/adapters/mcp.py +208 -0
- toolplane/adapters/python.py +46 -0
- toolplane/backends/__init__.py +7 -0
- toolplane/backends/_python.py +12 -0
- toolplane/backends/base.py +27 -0
- toolplane/backends/local.py +178 -0
- toolplane/backends/pyodide_deno.py +519 -0
- toolplane/bridges/__init__.py +14 -0
- toolplane/bridges/base.py +49 -0
- toolplane/bridges/in_process.py +38 -0
- toolplane/bridges/rpc.py +116 -0
- toolplane/capabilities.py +190 -0
- toolplane/discovery.py +91 -0
- toolplane/errors.py +27 -0
- toolplane/execution.py +50 -0
- toolplane/registry.py +245 -0
- toolplane/runtime.py +194 -0
- toolplane-0.1.0.dist-info/METADATA +279 -0
- toolplane-0.1.0.dist-info/RECORD +24 -0
- toolplane-0.1.0.dist-info/WHEEL +4 -0
toolplane/__init__.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Programmable tool surfaces for Python code-mode agents."""
|
|
2
|
+
|
|
3
|
+
from .backends import PyodideDenoBackend
|
|
4
|
+
from .bridges import (
|
|
5
|
+
HttpCallbackBridge,
|
|
6
|
+
InProcessBridge,
|
|
7
|
+
ToolCallError,
|
|
8
|
+
ToolCallRequest,
|
|
9
|
+
ToolCallResponse,
|
|
10
|
+
)
|
|
11
|
+
from .capabilities import Capability
|
|
12
|
+
from .errors import (
|
|
13
|
+
BackendCapabilityError,
|
|
14
|
+
BackendNotFoundError,
|
|
15
|
+
CapabilityNotFoundError,
|
|
16
|
+
DuplicateCapabilityError,
|
|
17
|
+
NamespaceCollisionError,
|
|
18
|
+
ToolplaneError,
|
|
19
|
+
)
|
|
20
|
+
from .execution import BackendCapabilities, ExecutionError, ExecutionResult
|
|
21
|
+
from .registry import CapabilityRegistry
|
|
22
|
+
from .runtime import Toolplane
|
|
23
|
+
|
|
24
|
+
__version__ = "0.1.0"
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"__version__",
|
|
28
|
+
"BackendCapabilities",
|
|
29
|
+
"BackendCapabilityError",
|
|
30
|
+
"BackendNotFoundError",
|
|
31
|
+
"Capability",
|
|
32
|
+
"CapabilityNotFoundError",
|
|
33
|
+
"CapabilityRegistry",
|
|
34
|
+
"DuplicateCapabilityError",
|
|
35
|
+
"ExecutionError",
|
|
36
|
+
"ExecutionResult",
|
|
37
|
+
"HttpCallbackBridge",
|
|
38
|
+
"InProcessBridge",
|
|
39
|
+
"NamespaceCollisionError",
|
|
40
|
+
"PyodideDenoBackend",
|
|
41
|
+
"ToolCallError",
|
|
42
|
+
"ToolCallRequest",
|
|
43
|
+
"ToolCallResponse",
|
|
44
|
+
"Toolplane",
|
|
45
|
+
"ToolplaneError",
|
|
46
|
+
]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Capability adapters for external tool sources."""
|
|
2
|
+
|
|
3
|
+
from .cli_to_py import CliCommandResult, register_cli
|
|
4
|
+
from .mcp import register_mcp_config, register_mcp_server
|
|
5
|
+
from .python import register_python_namespace
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"CliCommandResult",
|
|
9
|
+
"register_cli",
|
|
10
|
+
"register_mcp_config",
|
|
11
|
+
"register_mcp_server",
|
|
12
|
+
"register_python_namespace",
|
|
13
|
+
]
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Ambient lazy CLI support for code-mode execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import builtins
|
|
7
|
+
import json
|
|
8
|
+
import keyword
|
|
9
|
+
import os
|
|
10
|
+
from collections.abc import Mapping, Sequence
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ..bridges.base import HostBridge
|
|
14
|
+
from ..capabilities import Capability
|
|
15
|
+
from ..errors import CapabilityNotFoundError
|
|
16
|
+
from ..registry import CapabilityRegistry
|
|
17
|
+
from .cli_to_py import normalize_cli_result
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
AMBIENT_CLI_CAPABILITY = "toolplane:cli/run"
|
|
21
|
+
RESERVED_CLI_NAMES = {"call_tool", "cli"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AmbientCliRunner:
|
|
25
|
+
"""Run CLI commands through cli-to-py, loading each binary lazily."""
|
|
26
|
+
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
self._apis: dict[str, Any] = {}
|
|
29
|
+
self._parsed_subcommands: set[tuple[str, str]] = set()
|
|
30
|
+
|
|
31
|
+
async def __call__(
|
|
32
|
+
self,
|
|
33
|
+
binary: str,
|
|
34
|
+
subcommand: str | None = None,
|
|
35
|
+
options: dict[str, Any] | None = None,
|
|
36
|
+
) -> dict[str, Any]:
|
|
37
|
+
api = await self._api(binary)
|
|
38
|
+
resolved_subcommand = _normalize_subcommand(subcommand)
|
|
39
|
+
if resolved_subcommand is not None:
|
|
40
|
+
await self._parse_subcommand(api, binary, resolved_subcommand)
|
|
41
|
+
command = (
|
|
42
|
+
api(resolved_subcommand, **dict(options or {}))
|
|
43
|
+
if resolved_subcommand
|
|
44
|
+
else api(**dict(options or {}))
|
|
45
|
+
)
|
|
46
|
+
return normalize_cli_result(await command)
|
|
47
|
+
|
|
48
|
+
async def _api(self, binary: str) -> Any:
|
|
49
|
+
if binary not in self._apis:
|
|
50
|
+
try:
|
|
51
|
+
from cli_to_py import convert
|
|
52
|
+
except ImportError as exc: # pragma: no cover - dependency is required
|
|
53
|
+
raise ImportError(
|
|
54
|
+
"Ambient CLI support requires cli-to-py in the environment."
|
|
55
|
+
) from exc
|
|
56
|
+
|
|
57
|
+
self._apis[binary] = await convert(binary, subcommands=False)
|
|
58
|
+
return self._apis[binary]
|
|
59
|
+
|
|
60
|
+
async def _parse_subcommand(self, api: Any, binary: str, subcommand: str) -> None:
|
|
61
|
+
key = (binary, subcommand)
|
|
62
|
+
if key in self._parsed_subcommands:
|
|
63
|
+
return
|
|
64
|
+
parser = getattr(api, "parse", None)
|
|
65
|
+
if callable(parser):
|
|
66
|
+
await parser(subcommand)
|
|
67
|
+
self._parsed_subcommands.add(key)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _normalize_subcommand(subcommand: str | None) -> str | None:
|
|
71
|
+
if subcommand is None:
|
|
72
|
+
return None
|
|
73
|
+
try:
|
|
74
|
+
from cli_to_py.case import snake_to_kebab
|
|
75
|
+
except ImportError: # pragma: no cover - dependency is required
|
|
76
|
+
return subcommand
|
|
77
|
+
return snake_to_kebab(subcommand)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def register_ambient_cli(registry: CapabilityRegistry) -> Capability:
|
|
81
|
+
"""Register Toolplane's hidden ambient CLI runner."""
|
|
82
|
+
try:
|
|
83
|
+
return registry.get(AMBIENT_CLI_CAPABILITY)
|
|
84
|
+
except CapabilityNotFoundError:
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
runner = AmbientCliRunner()
|
|
88
|
+
capability = Capability(
|
|
89
|
+
name=AMBIENT_CLI_CAPABILITY,
|
|
90
|
+
callable=runner,
|
|
91
|
+
description="Run a CLI command lazily through cli-to-py.",
|
|
92
|
+
parameters={
|
|
93
|
+
"type": "object",
|
|
94
|
+
"properties": {
|
|
95
|
+
"binary": {"type": "string"},
|
|
96
|
+
"subcommand": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
|
97
|
+
"options": {"type": "object"},
|
|
98
|
+
},
|
|
99
|
+
"required": ["binary"],
|
|
100
|
+
},
|
|
101
|
+
returns={
|
|
102
|
+
"type": "object",
|
|
103
|
+
"properties": {
|
|
104
|
+
"stdout": {"type": "string"},
|
|
105
|
+
"stderr": {"type": "string"},
|
|
106
|
+
"exit_code": {"type": "integer"},
|
|
107
|
+
"ok": {"type": "boolean"},
|
|
108
|
+
},
|
|
109
|
+
"required": ["stdout", "stderr", "exit_code", "ok"],
|
|
110
|
+
},
|
|
111
|
+
tags=frozenset({"toolplane", "cli"}),
|
|
112
|
+
source="toolplane",
|
|
113
|
+
hidden=True,
|
|
114
|
+
)
|
|
115
|
+
return registry.add(capability)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def discover_cli_names() -> tuple[str, ...]:
|
|
119
|
+
"""Return safe executable names from PATH without parsing their help output."""
|
|
120
|
+
names: set[str] = set()
|
|
121
|
+
for directory in os.get_exec_path():
|
|
122
|
+
try:
|
|
123
|
+
entries = os.scandir(directory)
|
|
124
|
+
except OSError:
|
|
125
|
+
continue
|
|
126
|
+
with entries:
|
|
127
|
+
for entry in entries:
|
|
128
|
+
name = entry.name
|
|
129
|
+
if _is_safe_cli_name(name) and _is_executable(entry.path):
|
|
130
|
+
names.add(name)
|
|
131
|
+
return tuple(sorted(names))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _is_executable(path: str) -> bool:
|
|
135
|
+
return os.path.isfile(path) and os.access(path, os.X_OK)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _is_safe_cli_name(name: str) -> bool:
|
|
139
|
+
return (
|
|
140
|
+
name.isidentifier()
|
|
141
|
+
and not keyword.iskeyword(name)
|
|
142
|
+
and not name.startswith("__")
|
|
143
|
+
and name not in vars(builtins)
|
|
144
|
+
and name not in RESERVED_CLI_NAMES
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def build_local_cli_namespace(
|
|
149
|
+
bridge: HostBridge,
|
|
150
|
+
names: Sequence[str],
|
|
151
|
+
*,
|
|
152
|
+
reserved: set[str] | frozenset[str] | None = None,
|
|
153
|
+
) -> dict[str, Any]:
|
|
154
|
+
reserved_names = set(reserved or ())
|
|
155
|
+
root = AmbientCliRoot(bridge)
|
|
156
|
+
namespace: dict[str, Any] = {"cli": root}
|
|
157
|
+
for name in names:
|
|
158
|
+
if name not in reserved_names and _is_safe_cli_name(name):
|
|
159
|
+
namespace[name] = AmbientCliBinary(bridge, name)
|
|
160
|
+
return namespace
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class AmbientCliRoot:
|
|
164
|
+
def __init__(self, bridge: HostBridge) -> None:
|
|
165
|
+
self._bridge = bridge
|
|
166
|
+
|
|
167
|
+
def __call__(self, binary: str) -> AmbientCliBinary:
|
|
168
|
+
return AmbientCliBinary(self._bridge, binary)
|
|
169
|
+
|
|
170
|
+
def __getattr__(self, binary: str) -> AmbientCliBinary:
|
|
171
|
+
if binary.startswith("_"):
|
|
172
|
+
raise AttributeError(binary)
|
|
173
|
+
return AmbientCliBinary(self._bridge, binary)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class AmbientCliBinary:
|
|
177
|
+
def __init__(self, bridge: HostBridge, binary: str) -> None:
|
|
178
|
+
self._bridge = bridge
|
|
179
|
+
self._binary = binary
|
|
180
|
+
|
|
181
|
+
def __call__(
|
|
182
|
+
self,
|
|
183
|
+
subcommand: str | None = None,
|
|
184
|
+
/,
|
|
185
|
+
**options: Any,
|
|
186
|
+
) -> AmbientCliCall:
|
|
187
|
+
return AmbientCliCall(
|
|
188
|
+
self._bridge,
|
|
189
|
+
binary=self._binary,
|
|
190
|
+
subcommand=subcommand,
|
|
191
|
+
options=options,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def __getattr__(self, subcommand: str) -> Any:
|
|
195
|
+
if subcommand.startswith("_"):
|
|
196
|
+
raise AttributeError(subcommand)
|
|
197
|
+
|
|
198
|
+
def dispatch(**options: Any) -> AmbientCliCall:
|
|
199
|
+
return AmbientCliCall(
|
|
200
|
+
self._bridge,
|
|
201
|
+
binary=self._binary,
|
|
202
|
+
subcommand=subcommand,
|
|
203
|
+
options=options,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
dispatch.__name__ = subcommand
|
|
207
|
+
return dispatch
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class AmbientCliCall:
|
|
211
|
+
def __init__(
|
|
212
|
+
self,
|
|
213
|
+
bridge: HostBridge,
|
|
214
|
+
*,
|
|
215
|
+
binary: str,
|
|
216
|
+
subcommand: str | None,
|
|
217
|
+
options: Mapping[str, Any],
|
|
218
|
+
) -> None:
|
|
219
|
+
self._bridge = bridge
|
|
220
|
+
self._binary = binary
|
|
221
|
+
self._subcommand = subcommand
|
|
222
|
+
self._options = dict(options)
|
|
223
|
+
self._task: asyncio.Task[Any] | None = None
|
|
224
|
+
|
|
225
|
+
def _as_task(self) -> asyncio.Task[Any]:
|
|
226
|
+
if self._task is None:
|
|
227
|
+
self._task = asyncio.create_task(
|
|
228
|
+
self._bridge.call_tool(
|
|
229
|
+
AMBIENT_CLI_CAPABILITY,
|
|
230
|
+
{
|
|
231
|
+
"binary": self._binary,
|
|
232
|
+
"subcommand": self._subcommand,
|
|
233
|
+
"options": self._options,
|
|
234
|
+
},
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
return self._task
|
|
238
|
+
|
|
239
|
+
def __await__(self) -> Any:
|
|
240
|
+
return self._as_task().__await__()
|
|
241
|
+
|
|
242
|
+
async def text(self) -> str:
|
|
243
|
+
result = await self._as_task()
|
|
244
|
+
return str(result.get("stdout", "")).strip()
|
|
245
|
+
|
|
246
|
+
async def lines(self) -> list[str]:
|
|
247
|
+
text = await self.text()
|
|
248
|
+
return text.splitlines() if text else []
|
|
249
|
+
|
|
250
|
+
async def json(self, **kwargs: Any) -> Any:
|
|
251
|
+
result = await self._as_task()
|
|
252
|
+
return json.loads(str(result.get("stdout", "")), **kwargs)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def render_pyodide_cli_namespace(
|
|
256
|
+
names: Sequence[str],
|
|
257
|
+
*,
|
|
258
|
+
reserved: set[str] | frozenset[str] | None = None,
|
|
259
|
+
) -> str:
|
|
260
|
+
reserved_names = set(reserved or ())
|
|
261
|
+
top_level = [
|
|
262
|
+
name
|
|
263
|
+
for name in names
|
|
264
|
+
if name not in reserved_names and _is_safe_cli_name(name)
|
|
265
|
+
]
|
|
266
|
+
assignments = "\n".join(f"{name} = cli.{name}" for name in top_level)
|
|
267
|
+
return f"""
|
|
268
|
+
class _ToolplaneCliCall:
|
|
269
|
+
def __init__(self, binary, subcommand, options):
|
|
270
|
+
self.binary = binary
|
|
271
|
+
self.subcommand = subcommand
|
|
272
|
+
self.options = dict(options)
|
|
273
|
+
self._task = None
|
|
274
|
+
|
|
275
|
+
def _as_task(self):
|
|
276
|
+
import asyncio
|
|
277
|
+
if self._task is None:
|
|
278
|
+
self._task = asyncio.ensure_future(call_tool({AMBIENT_CLI_CAPABILITY!r}, {{
|
|
279
|
+
"binary": self.binary,
|
|
280
|
+
"subcommand": self.subcommand,
|
|
281
|
+
"options": self.options,
|
|
282
|
+
}}))
|
|
283
|
+
return self._task
|
|
284
|
+
|
|
285
|
+
def __await__(self):
|
|
286
|
+
return self._as_task().__await__()
|
|
287
|
+
|
|
288
|
+
async def text(self):
|
|
289
|
+
result = await self._as_task()
|
|
290
|
+
return str(result.get("stdout", "")).strip()
|
|
291
|
+
|
|
292
|
+
async def lines(self):
|
|
293
|
+
text = await self.text()
|
|
294
|
+
return text.splitlines() if text else []
|
|
295
|
+
|
|
296
|
+
async def json(self, **kwargs):
|
|
297
|
+
import json
|
|
298
|
+
result = await self._as_task()
|
|
299
|
+
return json.loads(str(result.get("stdout", "")), **kwargs)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class _ToolplaneCliBinary:
|
|
303
|
+
def __init__(self, binary):
|
|
304
|
+
self.binary = binary
|
|
305
|
+
|
|
306
|
+
def __call__(self, subcommand=None, /, **options):
|
|
307
|
+
return _ToolplaneCliCall(self.binary, subcommand, options)
|
|
308
|
+
|
|
309
|
+
def __getattr__(self, subcommand):
|
|
310
|
+
if subcommand.startswith("_"):
|
|
311
|
+
raise AttributeError(subcommand)
|
|
312
|
+
def dispatch(**options):
|
|
313
|
+
return _ToolplaneCliCall(self.binary, subcommand, options)
|
|
314
|
+
return dispatch
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
class _ToolplaneCliRoot:
|
|
318
|
+
def __call__(self, binary):
|
|
319
|
+
return _ToolplaneCliBinary(binary)
|
|
320
|
+
|
|
321
|
+
def __getattr__(self, binary):
|
|
322
|
+
if binary.startswith("_"):
|
|
323
|
+
raise AttributeError(binary)
|
|
324
|
+
return _ToolplaneCliBinary(binary)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
cli = _ToolplaneCliRoot()
|
|
328
|
+
{assignments}
|
|
329
|
+
"""
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Adapter for exposing cli-to-py commands as Toolplane capabilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from collections.abc import Callable, Mapping
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict
|
|
10
|
+
|
|
11
|
+
from ..capabilities import Capability, JsonSchema
|
|
12
|
+
from ..registry import CapabilityRegistry
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CliCommandResult(BaseModel):
|
|
16
|
+
model_config = ConfigDict(frozen=True)
|
|
17
|
+
|
|
18
|
+
stdout: str
|
|
19
|
+
stderr: str
|
|
20
|
+
exit_code: int
|
|
21
|
+
ok: bool
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def register_cli(
|
|
25
|
+
registry: CapabilityRegistry,
|
|
26
|
+
name: str,
|
|
27
|
+
command: Any,
|
|
28
|
+
*,
|
|
29
|
+
subcommand: str | None = None,
|
|
30
|
+
description: str | None = None,
|
|
31
|
+
parameters: JsonSchema | None = None,
|
|
32
|
+
tags: set[str] | frozenset[str] | None = None,
|
|
33
|
+
source: str = "cli-to-py",
|
|
34
|
+
) -> Capability:
|
|
35
|
+
"""Register a cli-to-py API object or command wrapper as a capability."""
|
|
36
|
+
callable_ = _make_callable(command, subcommand=subcommand)
|
|
37
|
+
capability = Capability(
|
|
38
|
+
name=name,
|
|
39
|
+
callable=callable_,
|
|
40
|
+
description=description
|
|
41
|
+
if description is not None
|
|
42
|
+
else _description(command, subcommand=subcommand),
|
|
43
|
+
parameters=parameters
|
|
44
|
+
if parameters is not None
|
|
45
|
+
else _parameters(command, subcommand=subcommand),
|
|
46
|
+
returns=_command_result_schema(),
|
|
47
|
+
tags=frozenset(tags or ()),
|
|
48
|
+
source=source,
|
|
49
|
+
)
|
|
50
|
+
return registry.add(capability)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _make_callable(command: Any, *, subcommand: str | None) -> Callable[..., Any]:
|
|
54
|
+
async def call_cli(**options: Any) -> dict[str, Any]:
|
|
55
|
+
if _is_cli_api(command):
|
|
56
|
+
value = command(subcommand, **options) if subcommand else command(**options)
|
|
57
|
+
else:
|
|
58
|
+
if subcommand is not None:
|
|
59
|
+
raise TypeError("subcommand is only valid for cli-to-py API objects")
|
|
60
|
+
value = command(**options)
|
|
61
|
+
if inspect.isawaitable(value):
|
|
62
|
+
value = await value
|
|
63
|
+
return _normalize_result(value)
|
|
64
|
+
|
|
65
|
+
call_cli.__name__ = _callable_name(command, subcommand=subcommand)
|
|
66
|
+
return call_cli
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _is_cli_api(value: Any) -> bool:
|
|
70
|
+
return hasattr(value, "schema") and hasattr(value, "binary_name")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _callable_name(command: Any, *, subcommand: str | None) -> str:
|
|
74
|
+
if _is_cli_api(command):
|
|
75
|
+
binary = str(getattr(command, "binary_name", "cli")).replace("-", "_")
|
|
76
|
+
suffix = (subcommand or "run").replace("-", "_")
|
|
77
|
+
return f"{binary}_{suffix}"
|
|
78
|
+
return getattr(command, "__name__", "cli_command")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _description(command: Any, *, subcommand: str | None) -> str:
|
|
82
|
+
parsed = _parsed_command(command, subcommand=subcommand)
|
|
83
|
+
if parsed is not None and getattr(parsed, "description", ""):
|
|
84
|
+
return str(parsed.description)
|
|
85
|
+
|
|
86
|
+
if _is_cli_api(command):
|
|
87
|
+
binary = getattr(command, "binary_name", "cli")
|
|
88
|
+
return f"Run `{binary}{f' {subcommand}' if subcommand else ''}`."
|
|
89
|
+
|
|
90
|
+
doc = inspect.getdoc(command) if callable(command) else None
|
|
91
|
+
if doc:
|
|
92
|
+
return doc.splitlines()[0].strip()
|
|
93
|
+
return "Run a CLI command."
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _parameters(command: Any, *, subcommand: str | None) -> JsonSchema:
|
|
97
|
+
parsed = _parsed_command(command, subcommand=subcommand)
|
|
98
|
+
if parsed is None:
|
|
99
|
+
return {
|
|
100
|
+
"type": "object",
|
|
101
|
+
"additionalProperties": True,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
properties: dict[str, Any] = {}
|
|
105
|
+
required: list[str] = []
|
|
106
|
+
|
|
107
|
+
for flag in getattr(parsed, "flags", None) or ():
|
|
108
|
+
key = _flag_key(flag)
|
|
109
|
+
schema = _flag_schema(flag)
|
|
110
|
+
properties[key] = schema
|
|
111
|
+
if getattr(flag, "is_required", False):
|
|
112
|
+
required.append(key)
|
|
113
|
+
|
|
114
|
+
positional_args = getattr(parsed, "positional_args", None) or ()
|
|
115
|
+
if positional_args:
|
|
116
|
+
properties["_"] = {
|
|
117
|
+
"type": "array",
|
|
118
|
+
"items": {"type": "string"},
|
|
119
|
+
"description": "Positional arguments.",
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
schema: JsonSchema = {
|
|
123
|
+
"type": "object",
|
|
124
|
+
"properties": properties,
|
|
125
|
+
"additionalProperties": False,
|
|
126
|
+
}
|
|
127
|
+
if required:
|
|
128
|
+
schema["required"] = required
|
|
129
|
+
return schema
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _parsed_command(command: Any, *, subcommand: str | None) -> Any | None:
|
|
133
|
+
if not _is_cli_api(command):
|
|
134
|
+
return None
|
|
135
|
+
schema = getattr(command, "schema", None)
|
|
136
|
+
root = getattr(schema, "command", None)
|
|
137
|
+
if root is None:
|
|
138
|
+
return None
|
|
139
|
+
if subcommand is None:
|
|
140
|
+
return root
|
|
141
|
+
|
|
142
|
+
finder = getattr(command, "_find_subcommand", None)
|
|
143
|
+
if callable(finder):
|
|
144
|
+
found = finder(subcommand)
|
|
145
|
+
if found is not None:
|
|
146
|
+
return found
|
|
147
|
+
|
|
148
|
+
for candidate in getattr(root, "subcommands", None) or ():
|
|
149
|
+
if getattr(candidate, "name", None) == subcommand:
|
|
150
|
+
return candidate
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _flag_key(flag: Any) -> str:
|
|
155
|
+
long_name = getattr(flag, "long_name", "")
|
|
156
|
+
if long_name:
|
|
157
|
+
return str(long_name).replace("-", "_")
|
|
158
|
+
short_name = str(getattr(flag, "short_name", "")).lstrip("-")
|
|
159
|
+
return short_name.replace("-", "_")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _flag_schema(flag: Any) -> JsonSchema:
|
|
163
|
+
takes_value = bool(getattr(flag, "takes_value", False))
|
|
164
|
+
schema: JsonSchema = {"type": "string"} if takes_value else {"type": "boolean"}
|
|
165
|
+
|
|
166
|
+
description = getattr(flag, "description", "")
|
|
167
|
+
if description:
|
|
168
|
+
schema["description"] = str(description)
|
|
169
|
+
|
|
170
|
+
choices = getattr(flag, "choices", None)
|
|
171
|
+
if choices:
|
|
172
|
+
schema["enum"] = list(choices)
|
|
173
|
+
|
|
174
|
+
default = getattr(flag, "default_value", None)
|
|
175
|
+
if default is not None:
|
|
176
|
+
schema["default"] = default
|
|
177
|
+
|
|
178
|
+
return schema
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _normalize_result(value: Any) -> dict[str, Any]:
|
|
182
|
+
return normalize_cli_result(value)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def normalize_cli_result(value: Any) -> dict[str, Any]:
|
|
186
|
+
if isinstance(value, Mapping):
|
|
187
|
+
data = dict(value)
|
|
188
|
+
if {"stdout", "stderr", "exit_code"} <= data.keys():
|
|
189
|
+
exit_code = int(data["exit_code"])
|
|
190
|
+
return CliCommandResult(
|
|
191
|
+
stdout=str(data["stdout"]),
|
|
192
|
+
stderr=str(data["stderr"]),
|
|
193
|
+
exit_code=exit_code,
|
|
194
|
+
ok=bool(data.get("ok", exit_code == 0)),
|
|
195
|
+
).model_dump()
|
|
196
|
+
|
|
197
|
+
if all(hasattr(value, attr) for attr in ("stdout", "stderr", "exit_code")):
|
|
198
|
+
exit_code = int(getattr(value, "exit_code"))
|
|
199
|
+
ok = value.ok() if callable(getattr(value, "ok", None)) else exit_code == 0
|
|
200
|
+
return CliCommandResult(
|
|
201
|
+
stdout=str(getattr(value, "stdout")),
|
|
202
|
+
stderr=str(getattr(value, "stderr")),
|
|
203
|
+
exit_code=exit_code,
|
|
204
|
+
ok=bool(ok),
|
|
205
|
+
).model_dump()
|
|
206
|
+
|
|
207
|
+
raise TypeError(
|
|
208
|
+
"cli-to-py adapter expected a command result with stdout, stderr, and exit_code"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _command_result_schema() -> JsonSchema:
|
|
213
|
+
return {
|
|
214
|
+
"type": "object",
|
|
215
|
+
"properties": {
|
|
216
|
+
"stdout": {"type": "string"},
|
|
217
|
+
"stderr": {"type": "string"},
|
|
218
|
+
"exit_code": {"type": "integer"},
|
|
219
|
+
"ok": {"type": "boolean"},
|
|
220
|
+
},
|
|
221
|
+
"required": ["stdout", "stderr", "exit_code", "ok"],
|
|
222
|
+
"additionalProperties": False,
|
|
223
|
+
}
|