utcp-cli 1.0.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.
- utcp_cli/__init__.py +13 -0
- utcp_cli/cli_call_template.py +44 -0
- utcp_cli/cli_communication_protocol.py +482 -0
- utcp_cli-1.0.0.dist-info/METADATA +25 -0
- utcp_cli-1.0.0.dist-info/RECORD +8 -0
- utcp_cli-1.0.0.dist-info/WHEEL +5 -0
- utcp_cli-1.0.0.dist-info/entry_points.txt +2 -0
- utcp_cli-1.0.0.dist-info/top_level.txt +1 -0
utcp_cli/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from utcp.plugins.discovery import register_communication_protocol, register_call_template
|
|
2
|
+
from utcp_cli.cli_communication_protocol import CliCommunicationProtocol
|
|
3
|
+
from utcp_cli.cli_call_template import CliCallTemplate, CliCallTemplateSerializer
|
|
4
|
+
|
|
5
|
+
def register():
|
|
6
|
+
register_communication_protocol("cli", CliCommunicationProtocol())
|
|
7
|
+
register_call_template("cli", CliCallTemplateSerializer())
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"CliCommunicationProtocol",
|
|
11
|
+
"CliCallTemplate",
|
|
12
|
+
"CliCallTemplateSerializer",
|
|
13
|
+
]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from typing import Optional, Dict, Literal
|
|
2
|
+
from pydantic import Field
|
|
3
|
+
|
|
4
|
+
from utcp.data.call_template import CallTemplate
|
|
5
|
+
from utcp.interfaces.serializer import Serializer
|
|
6
|
+
from utcp.exceptions import UtcpSerializerValidationError
|
|
7
|
+
import traceback
|
|
8
|
+
|
|
9
|
+
class CliCallTemplate(CallTemplate):
|
|
10
|
+
"""Call template configuration for Command Line Interface tools.
|
|
11
|
+
|
|
12
|
+
Enables execution of command-line tools and programs as UTCP providers.
|
|
13
|
+
Supports environment variable injection and custom working directories.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
call_template_type: Always "cli" for CLI providers.
|
|
17
|
+
command_name: The name or path of the command to execute.
|
|
18
|
+
env_vars: Optional environment variables to set during command execution.
|
|
19
|
+
working_dir: Optional custom working directory for command execution.
|
|
20
|
+
auth: Always None - CLI providers don't support authentication.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
call_template_type: Literal["cli"] = "cli"
|
|
24
|
+
command_name: str
|
|
25
|
+
env_vars: Optional[Dict[str, str]] = Field(
|
|
26
|
+
default=None, description="Environment variables to set when executing the command"
|
|
27
|
+
)
|
|
28
|
+
working_dir: Optional[str] = Field(
|
|
29
|
+
default=None, description="Working directory for command execution"
|
|
30
|
+
)
|
|
31
|
+
auth: None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CliCallTemplateSerializer(Serializer[CliCallTemplate]):
|
|
35
|
+
"""Serializer for CliCallTemplate."""
|
|
36
|
+
|
|
37
|
+
def to_dict(self, obj: CliCallTemplate) -> dict:
|
|
38
|
+
return obj.model_dump()
|
|
39
|
+
|
|
40
|
+
def validate_dict(self, obj: dict) -> CliCallTemplate:
|
|
41
|
+
try:
|
|
42
|
+
return CliCallTemplate.model_validate(obj)
|
|
43
|
+
except Exception as e:
|
|
44
|
+
raise UtcpSerializerValidationError("Invalid CliCallTemplate: " + traceback.format_exc()) from e
|
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
"""Command Line Interface (CLI) transport for UTCP client.
|
|
2
|
+
|
|
3
|
+
This module provides the CLI transport implementation that enables UTCP clients
|
|
4
|
+
to interact with command-line tools and processes. It handles tool discovery
|
|
5
|
+
through startup commands, tool execution with proper argument formatting,
|
|
6
|
+
and output processing with JSON parsing capabilities.
|
|
7
|
+
|
|
8
|
+
Key Features:
|
|
9
|
+
- Asynchronous command execution with timeout handling
|
|
10
|
+
- Tool discovery via startup commands that output UTCP manuals
|
|
11
|
+
- Flexible argument formatting for command-line flags
|
|
12
|
+
- Environment variable support for authentication and configuration
|
|
13
|
+
- JSON output parsing with fallback to raw text
|
|
14
|
+
- Cross-platform command parsing (Windows/Unix)
|
|
15
|
+
- Working directory control for command execution
|
|
16
|
+
|
|
17
|
+
Security:
|
|
18
|
+
- Command execution is isolated through subprocess
|
|
19
|
+
- Environment variables can be controlled per provider
|
|
20
|
+
- Working directory can be restricted
|
|
21
|
+
"""
|
|
22
|
+
import asyncio
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import shlex
|
|
26
|
+
from typing import Dict, Any, List, Optional, Callable, AsyncGenerator
|
|
27
|
+
|
|
28
|
+
from utcp.interfaces.communication_protocol import CommunicationProtocol
|
|
29
|
+
from utcp.data.call_template import CallTemplate, CallTemplateSerializer
|
|
30
|
+
from utcp.data.tool import Tool
|
|
31
|
+
from utcp.data.utcp_manual import UtcpManual, UtcpManualSerializer
|
|
32
|
+
from utcp.data.register_manual_response import RegisterManualResult
|
|
33
|
+
from utcp_cli.cli_call_template import CliCallTemplate, CliCallTemplateSerializer
|
|
34
|
+
import logging
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class CliCommunicationProtocol(CommunicationProtocol):
|
|
40
|
+
"""Transport implementation for CLI-based tool providers.
|
|
41
|
+
|
|
42
|
+
Handles communication with command-line tools by executing processes
|
|
43
|
+
and managing their input/output. Supports both tool discovery and
|
|
44
|
+
execution phases with comprehensive error handling and timeout management.
|
|
45
|
+
|
|
46
|
+
Features:
|
|
47
|
+
- Asynchronous subprocess execution with proper cleanup
|
|
48
|
+
- Tool discovery through startup commands returning UTCP manuals
|
|
49
|
+
- Flexible argument formatting for various CLI conventions
|
|
50
|
+
- Environment variable injection for authentication
|
|
51
|
+
- JSON output parsing with graceful fallback to text
|
|
52
|
+
- Cross-platform command parsing and execution
|
|
53
|
+
- Configurable working directories and timeouts
|
|
54
|
+
- Process lifecycle management with proper termination
|
|
55
|
+
|
|
56
|
+
Architecture:
|
|
57
|
+
CLI tools are discovered by executing the provider's command_name
|
|
58
|
+
and parsing the output for UTCP manual JSON. Tool calls execute
|
|
59
|
+
the same command with formatted arguments and return processed output.
|
|
60
|
+
|
|
61
|
+
Attributes:
|
|
62
|
+
_log: Logger function for debugging and error reporting.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self):
|
|
66
|
+
"""Initialize the CLI transport."""
|
|
67
|
+
|
|
68
|
+
def _log_info(self, message: str):
|
|
69
|
+
"""Log informational messages."""
|
|
70
|
+
logger.info(f"[CliCommunicationProtocol] {message}")
|
|
71
|
+
|
|
72
|
+
def _log_error(self, message: str):
|
|
73
|
+
"""Log error messages."""
|
|
74
|
+
logger.error(f"[CliCommunicationProtocol Error] {message}")
|
|
75
|
+
|
|
76
|
+
def _prepare_environment(self, provider: CliCallTemplate) -> Dict[str, str]:
|
|
77
|
+
"""Prepare environment variables for command execution.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
provider: The CLI provider
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Environment variables dictionary
|
|
84
|
+
"""
|
|
85
|
+
import os
|
|
86
|
+
env = os.environ.copy()
|
|
87
|
+
|
|
88
|
+
# Add custom environment variables if provided
|
|
89
|
+
if provider.env_vars:
|
|
90
|
+
env.update(provider.env_vars)
|
|
91
|
+
|
|
92
|
+
return env
|
|
93
|
+
|
|
94
|
+
async def _execute_command(
|
|
95
|
+
self,
|
|
96
|
+
command: List[str],
|
|
97
|
+
env: Dict[str, str],
|
|
98
|
+
timeout: float = 30.0,
|
|
99
|
+
input_data: Optional[str] = None,
|
|
100
|
+
working_dir: Optional[str] = None
|
|
101
|
+
) -> tuple[str, str, int]:
|
|
102
|
+
"""Execute a command asynchronously.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
command: Command and arguments to execute
|
|
106
|
+
env: Environment variables
|
|
107
|
+
timeout: Timeout in seconds
|
|
108
|
+
input_data: Optional input data to pass to the command
|
|
109
|
+
working_dir: Working directory for command execution
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Tuple of (stdout, stderr, return_code)
|
|
113
|
+
"""
|
|
114
|
+
process = None
|
|
115
|
+
try:
|
|
116
|
+
process = await asyncio.create_subprocess_exec(
|
|
117
|
+
*command,
|
|
118
|
+
stdout=asyncio.subprocess.PIPE,
|
|
119
|
+
stderr=asyncio.subprocess.PIPE,
|
|
120
|
+
env=env,
|
|
121
|
+
cwd=working_dir,
|
|
122
|
+
stdin=asyncio.subprocess.PIPE if input_data else None
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
126
|
+
process.communicate(input=input_data.encode('utf-8') if input_data else None),
|
|
127
|
+
timeout=timeout
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
stdout = stdout_bytes.decode('utf-8', errors='replace')
|
|
131
|
+
stderr = stderr_bytes.decode('utf-8', errors='replace')
|
|
132
|
+
|
|
133
|
+
return stdout, stderr, process.returncode or 0
|
|
134
|
+
|
|
135
|
+
except asyncio.TimeoutError:
|
|
136
|
+
# Kill the process if it times out
|
|
137
|
+
if process:
|
|
138
|
+
try:
|
|
139
|
+
process.kill()
|
|
140
|
+
await process.wait()
|
|
141
|
+
except ProcessLookupError:
|
|
142
|
+
pass # Process already terminated
|
|
143
|
+
self._log_error(f"Command timed out after {timeout} seconds: {' '.join(command)}")
|
|
144
|
+
raise
|
|
145
|
+
except Exception as e:
|
|
146
|
+
# Ensure process is cleaned up on any error
|
|
147
|
+
if process:
|
|
148
|
+
try:
|
|
149
|
+
process.kill()
|
|
150
|
+
await process.wait()
|
|
151
|
+
except ProcessLookupError:
|
|
152
|
+
pass # Process already terminated
|
|
153
|
+
self._log_error(f"Error executing command {' '.join(command)}: {e}")
|
|
154
|
+
raise
|
|
155
|
+
|
|
156
|
+
async def register_manual(self, caller, manual_call_template: CallTemplate) -> RegisterManualResult:
|
|
157
|
+
"""Register a CLI manual and discover its tools.
|
|
158
|
+
|
|
159
|
+
Executes the call template's command_name and looks for a UTCP manual JSON in the output.
|
|
160
|
+
"""
|
|
161
|
+
if not isinstance(manual_call_template, CliCallTemplate):
|
|
162
|
+
raise ValueError("CliCommunicationProtocol can only be used with CliCallTemplate")
|
|
163
|
+
|
|
164
|
+
if not manual_call_template.command_name:
|
|
165
|
+
raise ValueError(f"CliCallTemplate '{manual_call_template.name}' must have command_name set")
|
|
166
|
+
|
|
167
|
+
self._log_info(
|
|
168
|
+
f"Registering CLI manual '{manual_call_template.name}' with command '{manual_call_template.command_name}'"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
env = self._prepare_environment(manual_call_template)
|
|
173
|
+
# Parse command string into proper arguments
|
|
174
|
+
# Use posix=False on Windows, posix=True on Unix-like systems
|
|
175
|
+
command = shlex.split(manual_call_template.command_name, posix=(os.name != 'nt'))
|
|
176
|
+
|
|
177
|
+
self._log_info(f"Executing command for tool discovery: {' '.join(command)}")
|
|
178
|
+
|
|
179
|
+
stdout, stderr, return_code = await self._execute_command(
|
|
180
|
+
command,
|
|
181
|
+
env,
|
|
182
|
+
timeout=30.0,
|
|
183
|
+
working_dir=manual_call_template.working_dir,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# Get output based on exit code
|
|
187
|
+
output = stdout if return_code == 0 else stderr
|
|
188
|
+
|
|
189
|
+
if not output.strip():
|
|
190
|
+
self._log_info(
|
|
191
|
+
f"No output from command '{manual_call_template.command_name}'"
|
|
192
|
+
)
|
|
193
|
+
return RegisterManualResult(
|
|
194
|
+
success=False,
|
|
195
|
+
manual_call_template=manual_call_template,
|
|
196
|
+
manual=UtcpManual(utcp_version="1.0.0", manual_version="0.0.0", tools=[]),
|
|
197
|
+
errors=[
|
|
198
|
+
f"No output from discovery command for CLI provider '{manual_call_template.name}'"
|
|
199
|
+
],
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# Try to parse UTCPManual from the output
|
|
203
|
+
utcp_manual = self._extract_utcp_manual_from_output(
|
|
204
|
+
output, manual_call_template.name
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
if utcp_manual is None:
|
|
208
|
+
error_msg = (
|
|
209
|
+
f"Could not parse UTCP manual from CLI provider '{manual_call_template.name}' output"
|
|
210
|
+
)
|
|
211
|
+
self._log_error(error_msg)
|
|
212
|
+
return RegisterManualResult(
|
|
213
|
+
success=False,
|
|
214
|
+
manual_call_template=manual_call_template,
|
|
215
|
+
manual=UtcpManual(utcp_version="1.0.0", manual_version="0.0.0", tools=[]),
|
|
216
|
+
errors=[error_msg],
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
self._log_info(
|
|
220
|
+
f"Discovered {len(utcp_manual.tools)} tools from CLI provider '{manual_call_template.name}'"
|
|
221
|
+
)
|
|
222
|
+
return RegisterManualResult(
|
|
223
|
+
success=True,
|
|
224
|
+
manual_call_template=manual_call_template,
|
|
225
|
+
manual=utcp_manual,
|
|
226
|
+
errors=[],
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
except Exception as e:
|
|
230
|
+
error_msg = f"Error discovering tools from CLI provider '{manual_call_template.name}': {e}"
|
|
231
|
+
self._log_error(error_msg)
|
|
232
|
+
return RegisterManualResult(
|
|
233
|
+
success=False,
|
|
234
|
+
manual_call_template=manual_call_template,
|
|
235
|
+
manual=UtcpManual(utcp_version="1.0.0", manual_version="0.0.0", tools=[]),
|
|
236
|
+
errors=[error_msg],
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
async def deregister_manual(self, caller, manual_call_template: CallTemplate) -> None:
|
|
240
|
+
"""Deregister a CLI manual (no-op)."""
|
|
241
|
+
if isinstance(manual_call_template, CliCallTemplate):
|
|
242
|
+
self._log_info(
|
|
243
|
+
f"Deregistering CLI manual '{manual_call_template.name}' (no-op)"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
def _format_arguments(self, tool_args: Dict[str, Any]) -> List[str]:
|
|
247
|
+
"""Format arguments for command-line execution.
|
|
248
|
+
|
|
249
|
+
Converts a dictionary of arguments into command-line flags and values.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
tool_args: Dictionary of argument names and values
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
List of command-line arguments
|
|
256
|
+
"""
|
|
257
|
+
args = []
|
|
258
|
+
for key, value in tool_args.items():
|
|
259
|
+
if isinstance(value, bool):
|
|
260
|
+
if value:
|
|
261
|
+
args.append(f"--{key}")
|
|
262
|
+
elif isinstance(value, (list, tuple)):
|
|
263
|
+
for item in value:
|
|
264
|
+
args.extend([f"--{key}", str(item)])
|
|
265
|
+
else:
|
|
266
|
+
args.extend([f"--{key}", str(value)])
|
|
267
|
+
return args
|
|
268
|
+
|
|
269
|
+
def _extract_utcp_manual_from_output(self, output: str, provider_name: str) -> Optional[UtcpManual]:
|
|
270
|
+
"""Extract a UTCP manual from command output.
|
|
271
|
+
|
|
272
|
+
Tries to parse the output as a UTCP manual. If it instead looks like a list of tools,
|
|
273
|
+
wraps them in a basic UtcpManual structure.
|
|
274
|
+
"""
|
|
275
|
+
# Try to parse the entire output as JSON first
|
|
276
|
+
try:
|
|
277
|
+
data = json.loads(output.strip())
|
|
278
|
+
if isinstance(data, dict) and "utcp_version" in data and "tools" in data:
|
|
279
|
+
try:
|
|
280
|
+
return UtcpManualSerializer().validate_dict(data)
|
|
281
|
+
except Exception as e:
|
|
282
|
+
self._log_error(
|
|
283
|
+
f"Invalid UTCP manual format from provider '{provider_name}': {e}"
|
|
284
|
+
)
|
|
285
|
+
# Fallback: try to parse tools from possibly-legacy structure
|
|
286
|
+
tools = self._parse_tool_data(data, provider_name)
|
|
287
|
+
if tools:
|
|
288
|
+
return UtcpManual(utcp_version="1.0.0", manual_version="0.0.0", tools=tools)
|
|
289
|
+
return None
|
|
290
|
+
# Fallback: try to parse as tools
|
|
291
|
+
tools = self._parse_tool_data(data, provider_name)
|
|
292
|
+
if tools:
|
|
293
|
+
return UtcpManual(utcp_version="1.0.0", manual_version="0.0.0", tools=tools)
|
|
294
|
+
except json.JSONDecodeError:
|
|
295
|
+
pass
|
|
296
|
+
|
|
297
|
+
# Look for JSON objects within the output text and aggregate tools
|
|
298
|
+
aggregated_tools: List[Tool] = []
|
|
299
|
+
lines = output.split('\n')
|
|
300
|
+
for line in lines:
|
|
301
|
+
line = line.strip()
|
|
302
|
+
if line.startswith('{') and line.endswith('}'):
|
|
303
|
+
try:
|
|
304
|
+
data = json.loads(line)
|
|
305
|
+
# If a full manual is found in a line, return it immediately
|
|
306
|
+
if isinstance(data, dict) and "utcp_version" in data and "tools" in data:
|
|
307
|
+
try:
|
|
308
|
+
return UtcpManualSerializer().validate_dict(data)
|
|
309
|
+
except Exception as e:
|
|
310
|
+
self._log_error(
|
|
311
|
+
f"Invalid UTCP manual format from provider '{provider_name}': {e}"
|
|
312
|
+
)
|
|
313
|
+
# Fallback: try to parse tools from possibly-legacy structure
|
|
314
|
+
tools = self._parse_tool_data(data, provider_name)
|
|
315
|
+
if tools:
|
|
316
|
+
return UtcpManual(utcp_version="1.0.0", manual_version="0.0.0", tools=tools)
|
|
317
|
+
return None
|
|
318
|
+
found_tools = self._parse_tool_data(data, provider_name)
|
|
319
|
+
aggregated_tools.extend(found_tools)
|
|
320
|
+
except json.JSONDecodeError:
|
|
321
|
+
continue
|
|
322
|
+
|
|
323
|
+
if aggregated_tools:
|
|
324
|
+
return UtcpManual(utcp_version="1.0.0", manual_version="0.0.0", tools=aggregated_tools)
|
|
325
|
+
|
|
326
|
+
return None
|
|
327
|
+
|
|
328
|
+
def _build_tool_from_dict(self, tool_data: Any, provider_name: str) -> Optional[Tool]:
|
|
329
|
+
"""Build a Tool object from a dictionary, supporting legacy keys.
|
|
330
|
+
|
|
331
|
+
This maps legacy 'tool_provider' into the new 'tool_call_template'
|
|
332
|
+
using the appropriate call template serializers.
|
|
333
|
+
"""
|
|
334
|
+
try:
|
|
335
|
+
if isinstance(tool_data, dict):
|
|
336
|
+
# If already new-style and call template is a dict, validate it
|
|
337
|
+
if "tool_call_template" in tool_data and isinstance(tool_data["tool_call_template"], dict):
|
|
338
|
+
td = dict(tool_data)
|
|
339
|
+
td["tool_call_template"] = CallTemplateSerializer().validate_dict(td["tool_call_template"])
|
|
340
|
+
return Tool(**td)
|
|
341
|
+
|
|
342
|
+
# Legacy style: 'tool_provider'
|
|
343
|
+
if "tool_provider" in tool_data and isinstance(tool_data["tool_provider"], dict):
|
|
344
|
+
provider = tool_data["tool_provider"]
|
|
345
|
+
provider_type = provider.get("provider_type") or provider.get("type")
|
|
346
|
+
# Normalize to call template dict
|
|
347
|
+
call_template_dict = {k: v for k, v in provider.items() if k != "provider_type"}
|
|
348
|
+
call_template_dict["type"] = provider_type
|
|
349
|
+
|
|
350
|
+
# Validate based on type
|
|
351
|
+
if provider_type == "cli":
|
|
352
|
+
call_template = CliCallTemplateSerializer().validate_dict(call_template_dict)
|
|
353
|
+
else:
|
|
354
|
+
call_template = CallTemplateSerializer().validate_dict(call_template_dict)
|
|
355
|
+
|
|
356
|
+
td = dict(tool_data)
|
|
357
|
+
td.pop("tool_provider", None)
|
|
358
|
+
td["tool_call_template"] = call_template
|
|
359
|
+
return Tool(**td)
|
|
360
|
+
|
|
361
|
+
# Already a Tool-like dict with correct fields
|
|
362
|
+
return Tool(**tool_data)
|
|
363
|
+
except Exception as e:
|
|
364
|
+
self._log_error(f"Invalid tool definition from provider '{provider_name}': {e}")
|
|
365
|
+
return None
|
|
366
|
+
return None
|
|
367
|
+
|
|
368
|
+
def _parse_tool_data(self, data: Any, provider_name: str) -> List[Tool]:
|
|
369
|
+
"""Parse tool data from JSON.
|
|
370
|
+
|
|
371
|
+
Supports both the new format (with 'tool_call_template') and the
|
|
372
|
+
legacy format (with 'tool_provider').
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
data: JSON data to parse
|
|
376
|
+
provider_name: Name of the provider for logging
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
List of tools parsed from the data
|
|
380
|
+
"""
|
|
381
|
+
tools: List[Tool] = []
|
|
382
|
+
if isinstance(data, dict):
|
|
383
|
+
if 'tools' in data and isinstance(data['tools'], list):
|
|
384
|
+
for item in data['tools']:
|
|
385
|
+
built = self._build_tool_from_dict(item, provider_name)
|
|
386
|
+
if built is not None:
|
|
387
|
+
tools.append(built)
|
|
388
|
+
return tools
|
|
389
|
+
elif 'name' in data and 'description' in data:
|
|
390
|
+
built = self._build_tool_from_dict(data, provider_name)
|
|
391
|
+
return [built] if built is not None else []
|
|
392
|
+
elif isinstance(data, list):
|
|
393
|
+
for item in data:
|
|
394
|
+
built = self._build_tool_from_dict(item, provider_name)
|
|
395
|
+
if built is not None:
|
|
396
|
+
tools.append(built)
|
|
397
|
+
return tools
|
|
398
|
+
|
|
399
|
+
return tools
|
|
400
|
+
|
|
401
|
+
async def call_tool(self, caller, tool_name: str, tool_args: Dict[str, Any], tool_call_template: CallTemplate) -> Any:
|
|
402
|
+
"""Call a CLI tool.
|
|
403
|
+
|
|
404
|
+
Executes the command specified by provider.command_name with the provided arguments.
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
caller: The UTCP client that is calling this method.
|
|
408
|
+
tool_name: Name of the tool to call
|
|
409
|
+
tool_args: Arguments for the tool call
|
|
410
|
+
tool_call_template: The CliCallTemplate for the tool
|
|
411
|
+
|
|
412
|
+
Returns:
|
|
413
|
+
The output from the command execution based on exit code:
|
|
414
|
+
- If exit code is 0: stdout (parsed as JSON if possible, otherwise raw string)
|
|
415
|
+
- If exit code is not 0: stderr
|
|
416
|
+
|
|
417
|
+
Raises:
|
|
418
|
+
ValueError: If provider is not a CliProvider or command_name is not set
|
|
419
|
+
"""
|
|
420
|
+
if not isinstance(tool_call_template, CliCallTemplate):
|
|
421
|
+
raise ValueError("CliCommunicationProtocol can only be used with CliCallTemplate")
|
|
422
|
+
|
|
423
|
+
if not tool_call_template.command_name:
|
|
424
|
+
raise ValueError(f"CliCallTemplate '{tool_call_template.name}' must have command_name set")
|
|
425
|
+
|
|
426
|
+
# Build the command
|
|
427
|
+
# Parse command string into proper arguments
|
|
428
|
+
# Use posix=False on Windows, posix=True on Unix-like systems
|
|
429
|
+
command = shlex.split(tool_call_template.command_name, posix=(os.name != 'nt'))
|
|
430
|
+
|
|
431
|
+
# Add formatted arguments
|
|
432
|
+
if tool_args:
|
|
433
|
+
command.extend(self._format_arguments(tool_args))
|
|
434
|
+
|
|
435
|
+
self._log_info(f"Executing CLI tool '{tool_name}': {' '.join(command)}")
|
|
436
|
+
|
|
437
|
+
try:
|
|
438
|
+
env = self._prepare_environment(tool_call_template)
|
|
439
|
+
|
|
440
|
+
stdout, stderr, return_code = await self._execute_command(
|
|
441
|
+
command,
|
|
442
|
+
env,
|
|
443
|
+
timeout=60.0, # Longer timeout for tool execution
|
|
444
|
+
working_dir=tool_call_template.working_dir
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
# Get output based on exit code
|
|
448
|
+
if return_code == 0:
|
|
449
|
+
output = stdout
|
|
450
|
+
self._log_info(f"CLI tool '{tool_name}' executed successfully (exit code 0)")
|
|
451
|
+
else:
|
|
452
|
+
output = stderr
|
|
453
|
+
self._log_info(f"CLI tool '{tool_name}' exited with code {return_code}, returning stderr")
|
|
454
|
+
|
|
455
|
+
# Try to parse output as JSON, fall back to raw string
|
|
456
|
+
if output.strip():
|
|
457
|
+
try:
|
|
458
|
+
result = json.loads(output)
|
|
459
|
+
self._log_info(f"Returning JSON output from CLI tool '{tool_name}'")
|
|
460
|
+
return result
|
|
461
|
+
except json.JSONDecodeError:
|
|
462
|
+
# Return raw string output
|
|
463
|
+
self._log_info(f"Returning text output from CLI tool '{tool_name}'")
|
|
464
|
+
return output.strip()
|
|
465
|
+
else:
|
|
466
|
+
self._log_info(f"CLI tool '{tool_name}' produced no output")
|
|
467
|
+
return ""
|
|
468
|
+
|
|
469
|
+
except Exception as e:
|
|
470
|
+
self._log_error(f"Error executing CLI tool '{tool_name}': {e}")
|
|
471
|
+
raise
|
|
472
|
+
|
|
473
|
+
async def call_tool_streaming(self, caller, tool_name: str, tool_args: Dict[str, Any], tool_call_template: CallTemplate) -> AsyncGenerator[Any, None]:
|
|
474
|
+
"""Streaming calls are not supported for CLI protocol."""
|
|
475
|
+
raise NotImplementedError("Streaming is not supported by the CLI communication protocol.")
|
|
476
|
+
|
|
477
|
+
async def close(self) -> None:
|
|
478
|
+
"""Close the transport.
|
|
479
|
+
|
|
480
|
+
This is a no-op for CLI transports since they don't maintain connections.
|
|
481
|
+
"""
|
|
482
|
+
self._log_info("Closing CLI transport (no-op)")
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: utcp-cli
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Universal Tool Calling Protocol (UTCP) client library for Python
|
|
5
|
+
Author: UTCP Contributors
|
|
6
|
+
License-Expression: MPL-2.0
|
|
7
|
+
Project-URL: Homepage, https://utcp.io
|
|
8
|
+
Project-URL: Source, https://github.com/universal-tool-calling-protocol/python-utcp
|
|
9
|
+
Project-URL: Issues, https://github.com/universal-tool-calling-protocol/python-utcp/issues
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: pydantic>=2.0
|
|
17
|
+
Requires-Dist: pyyaml>=6.0
|
|
18
|
+
Requires-Dist: utcp>=1.0
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: build; extra == "dev"
|
|
21
|
+
Requires-Dist: pytest; extra == "dev"
|
|
22
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
24
|
+
Requires-Dist: coverage; extra == "dev"
|
|
25
|
+
Requires-Dist: twine; extra == "dev"
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
utcp_cli/__init__.py,sha256=zK1L7zAnoyL0_LNK_PqcQGqcux4Mnu3aCSO1LucHx48,513
|
|
2
|
+
utcp_cli/cli_call_template.py,sha256=EWoDRIAZgzbX_oAPNs5gaPkXtrKNW2fugJCR1GVmTzo,1752
|
|
3
|
+
utcp_cli/cli_communication_protocol.py,sha256=nmY2LZ94DqnpVqBL8JMNYFU-qqGi0Z6wchNwMFoy_Hk,21191
|
|
4
|
+
utcp_cli-1.0.0.dist-info/METADATA,sha256=wNor53c0dqjV1m8C6TGGPQf_g_948vQYVObZKd0_S0A,1006
|
|
5
|
+
utcp_cli-1.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
6
|
+
utcp_cli-1.0.0.dist-info/entry_points.txt,sha256=MaJkklyk1JAv68ox_edGI2LnDGxPCgnK87eh6GaHsXM,39
|
|
7
|
+
utcp_cli-1.0.0.dist-info/top_level.txt,sha256=hAg-77aejYrMlOrHfMOPMfKy8cCR690cdu7qeXYHpdY,9
|
|
8
|
+
utcp_cli-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
utcp_cli
|