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.
- bench/__init__.py +5 -0
- bench/cli.py +69 -0
- bench/harness/__init__.py +66 -0
- bench/harness/client.py +692 -0
- bench/harness/config.py +397 -0
- bench/harness/csv_writer.py +109 -0
- bench/harness/evaluate.py +512 -0
- bench/harness/metrics.py +283 -0
- bench/harness/runner.py +899 -0
- bench/py.typed +0 -0
- bench/reporter.py +629 -0
- bench/run.py +487 -0
- bench/secrets.py +101 -0
- bench/utils.py +16 -0
- onetool/__init__.py +4 -0
- onetool/cli.py +391 -0
- onetool/py.typed +0 -0
- onetool_mcp-1.0.0b1.dist-info/METADATA +163 -0
- onetool_mcp-1.0.0b1.dist-info/RECORD +132 -0
- onetool_mcp-1.0.0b1.dist-info/WHEEL +4 -0
- onetool_mcp-1.0.0b1.dist-info/entry_points.txt +3 -0
- onetool_mcp-1.0.0b1.dist-info/licenses/LICENSE.txt +687 -0
- onetool_mcp-1.0.0b1.dist-info/licenses/NOTICE.txt +64 -0
- ot/__init__.py +37 -0
- ot/__main__.py +6 -0
- ot/_cli.py +107 -0
- ot/_tui.py +53 -0
- ot/config/__init__.py +46 -0
- ot/config/defaults/bench.yaml +4 -0
- ot/config/defaults/diagram-templates/api-flow.mmd +33 -0
- ot/config/defaults/diagram-templates/c4-context.puml +30 -0
- ot/config/defaults/diagram-templates/class-diagram.mmd +87 -0
- ot/config/defaults/diagram-templates/feature-mindmap.mmd +70 -0
- ot/config/defaults/diagram-templates/microservices.d2 +81 -0
- ot/config/defaults/diagram-templates/project-gantt.mmd +37 -0
- ot/config/defaults/diagram-templates/state-machine.mmd +42 -0
- ot/config/defaults/onetool.yaml +25 -0
- ot/config/defaults/prompts.yaml +97 -0
- ot/config/defaults/servers.yaml +7 -0
- ot/config/defaults/snippets.yaml +4 -0
- ot/config/defaults/tool_templates/__init__.py +7 -0
- ot/config/defaults/tool_templates/extension.py +52 -0
- ot/config/defaults/tool_templates/isolated.py +61 -0
- ot/config/dynamic.py +121 -0
- ot/config/global_templates/__init__.py +2 -0
- ot/config/global_templates/bench-secrets-template.yaml +6 -0
- ot/config/global_templates/bench.yaml +9 -0
- ot/config/global_templates/onetool.yaml +27 -0
- ot/config/global_templates/secrets-template.yaml +44 -0
- ot/config/global_templates/servers.yaml +18 -0
- ot/config/global_templates/snippets.yaml +235 -0
- ot/config/loader.py +1087 -0
- ot/config/mcp.py +145 -0
- ot/config/secrets.py +190 -0
- ot/config/tool_config.py +125 -0
- ot/decorators.py +116 -0
- ot/executor/__init__.py +35 -0
- ot/executor/base.py +16 -0
- ot/executor/fence_processor.py +83 -0
- ot/executor/linter.py +142 -0
- ot/executor/pack_proxy.py +260 -0
- ot/executor/param_resolver.py +140 -0
- ot/executor/pep723.py +288 -0
- ot/executor/result_store.py +369 -0
- ot/executor/runner.py +496 -0
- ot/executor/simple.py +163 -0
- ot/executor/tool_loader.py +396 -0
- ot/executor/validator.py +398 -0
- ot/executor/worker_pool.py +388 -0
- ot/executor/worker_proxy.py +189 -0
- ot/http_client.py +145 -0
- ot/logging/__init__.py +37 -0
- ot/logging/config.py +315 -0
- ot/logging/entry.py +213 -0
- ot/logging/format.py +188 -0
- ot/logging/span.py +349 -0
- ot/meta.py +1555 -0
- ot/paths.py +453 -0
- ot/prompts.py +218 -0
- ot/proxy/__init__.py +21 -0
- ot/proxy/manager.py +396 -0
- ot/py.typed +0 -0
- ot/registry/__init__.py +189 -0
- ot/registry/models.py +57 -0
- ot/registry/parser.py +269 -0
- ot/registry/registry.py +413 -0
- ot/server.py +315 -0
- ot/shortcuts/__init__.py +15 -0
- ot/shortcuts/aliases.py +87 -0
- ot/shortcuts/snippets.py +258 -0
- ot/stats/__init__.py +35 -0
- ot/stats/html.py +250 -0
- ot/stats/jsonl_writer.py +283 -0
- ot/stats/reader.py +354 -0
- ot/stats/timing.py +57 -0
- ot/support.py +63 -0
- ot/tools.py +114 -0
- ot/utils/__init__.py +81 -0
- ot/utils/batch.py +161 -0
- ot/utils/cache.py +120 -0
- ot/utils/deps.py +403 -0
- ot/utils/exceptions.py +23 -0
- ot/utils/factory.py +179 -0
- ot/utils/format.py +65 -0
- ot/utils/http.py +202 -0
- ot/utils/platform.py +45 -0
- ot/utils/sanitize.py +130 -0
- ot/utils/truncate.py +69 -0
- ot_tools/__init__.py +4 -0
- ot_tools/_convert/__init__.py +12 -0
- ot_tools/_convert/excel.py +279 -0
- ot_tools/_convert/pdf.py +254 -0
- ot_tools/_convert/powerpoint.py +268 -0
- ot_tools/_convert/utils.py +358 -0
- ot_tools/_convert/word.py +283 -0
- ot_tools/brave_search.py +604 -0
- ot_tools/code_search.py +736 -0
- ot_tools/context7.py +495 -0
- ot_tools/convert.py +614 -0
- ot_tools/db.py +415 -0
- ot_tools/diagram.py +1604 -0
- ot_tools/diagram.yaml +167 -0
- ot_tools/excel.py +1372 -0
- ot_tools/file.py +1348 -0
- ot_tools/firecrawl.py +732 -0
- ot_tools/grounding_search.py +646 -0
- ot_tools/package.py +604 -0
- ot_tools/py.typed +0 -0
- ot_tools/ripgrep.py +544 -0
- ot_tools/scaffold.py +471 -0
- ot_tools/transform.py +213 -0
- ot_tools/web_fetch.py +384 -0
ot/server.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""FastMCP server implementation with a single 'run' tool.
|
|
2
|
+
|
|
3
|
+
The agent generates function call syntax with __ot prefix:
|
|
4
|
+
__ot context7.search(query="next.js")
|
|
5
|
+
__ot context7.doc(library_key="vercel/next.js", topic="routing")
|
|
6
|
+
__ot `demo.upper(text="hello")`
|
|
7
|
+
|
|
8
|
+
Or Python code blocks:
|
|
9
|
+
__ot
|
|
10
|
+
```python
|
|
11
|
+
metals = ["Gold", "Silver", "Bronze"]
|
|
12
|
+
results = {}
|
|
13
|
+
for metal in metals:
|
|
14
|
+
results[metal] = brave.web_search(query=f"{metal} price", count=3)
|
|
15
|
+
return results
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Or direct MCP calls:
|
|
19
|
+
mcp__onetool__run(command='brave.web_search(query="test")')
|
|
20
|
+
|
|
21
|
+
Supported prefixes: __ot, __ot__run, __onetool, __onetool__run, mcp__onetool__run
|
|
22
|
+
Note: mcp__ot__run is NOT valid.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import time
|
|
28
|
+
from contextlib import asynccontextmanager
|
|
29
|
+
from typing import TYPE_CHECKING, Any
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from collections.abc import AsyncIterator
|
|
33
|
+
|
|
34
|
+
from fastmcp import Context, FastMCP
|
|
35
|
+
from loguru import logger
|
|
36
|
+
|
|
37
|
+
from ot.config.loader import get_config
|
|
38
|
+
from ot.executor import SimpleExecutor, execute_command
|
|
39
|
+
from ot.executor.runner import prepare_command
|
|
40
|
+
|
|
41
|
+
# Import logging first to remove Loguru's default console handler
|
|
42
|
+
from ot.logging import LogSpan, configure_logging
|
|
43
|
+
from ot.prompts import get_prompts, get_tool_description, get_tool_examples
|
|
44
|
+
from ot.proxy import get_proxy_manager
|
|
45
|
+
from ot.registry import get_registry
|
|
46
|
+
from ot.stats import (
|
|
47
|
+
JsonlStatsWriter,
|
|
48
|
+
get_client_name,
|
|
49
|
+
set_stats_writer,
|
|
50
|
+
)
|
|
51
|
+
from ot.support import get_startup_message
|
|
52
|
+
|
|
53
|
+
_config = get_config()
|
|
54
|
+
|
|
55
|
+
# Initialize logging to serve.log
|
|
56
|
+
configure_logging(log_name="serve")
|
|
57
|
+
|
|
58
|
+
# Global stats writer (unified JSONL for both run and tool stats)
|
|
59
|
+
_stats_writer: JsonlStatsWriter | None = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _get_instructions() -> str:
|
|
63
|
+
"""Generate MCP server instructions.
|
|
64
|
+
|
|
65
|
+
Note: Tool descriptions are NOT included here - they come through
|
|
66
|
+
the MCP tool definitions which the client converts to function calling format.
|
|
67
|
+
"""
|
|
68
|
+
# Load prompts from config (loaded via include: or inline prompts:)
|
|
69
|
+
prompts = get_prompts(inline_prompts=_config.prompts)
|
|
70
|
+
|
|
71
|
+
# Return instructions from prompts.yaml
|
|
72
|
+
return prompts.instructions.strip()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@asynccontextmanager
|
|
76
|
+
async def _lifespan(_server: FastMCP) -> AsyncIterator[None]:
|
|
77
|
+
"""Manage server lifecycle - startup and shutdown."""
|
|
78
|
+
global _stats_writer
|
|
79
|
+
|
|
80
|
+
with LogSpan(span="mcp.server.start") as start_span:
|
|
81
|
+
# Startup: connect to proxy MCP servers
|
|
82
|
+
proxy = get_proxy_manager()
|
|
83
|
+
if _config.servers:
|
|
84
|
+
with LogSpan(span="server.startup.proxy", serverCount=len(_config.servers)):
|
|
85
|
+
await proxy.connect(_config.servers)
|
|
86
|
+
start_span.add("proxyCount", len(_config.servers))
|
|
87
|
+
|
|
88
|
+
# Log tool count from registry
|
|
89
|
+
registry = get_registry()
|
|
90
|
+
start_span.add("toolCount", len(registry.tools))
|
|
91
|
+
|
|
92
|
+
# Startup: initialize unified JSONL stats writer if enabled
|
|
93
|
+
if _config.stats.enabled:
|
|
94
|
+
stats_path = _config.get_stats_file_path()
|
|
95
|
+
flush_interval = _config.stats.flush_interval_seconds
|
|
96
|
+
|
|
97
|
+
_stats_writer = JsonlStatsWriter(
|
|
98
|
+
path=stats_path,
|
|
99
|
+
flush_interval=flush_interval,
|
|
100
|
+
)
|
|
101
|
+
await _stats_writer.start()
|
|
102
|
+
set_stats_writer(_stats_writer)
|
|
103
|
+
|
|
104
|
+
start_span.add("statsEnabled", True)
|
|
105
|
+
start_span.add("statsPath", str(stats_path))
|
|
106
|
+
|
|
107
|
+
# Log support message
|
|
108
|
+
logger.info(get_startup_message())
|
|
109
|
+
|
|
110
|
+
yield
|
|
111
|
+
|
|
112
|
+
with LogSpan(span="mcp.server.stop") as stop_span:
|
|
113
|
+
# Shutdown: stop stats writer
|
|
114
|
+
if _stats_writer is not None:
|
|
115
|
+
await _stats_writer.stop()
|
|
116
|
+
set_stats_writer(None)
|
|
117
|
+
stop_span.add("statsStopped", True)
|
|
118
|
+
|
|
119
|
+
# Shutdown: disconnect from proxy MCP servers
|
|
120
|
+
if proxy.servers:
|
|
121
|
+
with LogSpan(span="server.shutdown.proxy", serverCount=len(proxy.servers)):
|
|
122
|
+
await proxy.shutdown()
|
|
123
|
+
stop_span.add("proxyCount", len(proxy.servers))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
mcp = FastMCP(
|
|
127
|
+
name="ot",
|
|
128
|
+
instructions=_get_instructions(),
|
|
129
|
+
lifespan=_lifespan,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# =============================================================================
|
|
134
|
+
# MCP Logging - Dynamic log level control
|
|
135
|
+
# =============================================================================
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@mcp._mcp_server.set_logging_level() # type: ignore[no-untyped-call,untyped-decorator]
|
|
139
|
+
async def handle_set_logging_level(level: str) -> None:
|
|
140
|
+
"""Handle logging/setLevel requests from MCP clients.
|
|
141
|
+
|
|
142
|
+
Allows clients to dynamically change the server's log level.
|
|
143
|
+
"""
|
|
144
|
+
# Map MCP LoggingLevel to Python logging levels
|
|
145
|
+
level_map = {
|
|
146
|
+
"debug": "DEBUG",
|
|
147
|
+
"info": "INFO",
|
|
148
|
+
"notice": "INFO", # MCP notice -> INFO
|
|
149
|
+
"warning": "WARNING",
|
|
150
|
+
"error": "ERROR",
|
|
151
|
+
"critical": "CRITICAL",
|
|
152
|
+
"alert": "CRITICAL", # MCP alert -> CRITICAL
|
|
153
|
+
"emergency": "CRITICAL", # MCP emergency -> CRITICAL
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
log_level = level_map.get(str(level).lower(), "INFO")
|
|
157
|
+
logger.info(f"Log level change requested: {level} -> {log_level}")
|
|
158
|
+
|
|
159
|
+
# Reconfigure logging with new level
|
|
160
|
+
configure_logging(log_name="serve", level=log_level)
|
|
161
|
+
logger.info(f"Logging reconfigured at level {log_level}")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# =============================================================================
|
|
165
|
+
# MCP Resources - Tool discoverability
|
|
166
|
+
# =============================================================================
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@mcp.resource("ot://tools")
|
|
170
|
+
def list_tools_resource() -> list[dict[str, str]]:
|
|
171
|
+
"""List all available tools with signatures and descriptions."""
|
|
172
|
+
registry = get_registry()
|
|
173
|
+
prompts = get_prompts(inline_prompts=_config.prompts)
|
|
174
|
+
|
|
175
|
+
tools_list = []
|
|
176
|
+
|
|
177
|
+
# Add local tools
|
|
178
|
+
for tool in registry.tools.values():
|
|
179
|
+
desc = get_tool_description(prompts, tool.name, tool.description)
|
|
180
|
+
tools_list.append(
|
|
181
|
+
{
|
|
182
|
+
"name": tool.name,
|
|
183
|
+
"signature": tool.signature,
|
|
184
|
+
"description": desc,
|
|
185
|
+
}
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Add proxied tools
|
|
189
|
+
proxy = get_proxy_manager()
|
|
190
|
+
for proxy_tool in proxy.list_tools():
|
|
191
|
+
tools_list.append(
|
|
192
|
+
{
|
|
193
|
+
"name": f"{proxy_tool.server}.{proxy_tool.name}",
|
|
194
|
+
"signature": f"{proxy_tool.server}.{proxy_tool.name}(...)",
|
|
195
|
+
"description": f"[proxy] {proxy_tool.description}",
|
|
196
|
+
}
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
return tools_list
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@mcp.resource("ot://tool/{name}")
|
|
203
|
+
def get_tool_resource(name: str) -> dict[str, Any]:
|
|
204
|
+
"""Get detailed information about a specific tool."""
|
|
205
|
+
registry = get_registry()
|
|
206
|
+
prompts = get_prompts(inline_prompts=_config.prompts)
|
|
207
|
+
|
|
208
|
+
tool = registry.tools.get(name)
|
|
209
|
+
if not tool:
|
|
210
|
+
return {"error": f"Tool '{name}' not found"}
|
|
211
|
+
|
|
212
|
+
desc = get_tool_description(prompts, tool.name, tool.description)
|
|
213
|
+
examples = get_tool_examples(prompts, tool.name)
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
"name": tool.name,
|
|
217
|
+
"module": tool.module,
|
|
218
|
+
"signature": tool.signature,
|
|
219
|
+
"description": desc,
|
|
220
|
+
"args": [
|
|
221
|
+
{
|
|
222
|
+
"name": arg.name,
|
|
223
|
+
"type": arg.type,
|
|
224
|
+
"default": arg.default,
|
|
225
|
+
"description": arg.description,
|
|
226
|
+
}
|
|
227
|
+
for arg in tool.args
|
|
228
|
+
],
|
|
229
|
+
"returns": tool.returns,
|
|
230
|
+
"examples": examples or tool.examples,
|
|
231
|
+
"tags": tool.tags,
|
|
232
|
+
"enabled": tool.enabled,
|
|
233
|
+
"deprecated": tool.deprecated,
|
|
234
|
+
"deprecated_message": tool.deprecated_message,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# Global executor instance
|
|
239
|
+
_executor: SimpleExecutor | None = None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _get_executor() -> SimpleExecutor:
|
|
243
|
+
"""Get or create the executor."""
|
|
244
|
+
global _executor
|
|
245
|
+
|
|
246
|
+
if _executor is None:
|
|
247
|
+
_executor = SimpleExecutor()
|
|
248
|
+
|
|
249
|
+
return _executor
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _get_run_description() -> str:
|
|
253
|
+
"""Get run tool description from prompts config.
|
|
254
|
+
|
|
255
|
+
Raises:
|
|
256
|
+
ValueError: If run tool description not found in prompts.yaml
|
|
257
|
+
"""
|
|
258
|
+
prompts = get_prompts(inline_prompts=_config.prompts)
|
|
259
|
+
desc = get_tool_description(prompts, "run", "")
|
|
260
|
+
if not desc:
|
|
261
|
+
raise ValueError("Missing 'run' tool description in prompts.yaml")
|
|
262
|
+
return desc
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@mcp.tool(
|
|
266
|
+
description=_get_run_description(),
|
|
267
|
+
annotations={
|
|
268
|
+
"title": "Execute OneTool Command",
|
|
269
|
+
"readOnlyHint": False,
|
|
270
|
+
"destructiveHint": False,
|
|
271
|
+
"idempotentHint": False,
|
|
272
|
+
"openWorldHint": True,
|
|
273
|
+
},
|
|
274
|
+
)
|
|
275
|
+
async def run(command: str, ctx: Context) -> str: # noqa: ARG001
|
|
276
|
+
# Get registry (cached, no rescan per request) and executor
|
|
277
|
+
registry = get_registry()
|
|
278
|
+
executor = _get_executor()
|
|
279
|
+
|
|
280
|
+
# Record start time for stats
|
|
281
|
+
start_time = time.monotonic()
|
|
282
|
+
|
|
283
|
+
# Step 1: Prepare and validate command
|
|
284
|
+
prepared = prepare_command(command)
|
|
285
|
+
|
|
286
|
+
if prepared.error:
|
|
287
|
+
return f"Error: {prepared.error}"
|
|
288
|
+
|
|
289
|
+
# Step 2: Execute through unified runner (skip validation since already done)
|
|
290
|
+
result = await execute_command(
|
|
291
|
+
command,
|
|
292
|
+
registry,
|
|
293
|
+
executor,
|
|
294
|
+
prepared_code=prepared.code,
|
|
295
|
+
skip_validation=True,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
# Record run-level stats if enabled
|
|
299
|
+
if _stats_writer is not None:
|
|
300
|
+
duration_ms = int((time.monotonic() - start_time) * 1000)
|
|
301
|
+
_stats_writer.record_run(
|
|
302
|
+
client=get_client_name(),
|
|
303
|
+
chars_in=len(command),
|
|
304
|
+
chars_out=len(result.result),
|
|
305
|
+
duration_ms=duration_ms,
|
|
306
|
+
success=result.success,
|
|
307
|
+
error_type=result.error_type,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
return result.result
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def main() -> None:
|
|
314
|
+
"""Run the MCP server over stdio transport."""
|
|
315
|
+
mcp.run(show_banner=False)
|
ot/shortcuts/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Shortcuts system for OneTool.
|
|
2
|
+
|
|
3
|
+
Provides aliases and snippets for simplified tool invocation:
|
|
4
|
+
- Aliases: Short names mapping to full function names (e.g., ws -> brave.web_search)
|
|
5
|
+
- Snippets: Jinja2 templates with variable substitution ($wsq q1=AI q2=ML p=Compare)
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from ot.shortcuts.aliases import resolve_alias
|
|
9
|
+
from ot.shortcuts.snippets import expand_snippet, parse_snippet
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"expand_snippet",
|
|
13
|
+
"parse_snippet",
|
|
14
|
+
"resolve_alias",
|
|
15
|
+
]
|
ot/shortcuts/aliases.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Alias resolution for OneTool shortcuts.
|
|
2
|
+
|
|
3
|
+
Resolves short alias names to their full namespaced function names.
|
|
4
|
+
E.g., ws(query="test") -> brave.web_search(query="test")
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from ot.config import OneToolConfig
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def resolve_alias(code: str, config: OneToolConfig) -> str:
|
|
17
|
+
"""Resolve aliases in code to their full function names.
|
|
18
|
+
|
|
19
|
+
Replaces alias function calls with their target:
|
|
20
|
+
- ws(query="test") -> brave.web_search(query="test")
|
|
21
|
+
- c7(query="react") -> context7.search(query="react")
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
code: Python code potentially containing alias calls
|
|
25
|
+
config: Configuration with alias mappings
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Code with aliases resolved to full names
|
|
29
|
+
"""
|
|
30
|
+
if not config.alias:
|
|
31
|
+
return code
|
|
32
|
+
|
|
33
|
+
result = code
|
|
34
|
+
|
|
35
|
+
# Sort aliases by length (longest first) to avoid partial matches
|
|
36
|
+
# e.g., "wsb" should be matched before "ws"
|
|
37
|
+
sorted_aliases = sorted(config.alias.keys(), key=len, reverse=True)
|
|
38
|
+
|
|
39
|
+
for alias_name in sorted_aliases:
|
|
40
|
+
target = config.alias[alias_name]
|
|
41
|
+
|
|
42
|
+
# Match alias followed by ( but not preceded by . or alphanumeric
|
|
43
|
+
# This prevents matching "foo.ws(" or "aws("
|
|
44
|
+
pattern = rf"(?<![.\w]){re.escape(alias_name)}\("
|
|
45
|
+
|
|
46
|
+
if re.search(pattern, result):
|
|
47
|
+
result = re.sub(pattern, f"{target}(", result)
|
|
48
|
+
|
|
49
|
+
return result
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def validate_aliases(config: OneToolConfig) -> list[str]:
|
|
53
|
+
"""Validate alias configuration for circular references.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
config: Configuration with alias mappings
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
List of validation errors (empty if valid)
|
|
60
|
+
"""
|
|
61
|
+
errors: list[str] = []
|
|
62
|
+
|
|
63
|
+
# Check for circular aliases
|
|
64
|
+
for alias_name, target in config.alias.items():
|
|
65
|
+
# Extract just the function name from target (before any dot)
|
|
66
|
+
target_base = target.split(".")[0] if "." in target else target
|
|
67
|
+
|
|
68
|
+
# Check if target points to another alias
|
|
69
|
+
if target_base in config.alias:
|
|
70
|
+
# Follow the chain to detect cycles
|
|
71
|
+
visited = {alias_name}
|
|
72
|
+
current = target_base
|
|
73
|
+
|
|
74
|
+
while current in config.alias:
|
|
75
|
+
if current in visited:
|
|
76
|
+
errors.append(
|
|
77
|
+
f"Circular alias detected: '{alias_name}' -> '{target}' "
|
|
78
|
+
f"creates a cycle through '{current}'"
|
|
79
|
+
)
|
|
80
|
+
break
|
|
81
|
+
visited.add(current)
|
|
82
|
+
next_target = config.alias[current]
|
|
83
|
+
current = (
|
|
84
|
+
next_target.split(".")[0] if "." in next_target else next_target
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
return errors
|
ot/shortcuts/snippets.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Snippet parsing and expansion for OneTool shortcuts.
|
|
2
|
+
|
|
3
|
+
Handles snippet syntax parsing and Jinja2 template expansion:
|
|
4
|
+
- Single-line: $wsq q1=AI q2=ML p=Compare
|
|
5
|
+
- Multi-line: $wsq\nq1: AI\nq2: ML\np: Compare
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
from loguru import logger
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from ot.config import OneToolConfig, SnippetDef
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
from jinja2 import Environment, StrictUndefined, TemplateSyntaxError
|
|
21
|
+
except ImportError as e:
|
|
22
|
+
raise ImportError(
|
|
23
|
+
"jinja2 is required for snippets. Install with: pip install jinja2"
|
|
24
|
+
) from e
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ParsedSnippet:
|
|
29
|
+
"""Result of parsing a snippet invocation."""
|
|
30
|
+
|
|
31
|
+
name: str
|
|
32
|
+
params: dict[str, str]
|
|
33
|
+
raw: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_snippet(code: str) -> bool:
|
|
37
|
+
"""Check if code is a snippet invocation (starts with $).
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
code: Code to check
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
True if code starts with $ (snippet syntax)
|
|
44
|
+
"""
|
|
45
|
+
stripped = code.strip()
|
|
46
|
+
# Must start with $ but not be $variable inside other code
|
|
47
|
+
return stripped.startswith("$") and not stripped.startswith("${")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse_snippet(code: str) -> ParsedSnippet:
|
|
51
|
+
"""Parse a snippet invocation into name and parameters.
|
|
52
|
+
|
|
53
|
+
Supports two syntaxes:
|
|
54
|
+
- Single-line: $name key=value key2=value2
|
|
55
|
+
- Multi-line: $name\\nkey: value\\nkey2: value2
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
code: Snippet invocation string
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
ParsedSnippet with name and extracted parameters
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
ValueError: If snippet syntax is invalid
|
|
65
|
+
"""
|
|
66
|
+
stripped = code.strip()
|
|
67
|
+
|
|
68
|
+
if not stripped.startswith("$"):
|
|
69
|
+
raise ValueError(f"Snippet must start with $: {stripped[:50]}")
|
|
70
|
+
|
|
71
|
+
# Remove $ prefix
|
|
72
|
+
content = stripped[1:]
|
|
73
|
+
|
|
74
|
+
# Check for multi-line (has newline after snippet name)
|
|
75
|
+
lines = content.split("\n")
|
|
76
|
+
first_line = lines[0].strip()
|
|
77
|
+
|
|
78
|
+
# Extract snippet name (first word)
|
|
79
|
+
name_match = re.match(r"^(\w+)", first_line)
|
|
80
|
+
if not name_match:
|
|
81
|
+
raise ValueError(f"Invalid snippet name: {first_line[:50]}")
|
|
82
|
+
|
|
83
|
+
name = name_match.group(1)
|
|
84
|
+
|
|
85
|
+
# Check if multi-line or single-line
|
|
86
|
+
if len(lines) > 1:
|
|
87
|
+
return _parse_multiline_snippet(name, lines[1:], stripped)
|
|
88
|
+
else:
|
|
89
|
+
return _parse_singleline_snippet(name, first_line[len(name) :], stripped)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _strip_quotes(value: str) -> str:
|
|
93
|
+
"""Strip matching outer quotes from a value.
|
|
94
|
+
|
|
95
|
+
Handles both single and double quotes. Only strips if quotes are balanced.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
value: String that may have outer quotes
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
String with outer quotes removed if present and balanced
|
|
102
|
+
"""
|
|
103
|
+
if len(value) >= 2 and (
|
|
104
|
+
(value.startswith('"') and value.endswith('"'))
|
|
105
|
+
or (value.startswith("'") and value.endswith("'"))
|
|
106
|
+
):
|
|
107
|
+
return value[1:-1]
|
|
108
|
+
return value
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _parse_singleline_snippet(name: str, params_str: str, raw: str) -> ParsedSnippet:
|
|
112
|
+
"""Parse single-line snippet parameters: key=value key2="value with spaces".
|
|
113
|
+
|
|
114
|
+
Values extend until the next key= or end of string.
|
|
115
|
+
Outer quotes are stripped from values (key="value" becomes key=value).
|
|
116
|
+
Escaped equals (\\=) are preserved in values.
|
|
117
|
+
"""
|
|
118
|
+
params: dict[str, str] = {}
|
|
119
|
+
params_str = params_str.strip()
|
|
120
|
+
|
|
121
|
+
if not params_str:
|
|
122
|
+
return ParsedSnippet(name=name, params=params, raw=raw)
|
|
123
|
+
|
|
124
|
+
# Replace escaped equals with placeholder
|
|
125
|
+
placeholder = "\x00EQUALS\x00"
|
|
126
|
+
params_str = params_str.replace("\\=", placeholder)
|
|
127
|
+
|
|
128
|
+
# Find all key=value pairs
|
|
129
|
+
# Pattern: word followed by = and then value until next word= or end
|
|
130
|
+
pattern = r"(\w+)=((?:[^=]|$)*?)(?=\s+\w+=|$)"
|
|
131
|
+
matches = re.findall(pattern, params_str)
|
|
132
|
+
|
|
133
|
+
for key, value in matches:
|
|
134
|
+
# Restore escaped equals and strip whitespace
|
|
135
|
+
value = value.replace(placeholder, "=").strip()
|
|
136
|
+
# Strip outer quotes from value (e.g., packages="react" -> packages=react)
|
|
137
|
+
value = _strip_quotes(value)
|
|
138
|
+
params[key] = value
|
|
139
|
+
|
|
140
|
+
return ParsedSnippet(name=name, params=params, raw=raw)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _parse_multiline_snippet(name: str, lines: list[str], raw: str) -> ParsedSnippet:
|
|
144
|
+
"""Parse multi-line snippet parameters: key: value.
|
|
145
|
+
|
|
146
|
+
Blank line terminates the snippet parameters.
|
|
147
|
+
Only the first colon is the separator (colons in values are preserved).
|
|
148
|
+
Outer quotes are stripped from values for consistency with single-line format.
|
|
149
|
+
"""
|
|
150
|
+
params: dict[str, str] = {}
|
|
151
|
+
|
|
152
|
+
for line in lines:
|
|
153
|
+
stripped = line.strip()
|
|
154
|
+
|
|
155
|
+
# Blank line terminates
|
|
156
|
+
if not stripped:
|
|
157
|
+
break
|
|
158
|
+
|
|
159
|
+
# Parse key: value (only first colon is separator)
|
|
160
|
+
colon_idx = stripped.find(":")
|
|
161
|
+
if colon_idx == -1:
|
|
162
|
+
logger.warning(f"Invalid snippet line (no colon): {stripped}")
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
key = stripped[:colon_idx].strip()
|
|
166
|
+
value = stripped[colon_idx + 1 :].strip()
|
|
167
|
+
|
|
168
|
+
if not key:
|
|
169
|
+
logger.warning(f"Empty key in snippet line: {stripped}")
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
# Strip outer quotes from value for consistency
|
|
173
|
+
value = _strip_quotes(value)
|
|
174
|
+
params[key] = value
|
|
175
|
+
|
|
176
|
+
return ParsedSnippet(name=name, params=params, raw=raw)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def expand_snippet(
|
|
180
|
+
parsed: ParsedSnippet,
|
|
181
|
+
config: OneToolConfig,
|
|
182
|
+
) -> str:
|
|
183
|
+
"""Expand a parsed snippet using Jinja2 templating.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
parsed: Parsed snippet with name and parameters
|
|
187
|
+
config: Configuration with snippet definitions
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
Expanded Python code from the snippet template
|
|
191
|
+
|
|
192
|
+
Raises:
|
|
193
|
+
ValueError: If snippet not found, missing required params, or Jinja2 error
|
|
194
|
+
"""
|
|
195
|
+
if parsed.name not in config.snippets:
|
|
196
|
+
available = ", ".join(sorted(config.snippets.keys())) or "(none)"
|
|
197
|
+
raise ValueError(f"Unknown snippet '{parsed.name}'. Available: {available}")
|
|
198
|
+
|
|
199
|
+
snippet_def: SnippetDef = config.snippets[parsed.name]
|
|
200
|
+
|
|
201
|
+
# Build context with defaults and provided values
|
|
202
|
+
context: dict[str, Any] = {}
|
|
203
|
+
|
|
204
|
+
# Apply defaults first
|
|
205
|
+
for param_name, param_def in snippet_def.params.items():
|
|
206
|
+
if param_def.default is not None:
|
|
207
|
+
context[param_name] = param_def.default
|
|
208
|
+
|
|
209
|
+
# Apply provided values
|
|
210
|
+
for key, value in parsed.params.items():
|
|
211
|
+
if key not in snippet_def.params:
|
|
212
|
+
logger.warning(
|
|
213
|
+
f"Unknown parameter '{key}' for snippet '{parsed.name}' (ignored)"
|
|
214
|
+
)
|
|
215
|
+
context[key] = value
|
|
216
|
+
|
|
217
|
+
# Check required parameters
|
|
218
|
+
for param_name, param_def in snippet_def.params.items():
|
|
219
|
+
if param_def.required and param_name not in context:
|
|
220
|
+
raise ValueError(
|
|
221
|
+
f"Snippet '{parsed.name}' requires parameter '{param_name}'"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
# Render template with Jinja2
|
|
225
|
+
try:
|
|
226
|
+
env = Environment(undefined=StrictUndefined)
|
|
227
|
+
template = env.from_string(snippet_def.body)
|
|
228
|
+
return template.render(**context)
|
|
229
|
+
except TemplateSyntaxError as e:
|
|
230
|
+
raise ValueError(f"Jinja2 syntax error in snippet '{parsed.name}': {e}") from e
|
|
231
|
+
except Exception as e:
|
|
232
|
+
# StrictUndefined raises UndefinedError for missing variables
|
|
233
|
+
if "undefined" in str(e).lower():
|
|
234
|
+
raise ValueError(
|
|
235
|
+
f"Undefined variable in snippet '{parsed.name}': {e}"
|
|
236
|
+
) from e
|
|
237
|
+
raise ValueError(f"Error expanding snippet '{parsed.name}': {e}") from e
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def validate_snippets(config: OneToolConfig) -> list[str]:
|
|
241
|
+
"""Validate snippet definitions for Jinja2 syntax errors.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
config: Configuration with snippet definitions
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
List of validation errors (empty if valid)
|
|
248
|
+
"""
|
|
249
|
+
errors: list[str] = []
|
|
250
|
+
env = Environment(undefined=StrictUndefined)
|
|
251
|
+
|
|
252
|
+
for name, snippet_def in config.snippets.items():
|
|
253
|
+
try:
|
|
254
|
+
env.from_string(snippet_def.body)
|
|
255
|
+
except TemplateSyntaxError as e:
|
|
256
|
+
errors.append(f"Snippet '{name}' has invalid Jinja2 syntax: {e}")
|
|
257
|
+
|
|
258
|
+
return errors
|