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 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
+ }