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/config/loader.py ADDED
@@ -0,0 +1,1087 @@
1
+ """YAML configuration loading for OneTool.
2
+
3
+ Loads onetool.yaml with tool discovery patterns and settings.
4
+
5
+ Example onetool.yaml:
6
+
7
+ version: 1
8
+
9
+ include:
10
+ - prompts.yaml # prompts: section
11
+ - snippets.yaml # snippets: section
12
+
13
+ tools_dir:
14
+ - src/ot_tools/*.py
15
+
16
+ transform:
17
+ model: anthropic/claude-3-5-haiku
18
+
19
+ secrets_file: secrets.yaml # default: sibling of onetool.yaml
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import glob
25
+ import os
26
+ import threading
27
+ from pathlib import Path
28
+ from typing import Any, Literal
29
+
30
+ import yaml
31
+ from loguru import logger
32
+ from pydantic import (
33
+ BaseModel,
34
+ ConfigDict,
35
+ Field,
36
+ PrivateAttr,
37
+ field_validator,
38
+ model_validator,
39
+ )
40
+
41
+ from ot.config.mcp import McpServerConfig, expand_secrets
42
+ from ot.paths import (
43
+ CONFIG_SUBDIR,
44
+ get_bundled_config_dir,
45
+ get_config_dir,
46
+ get_effective_cwd,
47
+ get_global_dir,
48
+ )
49
+
50
+ # Current config schema version
51
+ CURRENT_CONFIG_VERSION = 1
52
+
53
+
54
+ class TransformConfig(BaseModel):
55
+ """Configuration for the transform() tool."""
56
+
57
+ model: str = Field(default="", description="Model for code generation")
58
+ base_url: str = Field(default="", description="Base URL for OpenAI-compatible API")
59
+ max_tokens: int = Field(default=4096, description="Max output tokens")
60
+
61
+
62
+ class SnippetParam(BaseModel):
63
+ """Parameter definition for a snippet."""
64
+
65
+ required: bool = Field(
66
+ default=True, description="Whether this parameter is required"
67
+ )
68
+ default: Any = Field(default=None, description="Default value if not provided")
69
+ description: str = Field(default="", description="Description of the parameter")
70
+
71
+
72
+ class SnippetDef(BaseModel):
73
+ """Definition of a reusable snippet template."""
74
+
75
+ description: str = Field(
76
+ default="", description="Description of what this snippet does"
77
+ )
78
+ params: dict[str, SnippetParam] = Field(
79
+ default_factory=dict, description="Parameter definitions"
80
+ )
81
+ body: str = Field(
82
+ ..., description="Jinja2 template body that expands to Python code"
83
+ )
84
+
85
+
86
+ # ==================== Core Configuration Models ====================
87
+ # Note: Tool-specific configs (BraveConfig, GroundConfig, etc.) have been
88
+ # moved to their respective tool files. Tools access config via
89
+ # get_tool_config(pack, Config) at runtime.
90
+
91
+
92
+ class MsgTopicConfig(BaseModel):
93
+ """Topic-to-file mapping for message routing."""
94
+
95
+ pattern: str = Field(
96
+ ...,
97
+ description="Glob-style topic pattern (e.g., 'status:*', 'doc:*')",
98
+ )
99
+ file: str = Field(
100
+ ...,
101
+ description="File path for messages matching this pattern (supports ~ and ${VAR})",
102
+ )
103
+
104
+
105
+ class MsgConfig(BaseModel):
106
+ """Message tool configuration."""
107
+
108
+ topics: list[MsgTopicConfig] = Field(
109
+ default_factory=list,
110
+ description="Topic patterns mapped to output files (first match wins)",
111
+ )
112
+
113
+
114
+ class OutputSanitizationConfig(BaseModel):
115
+ """Output sanitization configuration for prompt injection protection.
116
+
117
+ Protects against indirect prompt injection by sanitizing tool outputs
118
+ that may contain malicious payloads from external content.
119
+
120
+ Three-layer defense:
121
+ 1. Trigger sanitization: Replace __ot, mcp__onetool patterns
122
+ 2. Tag sanitization: Remove <external-content-*> patterns
123
+ 3. GUID-tagged boundaries: Wrap content in unpredictable tags
124
+ """
125
+
126
+ enabled: bool = Field(
127
+ default=True,
128
+ description="Global toggle for output sanitization",
129
+ )
130
+
131
+
132
+ class SecurityConfig(BaseModel):
133
+ """Code validation security configuration.
134
+
135
+ Controls code validation with three-tier pattern system:
136
+ - allow: Execute silently (highest priority)
137
+ - warned: Log warning but allow execution
138
+ - blocked: Reject with error (lowest priority)
139
+
140
+ Priority order: allow > warned > blocked
141
+
142
+ Patterns support fnmatch wildcards:
143
+ - '*' matches any characters (e.g., 'subprocess.*' matches 'subprocess.run')
144
+ - '?' matches a single character
145
+ - '[seq]' matches any character in seq
146
+
147
+ Pattern matching logic (handled automatically by validator):
148
+ - Patterns WITHOUT dots (e.g., 'exec', 'subprocess') match:
149
+ * Builtin function calls: exec(), eval()
150
+ * Import statements: import subprocess, from os import system
151
+ - Patterns WITH dots (e.g., 'subprocess.*', 'os.system') match:
152
+ * Qualified function calls: subprocess.run(), os.system()
153
+
154
+ Configuration behavior:
155
+ - blocked/warned lists EXTEND the built-in defaults (additive)
156
+ - Adding a pattern to 'warned' downgrades it from blocked (if in defaults)
157
+ - Use 'allow' list to exempt specific patterns entirely (no warning)
158
+
159
+ Example configurations:
160
+ # Air-gapped mode - block network tools
161
+ security:
162
+ block: [brave.*, web_fetch.*, context7.*]
163
+
164
+ # Trust file ops
165
+ security:
166
+ allow: [file.*]
167
+ """
168
+
169
+ validate_code: bool = Field(
170
+ default=True,
171
+ description="Enable AST-based code validation before execution",
172
+ )
173
+
174
+ enabled: bool = Field(
175
+ default=True,
176
+ description="Enable security pattern checking (requires validate_code)",
177
+ )
178
+
179
+ # Blocked patterns - EXTENDS built-in defaults (prevents accidental removal)
180
+ # Use 'allow' to exempt specific patterns if needed
181
+ blocked: list[str] = Field(
182
+ default_factory=list,
183
+ description="Additional patterns to block (extends defaults, not replaces)",
184
+ )
185
+
186
+ # Warned patterns - EXTENDS built-in defaults
187
+ warned: list[str] = Field(
188
+ default_factory=list,
189
+ description="Additional patterns to warn on (extends defaults, not replaces)",
190
+ )
191
+
192
+ # Allow list - explicitly exempt patterns from defaults
193
+ # Use this to remove a default pattern you need (e.g., allow 'open' for file tools)
194
+ allow: list[str] = Field(
195
+ default_factory=list,
196
+ description="Patterns to exempt from blocking/warning (removes from defaults)",
197
+ )
198
+
199
+ # Output sanitization configuration
200
+ sanitize: OutputSanitizationConfig = Field(
201
+ default_factory=OutputSanitizationConfig,
202
+ description="Output sanitization for prompt injection protection",
203
+ )
204
+
205
+
206
+ class OutputConfig(BaseModel):
207
+ """Large output handling configuration.
208
+
209
+ When tool outputs exceed max_inline_size, they are stored to disk
210
+ and a summary with a query handle is returned instead.
211
+ """
212
+
213
+ max_inline_size: int = Field(
214
+ default=50000,
215
+ ge=0,
216
+ description="Max output size in bytes before storing to disk. Set to 0 to disable.",
217
+ )
218
+ result_store_dir: str = Field(
219
+ default="tmp",
220
+ description="Directory for result files (relative to .onetool/)",
221
+ )
222
+ result_ttl: int = Field(
223
+ default=3600,
224
+ ge=0,
225
+ description="Time-to-live in seconds for stored results (0 = no expiry)",
226
+ )
227
+ preview_lines: int = Field(
228
+ default=10,
229
+ ge=0,
230
+ description="Number of preview lines to include in summary",
231
+ )
232
+
233
+
234
+ class StatsConfig(BaseModel):
235
+ """Runtime statistics collection configuration."""
236
+
237
+ enabled: bool = Field(
238
+ default=True,
239
+ description="Enable statistics collection",
240
+ )
241
+ persist_dir: str = Field(
242
+ default="stats",
243
+ description="Directory for stats files (relative to .onetool/)",
244
+ )
245
+ persist_path: str = Field(
246
+ default="stats.jsonl",
247
+ description="Filename for stats persistence (within persist_dir)",
248
+ )
249
+ flush_interval_seconds: int = Field(
250
+ default=30,
251
+ ge=1,
252
+ le=300,
253
+ description="Interval in seconds between flushing stats to disk",
254
+ )
255
+ context_per_call: int = Field(
256
+ default=30000,
257
+ ge=0,
258
+ description="Estimated context tokens saved per consolidated tool call",
259
+ )
260
+ time_overhead_per_call_ms: int = Field(
261
+ default=4000,
262
+ ge=0,
263
+ description="Estimated time overhead in ms saved per consolidated tool call",
264
+ )
265
+ model: str = Field(
266
+ default="anthropic/claude-opus-4.5",
267
+ description="Model for cost estimation (e.g., anthropic/claude-opus-4.5)",
268
+ )
269
+ cost_per_million_input_tokens: float = Field(
270
+ default=15.0,
271
+ ge=0,
272
+ description="Cost in USD per million input tokens",
273
+ )
274
+ cost_per_million_output_tokens: float = Field(
275
+ default=75.0,
276
+ ge=0,
277
+ description="Cost in USD per million output tokens",
278
+ )
279
+ chars_per_token: float = Field(
280
+ default=4.0,
281
+ ge=1.0,
282
+ description="Average characters per token for estimation",
283
+ )
284
+
285
+
286
+ class ToolsConfig(BaseModel):
287
+ """Aggregated tool configurations.
288
+
289
+ Core configs (msg, stats) are typed fields. Tool-specific configs
290
+ (brave, ground, etc.) are allowed as extra fields and accessed via
291
+ get_tool_config() with schemas defined in tool files.
292
+ """
293
+
294
+ model_config = ConfigDict(extra="allow")
295
+
296
+ # Core configs - always available
297
+ msg: MsgConfig = Field(default_factory=MsgConfig)
298
+ stats: StatsConfig = Field(default_factory=StatsConfig)
299
+
300
+
301
+ class OneToolConfig(BaseModel):
302
+ """Root configuration for OneTool V1."""
303
+
304
+ # Private attribute to track config file location (not serialized)
305
+ # Note: Path is natively supported by Pydantic, no arbitrary_types_allowed needed
306
+ _config_dir: Path | None = PrivateAttr(default=None)
307
+
308
+ version: int = Field(
309
+ default=1,
310
+ description="Config schema version for migration support",
311
+ )
312
+
313
+ inherit: Literal["global", "bundled", "none"] = Field(
314
+ default="global",
315
+ description=(
316
+ "Config inheritance mode:\n"
317
+ " - 'global' (default): Merge ~/.onetool/onetool.yaml first, then "
318
+ "bundled defaults as fallback. Use for project configs that extend user prefs.\n"
319
+ " - 'bundled': Merge package defaults only, skip global config. "
320
+ "Use for reproducible configs that shouldn't depend on user settings.\n"
321
+ " - 'none': Standalone config with no inheritance. "
322
+ "Use for fully self-contained configs."
323
+ ),
324
+ )
325
+
326
+ include: list[str] = Field(
327
+ default_factory=list,
328
+ description="Files to deep-merge into config (processed before validation)",
329
+ )
330
+
331
+ transform: TransformConfig = Field(
332
+ default_factory=TransformConfig, description="transform() tool configuration"
333
+ )
334
+
335
+ alias: dict[str, str] = Field(
336
+ default_factory=dict,
337
+ description="Short alias names mapping to full function names (e.g., ws -> brave.web_search)",
338
+ )
339
+
340
+ snippets: dict[str, SnippetDef] = Field(
341
+ default_factory=dict,
342
+ description="Reusable snippet templates with Jinja2 variable substitution",
343
+ )
344
+
345
+ servers: dict[str, McpServerConfig] = Field(
346
+ default_factory=dict,
347
+ description="External MCP servers to proxy through OneTool",
348
+ )
349
+
350
+ tools: ToolsConfig = Field(
351
+ default_factory=ToolsConfig,
352
+ description="Tool-specific configuration (timeouts, limits, etc.)",
353
+ )
354
+
355
+ security: SecurityConfig = Field(
356
+ default_factory=SecurityConfig,
357
+ description="Code validation and security pattern configuration",
358
+ )
359
+
360
+ stats: StatsConfig = Field(
361
+ default_factory=StatsConfig,
362
+ description="Runtime statistics collection configuration (replaces tools.stats)",
363
+ )
364
+
365
+ output: OutputConfig = Field(
366
+ default_factory=OutputConfig,
367
+ description="Large output handling configuration",
368
+ )
369
+
370
+ tools_dir: list[str] = Field(
371
+ default_factory=lambda: ["tools/*.py"],
372
+ description="Glob patterns for tool discovery (relative to OT_DIR .onetool/, or absolute)",
373
+ )
374
+ secrets_file: str = Field(
375
+ default="config/secrets.yaml",
376
+ description="Path to secrets file (relative to OT_DIR .onetool/, or absolute)",
377
+ )
378
+ prompts: dict[str, Any] | None = Field(
379
+ default=None,
380
+ description="Inline prompts config (can also be loaded via include:)",
381
+ )
382
+
383
+ log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = Field(
384
+ default="INFO", description="Logging level"
385
+ )
386
+ log_dir: str = Field(
387
+ default="logs",
388
+ description="Directory for log files (relative to .onetool/)",
389
+ )
390
+ compact_max_length: int = Field(
391
+ default=120, description="Max value length in compact console output"
392
+ )
393
+ log_verbose: bool = Field(
394
+ default=False,
395
+ description="Disable log truncation for debugging (full values in output)",
396
+ )
397
+ debug_tracebacks: bool = Field(
398
+ default=False,
399
+ description="Show verbose tracebacks with local variables on errors",
400
+ )
401
+
402
+ @field_validator("snippets", "servers", "alias", mode="before")
403
+ @classmethod
404
+ def empty_dict_if_none(cls, v: Any) -> Any:
405
+ """Convert None to empty dict for dict-type fields.
406
+
407
+ This handles YAML files where the key exists but all values are commented out,
408
+ which YAML parses as None instead of an empty dict.
409
+ """
410
+ return {} if v is None else v
411
+
412
+ @model_validator(mode="before")
413
+ @classmethod
414
+ def migrate_tools_stats(cls, data: Any) -> Any:
415
+ """Migrate tools.stats to root-level stats with deprecation warning.
416
+
417
+ During the deprecation period, supports both paths:
418
+ - Root-level `stats:` (preferred)
419
+ - Legacy `tools.stats:` (deprecated)
420
+
421
+ If both exist, root-level takes precedence.
422
+ """
423
+ if not isinstance(data, dict):
424
+ return data
425
+
426
+ tools = data.get("tools")
427
+ if not isinstance(tools, dict):
428
+ return data
429
+
430
+ legacy_stats = tools.get("stats")
431
+ root_stats = data.get("stats")
432
+
433
+ if legacy_stats and not root_stats:
434
+ # Only tools.stats exists - migrate with warning
435
+ logger.warning(
436
+ "Deprecation: 'tools.stats' config path is deprecated. "
437
+ "Move to root-level 'stats:' section. "
438
+ "This will be removed in a future version."
439
+ )
440
+ data["stats"] = legacy_stats
441
+ elif legacy_stats and root_stats:
442
+ # Both exist - root takes precedence, log info
443
+ logger.debug(
444
+ "Both 'stats' and 'tools.stats' defined. "
445
+ "Using root-level 'stats' (ignoring deprecated 'tools.stats')."
446
+ )
447
+
448
+ return data
449
+
450
+ def get_tool_files(self) -> list[Path]:
451
+ """Get list of tool files matching configured glob patterns.
452
+
453
+ Pattern resolution (all relative to OT_DIR .onetool/):
454
+ - Absolute paths: used as-is
455
+ - ~ paths: expanded to home directory
456
+ - Relative patterns: resolved relative to OT_DIR (.onetool/)
457
+
458
+ Returns:
459
+ List of Path objects for tool files
460
+ """
461
+ tool_files: list[Path] = []
462
+ cwd = get_effective_cwd()
463
+
464
+ # Determine OT_DIR for resolving patterns
465
+ if self._config_dir is not None:
466
+ # _config_dir is the config/ subdirectory, go up to .onetool/
467
+ ot_dir = self._config_dir.parent
468
+ else:
469
+ # Fallback: cwd/.onetool
470
+ ot_dir = cwd / ".onetool"
471
+
472
+ for pattern in self.tools_dir:
473
+ # Expand ~ first
474
+ expanded = Path(pattern).expanduser()
475
+
476
+ # Determine resolved pattern for globbing
477
+ if expanded.is_absolute():
478
+ # Absolute pattern - use as-is
479
+ resolved_pattern = str(expanded)
480
+ else:
481
+ # All relative patterns resolve against OT_DIR
482
+ resolved_pattern = str(ot_dir / pattern)
483
+
484
+ # Use glob.glob() for cross-platform compatibility
485
+ for match in glob.glob(resolved_pattern, recursive=True): # noqa: PTH207
486
+ path = Path(match)
487
+ if path.is_file() and path.suffix == ".py":
488
+ tool_files.append(path)
489
+
490
+ return sorted(set(tool_files))
491
+
492
+ def _resolve_onetool_relative_path(self, path_str: str) -> Path:
493
+ """Resolve a path relative to the .onetool directory.
494
+
495
+ Handles:
496
+ - Absolute paths: returned as-is
497
+ - ~ expansion: expanded to home directory
498
+ - Relative paths: resolved relative to .onetool/ directory (parent of config/)
499
+
500
+ Note: Does NOT expand ${VAR} - use ~/path instead of ${HOME}/path.
501
+
502
+ Args:
503
+ path_str: Path string to resolve
504
+
505
+ Returns:
506
+ Resolved absolute Path
507
+ """
508
+ # Only expand ~ (no ${VAR} expansion)
509
+ path = Path(path_str).expanduser()
510
+
511
+ # If absolute after expansion, use as-is
512
+ if path.is_absolute():
513
+ return path
514
+
515
+ # Resolve relative to .onetool/ directory (parent of config/)
516
+ if self._config_dir is not None:
517
+ # _config_dir is the config/ subdirectory, go up to .onetool/
518
+ onetool_dir = self._config_dir.parent
519
+ return (onetool_dir / path).resolve()
520
+
521
+ # Fallback: resolve relative to cwd/.onetool
522
+ return (get_effective_cwd() / ".onetool" / path).resolve()
523
+
524
+ def _resolve_config_relative_path(self, path_str: str) -> Path:
525
+ """Resolve a path relative to the config directory.
526
+
527
+ Handles:
528
+ - Absolute paths: returned as-is
529
+ - ~ expansion: expanded to home directory
530
+ - Relative paths: resolved relative to config/ directory
531
+
532
+ Note: Does NOT expand ${VAR} - use ~/path instead of ${HOME}/path.
533
+
534
+ Args:
535
+ path_str: Path string to resolve
536
+
537
+ Returns:
538
+ Resolved absolute Path
539
+ """
540
+ # Only expand ~ (no ${VAR} expansion)
541
+ path = Path(path_str).expanduser()
542
+
543
+ # If absolute after expansion, use as-is
544
+ if path.is_absolute():
545
+ return path
546
+
547
+ # Resolve relative to config directory
548
+ if self._config_dir is not None:
549
+ return (self._config_dir / path).resolve()
550
+
551
+ # Fallback: resolve relative to cwd/.onetool/config
552
+ return (get_effective_cwd() / ".onetool" / CONFIG_SUBDIR / path).resolve()
553
+
554
+ def get_secrets_file_path(self) -> Path:
555
+ """Get the resolved path to the secrets configuration file.
556
+
557
+ Path is resolved relative to OT_DIR (.onetool/).
558
+
559
+ Returns:
560
+ Absolute Path to secrets file
561
+ """
562
+ return self._resolve_onetool_relative_path(self.secrets_file)
563
+
564
+ def get_log_dir_path(self) -> Path:
565
+ """Get the resolved path to the log directory.
566
+
567
+ Path is resolved relative to the .onetool/ directory.
568
+
569
+ Returns:
570
+ Absolute Path to log directory
571
+ """
572
+ return self._resolve_onetool_relative_path(self.log_dir)
573
+
574
+ def get_stats_dir_path(self) -> Path:
575
+ """Get the resolved path to the stats directory.
576
+
577
+ Path is resolved relative to the .onetool/ directory.
578
+
579
+ Returns:
580
+ Absolute Path to stats directory
581
+ """
582
+ return self._resolve_onetool_relative_path(self.stats.persist_dir)
583
+
584
+ def get_stats_file_path(self) -> Path:
585
+ """Get the resolved path to the stats JSONL file.
586
+
587
+ Stats file is stored in the stats directory.
588
+
589
+ Returns:
590
+ Absolute Path to stats file
591
+ """
592
+ return self.get_stats_dir_path() / self.stats.persist_path
593
+
594
+ def get_result_store_path(self) -> Path:
595
+ """Get the resolved path to the result store directory.
596
+
597
+ Path is resolved relative to the .onetool/ directory.
598
+
599
+ Returns:
600
+ Absolute Path to result store directory
601
+ """
602
+ return self._resolve_onetool_relative_path(self.output.result_store_dir)
603
+
604
+
605
+ def _resolve_config_path(config_path: Path | str | None) -> Path | None:
606
+ """Resolve config path from explicit path, env var, or default locations.
607
+
608
+ Resolution order:
609
+ 1. Explicit config_path if provided
610
+ 2. ONETOOL_CONFIG env var
611
+ 3. cwd/.onetool/config/onetool.yaml
612
+ 4. ~/.onetool/config/onetool.yaml
613
+ 5. None (use defaults)
614
+
615
+ Args:
616
+ config_path: Explicit path to config file (may be None).
617
+
618
+ Returns:
619
+ Resolved Path or None if no config file found.
620
+ """
621
+ if config_path is not None:
622
+ return Path(config_path)
623
+
624
+ env_config = os.getenv("ONETOOL_CONFIG")
625
+ if env_config:
626
+ return Path(env_config)
627
+
628
+ cwd = get_effective_cwd()
629
+ project_config = cwd / ".onetool" / CONFIG_SUBDIR / "onetool.yaml"
630
+ if project_config.exists():
631
+ return project_config
632
+
633
+ global_config = get_config_dir(get_global_dir()) / "onetool.yaml"
634
+ if global_config.exists():
635
+ return global_config
636
+
637
+ return None
638
+
639
+
640
+ def _load_yaml_file(config_path: Path) -> dict[str, Any]:
641
+ """Load and parse YAML file with error handling.
642
+
643
+ Args:
644
+ config_path: Path to YAML file.
645
+
646
+ Returns:
647
+ Parsed YAML data as dict.
648
+
649
+ Raises:
650
+ FileNotFoundError: If file doesn't exist.
651
+ ValueError: If YAML is invalid or file can't be read.
652
+ """
653
+ if not config_path.exists():
654
+ raise FileNotFoundError(f"Config file not found: {config_path}")
655
+
656
+ try:
657
+ with config_path.open() as f:
658
+ raw_data = yaml.safe_load(f)
659
+ except yaml.YAMLError as e:
660
+ raise ValueError(f"Invalid YAML in {config_path}: {e}") from e
661
+ except OSError as e:
662
+ raise ValueError(f"Error reading {config_path}: {e}") from e
663
+
664
+ return raw_data if raw_data is not None else {}
665
+
666
+
667
+ def _expand_secrets_recursive(data: Any) -> Any:
668
+ """Recursively expand ${VAR} from secrets.yaml in config data.
669
+
670
+ Args:
671
+ data: Config data (dict, list, or scalar).
672
+
673
+ Returns:
674
+ Data with secrets expanded.
675
+ """
676
+ if isinstance(data, dict):
677
+ return {k: _expand_secrets_recursive(v) for k, v in data.items()}
678
+ elif isinstance(data, list):
679
+ return [_expand_secrets_recursive(v) for v in data]
680
+ elif isinstance(data, str):
681
+ return expand_secrets(data)
682
+ return data
683
+
684
+
685
+ def _validate_version(data: dict[str, Any], config_path: Path) -> None:
686
+ """Validate config version and set default if missing.
687
+
688
+ Args:
689
+ data: Config data dict (modified in place).
690
+ config_path: Path to config file (for error messages).
691
+
692
+ Raises:
693
+ ValueError: If version is unsupported.
694
+ """
695
+ config_version = data.get("version")
696
+ if config_version is None:
697
+ logger.warning(
698
+ f"Config file missing 'version' field, assuming version 1. "
699
+ f"Add 'version: {CURRENT_CONFIG_VERSION}' to {config_path}"
700
+ )
701
+ data["version"] = 1
702
+ elif config_version > CURRENT_CONFIG_VERSION:
703
+ raise ValueError(
704
+ f"Config version {config_version} is not supported. "
705
+ f"Maximum supported version is {CURRENT_CONFIG_VERSION}. "
706
+ f"Please upgrade OneTool: uv tool upgrade onetool"
707
+ )
708
+
709
+
710
+ def _remove_legacy_fields(data: dict[str, Any]) -> None:
711
+ """Remove V1-unsupported fields from config data.
712
+
713
+ Args:
714
+ data: Config data dict (modified in place).
715
+ """
716
+ for key in ["mounts", "profile"]:
717
+ if key in data:
718
+ logger.debug(f"Ignoring legacy config field '{key}'")
719
+ del data[key]
720
+
721
+
722
+ def _resolve_include_path(include_path_str: str, ot_dir: Path) -> Path | None:
723
+ """Resolve an include path using three-tier fallback.
724
+
725
+ Search order:
726
+ 1. ot_dir (project .onetool/ or wherever the config's OT_DIR is)
727
+ 2. global (~/.onetool/)
728
+ 3. bundled (package defaults)
729
+
730
+ Supports:
731
+ - Absolute paths (used as-is)
732
+ - ~ expansion (expands to home directory)
733
+ - Relative paths (searched in three-tier order)
734
+
735
+ Args:
736
+ include_path_str: Path string from include directive
737
+ ot_dir: The .onetool/ directory (OT_DIR) for the config
738
+
739
+ Returns:
740
+ Resolved Path if found, None otherwise
741
+ """
742
+ # Expand ~ first
743
+ include_path = Path(include_path_str).expanduser()
744
+
745
+ # Absolute paths are used as-is
746
+ if include_path.is_absolute():
747
+ if include_path.exists():
748
+ logger.debug(f"Include resolved (absolute): {include_path}")
749
+ return include_path
750
+ return None
751
+
752
+ # Tier 1: ot_dir (project .onetool/ or current OT_DIR)
753
+ tier1 = (ot_dir / include_path).resolve()
754
+ if tier1.exists():
755
+ logger.debug(f"Include resolved (ot_dir): {tier1}")
756
+ return tier1
757
+
758
+ # Tier 2: global (~/.onetool/)
759
+ tier2 = (get_global_dir() / include_path).resolve()
760
+ if tier2.exists():
761
+ logger.debug(f"Include resolved (global): {tier2}")
762
+ return tier2
763
+
764
+ # Tier 3: bundled (package defaults)
765
+ try:
766
+ bundled_dir = get_bundled_config_dir()
767
+ tier3 = (bundled_dir / include_path).resolve()
768
+ if tier3.exists():
769
+ logger.debug(f"Include resolved (bundled): {tier3}")
770
+ return tier3
771
+ except FileNotFoundError:
772
+ # Bundled defaults not available
773
+ pass
774
+
775
+ logger.debug(f"Include not found in any tier: {include_path_str}")
776
+ return None
777
+
778
+
779
+ def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
780
+ """Deep merge two dictionaries, with override values taking precedence.
781
+
782
+ - Nested dicts are recursively merged
783
+ - Non-dict values (lists, scalars) are replaced entirely
784
+ - Keys in override not in base are added
785
+
786
+ Args:
787
+ base: Base dictionary (inputs not mutated)
788
+ override: Override dictionary (inputs not mutated, values take precedence)
789
+
790
+ Returns:
791
+ New merged dictionary
792
+ """
793
+ result = base.copy()
794
+
795
+ for key, override_value in override.items():
796
+ if key in result:
797
+ base_value = result[key]
798
+ # Only deep merge if both are dicts
799
+ if isinstance(base_value, dict) and isinstance(override_value, dict):
800
+ result[key] = _deep_merge(base_value, override_value)
801
+ else:
802
+ # Replace entirely (lists, scalars, or type mismatch)
803
+ result[key] = override_value
804
+ else:
805
+ # New key from override
806
+ result[key] = override_value
807
+
808
+ return result
809
+
810
+
811
+ def _load_includes(
812
+ data: dict[str, Any], ot_dir: Path, seen_paths: set[Path] | None = None
813
+ ) -> dict[str, Any]:
814
+ """Load and merge files from 'include:' list into config data.
815
+
816
+ Files are merged left-to-right (later files override earlier).
817
+ Inline content in the main file overrides everything.
818
+
819
+ Include resolution uses three-tier fallback:
820
+ 1. ot_dir (project .onetool/ or current OT_DIR)
821
+ 2. global (~/.onetool/)
822
+ 3. bundled (package defaults)
823
+
824
+ Args:
825
+ data: Config data dict containing optional 'include' key
826
+ ot_dir: The .onetool/ directory (OT_DIR) for resolving relative paths
827
+ seen_paths: Set of already-processed paths (for circular detection)
828
+
829
+ Returns:
830
+ Merged config data with includes processed
831
+ """
832
+ if seen_paths is None:
833
+ seen_paths = set()
834
+
835
+ include_list = data.get("include", [])
836
+ if not include_list:
837
+ return data
838
+
839
+ # Start with empty base for merging included files
840
+ merged: dict[str, Any] = {}
841
+
842
+ for include_path_str in include_list:
843
+ # Use three-tier resolution
844
+ include_path = _resolve_include_path(include_path_str, ot_dir)
845
+
846
+ if include_path is None:
847
+ logger.warning(f"Include file not found: {include_path_str}")
848
+ continue
849
+
850
+ # Circular include detection
851
+ if include_path in seen_paths:
852
+ logger.warning(f"Circular include detected, skipping: {include_path}")
853
+ continue
854
+
855
+ try:
856
+ with include_path.open() as f:
857
+ include_data = yaml.safe_load(f)
858
+
859
+ if not include_data or not isinstance(include_data, dict):
860
+ logger.debug(f"Empty or non-dict include file: {include_path}")
861
+ continue
862
+
863
+ # Recursively process nested includes (same OT_DIR for all nested includes)
864
+ new_seen = seen_paths | {include_path}
865
+ include_data = _load_includes(include_data, ot_dir, new_seen)
866
+
867
+ # Merge this include file (later overrides earlier)
868
+ merged = _deep_merge(merged, include_data)
869
+
870
+ logger.debug(f"Merged include file: {include_path}")
871
+
872
+ except yaml.YAMLError as e:
873
+ logger.error(f"Invalid YAML in include file {include_path}: {e}")
874
+ except OSError as e:
875
+ logger.error(f"Error reading include file {include_path}: {e}")
876
+
877
+ # Main file content (minus 'include' key) overrides everything
878
+ main_content = {k: v for k, v in data.items() if k != "include"}
879
+ result = _deep_merge(merged, main_content)
880
+
881
+ # Preserve the include list for reference (but it's already processed)
882
+ result["include"] = include_list
883
+
884
+ return result
885
+
886
+
887
+ def _load_base_config(inherit: str, current_config_path: Path | None) -> dict[str, Any]:
888
+ """Load base configuration for inheritance.
889
+
890
+ Args:
891
+ inherit: Inheritance mode (global, bundled, none)
892
+ current_config_path: Path to the current config file (to avoid self-include)
893
+
894
+ Returns:
895
+ Base config data dict to merge with current config
896
+ """
897
+ if inherit == "none":
898
+ return {}
899
+
900
+ # Try global first for 'global' mode
901
+ if inherit == "global":
902
+ global_config_path = get_config_dir(get_global_dir()) / "onetool.yaml"
903
+ if global_config_path.exists():
904
+ # Skip if this is the same file we're already loading
905
+ if (
906
+ current_config_path
907
+ and global_config_path.resolve() == current_config_path.resolve()
908
+ ):
909
+ logger.debug(
910
+ "Skipping global inheritance (loading global config itself)"
911
+ )
912
+ else:
913
+ try:
914
+ raw_data = _load_yaml_file(global_config_path)
915
+ # Process includes in global config - OT_DIR is ~/.onetool/
916
+ global_ot_dir = get_global_dir()
917
+ data = _load_includes(raw_data, global_ot_dir)
918
+ logger.debug(
919
+ f"Inherited base config from global: {global_config_path}"
920
+ )
921
+ return data
922
+ except (FileNotFoundError, ValueError) as e:
923
+ logger.warning(f"Failed to load global config for inheritance: {e}")
924
+
925
+ # Fall back to bundled for both 'global' (when global missing) and 'bundled' modes
926
+ try:
927
+ bundled_dir = get_bundled_config_dir()
928
+ bundled_config_path = bundled_dir / "onetool.yaml"
929
+ if bundled_config_path.exists():
930
+ raw_data = _load_yaml_file(bundled_config_path)
931
+ # Process includes in bundled config - bundled dir is flat (no config/ subdir)
932
+ data = _load_includes(raw_data, bundled_dir)
933
+ logger.debug(f"Inherited base config from bundled: {bundled_config_path}")
934
+ return data
935
+ except FileNotFoundError:
936
+ logger.debug("Bundled config not available for inheritance")
937
+
938
+ return {}
939
+
940
+
941
+ def load_config(config_path: Path | str | None = None) -> OneToolConfig:
942
+ """Load OneTool configuration from YAML file.
943
+
944
+ Resolution order (when config_path is None):
945
+ 1. ONETOOL_CONFIG env var
946
+ 2. cwd/.onetool/onetool.yaml (project config)
947
+ 3. ~/.onetool/onetool.yaml (global config)
948
+ 4. Built-in defaults (bundled with package)
949
+
950
+ Inheritance (controlled by 'inherit' field in your config):
951
+
952
+ 'global' (default):
953
+ Base: ~/.onetool/onetool.yaml → bundled defaults (if global missing)
954
+ Your config overrides the base. Use for project configs that
955
+ extend user preferences (API keys, timeouts, etc.).
956
+
957
+ 'bundled':
958
+ Base: bundled defaults only (ignores ~/.onetool/)
959
+ Your config overrides bundled. Use for reproducible configs
960
+ that shouldn't depend on user-specific settings.
961
+
962
+ 'none':
963
+ No base config. Your config is standalone.
964
+ Use for fully self-contained configurations.
965
+
966
+ Example minimal project config using global inheritance::
967
+
968
+ # .onetool/onetool.yaml
969
+ version: 1
970
+ # inherit: global (implicit default - gets API keys from ~/.onetool/)
971
+ tools_dir:
972
+ - ./tools/*.py
973
+
974
+ Args:
975
+ config_path: Path to config file (overrides resolution)
976
+
977
+ Returns:
978
+ Validated OneToolConfig
979
+
980
+ Raises:
981
+ FileNotFoundError: If explicit config path doesn't exist
982
+ ValueError: If YAML is invalid or validation fails
983
+ """
984
+ resolved_path = _resolve_config_path(config_path)
985
+
986
+ if resolved_path is None:
987
+ logger.debug("No config file found, using defaults")
988
+ config = OneToolConfig()
989
+ config._config_dir = get_effective_cwd() / ".onetool" / CONFIG_SUBDIR
990
+ return config
991
+
992
+ logger.debug(f"Loading config from {resolved_path}")
993
+
994
+ raw_data = _load_yaml_file(resolved_path)
995
+ expanded_data = _expand_secrets_recursive(raw_data)
996
+
997
+ # Process includes before validation (merges external files)
998
+ # Resolve includes from OT_DIR (.onetool/), not config_dir (.onetool/config/)
999
+ config_dir = resolved_path.parent.resolve()
1000
+ ot_dir = config_dir.parent # Go up from config/ to .onetool/
1001
+ merged_data = _load_includes(expanded_data, ot_dir)
1002
+
1003
+ # Determine inheritance mode (default: global)
1004
+ inherit = merged_data.get("inherit", "global")
1005
+ if inherit not in ("global", "bundled", "none"):
1006
+ logger.warning(f"Invalid inherit value '{inherit}', using 'global'")
1007
+ inherit = "global"
1008
+
1009
+ # Load and merge base config for inheritance
1010
+ base_config = _load_base_config(inherit, resolved_path)
1011
+ if base_config:
1012
+ # Base first, then current config overrides
1013
+ merged_data = _deep_merge(base_config, merged_data)
1014
+ logger.debug(f"Applied inheritance mode: {inherit}")
1015
+
1016
+ _validate_version(merged_data, resolved_path)
1017
+ _remove_legacy_fields(merged_data)
1018
+
1019
+ try:
1020
+ config = OneToolConfig.model_validate(merged_data)
1021
+ except Exception as e:
1022
+ raise ValueError(f"Invalid configuration in {resolved_path}: {e}") from e
1023
+
1024
+ config._config_dir = resolved_path.parent.resolve()
1025
+
1026
+ logger.info(f"Config loaded: version {config.version}")
1027
+
1028
+ return config
1029
+
1030
+
1031
+ # Global config instance (singleton pattern)
1032
+ # Thread-safety: Protected by _config_lock for safe concurrent access.
1033
+ _config: OneToolConfig | None = None
1034
+ _config_lock = threading.Lock()
1035
+
1036
+
1037
+ def is_log_verbose() -> bool:
1038
+ """Check if verbose logging is enabled.
1039
+
1040
+ Verbose mode disables log truncation, showing full values.
1041
+ Enabled by:
1042
+ - OT_LOG_VERBOSE=true environment variable (highest priority)
1043
+ - log_verbose: true in config file
1044
+
1045
+ Returns:
1046
+ True if verbose logging is enabled
1047
+ """
1048
+ # Environment variable takes priority
1049
+ env_verbose = os.getenv("OT_LOG_VERBOSE", "").lower()
1050
+ if env_verbose in ("true", "1", "yes"):
1051
+ return True
1052
+ if env_verbose in ("false", "0", "no"):
1053
+ return False
1054
+
1055
+ # Fall back to config (thread-safe read)
1056
+ with _config_lock:
1057
+ if _config is not None:
1058
+ return _config.log_verbose
1059
+
1060
+ return False
1061
+
1062
+
1063
+ def get_config(
1064
+ config_path: Path | str | None = None, reload: bool = False
1065
+ ) -> OneToolConfig:
1066
+ """Get or load the global configuration (singleton pattern).
1067
+
1068
+ Returns a cached config instance. On first call, loads config from disk.
1069
+ Subsequent calls return the cached instance unless reload=True.
1070
+
1071
+ Thread-safety: Protected by lock for safe concurrent access.
1072
+
1073
+ Args:
1074
+ config_path: Path to config file (only used on first load or reload).
1075
+ Ignored after config is cached unless reload=True.
1076
+ reload: Force reload configuration from disk. Use sparingly - primarily
1077
+ intended for testing. In production, restart the process to reload.
1078
+
1079
+ Returns:
1080
+ OneToolConfig instance (same instance on subsequent calls)
1081
+ """
1082
+ global _config
1083
+
1084
+ with _config_lock:
1085
+ if _config is None or reload:
1086
+ _config = load_config(config_path)
1087
+ return _config