onetool-mcp 1.0.0b1__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.
Files changed (132) hide show
  1. bench/__init__.py +5 -0
  2. bench/cli.py +69 -0
  3. bench/harness/__init__.py +66 -0
  4. bench/harness/client.py +692 -0
  5. bench/harness/config.py +397 -0
  6. bench/harness/csv_writer.py +109 -0
  7. bench/harness/evaluate.py +512 -0
  8. bench/harness/metrics.py +283 -0
  9. bench/harness/runner.py +899 -0
  10. bench/py.typed +0 -0
  11. bench/reporter.py +629 -0
  12. bench/run.py +487 -0
  13. bench/secrets.py +101 -0
  14. bench/utils.py +16 -0
  15. onetool/__init__.py +4 -0
  16. onetool/cli.py +391 -0
  17. onetool/py.typed +0 -0
  18. onetool_mcp-1.0.0b1.dist-info/METADATA +163 -0
  19. onetool_mcp-1.0.0b1.dist-info/RECORD +132 -0
  20. onetool_mcp-1.0.0b1.dist-info/WHEEL +4 -0
  21. onetool_mcp-1.0.0b1.dist-info/entry_points.txt +3 -0
  22. onetool_mcp-1.0.0b1.dist-info/licenses/LICENSE.txt +687 -0
  23. onetool_mcp-1.0.0b1.dist-info/licenses/NOTICE.txt +64 -0
  24. ot/__init__.py +37 -0
  25. ot/__main__.py +6 -0
  26. ot/_cli.py +107 -0
  27. ot/_tui.py +53 -0
  28. ot/config/__init__.py +46 -0
  29. ot/config/defaults/bench.yaml +4 -0
  30. ot/config/defaults/diagram-templates/api-flow.mmd +33 -0
  31. ot/config/defaults/diagram-templates/c4-context.puml +30 -0
  32. ot/config/defaults/diagram-templates/class-diagram.mmd +87 -0
  33. ot/config/defaults/diagram-templates/feature-mindmap.mmd +70 -0
  34. ot/config/defaults/diagram-templates/microservices.d2 +81 -0
  35. ot/config/defaults/diagram-templates/project-gantt.mmd +37 -0
  36. ot/config/defaults/diagram-templates/state-machine.mmd +42 -0
  37. ot/config/defaults/onetool.yaml +25 -0
  38. ot/config/defaults/prompts.yaml +97 -0
  39. ot/config/defaults/servers.yaml +7 -0
  40. ot/config/defaults/snippets.yaml +4 -0
  41. ot/config/defaults/tool_templates/__init__.py +7 -0
  42. ot/config/defaults/tool_templates/extension.py +52 -0
  43. ot/config/defaults/tool_templates/isolated.py +61 -0
  44. ot/config/dynamic.py +121 -0
  45. ot/config/global_templates/__init__.py +2 -0
  46. ot/config/global_templates/bench-secrets-template.yaml +6 -0
  47. ot/config/global_templates/bench.yaml +9 -0
  48. ot/config/global_templates/onetool.yaml +27 -0
  49. ot/config/global_templates/secrets-template.yaml +44 -0
  50. ot/config/global_templates/servers.yaml +18 -0
  51. ot/config/global_templates/snippets.yaml +235 -0
  52. ot/config/loader.py +1087 -0
  53. ot/config/mcp.py +145 -0
  54. ot/config/secrets.py +190 -0
  55. ot/config/tool_config.py +125 -0
  56. ot/decorators.py +116 -0
  57. ot/executor/__init__.py +35 -0
  58. ot/executor/base.py +16 -0
  59. ot/executor/fence_processor.py +83 -0
  60. ot/executor/linter.py +142 -0
  61. ot/executor/pack_proxy.py +260 -0
  62. ot/executor/param_resolver.py +140 -0
  63. ot/executor/pep723.py +288 -0
  64. ot/executor/result_store.py +369 -0
  65. ot/executor/runner.py +496 -0
  66. ot/executor/simple.py +163 -0
  67. ot/executor/tool_loader.py +396 -0
  68. ot/executor/validator.py +398 -0
  69. ot/executor/worker_pool.py +388 -0
  70. ot/executor/worker_proxy.py +189 -0
  71. ot/http_client.py +145 -0
  72. ot/logging/__init__.py +37 -0
  73. ot/logging/config.py +315 -0
  74. ot/logging/entry.py +213 -0
  75. ot/logging/format.py +188 -0
  76. ot/logging/span.py +349 -0
  77. ot/meta.py +1555 -0
  78. ot/paths.py +453 -0
  79. ot/prompts.py +218 -0
  80. ot/proxy/__init__.py +21 -0
  81. ot/proxy/manager.py +396 -0
  82. ot/py.typed +0 -0
  83. ot/registry/__init__.py +189 -0
  84. ot/registry/models.py +57 -0
  85. ot/registry/parser.py +269 -0
  86. ot/registry/registry.py +413 -0
  87. ot/server.py +315 -0
  88. ot/shortcuts/__init__.py +15 -0
  89. ot/shortcuts/aliases.py +87 -0
  90. ot/shortcuts/snippets.py +258 -0
  91. ot/stats/__init__.py +35 -0
  92. ot/stats/html.py +250 -0
  93. ot/stats/jsonl_writer.py +283 -0
  94. ot/stats/reader.py +354 -0
  95. ot/stats/timing.py +57 -0
  96. ot/support.py +63 -0
  97. ot/tools.py +114 -0
  98. ot/utils/__init__.py +81 -0
  99. ot/utils/batch.py +161 -0
  100. ot/utils/cache.py +120 -0
  101. ot/utils/deps.py +403 -0
  102. ot/utils/exceptions.py +23 -0
  103. ot/utils/factory.py +179 -0
  104. ot/utils/format.py +65 -0
  105. ot/utils/http.py +202 -0
  106. ot/utils/platform.py +45 -0
  107. ot/utils/sanitize.py +130 -0
  108. ot/utils/truncate.py +69 -0
  109. ot_tools/__init__.py +4 -0
  110. ot_tools/_convert/__init__.py +12 -0
  111. ot_tools/_convert/excel.py +279 -0
  112. ot_tools/_convert/pdf.py +254 -0
  113. ot_tools/_convert/powerpoint.py +268 -0
  114. ot_tools/_convert/utils.py +358 -0
  115. ot_tools/_convert/word.py +283 -0
  116. ot_tools/brave_search.py +604 -0
  117. ot_tools/code_search.py +736 -0
  118. ot_tools/context7.py +495 -0
  119. ot_tools/convert.py +614 -0
  120. ot_tools/db.py +415 -0
  121. ot_tools/diagram.py +1604 -0
  122. ot_tools/diagram.yaml +167 -0
  123. ot_tools/excel.py +1372 -0
  124. ot_tools/file.py +1348 -0
  125. ot_tools/firecrawl.py +732 -0
  126. ot_tools/grounding_search.py +646 -0
  127. ot_tools/package.py +604 -0
  128. ot_tools/py.typed +0 -0
  129. ot_tools/ripgrep.py +544 -0
  130. ot_tools/scaffold.py +471 -0
  131. ot_tools/transform.py +213 -0
  132. ot_tools/web_fetch.py +384 -0
ot/registry/parser.py ADDED
@@ -0,0 +1,269 @@
1
+ """AST parsing utilities for extracting function information."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ from typing import Any
7
+
8
+ from docstring_parser import parse as parse_docstring_lib
9
+
10
+ from .models import ArgInfo, ToolInfo
11
+
12
+
13
+ def parse_function(
14
+ node: ast.FunctionDef, module: str, pack: str | None = None
15
+ ) -> ToolInfo:
16
+ """Extract information from a function AST node.
17
+
18
+ Args:
19
+ node: AST FunctionDef node.
20
+ module: Module path for the function.
21
+ pack: Optional pack name for namespace-qualified tool names.
22
+
23
+ Returns:
24
+ ToolInfo with extracted signature and docstring info.
25
+ """
26
+ # Extract signature (with pack-qualified name if pack is provided)
27
+ signature = extract_signature(node, pack=pack)
28
+
29
+ # Parse docstring
30
+ docstring = ast.get_docstring(node) or ""
31
+ doc_info = parse_docstring(docstring)
32
+
33
+ # Extract args
34
+ args = extract_args(node, doc_info.get("args", {}))
35
+
36
+ # Extract @tool decorator metadata if present
37
+ decorator_info = extract_tool_decorator(node)
38
+
39
+ # Decorator description overrides docstring if provided
40
+ description = decorator_info.get("description") or doc_info.get("description", "")
41
+
42
+ # Use pack-qualified name if pack is provided
43
+ qualified_name = f"{pack}.{node.name}" if pack else node.name
44
+
45
+ return ToolInfo(
46
+ name=qualified_name,
47
+ pack=pack,
48
+ module=module,
49
+ signature=signature,
50
+ description=description,
51
+ args=args,
52
+ returns=doc_info.get("returns", ""),
53
+ examples=decorator_info.get("examples", []),
54
+ tags=decorator_info.get("tags", []),
55
+ enabled=decorator_info.get("enabled", True),
56
+ deprecated=decorator_info.get("deprecated", False),
57
+ deprecated_message=decorator_info.get("deprecated_message"),
58
+ )
59
+
60
+
61
+ def extract_tool_decorator(node: ast.FunctionDef) -> dict[str, Any]:
62
+ """Extract metadata from @tool decorator if present.
63
+
64
+ Args:
65
+ node: AST FunctionDef node.
66
+
67
+ Returns:
68
+ Dict with decorator metadata (description, examples, tags, enabled, deprecated).
69
+ """
70
+ result: dict[str, Any] = {}
71
+
72
+ for decorator in node.decorator_list:
73
+ # Look for @tool(...) decorator
74
+ if isinstance(decorator, ast.Call):
75
+ func = decorator.func
76
+ if isinstance(func, ast.Name) and func.id == "tool":
77
+ # Extract keyword arguments
78
+ for keyword in decorator.keywords:
79
+ if keyword.arg == "description":
80
+ if isinstance(keyword.value, ast.Constant):
81
+ result["description"] = keyword.value.value
82
+ elif keyword.arg == "examples":
83
+ if isinstance(keyword.value, ast.List):
84
+ result["examples"] = [
85
+ elt.value
86
+ for elt in keyword.value.elts
87
+ if isinstance(elt, ast.Constant)
88
+ ]
89
+ elif keyword.arg == "tags":
90
+ if isinstance(keyword.value, ast.List):
91
+ result["tags"] = [
92
+ elt.value
93
+ for elt in keyword.value.elts
94
+ if isinstance(elt, ast.Constant)
95
+ ]
96
+ elif keyword.arg == "enabled":
97
+ if isinstance(keyword.value, ast.Constant):
98
+ result["enabled"] = keyword.value.value
99
+ elif keyword.arg == "deprecated" and isinstance(
100
+ keyword.value, ast.Constant
101
+ ):
102
+ result["deprecated"] = keyword.value.value
103
+ elif keyword.arg == "deprecated_message" and isinstance(
104
+ keyword.value, ast.Constant
105
+ ):
106
+ result["deprecated_message"] = keyword.value.value
107
+ break
108
+
109
+ return result
110
+
111
+
112
+ def extract_signature(node: ast.FunctionDef, pack: str | None = None) -> str:
113
+ """Extract function signature string.
114
+
115
+ Args:
116
+ node: AST FunctionDef node.
117
+ pack: Optional pack name for namespace-qualified tool names.
118
+
119
+ Returns:
120
+ Signature string like 'pack.func_name(arg: type = default) -> return_type'
121
+ """
122
+ parts: list[str] = []
123
+
124
+ # Process regular args
125
+ args = node.args
126
+ defaults = args.defaults
127
+ num_defaults = len(defaults)
128
+ num_args = len(args.args)
129
+
130
+ for i, arg in enumerate(args.args):
131
+ arg_str = arg.arg
132
+ # Add type annotation
133
+ if arg.annotation:
134
+ arg_str += f": {annotation_to_str(arg.annotation)}"
135
+
136
+ # Add default value if present
137
+ default_idx = i - (num_args - num_defaults)
138
+ if default_idx >= 0:
139
+ default = defaults[default_idx]
140
+ arg_str += f" = {value_to_str(default)}"
141
+
142
+ parts.append(arg_str)
143
+
144
+ # Process keyword-only args
145
+ kw_defaults = args.kw_defaults
146
+ for i, arg in enumerate(args.kwonlyargs):
147
+ arg_str = arg.arg
148
+ if arg.annotation:
149
+ arg_str += f": {annotation_to_str(arg.annotation)}"
150
+ if kw_defaults[i] is not None:
151
+ arg_str += f" = {value_to_str(kw_defaults[i])}"
152
+ parts.append(arg_str)
153
+
154
+ # Build signature with pack-qualified name if pack is provided
155
+ func_name = f"{pack}.{node.name}" if pack else node.name
156
+ sig = f"{func_name}({', '.join(parts)})"
157
+
158
+ # Add return type
159
+ if node.returns:
160
+ sig += f" -> {annotation_to_str(node.returns)}"
161
+
162
+ return sig
163
+
164
+
165
+ def annotation_to_str(node: ast.expr) -> str:
166
+ """Convert AST annotation node to string.
167
+
168
+ Args:
169
+ node: AST expression node representing a type annotation.
170
+
171
+ Returns:
172
+ String representation of the type.
173
+ """
174
+ return ast.unparse(node)
175
+
176
+
177
+ def value_to_str(node: ast.expr | None) -> str:
178
+ """Convert AST value node to string representation.
179
+
180
+ Args:
181
+ node: AST expression node representing a default value.
182
+
183
+ Returns:
184
+ String representation of the value.
185
+ """
186
+ if node is None:
187
+ return "None"
188
+ return ast.unparse(node)
189
+
190
+
191
+ def extract_args(
192
+ node: ast.FunctionDef, docstring_args: dict[str, str]
193
+ ) -> list[ArgInfo]:
194
+ """Extract argument information from function node.
195
+
196
+ Args:
197
+ node: AST FunctionDef node.
198
+ docstring_args: Dict of arg_name -> description from docstring.
199
+
200
+ Returns:
201
+ List of ArgInfo objects.
202
+ """
203
+ result: list[ArgInfo] = []
204
+ args = node.args
205
+ defaults = args.defaults
206
+ num_defaults = len(defaults)
207
+ num_args = len(args.args)
208
+
209
+ for i, arg in enumerate(args.args):
210
+ type_str = "Any"
211
+ if arg.annotation:
212
+ type_str = annotation_to_str(arg.annotation)
213
+
214
+ default_str: str | None = None
215
+ default_idx = i - (num_args - num_defaults)
216
+ if default_idx >= 0:
217
+ default_str = value_to_str(defaults[default_idx])
218
+
219
+ result.append(
220
+ ArgInfo(
221
+ name=arg.arg,
222
+ type=type_str,
223
+ default=default_str,
224
+ description=docstring_args.get(arg.arg, ""),
225
+ )
226
+ )
227
+
228
+ # Keyword-only args
229
+ kw_defaults = args.kw_defaults
230
+ for i, arg in enumerate(args.kwonlyargs):
231
+ type_str = "Any"
232
+ if arg.annotation:
233
+ type_str = annotation_to_str(arg.annotation)
234
+
235
+ default_str = None
236
+ if kw_defaults[i] is not None:
237
+ default_str = value_to_str(kw_defaults[i])
238
+
239
+ result.append(
240
+ ArgInfo(
241
+ name=arg.arg,
242
+ type=type_str,
243
+ default=default_str,
244
+ description=docstring_args.get(arg.arg, ""),
245
+ )
246
+ )
247
+
248
+ return result
249
+
250
+
251
+ def parse_docstring(docstring: str) -> dict[str, Any]:
252
+ """Parse Google-style docstring.
253
+
254
+ Args:
255
+ docstring: The docstring to parse.
256
+
257
+ Returns:
258
+ Dict with 'description', 'args', and 'returns' keys.
259
+ """
260
+ if not docstring:
261
+ return {"description": "", "args": {}, "returns": ""}
262
+
263
+ parsed = parse_docstring_lib(docstring)
264
+
265
+ return {
266
+ "description": parsed.short_description or "",
267
+ "args": {p.arg_name: p.description or "" for p in parsed.params},
268
+ "returns": parsed.returns.description if parsed.returns else "",
269
+ }
@@ -0,0 +1,413 @@
1
+ """Tool registry class for auto-discovering Python tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import json
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, Any, cast
9
+
10
+ from loguru import logger
11
+
12
+ from ot.logging import LogEntry
13
+
14
+ from .parser import parse_function
15
+
16
+ if TYPE_CHECKING:
17
+ from .models import ToolInfo
18
+
19
+
20
+ class ToolRegistry:
21
+ """Registry for auto-discovered Python tools.
22
+
23
+ Scans a directory for Python files and extracts function information
24
+ using AST parsing (no execution required).
25
+ """
26
+
27
+ def __init__(self, tools_path: Path | None = None) -> None:
28
+ """Initialize the registry.
29
+
30
+ Args:
31
+ tools_path: Path to tools directory. Defaults to 'src/ot_tools/'.
32
+ """
33
+ self._tools_path = tools_path or Path("src/ot_tools")
34
+ self._tools: dict[str, ToolInfo] = {}
35
+
36
+ @property
37
+ def tools_path(self) -> Path:
38
+ """Return the tools directory path."""
39
+ return self._tools_path
40
+
41
+ @property
42
+ def tools(self) -> dict[str, ToolInfo]:
43
+ """Return dictionary of registered tools by name."""
44
+ return self._tools.copy()
45
+
46
+ def scan_files(self, files: list[Path]) -> list[ToolInfo]:
47
+ """Scan specific Python files and register public functions.
48
+
49
+ Args:
50
+ files: List of Python files to scan.
51
+
52
+ Returns:
53
+ List of registered ToolInfo objects.
54
+ """
55
+ # Track previous state to detect changes
56
+ previous_tools = dict(self._tools)
57
+ self._tools.clear()
58
+
59
+ for py_file in files:
60
+ if not py_file.exists():
61
+ logger.debug(
62
+ LogEntry(span="registry.scan", path=str(py_file), exists=False)
63
+ )
64
+ continue
65
+ try:
66
+ tools = self.parse_file(py_file)
67
+ for tool in tools:
68
+ if tool.name in self._tools:
69
+ logger.warning(
70
+ LogEntry(
71
+ span="registry.duplicate",
72
+ tool=tool.name,
73
+ file=str(py_file),
74
+ )
75
+ )
76
+ self._tools[tool.name] = tool
77
+ except SyntaxError as e:
78
+ logger.warning(
79
+ LogEntry(
80
+ span="registry.error",
81
+ file=str(py_file),
82
+ error=str(e),
83
+ errorType="SyntaxError",
84
+ )
85
+ )
86
+ except Exception as e:
87
+ logger.warning(
88
+ LogEntry(
89
+ span="registry.error",
90
+ file=str(py_file),
91
+ error=str(e),
92
+ errorType=type(e).__name__,
93
+ )
94
+ )
95
+
96
+ # Detect added/changed/removed tools
97
+ added: list[str] = []
98
+ changed: list[str] = []
99
+ removed: list[str] = []
100
+
101
+ for name, tool in self._tools.items():
102
+ if name not in previous_tools:
103
+ added.append(name)
104
+ elif tool.signature != previous_tools[name].signature:
105
+ changed.append(name)
106
+
107
+ for name in previous_tools:
108
+ if name not in self._tools:
109
+ removed.append(name)
110
+
111
+ # Only log if this is first scan or there are changes
112
+ if not previous_tools:
113
+ # First scan - log all tools
114
+ logger.info(
115
+ LogEntry(
116
+ span="registry.ready",
117
+ path="tools",
118
+ toolCount=len(self._tools),
119
+ tools=[tool.name for tool in self._tools.values()],
120
+ )
121
+ )
122
+ elif added or changed or removed:
123
+ # Subsequent scan with changes
124
+ logger.info(
125
+ LogEntry(
126
+ span="registry.changed",
127
+ path="tools",
128
+ toolCount=len(self._tools),
129
+ added=added if added else None,
130
+ changed=changed if changed else None,
131
+ removed=removed if removed else None,
132
+ )
133
+ )
134
+
135
+ return list(self._tools.values())
136
+
137
+ def scan_directory(self, path: Path | None = None) -> list[ToolInfo]:
138
+ """Scan directory for Python files and register public functions.
139
+
140
+ Args:
141
+ path: Directory to scan. Defaults to self.tools_path.
142
+
143
+ Returns:
144
+ List of registered ToolInfo objects.
145
+ """
146
+ scan_path = path or self._tools_path
147
+
148
+ if not scan_path.exists():
149
+ logger.debug(
150
+ LogEntry(span="registry.scan", path=str(scan_path), exists=False)
151
+ )
152
+ return []
153
+
154
+ if not scan_path.is_dir():
155
+ logger.warning(
156
+ LogEntry(span="registry.scan", path=str(scan_path), isDir=False)
157
+ )
158
+ return []
159
+
160
+ py_files = list(scan_path.glob("*.py"))
161
+ return self.scan_files(py_files)
162
+
163
+ def parse_file(self, path: Path) -> list[ToolInfo]:
164
+ """Parse a Python file and extract public function information.
165
+
166
+ Args:
167
+ path: Path to Python file.
168
+
169
+ Returns:
170
+ List of ToolInfo for exported functions (respects __all__ and pack).
171
+ """
172
+ source = path.read_text(encoding="utf-8")
173
+ tree = ast.parse(source, filename=str(path))
174
+
175
+ # Module name: tools/gold_prices.py -> tools.gold_prices
176
+ module_name = f"tools.{path.stem}"
177
+
178
+ # Extract pack variable (e.g., pack = "code")
179
+ pack = self._extract_pack(tree)
180
+
181
+ # Extract __all__ list if present
182
+ export_names = self._extract_all(tree)
183
+
184
+ # Extract __ot_requires__ dependencies
185
+ requires = self._extract_requires(tree)
186
+
187
+ tools: list[ToolInfo] = []
188
+ for node in ast.walk(tree):
189
+ if isinstance(node, ast.FunctionDef):
190
+ # Skip private functions
191
+ if node.name.startswith("_"):
192
+ continue
193
+
194
+ # If __all__ is defined, only include exported functions
195
+ if export_names is not None and node.name not in export_names:
196
+ continue
197
+
198
+ tool = parse_function(node, module_name, pack=pack)
199
+ # Attach module-level requires to tool
200
+ if requires:
201
+ tool.requires = requires
202
+ tools.append(tool)
203
+
204
+ return tools
205
+
206
+ def _extract_pack(self, tree: ast.Module) -> str | None:
207
+ """Extract pack variable from module AST.
208
+
209
+ Looks for module-level assignment: pack = "pack_name"
210
+
211
+ Args:
212
+ tree: Parsed AST module.
213
+
214
+ Returns:
215
+ Pack name string if found, None otherwise.
216
+ """
217
+ for node in tree.body:
218
+ if isinstance(node, ast.Assign):
219
+ for target in node.targets:
220
+ if (
221
+ isinstance(target, ast.Name)
222
+ and target.id == "pack"
223
+ and isinstance(node.value, ast.Constant)
224
+ and isinstance(node.value.value, str)
225
+ ):
226
+ return node.value.value
227
+ return None
228
+
229
+ def _extract_all(self, tree: ast.Module) -> set[str] | None:
230
+ """Extract __all__ list from module AST.
231
+
232
+ Looks for module-level assignment: __all__ = ["func1", "func2"]
233
+
234
+ Args:
235
+ tree: Parsed AST module.
236
+
237
+ Returns:
238
+ Set of exported names if __all__ is defined, None otherwise.
239
+ """
240
+ for node in tree.body:
241
+ if isinstance(node, ast.Assign):
242
+ for target in node.targets:
243
+ if (
244
+ isinstance(target, ast.Name)
245
+ and target.id == "__all__"
246
+ and isinstance(node.value, ast.List)
247
+ ):
248
+ names: set[str] = set()
249
+ for elt in node.value.elts:
250
+ if isinstance(elt, ast.Constant) and isinstance(
251
+ elt.value, str
252
+ ):
253
+ names.add(elt.value)
254
+ return names
255
+ return None
256
+
257
+ def _extract_requires(
258
+ self, tree: ast.Module
259
+ ) -> dict[str, list[tuple[str, ...] | dict[str, str] | str]] | None:
260
+ """Extract __ot_requires__ dict from module AST.
261
+
262
+ Looks for module-level assignment: __ot_requires__ = {"cli": [...], "lib": [...]}
263
+
264
+ Args:
265
+ tree: Parsed AST module.
266
+
267
+ Returns:
268
+ Dict with 'cli' and 'lib' dependency lists if found, None otherwise.
269
+ """
270
+ for node in tree.body:
271
+ if isinstance(node, ast.Assign):
272
+ for target in node.targets:
273
+ if isinstance(target, ast.Name) and target.id == "__ot_requires__":
274
+ try:
275
+ # Safely evaluate the dict literal
276
+ result = ast.literal_eval(ast.unparse(node.value))
277
+ return cast(
278
+ "dict[str, list[tuple[str, ...] | dict[str, str] | str]]",
279
+ result,
280
+ )
281
+ except (ValueError, TypeError):
282
+ return None
283
+ return None
284
+
285
+ def format_json(self) -> str:
286
+ """Format registry as JSON for LLM context.
287
+
288
+ Returns:
289
+ JSON string with tool definitions.
290
+ """
291
+ if not self._tools:
292
+ return '{"tools":[]}'
293
+
294
+ tools_list: list[dict[str, Any]] = []
295
+ for tool in self._tools.values():
296
+ tool_dict: dict[str, Any] = {
297
+ "name": tool.name,
298
+ "module": tool.module,
299
+ "signature": tool.signature,
300
+ }
301
+ if tool.description:
302
+ tool_dict["description"] = tool.description
303
+ if tool.args:
304
+ tool_dict["args"] = [
305
+ {
306
+ k: v
307
+ for k, v in {
308
+ "name": arg.name,
309
+ "type": arg.type,
310
+ "default": arg.default,
311
+ "description": arg.description if arg.description else None,
312
+ }.items()
313
+ if v is not None
314
+ }
315
+ for arg in tool.args
316
+ ]
317
+ if tool.returns:
318
+ tool_dict["returns"] = tool.returns
319
+
320
+ tools_list.append(tool_dict)
321
+
322
+ return json.dumps({"tools": tools_list}, ensure_ascii=False, indent=2)
323
+
324
+ def format_summary(self) -> str:
325
+ """Format registry summary for CLI display.
326
+
327
+ Returns:
328
+ Human-readable summary of registered tools.
329
+ """
330
+ if not self._tools:
331
+ return "No tools registered."
332
+
333
+ lines = [f"Registered tools ({len(self._tools)}):"]
334
+ for tool in self._tools.values():
335
+ lines.append(f" - {tool.signature}")
336
+ if tool.description:
337
+ lines.append(f" {tool.description}")
338
+
339
+ return "\n".join(lines)
340
+
341
+ def get_tool(self, name: str) -> ToolInfo | None:
342
+ """Get tool by name.
343
+
344
+ Args:
345
+ name: Tool function name.
346
+
347
+ Returns:
348
+ ToolInfo if found, None otherwise.
349
+ """
350
+ return self._tools.get(name)
351
+
352
+ def register_tool(self, tool: ToolInfo) -> None:
353
+ """Register a tool programmatically.
354
+
355
+ Used for tools that aren't loaded from files (e.g., ot pack tools).
356
+
357
+ Args:
358
+ tool: ToolInfo object to register.
359
+ """
360
+ self._tools[tool.name] = tool
361
+
362
+ def describe_tool(self, name: str) -> str:
363
+ """Get detailed description of a tool.
364
+
365
+ Args:
366
+ name: Tool function name.
367
+
368
+ Returns:
369
+ Detailed tool description string.
370
+ """
371
+ tool = self._tools.get(name)
372
+ if not tool:
373
+ return f"Tool '{name}' not found."
374
+
375
+ lines = [
376
+ f"Tool: {tool.name}",
377
+ f"Module: {tool.module}",
378
+ f"Signature: {tool.signature}",
379
+ ]
380
+
381
+ # Show deprecation warning first
382
+ if tool.deprecated:
383
+ msg = tool.deprecated_message or "This tool is deprecated"
384
+ lines.append(f"DEPRECATED: {msg}")
385
+
386
+ if not tool.enabled:
387
+ lines.append("Status: DISABLED")
388
+
389
+ if tool.description:
390
+ lines.append(f"Description: {tool.description}")
391
+
392
+ if tool.tags:
393
+ lines.append(f"Tags: {', '.join(tool.tags)}")
394
+
395
+ if tool.args:
396
+ lines.append("Arguments:")
397
+ for arg in tool.args:
398
+ arg_line = f" - {arg.name}: {arg.type}"
399
+ if arg.default is not None:
400
+ arg_line += f" = {arg.default}"
401
+ lines.append(arg_line)
402
+ if arg.description:
403
+ lines.append(f" {arg.description}")
404
+
405
+ if tool.returns:
406
+ lines.append(f"Returns: {tool.returns}")
407
+
408
+ if tool.examples:
409
+ lines.append("Examples:")
410
+ for example in tool.examples:
411
+ lines.append(f" {example}")
412
+
413
+ return "\n".join(lines)