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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [utcp.plugins]
2
+ cli = utcp_cli:register
@@ -0,0 +1 @@
1
+ utcp_cli