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/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
|