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/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)
@@ -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
+ ]
@@ -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
@@ -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