onetool-mcp 1.0.0rc2__py3-none-any.whl → 1.0.0rc3__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.
- onetool/cli.py +2 -0
- {onetool_mcp-1.0.0rc2.dist-info → onetool_mcp-1.0.0rc3.dist-info}/METADATA +26 -33
- {onetool_mcp-1.0.0rc2.dist-info → onetool_mcp-1.0.0rc3.dist-info}/RECORD +31 -33
- ot/config/__init__.py +90 -48
- ot/config/global_templates/__init__.py +2 -2
- ot/config/global_templates/diagram-templates/api-flow.mmd +33 -33
- ot/config/global_templates/diagram-templates/c4-context.puml +30 -30
- ot/config/global_templates/diagram-templates/class-diagram.mmd +87 -87
- ot/config/global_templates/diagram-templates/feature-mindmap.mmd +70 -70
- ot/config/global_templates/diagram-templates/microservices.d2 +81 -81
- ot/config/global_templates/diagram-templates/project-gantt.mmd +37 -37
- ot/config/global_templates/diagram-templates/state-machine.mmd +42 -42
- ot/config/global_templates/diagram.yaml +167 -167
- ot/config/global_templates/onetool.yaml +2 -0
- ot/config/global_templates/prompts.yaml +102 -102
- ot/config/global_templates/security.yaml +1 -4
- ot/config/global_templates/servers.yaml +1 -1
- ot/config/global_templates/tool_templates/__init__.py +7 -7
- ot/config/loader.py +226 -869
- ot/config/models.py +735 -0
- ot/config/secrets.py +243 -192
- ot/executor/tool_loader.py +10 -1
- ot/executor/validator.py +11 -1
- ot/meta.py +338 -33
- ot/prompts.py +228 -218
- ot/proxy/manager.py +168 -8
- ot/registry/__init__.py +199 -189
- ot/config/dynamic.py +0 -121
- ot/config/mcp.py +0 -149
- ot/config/tool_config.py +0 -125
- {onetool_mcp-1.0.0rc2.dist-info → onetool_mcp-1.0.0rc3.dist-info}/WHEEL +0 -0
- {onetool_mcp-1.0.0rc2.dist-info → onetool_mcp-1.0.0rc3.dist-info}/entry_points.txt +0 -0
- {onetool_mcp-1.0.0rc2.dist-info → onetool_mcp-1.0.0rc3.dist-info}/licenses/LICENSE.txt +0 -0
- {onetool_mcp-1.0.0rc2.dist-info → onetool_mcp-1.0.0rc3.dist-info}/licenses/NOTICE.txt +0 -0
ot/config/loader.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
"""YAML configuration loading for OneTool.
|
|
1
|
+
"""YAML configuration loading for OneTool V2 (global-only).
|
|
2
2
|
|
|
3
|
-
Loads onetool.yaml with tool discovery patterns and settings.
|
|
3
|
+
Loads onetool.yaml with tool discovery patterns and settings from global config only.
|
|
4
4
|
|
|
5
5
|
Example onetool.yaml:
|
|
6
6
|
|
|
@@ -10,45 +10,39 @@ Example onetool.yaml:
|
|
|
10
10
|
- prompts.yaml # prompts: section
|
|
11
11
|
- snippets.yaml # snippets: section
|
|
12
12
|
|
|
13
|
+
env:
|
|
14
|
+
HOME: /home/user
|
|
15
|
+
LANG: en_US.UTF-8
|
|
16
|
+
|
|
13
17
|
tools_dir:
|
|
14
|
-
-
|
|
18
|
+
- tools/*.py
|
|
15
19
|
|
|
16
20
|
transform:
|
|
17
21
|
model: anthropic/claude-3-5-haiku
|
|
18
|
-
|
|
19
|
-
secrets_file: secrets.yaml # default: sibling of onetool.yaml
|
|
20
22
|
"""
|
|
21
23
|
|
|
22
24
|
from __future__ import annotations
|
|
23
25
|
|
|
24
|
-
import glob
|
|
25
26
|
import os
|
|
26
27
|
import threading
|
|
27
28
|
from pathlib import Path
|
|
28
|
-
from typing import Any,
|
|
29
|
+
from typing import Any, TypeVar
|
|
29
30
|
|
|
30
31
|
import yaml
|
|
31
32
|
from loguru import logger
|
|
32
|
-
from pydantic import
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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_config_dir,
|
|
45
|
-
get_effective_cwd,
|
|
46
|
-
get_global_dir,
|
|
47
|
-
)
|
|
33
|
+
from pydantic import BaseModel
|
|
34
|
+
|
|
35
|
+
from ot.config.models import OneToolConfig
|
|
36
|
+
from ot.config.secrets import expand_vars, get_secrets
|
|
48
37
|
|
|
49
38
|
# Current config schema version
|
|
50
39
|
CURRENT_CONFIG_VERSION = 1
|
|
51
40
|
|
|
41
|
+
# Maximum include depth to prevent infinite loops
|
|
42
|
+
MAX_INCLUDE_DEPTH = 5
|
|
43
|
+
|
|
44
|
+
T = TypeVar("T", bound=BaseModel)
|
|
45
|
+
|
|
52
46
|
|
|
53
47
|
class ConfigNotFoundError(Exception):
|
|
54
48
|
"""Raised when configuration file is not found and no fallback is available.
|
|
@@ -58,689 +52,14 @@ class ConfigNotFoundError(Exception):
|
|
|
58
52
|
"""
|
|
59
53
|
|
|
60
54
|
|
|
61
|
-
class TransformConfig(BaseModel):
|
|
62
|
-
"""Configuration for the transform() tool."""
|
|
63
|
-
|
|
64
|
-
model: str = Field(default="", description="Model for code generation")
|
|
65
|
-
base_url: str = Field(default="", description="Base URL for OpenAI-compatible API")
|
|
66
|
-
max_tokens: int = Field(default=4096, description="Max output tokens")
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
class SnippetParam(BaseModel):
|
|
70
|
-
"""Parameter definition for a snippet."""
|
|
71
|
-
|
|
72
|
-
required: bool = Field(
|
|
73
|
-
default=True, description="Whether this parameter is required"
|
|
74
|
-
)
|
|
75
|
-
default: Any = Field(default=None, description="Default value if not provided")
|
|
76
|
-
description: str = Field(default="", description="Description of the parameter")
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
class SnippetDef(BaseModel):
|
|
80
|
-
"""Definition of a reusable snippet template."""
|
|
81
|
-
|
|
82
|
-
description: str = Field(
|
|
83
|
-
default="", description="Description of what this snippet does"
|
|
84
|
-
)
|
|
85
|
-
params: dict[str, SnippetParam] = Field(
|
|
86
|
-
default_factory=dict, description="Parameter definitions"
|
|
87
|
-
)
|
|
88
|
-
body: str = Field(
|
|
89
|
-
..., description="Jinja2 template body that expands to Python code"
|
|
90
|
-
)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
# ==================== Core Configuration Models ====================
|
|
94
|
-
# Note: Tool-specific configs (BraveConfig, GroundConfig, etc.) have been
|
|
95
|
-
# moved to their respective tool files. Tools access config via
|
|
96
|
-
# get_tool_config(pack, Config) at runtime.
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
class MsgTopicConfig(BaseModel):
|
|
100
|
-
"""Topic-to-file mapping for message routing."""
|
|
101
|
-
|
|
102
|
-
pattern: str = Field(
|
|
103
|
-
...,
|
|
104
|
-
description="Glob-style topic pattern (e.g., 'status:*', 'doc:*')",
|
|
105
|
-
)
|
|
106
|
-
file: str = Field(
|
|
107
|
-
...,
|
|
108
|
-
description="File path for messages matching this pattern (supports ~ and ${VAR})",
|
|
109
|
-
)
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
class MsgConfig(BaseModel):
|
|
113
|
-
"""Message tool configuration."""
|
|
114
|
-
|
|
115
|
-
topics: list[MsgTopicConfig] = Field(
|
|
116
|
-
default_factory=list,
|
|
117
|
-
description="Topic patterns mapped to output files (first match wins)",
|
|
118
|
-
)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
class OutputSanitizationConfig(BaseModel):
|
|
122
|
-
"""Output sanitization configuration for prompt injection protection.
|
|
123
|
-
|
|
124
|
-
Protects against indirect prompt injection by sanitizing tool outputs
|
|
125
|
-
that may contain malicious payloads from external content.
|
|
126
|
-
|
|
127
|
-
Three-layer defense:
|
|
128
|
-
1. Trigger sanitization: Replace __ot, mcp__onetool patterns
|
|
129
|
-
2. Tag sanitization: Remove <external-content-*> patterns
|
|
130
|
-
3. GUID-tagged boundaries: Wrap content in unpredictable tags
|
|
131
|
-
"""
|
|
132
|
-
|
|
133
|
-
enabled: bool = Field(
|
|
134
|
-
default=True,
|
|
135
|
-
description="Global toggle for output sanitization",
|
|
136
|
-
)
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
def _flatten_nested_list(items: list[Any]) -> list[str]:
|
|
140
|
-
"""Flatten nested lists/arrays into a single list of strings.
|
|
141
|
-
|
|
142
|
-
Supports the compact array format in security.yaml:
|
|
143
|
-
allow:
|
|
144
|
-
- [str, int, float] # Grouped for readability
|
|
145
|
-
- print # Single item
|
|
146
|
-
|
|
147
|
-
Args:
|
|
148
|
-
items: List that may contain strings or nested lists
|
|
149
|
-
|
|
150
|
-
Returns:
|
|
151
|
-
Flattened list of strings
|
|
152
|
-
"""
|
|
153
|
-
result: list[str] = []
|
|
154
|
-
for item in items:
|
|
155
|
-
if isinstance(item, list):
|
|
156
|
-
result.extend(str(x) for x in item)
|
|
157
|
-
else:
|
|
158
|
-
result.append(str(item))
|
|
159
|
-
return result
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
class BuiltinsConfig(BaseModel):
|
|
163
|
-
"""Builtins allowlist configuration."""
|
|
164
|
-
|
|
165
|
-
allow: list[str] = Field(
|
|
166
|
-
default_factory=list,
|
|
167
|
-
description="Allowed builtin functions and types",
|
|
168
|
-
)
|
|
169
|
-
|
|
170
|
-
@field_validator("allow", mode="before")
|
|
171
|
-
@classmethod
|
|
172
|
-
def flatten_allow(cls, v: Any) -> list[str]:
|
|
173
|
-
"""Flatten nested arrays in allow list."""
|
|
174
|
-
if isinstance(v, list):
|
|
175
|
-
return _flatten_nested_list(v)
|
|
176
|
-
return v if v else []
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
class ImportsConfig(BaseModel):
|
|
180
|
-
"""Imports allowlist configuration."""
|
|
181
|
-
|
|
182
|
-
allow: list[str] = Field(
|
|
183
|
-
default_factory=list,
|
|
184
|
-
description="Allowed import modules",
|
|
185
|
-
)
|
|
186
|
-
warn: list[str] = Field(
|
|
187
|
-
default_factory=list,
|
|
188
|
-
description="Imports that trigger warnings but are allowed",
|
|
189
|
-
)
|
|
190
|
-
|
|
191
|
-
@field_validator("allow", "warn", mode="before")
|
|
192
|
-
@classmethod
|
|
193
|
-
def flatten_lists(cls, v: Any) -> list[str]:
|
|
194
|
-
"""Flatten nested arrays in lists."""
|
|
195
|
-
if isinstance(v, list):
|
|
196
|
-
return _flatten_nested_list(v)
|
|
197
|
-
return v if v else []
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
class CallsConfig(BaseModel):
|
|
201
|
-
"""Qualified calls configuration."""
|
|
202
|
-
|
|
203
|
-
allow: list[str] = Field(
|
|
204
|
-
default_factory=list,
|
|
205
|
-
description="Allowed qualified function calls (e.g., 'json.loads')",
|
|
206
|
-
)
|
|
207
|
-
block: list[str] = Field(
|
|
208
|
-
default_factory=list,
|
|
209
|
-
description="Blocked qualified function calls (e.g., 'pickle.*')",
|
|
210
|
-
)
|
|
211
|
-
warn: list[str] = Field(
|
|
212
|
-
default_factory=list,
|
|
213
|
-
description="Qualified calls that trigger warnings",
|
|
214
|
-
)
|
|
215
|
-
|
|
216
|
-
@field_validator("allow", "block", "warn", mode="before")
|
|
217
|
-
@classmethod
|
|
218
|
-
def flatten_lists(cls, v: Any) -> list[str]:
|
|
219
|
-
"""Flatten nested arrays in lists."""
|
|
220
|
-
if isinstance(v, list):
|
|
221
|
-
return _flatten_nested_list(v)
|
|
222
|
-
return v if v else []
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
class DundersConfig(BaseModel):
|
|
226
|
-
"""Magic variable (dunder) configuration."""
|
|
227
|
-
|
|
228
|
-
allow: list[str] = Field(
|
|
229
|
-
default_factory=list,
|
|
230
|
-
description="Allowed magic variables (e.g., '__format__')",
|
|
231
|
-
)
|
|
232
|
-
|
|
233
|
-
@field_validator("allow", mode="before")
|
|
234
|
-
@classmethod
|
|
235
|
-
def flatten_allow(cls, v: Any) -> list[str]:
|
|
236
|
-
"""Flatten nested arrays in allow list."""
|
|
237
|
-
if isinstance(v, list):
|
|
238
|
-
return _flatten_nested_list(v)
|
|
239
|
-
return v if v else []
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
class SecurityConfig(BaseModel):
|
|
243
|
-
"""Code validation security configuration.
|
|
244
|
-
|
|
245
|
-
Allowlist-based security model: block everything by default, explicitly
|
|
246
|
-
allow what's safe. Tool namespaces (ot.*, brave.*, etc.) are auto-allowed.
|
|
247
|
-
|
|
248
|
-
Configuration structure:
|
|
249
|
-
security:
|
|
250
|
-
builtins:
|
|
251
|
-
allow: [str, int, list, ...]
|
|
252
|
-
imports:
|
|
253
|
-
allow: [json, re, math, ...]
|
|
254
|
-
warn: [yaml]
|
|
255
|
-
calls:
|
|
256
|
-
block: [pickle.*, yaml.load]
|
|
257
|
-
warn: [random.seed]
|
|
258
|
-
dunders:
|
|
259
|
-
allow: [__format__, __sanitize__]
|
|
260
|
-
|
|
261
|
-
Patterns support fnmatch wildcards:
|
|
262
|
-
- '*' matches any characters (e.g., 'subprocess.*' matches 'subprocess.run')
|
|
263
|
-
- '?' matches a single character
|
|
264
|
-
- '[seq]' matches any character in seq
|
|
265
|
-
|
|
266
|
-
Compact array format for readability:
|
|
267
|
-
allow:
|
|
268
|
-
- [str, int, float] # Grouped items
|
|
269
|
-
- print # Single item
|
|
270
|
-
"""
|
|
271
|
-
|
|
272
|
-
validate_code: bool = Field(
|
|
273
|
-
default=True,
|
|
274
|
-
description="Enable AST-based code validation before execution",
|
|
275
|
-
)
|
|
276
|
-
|
|
277
|
-
enabled: bool = Field(
|
|
278
|
-
default=True,
|
|
279
|
-
description="Enable security pattern checking (requires validate_code)",
|
|
280
|
-
)
|
|
281
|
-
|
|
282
|
-
# New category-based allowlist configuration
|
|
283
|
-
builtins: BuiltinsConfig = Field(
|
|
284
|
-
default_factory=BuiltinsConfig,
|
|
285
|
-
description="Builtins allowlist configuration",
|
|
286
|
-
)
|
|
287
|
-
|
|
288
|
-
imports: ImportsConfig = Field(
|
|
289
|
-
default_factory=ImportsConfig,
|
|
290
|
-
description="Imports allowlist configuration",
|
|
291
|
-
)
|
|
292
|
-
|
|
293
|
-
calls: CallsConfig = Field(
|
|
294
|
-
default_factory=CallsConfig,
|
|
295
|
-
description="Qualified calls configuration",
|
|
296
|
-
)
|
|
297
|
-
|
|
298
|
-
dunders: DundersConfig = Field(
|
|
299
|
-
default_factory=DundersConfig,
|
|
300
|
-
description="Magic variable (dunder) configuration",
|
|
301
|
-
)
|
|
302
|
-
|
|
303
|
-
# Output sanitization configuration
|
|
304
|
-
sanitize: OutputSanitizationConfig = Field(
|
|
305
|
-
default_factory=OutputSanitizationConfig,
|
|
306
|
-
description="Output sanitization for prompt injection protection",
|
|
307
|
-
)
|
|
308
|
-
|
|
309
|
-
def get_allowed_builtins(self) -> frozenset[str]:
|
|
310
|
-
"""Get the set of allowed builtins."""
|
|
311
|
-
return frozenset(self.builtins.allow)
|
|
312
|
-
|
|
313
|
-
def get_allowed_imports(self) -> frozenset[str]:
|
|
314
|
-
"""Get the set of allowed imports."""
|
|
315
|
-
return frozenset(self.imports.allow)
|
|
316
|
-
|
|
317
|
-
def get_warned_imports(self) -> frozenset[str]:
|
|
318
|
-
"""Get the set of imports that trigger warnings."""
|
|
319
|
-
return frozenset(self.imports.warn)
|
|
320
|
-
|
|
321
|
-
def get_blocked_calls(self) -> frozenset[str]:
|
|
322
|
-
"""Get the set of blocked qualified calls."""
|
|
323
|
-
return frozenset(self.calls.block)
|
|
324
|
-
|
|
325
|
-
def get_warned_calls(self) -> frozenset[str]:
|
|
326
|
-
"""Get the set of qualified calls that trigger warnings."""
|
|
327
|
-
return frozenset(self.calls.warn)
|
|
328
|
-
|
|
329
|
-
def get_allowed_calls(self) -> frozenset[str]:
|
|
330
|
-
"""Get the set of explicitly allowed qualified calls."""
|
|
331
|
-
return frozenset(self.calls.allow)
|
|
332
|
-
|
|
333
|
-
def get_allowed_dunders(self) -> frozenset[str]:
|
|
334
|
-
"""Get the set of allowed magic variables."""
|
|
335
|
-
return frozenset(self.dunders.allow)
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
class OutputConfig(BaseModel):
|
|
339
|
-
"""Large output handling configuration.
|
|
340
|
-
|
|
341
|
-
When tool outputs exceed max_inline_size, they are stored to disk
|
|
342
|
-
and a summary with a query handle is returned instead.
|
|
343
|
-
"""
|
|
344
|
-
|
|
345
|
-
max_inline_size: int = Field(
|
|
346
|
-
default=50000,
|
|
347
|
-
ge=0,
|
|
348
|
-
description="Max output size in bytes before storing to disk. Set to 0 to disable.",
|
|
349
|
-
)
|
|
350
|
-
result_store_dir: str = Field(
|
|
351
|
-
default="tmp",
|
|
352
|
-
description="Directory for result files (relative to .onetool/)",
|
|
353
|
-
)
|
|
354
|
-
result_ttl: int = Field(
|
|
355
|
-
default=3600,
|
|
356
|
-
ge=0,
|
|
357
|
-
description="Time-to-live in seconds for stored results (0 = no expiry)",
|
|
358
|
-
)
|
|
359
|
-
preview_lines: int = Field(
|
|
360
|
-
default=10,
|
|
361
|
-
ge=0,
|
|
362
|
-
description="Number of preview lines to include in summary",
|
|
363
|
-
)
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
class StatsConfig(BaseModel):
|
|
367
|
-
"""Runtime statistics collection configuration."""
|
|
368
|
-
|
|
369
|
-
enabled: bool = Field(
|
|
370
|
-
default=True,
|
|
371
|
-
description="Enable statistics collection",
|
|
372
|
-
)
|
|
373
|
-
persist_dir: str = Field(
|
|
374
|
-
default="stats",
|
|
375
|
-
description="Directory for stats files (relative to .onetool/)",
|
|
376
|
-
)
|
|
377
|
-
persist_path: str = Field(
|
|
378
|
-
default="stats.jsonl",
|
|
379
|
-
description="Filename for stats persistence (within persist_dir)",
|
|
380
|
-
)
|
|
381
|
-
flush_interval_seconds: int = Field(
|
|
382
|
-
default=30,
|
|
383
|
-
ge=1,
|
|
384
|
-
le=300,
|
|
385
|
-
description="Interval in seconds between flushing stats to disk",
|
|
386
|
-
)
|
|
387
|
-
context_per_call: int = Field(
|
|
388
|
-
default=30000,
|
|
389
|
-
ge=0,
|
|
390
|
-
description="Estimated context tokens saved per consolidated tool call",
|
|
391
|
-
)
|
|
392
|
-
time_overhead_per_call_ms: int = Field(
|
|
393
|
-
default=4000,
|
|
394
|
-
ge=0,
|
|
395
|
-
description="Estimated time overhead in ms saved per consolidated tool call",
|
|
396
|
-
)
|
|
397
|
-
model: str = Field(
|
|
398
|
-
default="anthropic/claude-opus-4.5",
|
|
399
|
-
description="Model for cost estimation (e.g., anthropic/claude-opus-4.5)",
|
|
400
|
-
)
|
|
401
|
-
cost_per_million_input_tokens: float = Field(
|
|
402
|
-
default=15.0,
|
|
403
|
-
ge=0,
|
|
404
|
-
description="Cost in USD per million input tokens",
|
|
405
|
-
)
|
|
406
|
-
cost_per_million_output_tokens: float = Field(
|
|
407
|
-
default=75.0,
|
|
408
|
-
ge=0,
|
|
409
|
-
description="Cost in USD per million output tokens",
|
|
410
|
-
)
|
|
411
|
-
chars_per_token: float = Field(
|
|
412
|
-
default=4.0,
|
|
413
|
-
ge=1.0,
|
|
414
|
-
description="Average characters per token for estimation",
|
|
415
|
-
)
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
class ToolsConfig(BaseModel):
|
|
419
|
-
"""Aggregated tool configurations.
|
|
420
|
-
|
|
421
|
-
Core configs (msg, stats) are typed fields. Tool-specific configs
|
|
422
|
-
(brave, ground, etc.) are allowed as extra fields and accessed via
|
|
423
|
-
get_tool_config() with schemas defined in tool files.
|
|
424
|
-
"""
|
|
425
|
-
|
|
426
|
-
model_config = ConfigDict(extra="allow")
|
|
427
|
-
|
|
428
|
-
# Core configs - always available
|
|
429
|
-
msg: MsgConfig = Field(default_factory=MsgConfig)
|
|
430
|
-
stats: StatsConfig = Field(default_factory=StatsConfig)
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
class OneToolConfig(BaseModel):
|
|
434
|
-
"""Root configuration for OneTool V1."""
|
|
435
|
-
|
|
436
|
-
# Private attribute to track config file location (not serialized)
|
|
437
|
-
# Note: Path is natively supported by Pydantic, no arbitrary_types_allowed needed
|
|
438
|
-
_config_dir: Path | None = PrivateAttr(default=None)
|
|
439
|
-
|
|
440
|
-
version: int = Field(
|
|
441
|
-
default=1,
|
|
442
|
-
description="Config schema version for migration support",
|
|
443
|
-
)
|
|
444
|
-
|
|
445
|
-
inherit: Literal["global", "none"] = Field(
|
|
446
|
-
default="global",
|
|
447
|
-
description=(
|
|
448
|
-
"Config inheritance mode:\n"
|
|
449
|
-
" - 'global' (default): Merge ~/.onetool/onetool.yaml first. "
|
|
450
|
-
"Use for project configs that extend user prefs.\n"
|
|
451
|
-
" - 'none': Standalone config with no inheritance. "
|
|
452
|
-
"Use for fully self-contained configs."
|
|
453
|
-
),
|
|
454
|
-
)
|
|
455
|
-
|
|
456
|
-
include: list[str] = Field(
|
|
457
|
-
default_factory=list,
|
|
458
|
-
description="Files to deep-merge into config (processed before validation)",
|
|
459
|
-
)
|
|
460
|
-
|
|
461
|
-
transform: TransformConfig = Field(
|
|
462
|
-
default_factory=TransformConfig, description="transform() tool configuration"
|
|
463
|
-
)
|
|
464
|
-
|
|
465
|
-
alias: dict[str, str] = Field(
|
|
466
|
-
default_factory=dict,
|
|
467
|
-
description="Short alias names mapping to full function names (e.g., ws -> brave.web_search)",
|
|
468
|
-
)
|
|
469
|
-
|
|
470
|
-
snippets: dict[str, SnippetDef] = Field(
|
|
471
|
-
default_factory=dict,
|
|
472
|
-
description="Reusable snippet templates with Jinja2 variable substitution",
|
|
473
|
-
)
|
|
474
|
-
|
|
475
|
-
servers: dict[str, McpServerConfig] = Field(
|
|
476
|
-
default_factory=dict,
|
|
477
|
-
description="External MCP servers to proxy through OneTool",
|
|
478
|
-
)
|
|
479
|
-
|
|
480
|
-
tools: ToolsConfig = Field(
|
|
481
|
-
default_factory=ToolsConfig,
|
|
482
|
-
description="Tool-specific configuration (timeouts, limits, etc.)",
|
|
483
|
-
)
|
|
484
|
-
|
|
485
|
-
security: SecurityConfig = Field(
|
|
486
|
-
default_factory=SecurityConfig,
|
|
487
|
-
description="Code validation and security pattern configuration",
|
|
488
|
-
)
|
|
489
|
-
|
|
490
|
-
stats: StatsConfig = Field(
|
|
491
|
-
default_factory=StatsConfig,
|
|
492
|
-
description="Runtime statistics collection configuration (replaces tools.stats)",
|
|
493
|
-
)
|
|
494
|
-
|
|
495
|
-
output: OutputConfig = Field(
|
|
496
|
-
default_factory=OutputConfig,
|
|
497
|
-
description="Large output handling configuration",
|
|
498
|
-
)
|
|
499
|
-
|
|
500
|
-
tools_dir: list[str] = Field(
|
|
501
|
-
default_factory=lambda: ["tools/*.py"],
|
|
502
|
-
description="Glob patterns for tool discovery (relative to OT_DIR .onetool/, or absolute)",
|
|
503
|
-
)
|
|
504
|
-
secrets_file: str = Field(
|
|
505
|
-
default="config/secrets.yaml",
|
|
506
|
-
description="Path to secrets file (relative to OT_DIR .onetool/, or absolute)",
|
|
507
|
-
)
|
|
508
|
-
prompts: dict[str, Any] | None = Field(
|
|
509
|
-
default=None,
|
|
510
|
-
description="Inline prompts config (can also be loaded via include:)",
|
|
511
|
-
)
|
|
512
|
-
|
|
513
|
-
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = Field(
|
|
514
|
-
default="INFO", description="Logging level"
|
|
515
|
-
)
|
|
516
|
-
log_dir: str = Field(
|
|
517
|
-
default="logs",
|
|
518
|
-
description="Directory for log files (relative to .onetool/)",
|
|
519
|
-
)
|
|
520
|
-
compact_max_length: int = Field(
|
|
521
|
-
default=120, description="Max value length in compact console output"
|
|
522
|
-
)
|
|
523
|
-
log_verbose: bool = Field(
|
|
524
|
-
default=False,
|
|
525
|
-
description="Disable log truncation for debugging (full values in output)",
|
|
526
|
-
)
|
|
527
|
-
debug_tracebacks: bool = Field(
|
|
528
|
-
default=False,
|
|
529
|
-
description="Show verbose tracebacks with local variables on errors",
|
|
530
|
-
)
|
|
531
|
-
|
|
532
|
-
@field_validator("snippets", "servers", "alias", mode="before")
|
|
533
|
-
@classmethod
|
|
534
|
-
def empty_dict_if_none(cls, v: Any) -> Any:
|
|
535
|
-
"""Convert None to empty dict for dict-type fields.
|
|
536
|
-
|
|
537
|
-
This handles YAML files where the key exists but all values are commented out,
|
|
538
|
-
which YAML parses as None instead of an empty dict.
|
|
539
|
-
"""
|
|
540
|
-
return {} if v is None else v
|
|
541
|
-
|
|
542
|
-
@model_validator(mode="before")
|
|
543
|
-
@classmethod
|
|
544
|
-
def migrate_tools_stats(cls, data: Any) -> Any:
|
|
545
|
-
"""Migrate tools.stats to root-level stats with deprecation warning.
|
|
546
|
-
|
|
547
|
-
During the deprecation period, supports both paths:
|
|
548
|
-
- Root-level `stats:` (preferred)
|
|
549
|
-
- Legacy `tools.stats:` (deprecated)
|
|
550
|
-
|
|
551
|
-
If both exist, root-level takes precedence.
|
|
552
|
-
"""
|
|
553
|
-
if not isinstance(data, dict):
|
|
554
|
-
return data
|
|
555
|
-
|
|
556
|
-
tools = data.get("tools")
|
|
557
|
-
if not isinstance(tools, dict):
|
|
558
|
-
return data
|
|
559
|
-
|
|
560
|
-
legacy_stats = tools.get("stats")
|
|
561
|
-
root_stats = data.get("stats")
|
|
562
|
-
|
|
563
|
-
if legacy_stats and not root_stats:
|
|
564
|
-
# Only tools.stats exists - migrate with warning
|
|
565
|
-
logger.warning(
|
|
566
|
-
"Deprecation: 'tools.stats' config path is deprecated. "
|
|
567
|
-
"Move to root-level 'stats:' section. "
|
|
568
|
-
"This will be removed in a future version."
|
|
569
|
-
)
|
|
570
|
-
data["stats"] = legacy_stats
|
|
571
|
-
elif legacy_stats and root_stats:
|
|
572
|
-
# Both exist - root takes precedence, log info
|
|
573
|
-
logger.debug(
|
|
574
|
-
"Both 'stats' and 'tools.stats' defined. "
|
|
575
|
-
"Using root-level 'stats' (ignoring deprecated 'tools.stats')."
|
|
576
|
-
)
|
|
577
|
-
|
|
578
|
-
return data
|
|
579
|
-
|
|
580
|
-
def get_tool_files(self) -> list[Path]:
|
|
581
|
-
"""Get list of tool files matching configured glob patterns.
|
|
582
|
-
|
|
583
|
-
Pattern resolution (all relative to OT_DIR .onetool/):
|
|
584
|
-
- Absolute paths: used as-is
|
|
585
|
-
- ~ paths: expanded to home directory
|
|
586
|
-
- Relative patterns: resolved relative to OT_DIR (.onetool/)
|
|
587
|
-
|
|
588
|
-
Returns:
|
|
589
|
-
List of Path objects for tool files
|
|
590
|
-
"""
|
|
591
|
-
tool_files: list[Path] = []
|
|
592
|
-
cwd = get_effective_cwd()
|
|
593
|
-
|
|
594
|
-
# Determine OT_DIR for resolving patterns
|
|
595
|
-
if self._config_dir is not None:
|
|
596
|
-
# _config_dir is the config/ subdirectory, go up to .onetool/
|
|
597
|
-
ot_dir = self._config_dir.parent
|
|
598
|
-
else:
|
|
599
|
-
# Fallback: cwd/.onetool
|
|
600
|
-
ot_dir = cwd / ".onetool"
|
|
601
|
-
|
|
602
|
-
for pattern in self.tools_dir:
|
|
603
|
-
# Expand ~ first
|
|
604
|
-
expanded = Path(pattern).expanduser()
|
|
605
|
-
|
|
606
|
-
# Determine resolved pattern for globbing
|
|
607
|
-
if expanded.is_absolute():
|
|
608
|
-
# Absolute pattern - use as-is
|
|
609
|
-
resolved_pattern = str(expanded)
|
|
610
|
-
else:
|
|
611
|
-
# All relative patterns resolve against OT_DIR
|
|
612
|
-
resolved_pattern = str(ot_dir / pattern)
|
|
613
|
-
|
|
614
|
-
# Use glob.glob() for cross-platform compatibility
|
|
615
|
-
for match in glob.glob(resolved_pattern, recursive=True): # noqa: PTH207
|
|
616
|
-
path = Path(match)
|
|
617
|
-
if path.is_file() and path.suffix == ".py":
|
|
618
|
-
tool_files.append(path)
|
|
619
|
-
|
|
620
|
-
return sorted(set(tool_files))
|
|
621
|
-
|
|
622
|
-
def _resolve_onetool_relative_path(self, path_str: str) -> Path:
|
|
623
|
-
"""Resolve a path relative to the .onetool directory.
|
|
624
|
-
|
|
625
|
-
Handles:
|
|
626
|
-
- Absolute paths: returned as-is
|
|
627
|
-
- ~ expansion: expanded to home directory
|
|
628
|
-
- Relative paths: resolved relative to .onetool/ directory (parent of config/)
|
|
629
|
-
|
|
630
|
-
Note: Does NOT expand ${VAR} - use ~/path instead of ${HOME}/path.
|
|
631
|
-
|
|
632
|
-
Args:
|
|
633
|
-
path_str: Path string to resolve
|
|
634
|
-
|
|
635
|
-
Returns:
|
|
636
|
-
Resolved absolute Path
|
|
637
|
-
"""
|
|
638
|
-
# Only expand ~ (no ${VAR} expansion)
|
|
639
|
-
path = Path(path_str).expanduser()
|
|
640
|
-
|
|
641
|
-
# If absolute after expansion, use as-is
|
|
642
|
-
if path.is_absolute():
|
|
643
|
-
return path
|
|
644
|
-
|
|
645
|
-
# Resolve relative to .onetool/ directory (parent of config/)
|
|
646
|
-
if self._config_dir is not None:
|
|
647
|
-
# _config_dir is the config/ subdirectory, go up to .onetool/
|
|
648
|
-
onetool_dir = self._config_dir.parent
|
|
649
|
-
return (onetool_dir / path).resolve()
|
|
650
|
-
|
|
651
|
-
# Fallback: resolve relative to cwd/.onetool
|
|
652
|
-
return (get_effective_cwd() / ".onetool" / path).resolve()
|
|
653
|
-
|
|
654
|
-
def _resolve_config_relative_path(self, path_str: str) -> Path:
|
|
655
|
-
"""Resolve a path relative to the config directory.
|
|
656
|
-
|
|
657
|
-
Handles:
|
|
658
|
-
- Absolute paths: returned as-is
|
|
659
|
-
- ~ expansion: expanded to home directory
|
|
660
|
-
- Relative paths: resolved relative to config/ directory
|
|
661
|
-
|
|
662
|
-
Note: Does NOT expand ${VAR} - use ~/path instead of ${HOME}/path.
|
|
663
|
-
|
|
664
|
-
Args:
|
|
665
|
-
path_str: Path string to resolve
|
|
666
|
-
|
|
667
|
-
Returns:
|
|
668
|
-
Resolved absolute Path
|
|
669
|
-
"""
|
|
670
|
-
# Only expand ~ (no ${VAR} expansion)
|
|
671
|
-
path = Path(path_str).expanduser()
|
|
672
|
-
|
|
673
|
-
# If absolute after expansion, use as-is
|
|
674
|
-
if path.is_absolute():
|
|
675
|
-
return path
|
|
676
|
-
|
|
677
|
-
# Resolve relative to config directory
|
|
678
|
-
if self._config_dir is not None:
|
|
679
|
-
return (self._config_dir / path).resolve()
|
|
680
|
-
|
|
681
|
-
# Fallback: resolve relative to cwd/.onetool/config
|
|
682
|
-
return (get_effective_cwd() / ".onetool" / CONFIG_SUBDIR / path).resolve()
|
|
683
|
-
|
|
684
|
-
def get_secrets_file_path(self) -> Path:
|
|
685
|
-
"""Get the resolved path to the secrets configuration file.
|
|
686
|
-
|
|
687
|
-
Path is resolved relative to OT_DIR (.onetool/).
|
|
688
|
-
|
|
689
|
-
Returns:
|
|
690
|
-
Absolute Path to secrets file
|
|
691
|
-
"""
|
|
692
|
-
return self._resolve_onetool_relative_path(self.secrets_file)
|
|
693
|
-
|
|
694
|
-
def get_log_dir_path(self) -> Path:
|
|
695
|
-
"""Get the resolved path to the log directory.
|
|
696
|
-
|
|
697
|
-
Path is resolved relative to the .onetool/ directory.
|
|
698
|
-
|
|
699
|
-
Returns:
|
|
700
|
-
Absolute Path to log directory
|
|
701
|
-
"""
|
|
702
|
-
return self._resolve_onetool_relative_path(self.log_dir)
|
|
703
|
-
|
|
704
|
-
def get_stats_dir_path(self) -> Path:
|
|
705
|
-
"""Get the resolved path to the stats directory.
|
|
706
|
-
|
|
707
|
-
Path is resolved relative to the .onetool/ directory.
|
|
708
|
-
|
|
709
|
-
Returns:
|
|
710
|
-
Absolute Path to stats directory
|
|
711
|
-
"""
|
|
712
|
-
return self._resolve_onetool_relative_path(self.stats.persist_dir)
|
|
713
|
-
|
|
714
|
-
def get_stats_file_path(self) -> Path:
|
|
715
|
-
"""Get the resolved path to the stats JSONL file.
|
|
716
|
-
|
|
717
|
-
Stats file is stored in the stats directory.
|
|
718
|
-
|
|
719
|
-
Returns:
|
|
720
|
-
Absolute Path to stats file
|
|
721
|
-
"""
|
|
722
|
-
return self.get_stats_dir_path() / self.stats.persist_path
|
|
723
|
-
|
|
724
|
-
def get_result_store_path(self) -> Path:
|
|
725
|
-
"""Get the resolved path to the result store directory.
|
|
726
|
-
|
|
727
|
-
Path is resolved relative to the .onetool/ directory.
|
|
728
|
-
|
|
729
|
-
Returns:
|
|
730
|
-
Absolute Path to result store directory
|
|
731
|
-
"""
|
|
732
|
-
return self._resolve_onetool_relative_path(self.output.result_store_dir)
|
|
733
|
-
|
|
734
|
-
|
|
735
55
|
def _resolve_config_path(config_path: Path | str | None) -> Path | None:
|
|
736
|
-
"""Resolve config path from explicit path, env var, or
|
|
56
|
+
"""Resolve config path from explicit path, env var, or global location only.
|
|
737
57
|
|
|
738
58
|
Resolution order:
|
|
739
59
|
1. Explicit config_path if provided
|
|
740
60
|
2. ONETOOL_CONFIG env var
|
|
741
|
-
3.
|
|
742
|
-
4.
|
|
743
|
-
5. None (use defaults)
|
|
61
|
+
3. ~/.onetool/config/onetool.yaml (global only)
|
|
62
|
+
4. None (config not found)
|
|
744
63
|
|
|
745
64
|
Args:
|
|
746
65
|
config_path: Explicit path to config file (may be None).
|
|
@@ -755,12 +74,10 @@ def _resolve_config_path(config_path: Path | str | None) -> Path | None:
|
|
|
755
74
|
if env_config:
|
|
756
75
|
return Path(env_config)
|
|
757
76
|
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
if project_config.exists():
|
|
761
|
-
return project_config
|
|
77
|
+
# Global-only: no project config resolution
|
|
78
|
+
from ot.paths import CONFIG_SUBDIR, get_global_dir
|
|
762
79
|
|
|
763
|
-
global_config =
|
|
80
|
+
global_config = (get_global_dir() / CONFIG_SUBDIR / "onetool.yaml")
|
|
764
81
|
if global_config.exists():
|
|
765
82
|
return global_config
|
|
766
83
|
|
|
@@ -794,40 +111,62 @@ def _load_yaml_file(config_path: Path) -> dict[str, Any]:
|
|
|
794
111
|
return raw_data if raw_data is not None else {}
|
|
795
112
|
|
|
796
113
|
|
|
797
|
-
def
|
|
798
|
-
"""Recursively expand ${VAR} from secrets
|
|
114
|
+
def _expand_vars_recursive(data: Any) -> Any:
|
|
115
|
+
"""Recursively expand ${VAR} from secrets and env: in config data.
|
|
799
116
|
|
|
800
117
|
Args:
|
|
801
118
|
data: Config data (dict, list, or scalar).
|
|
802
119
|
|
|
803
120
|
Returns:
|
|
804
|
-
Data with
|
|
121
|
+
Data with variables expanded.
|
|
805
122
|
"""
|
|
806
123
|
if isinstance(data, dict):
|
|
807
|
-
return {k:
|
|
124
|
+
return {k: _expand_vars_recursive(v) for k, v in data.items()}
|
|
808
125
|
elif isinstance(data, list):
|
|
809
|
-
return [
|
|
810
|
-
elif isinstance(data, str):
|
|
811
|
-
return
|
|
126
|
+
return [_expand_vars_recursive(v) for v in data]
|
|
127
|
+
elif isinstance(data, str) and "${" in data:
|
|
128
|
+
return expand_vars(data)
|
|
812
129
|
return data
|
|
813
130
|
|
|
814
131
|
|
|
815
|
-
def
|
|
132
|
+
def _flatten_arrays_recursive(data: Any) -> Any:
|
|
133
|
+
"""Recursively flatten nested arrays in config data (compact array format).
|
|
134
|
+
|
|
135
|
+
Converts [[a, b], c, [d, e]] to [a, b, c, d, e].
|
|
136
|
+
This allows compact array notation in YAML config files.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
data: Config data (dict, list, or scalar).
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Data with nested arrays flattened.
|
|
143
|
+
"""
|
|
144
|
+
if isinstance(data, dict):
|
|
145
|
+
return {k: _flatten_arrays_recursive(v) for k, v in data.items()}
|
|
146
|
+
elif isinstance(data, list):
|
|
147
|
+
flattened = []
|
|
148
|
+
for item in data:
|
|
149
|
+
if isinstance(item, list):
|
|
150
|
+
# Recursively flatten nested list and extend
|
|
151
|
+
flattened.extend(_flatten_arrays_recursive(item))
|
|
152
|
+
else:
|
|
153
|
+
# Non-list items are appended as-is (but recursively processed)
|
|
154
|
+
flattened.append(_flatten_arrays_recursive(item))
|
|
155
|
+
return flattened
|
|
156
|
+
return data
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _validate_version(data: dict[str, Any]) -> None:
|
|
816
160
|
"""Validate config version and set default if missing.
|
|
817
161
|
|
|
818
162
|
Args:
|
|
819
163
|
data: Config data dict (modified in place).
|
|
820
|
-
config_path: Path to config file (for error messages).
|
|
821
164
|
|
|
822
165
|
Raises:
|
|
823
166
|
ValueError: If version is unsupported.
|
|
824
167
|
"""
|
|
825
168
|
config_version = data.get("version")
|
|
826
169
|
if config_version is None:
|
|
827
|
-
logger.warning(
|
|
828
|
-
f"Config file missing 'version' field, assuming version 1. "
|
|
829
|
-
f"Add 'version: {CURRENT_CONFIG_VERSION}' to {config_path}"
|
|
830
|
-
)
|
|
831
170
|
data["version"] = 1
|
|
832
171
|
config_version = 1
|
|
833
172
|
|
|
@@ -837,40 +176,19 @@ def _validate_version(data: dict[str, Any], config_path: Path) -> None:
|
|
|
837
176
|
f"Maximum supported version is {CURRENT_CONFIG_VERSION}. "
|
|
838
177
|
f"Please upgrade OneTool: uv tool upgrade onetool"
|
|
839
178
|
)
|
|
840
|
-
elif config_version < CURRENT_CONFIG_VERSION:
|
|
841
|
-
logger.warning(
|
|
842
|
-
f"Config version {config_version} is outdated (current: {CURRENT_CONFIG_VERSION}). "
|
|
843
|
-
f"Run 'onetool init reset' to update config templates."
|
|
844
|
-
)
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
def _remove_legacy_fields(data: dict[str, Any]) -> None:
|
|
848
|
-
"""Remove V1-unsupported fields from config data.
|
|
849
|
-
|
|
850
|
-
Args:
|
|
851
|
-
data: Config data dict (modified in place).
|
|
852
|
-
"""
|
|
853
|
-
for key in ["mounts", "profile"]:
|
|
854
|
-
if key in data:
|
|
855
|
-
logger.debug(f"Ignoring legacy config field '{key}'")
|
|
856
|
-
del data[key]
|
|
857
179
|
|
|
858
180
|
|
|
859
181
|
def _resolve_include_path(include_path_str: str, ot_dir: Path) -> Path | None:
|
|
860
|
-
"""Resolve an include path
|
|
861
|
-
|
|
862
|
-
Search order:
|
|
863
|
-
1. ot_dir (project .onetool/ or wherever the config's OT_DIR is)
|
|
864
|
-
2. global (~/.onetool/)
|
|
182
|
+
"""Resolve an include path relative to OT_DIR (.onetool/).
|
|
865
183
|
|
|
866
184
|
Supports:
|
|
867
185
|
- Absolute paths (used as-is)
|
|
868
186
|
- ~ expansion (expands to home directory)
|
|
869
|
-
- Relative paths (
|
|
187
|
+
- Relative paths (resolved relative to OT_DIR)
|
|
870
188
|
|
|
871
189
|
Args:
|
|
872
190
|
include_path_str: Path string from include directive
|
|
873
|
-
ot_dir: The .onetool/ directory (OT_DIR)
|
|
191
|
+
ot_dir: The .onetool/ directory (OT_DIR)
|
|
874
192
|
|
|
875
193
|
Returns:
|
|
876
194
|
Resolved Path if found, None otherwise
|
|
@@ -885,19 +203,13 @@ def _resolve_include_path(include_path_str: str, ot_dir: Path) -> Path | None:
|
|
|
885
203
|
return include_path
|
|
886
204
|
return None
|
|
887
205
|
|
|
888
|
-
#
|
|
889
|
-
|
|
890
|
-
if
|
|
891
|
-
logger.debug(f"Include resolved (ot_dir): {
|
|
892
|
-
return
|
|
893
|
-
|
|
894
|
-
# Tier 2: global (~/.onetool/)
|
|
895
|
-
tier2 = (get_global_dir() / include_path).resolve()
|
|
896
|
-
if tier2.exists():
|
|
897
|
-
logger.debug(f"Include resolved (global): {tier2}")
|
|
898
|
-
return tier2
|
|
206
|
+
# Relative paths: resolve from OT_DIR
|
|
207
|
+
resolved = (ot_dir / include_path).resolve()
|
|
208
|
+
if resolved.exists():
|
|
209
|
+
logger.debug(f"Include resolved (ot_dir): {resolved}")
|
|
210
|
+
return resolved
|
|
899
211
|
|
|
900
|
-
logger.
|
|
212
|
+
logger.warning(f"Include file not found: {include_path_str}")
|
|
901
213
|
return None
|
|
902
214
|
|
|
903
215
|
|
|
@@ -939,28 +251,32 @@ def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any
|
|
|
939
251
|
return result
|
|
940
252
|
|
|
941
253
|
|
|
942
|
-
def
|
|
943
|
-
data: dict[str, Any], ot_dir: Path,
|
|
254
|
+
def _process_includes(
|
|
255
|
+
data: dict[str, Any], ot_dir: Path, depth: int = 0
|
|
944
256
|
) -> dict[str, Any]:
|
|
945
257
|
"""Load and merge files from 'include:' list into config data.
|
|
946
258
|
|
|
947
259
|
Files are merged left-to-right (later files override earlier).
|
|
948
260
|
Inline content in the main file overrides everything.
|
|
949
261
|
|
|
950
|
-
Include resolution
|
|
951
|
-
1. ot_dir (project .onetool/ or current OT_DIR)
|
|
952
|
-
2. global (~/.onetool/)
|
|
262
|
+
Include resolution is single-tier (no fallback), relative to OT_DIR (.onetool/).
|
|
953
263
|
|
|
954
264
|
Args:
|
|
955
265
|
data: Config data dict containing optional 'include' key
|
|
956
266
|
ot_dir: The .onetool/ directory (OT_DIR) for resolving relative paths
|
|
957
|
-
|
|
267
|
+
depth: Current recursion depth (for limiting nested includes)
|
|
958
268
|
|
|
959
269
|
Returns:
|
|
960
270
|
Merged config data with includes processed
|
|
271
|
+
|
|
272
|
+
Raises:
|
|
273
|
+
ValueError: If include depth exceeds MAX_INCLUDE_DEPTH
|
|
961
274
|
"""
|
|
962
|
-
if
|
|
963
|
-
|
|
275
|
+
if depth > MAX_INCLUDE_DEPTH:
|
|
276
|
+
raise ValueError(
|
|
277
|
+
f"Include depth exceeded maximum ({MAX_INCLUDE_DEPTH}). "
|
|
278
|
+
f"Check for circular includes or deeply nested include chains."
|
|
279
|
+
)
|
|
964
280
|
|
|
965
281
|
include_list = data.get("include", [])
|
|
966
282
|
if not include_list:
|
|
@@ -970,16 +286,11 @@ def _load_includes(
|
|
|
970
286
|
merged: dict[str, Any] = {}
|
|
971
287
|
|
|
972
288
|
for include_path_str in include_list:
|
|
973
|
-
#
|
|
289
|
+
# Resolve include path (single-tier, no fallback)
|
|
974
290
|
include_path = _resolve_include_path(include_path_str, ot_dir)
|
|
975
291
|
|
|
976
292
|
if include_path is None:
|
|
977
|
-
|
|
978
|
-
continue
|
|
979
|
-
|
|
980
|
-
# Circular include detection
|
|
981
|
-
if include_path in seen_paths:
|
|
982
|
-
logger.warning(f"Circular include detected, skipping: {include_path}")
|
|
293
|
+
# Warning already logged in _resolve_include_path
|
|
983
294
|
continue
|
|
984
295
|
|
|
985
296
|
try:
|
|
@@ -990,9 +301,8 @@ def _load_includes(
|
|
|
990
301
|
logger.debug(f"Empty or non-dict include file: {include_path}")
|
|
991
302
|
continue
|
|
992
303
|
|
|
993
|
-
# Recursively process nested includes (same
|
|
994
|
-
|
|
995
|
-
include_data = _load_includes(include_data, ot_dir, new_seen)
|
|
304
|
+
# Recursively process nested includes (same ot_dir for all nested includes)
|
|
305
|
+
include_data = _process_includes(include_data, ot_dir, depth + 1)
|
|
996
306
|
|
|
997
307
|
# Merge this include file (later overrides earlier)
|
|
998
308
|
merged = _deep_merge(merged, include_data)
|
|
@@ -1014,78 +324,27 @@ def _load_includes(
|
|
|
1014
324
|
return result
|
|
1015
325
|
|
|
1016
326
|
|
|
1017
|
-
def _load_base_config(inherit: str, current_config_path: Path | None) -> dict[str, Any]:
|
|
1018
|
-
"""Load base configuration for inheritance.
|
|
1019
|
-
|
|
1020
|
-
Args:
|
|
1021
|
-
inherit: Inheritance mode (global or none)
|
|
1022
|
-
current_config_path: Path to the current config file (to avoid self-include)
|
|
1023
|
-
|
|
1024
|
-
Returns:
|
|
1025
|
-
Base config data dict to merge with current config
|
|
1026
|
-
"""
|
|
1027
|
-
if inherit == "none":
|
|
1028
|
-
return {}
|
|
1029
|
-
|
|
1030
|
-
# 'global' mode - inherit from global config
|
|
1031
|
-
if inherit == "global":
|
|
1032
|
-
global_config_path = get_config_dir(get_global_dir()) / "onetool.yaml"
|
|
1033
|
-
if global_config_path.exists():
|
|
1034
|
-
# Skip if this is the same file we're already loading
|
|
1035
|
-
if (
|
|
1036
|
-
current_config_path
|
|
1037
|
-
and global_config_path.resolve() == current_config_path.resolve()
|
|
1038
|
-
):
|
|
1039
|
-
logger.debug(
|
|
1040
|
-
"Skipping global inheritance (loading global config itself)"
|
|
1041
|
-
)
|
|
1042
|
-
return {}
|
|
1043
|
-
try:
|
|
1044
|
-
raw_data = _load_yaml_file(global_config_path)
|
|
1045
|
-
# Process includes in global config - OT_DIR is ~/.onetool/
|
|
1046
|
-
global_ot_dir = get_global_dir()
|
|
1047
|
-
data = _load_includes(raw_data, global_ot_dir)
|
|
1048
|
-
logger.debug(
|
|
1049
|
-
f"Inherited base config from global: {global_config_path}"
|
|
1050
|
-
)
|
|
1051
|
-
return data
|
|
1052
|
-
except (FileNotFoundError, ValueError) as e:
|
|
1053
|
-
logger.warning(f"Failed to load global config for inheritance: {e}")
|
|
1054
|
-
# Global config not available - no fallback (user must run 'onetool init')
|
|
1055
|
-
logger.debug("Global config not found for 'inherit: global' mode")
|
|
1056
|
-
return {}
|
|
1057
|
-
|
|
1058
|
-
# Invalid inherit value - should never reach here due to validation
|
|
1059
|
-
return {} # pragma: no cover
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
327
|
def load_config(config_path: Path | str | None = None) -> OneToolConfig:
|
|
1063
|
-
"""Load OneTool configuration from YAML file.
|
|
328
|
+
"""Load OneTool configuration from YAML file (global-only).
|
|
1064
329
|
|
|
1065
330
|
Resolution order (when config_path is None):
|
|
1066
331
|
1. ONETOOL_CONFIG env var
|
|
1067
|
-
2.
|
|
1068
|
-
3.
|
|
1069
|
-
4. ConfigNotFoundError (requires 'onetool init')
|
|
332
|
+
2. ~/.onetool/config/onetool.yaml (global config only)
|
|
333
|
+
3. ConfigNotFoundError (requires 'onetool init')
|
|
1070
334
|
|
|
1071
|
-
|
|
335
|
+
No project-level configuration or inheritance is supported in V2.
|
|
1072
336
|
|
|
1073
|
-
|
|
1074
|
-
Base: ~/.onetool/onetool.yaml
|
|
1075
|
-
Your config overrides the base. Use for project configs that
|
|
1076
|
-
extend user preferences (API keys, timeouts, etc.).
|
|
337
|
+
Example config::
|
|
1077
338
|
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
Use for fully self-contained configurations.
|
|
339
|
+
# ~/.onetool/config/onetool.yaml
|
|
340
|
+
version: 1
|
|
1081
341
|
|
|
1082
|
-
|
|
342
|
+
env:
|
|
343
|
+
HOME: /home/user
|
|
344
|
+
LANG: en_US.UTF-8
|
|
1083
345
|
|
|
1084
|
-
# .onetool/onetool.yaml
|
|
1085
|
-
version: 1
|
|
1086
|
-
# inherit: global (implicit default - gets settings from ~/.onetool/)
|
|
1087
346
|
tools_dir:
|
|
1088
|
-
-
|
|
347
|
+
- tools/*.py
|
|
1089
348
|
|
|
1090
349
|
Args:
|
|
1091
350
|
config_path: Path to config file (overrides resolution)
|
|
@@ -1108,36 +367,29 @@ def load_config(config_path: Path | str | None = None) -> OneToolConfig:
|
|
|
1108
367
|
logger.debug(f"Loading config from {resolved_path}")
|
|
1109
368
|
|
|
1110
369
|
raw_data = _load_yaml_file(resolved_path)
|
|
1111
|
-
expanded_data = _expand_secrets_recursive(raw_data)
|
|
1112
370
|
|
|
1113
371
|
# Process includes before validation (merges external files)
|
|
1114
372
|
# Resolve includes from OT_DIR (.onetool/), not config_dir (.onetool/config/)
|
|
1115
373
|
config_dir = resolved_path.parent.resolve()
|
|
1116
374
|
ot_dir = config_dir.parent # Go up from config/ to .onetool/
|
|
1117
|
-
merged_data =
|
|
1118
|
-
|
|
1119
|
-
# Determine inheritance mode (default: global)
|
|
1120
|
-
inherit = merged_data.get("inherit", "global")
|
|
1121
|
-
if inherit not in ("global", "none"):
|
|
1122
|
-
logger.warning(f"Invalid inherit value '{inherit}', using 'global'")
|
|
1123
|
-
inherit = "global"
|
|
375
|
+
merged_data = _process_includes(raw_data, ot_dir)
|
|
1124
376
|
|
|
1125
|
-
#
|
|
1126
|
-
|
|
1127
|
-
if base_config:
|
|
1128
|
-
# Base first, then current config overrides
|
|
1129
|
-
merged_data = _deep_merge(base_config, merged_data)
|
|
1130
|
-
logger.debug(f"Applied inheritance mode: {inherit}")
|
|
377
|
+
# Flatten nested arrays (compact array format support)
|
|
378
|
+
flattened_data = _flatten_arrays_recursive(merged_data)
|
|
1131
379
|
|
|
1132
|
-
_validate_version(
|
|
1133
|
-
_remove_legacy_fields(merged_data)
|
|
380
|
+
_validate_version(flattened_data)
|
|
1134
381
|
|
|
1135
382
|
try:
|
|
1136
|
-
config = OneToolConfig.model_validate(
|
|
383
|
+
config = OneToolConfig.model_validate(flattened_data)
|
|
1137
384
|
except Exception as e:
|
|
1138
385
|
raise ValueError(f"Invalid configuration in {resolved_path}: {e}") from e
|
|
1139
386
|
|
|
1140
|
-
config._config_dir =
|
|
387
|
+
config._config_dir = config_dir
|
|
388
|
+
|
|
389
|
+
# Load secrets AFTER config is loaded (secrets_file path now available)
|
|
390
|
+
# This fixes the chicken-and-egg problem where secrets_file couldn't be expanded
|
|
391
|
+
secrets_path = config.get_secrets_file_path()
|
|
392
|
+
get_secrets(secrets_path, reload=True)
|
|
1141
393
|
|
|
1142
394
|
logger.info(f"Config loaded: version {config.version}")
|
|
1143
395
|
|
|
@@ -1150,6 +402,39 @@ _config: OneToolConfig | None = None
|
|
|
1150
402
|
_config_lock = threading.Lock()
|
|
1151
403
|
|
|
1152
404
|
|
|
405
|
+
def get_config(
|
|
406
|
+
config_path: Path | str | None = None, reload: bool = False
|
|
407
|
+
) -> OneToolConfig:
|
|
408
|
+
"""Get or load the global configuration (singleton pattern).
|
|
409
|
+
|
|
410
|
+
Returns a cached config instance. On first call, loads config from disk.
|
|
411
|
+
Subsequent calls return the cached instance unless reload=True.
|
|
412
|
+
|
|
413
|
+
Thread-safety: Uses double-checked locking for efficient concurrent access.
|
|
414
|
+
|
|
415
|
+
Args:
|
|
416
|
+
config_path: Path to config file (only used on first load or reload).
|
|
417
|
+
Ignored after config is cached unless reload=True.
|
|
418
|
+
reload: Force reload configuration from disk. Use sparingly - primarily
|
|
419
|
+
intended for testing. In production, restart the process to reload.
|
|
420
|
+
|
|
421
|
+
Returns:
|
|
422
|
+
OneToolConfig instance (same instance on subsequent calls)
|
|
423
|
+
"""
|
|
424
|
+
global _config
|
|
425
|
+
|
|
426
|
+
# Fast path: return cached config without acquiring lock
|
|
427
|
+
if _config is not None and not reload:
|
|
428
|
+
return _config
|
|
429
|
+
|
|
430
|
+
# Slow path: acquire lock and load/reload config
|
|
431
|
+
with _config_lock:
|
|
432
|
+
# Double-check after acquiring lock (another thread may have loaded it)
|
|
433
|
+
if _config is None or reload:
|
|
434
|
+
_config = load_config(config_path)
|
|
435
|
+
return _config
|
|
436
|
+
|
|
437
|
+
|
|
1153
438
|
def is_log_verbose() -> bool:
|
|
1154
439
|
"""Check if verbose logging is enabled.
|
|
1155
440
|
|
|
@@ -1176,28 +461,100 @@ def is_log_verbose() -> bool:
|
|
|
1176
461
|
return False
|
|
1177
462
|
|
|
1178
463
|
|
|
1179
|
-
def
|
|
1180
|
-
|
|
1181
|
-
) -> OneToolConfig:
|
|
1182
|
-
"""Get or load the global configuration (singleton pattern).
|
|
464
|
+
def get_tool_config(pack: str, schema: type[T] | None = None) -> T | dict[str, Any]:
|
|
465
|
+
"""Get configuration for a tool pack.
|
|
1183
466
|
|
|
1184
|
-
|
|
1185
|
-
|
|
467
|
+
Args:
|
|
468
|
+
pack: Pack name (e.g., "brave", "ground", "context7")
|
|
469
|
+
schema: Optional Pydantic model class to validate and return typed config.
|
|
470
|
+
If provided, returns an instance of the schema with merged values.
|
|
471
|
+
If None, returns raw config dict.
|
|
472
|
+
|
|
473
|
+
Returns:
|
|
474
|
+
If schema provided: Instance of schema with config values merged
|
|
475
|
+
If no schema: Dict with raw config values (empty dict if not configured)
|
|
476
|
+
|
|
477
|
+
Example:
|
|
478
|
+
# With schema (recommended for type safety)
|
|
479
|
+
class Config(BaseModel):
|
|
480
|
+
timeout: float = 60.0
|
|
481
|
+
|
|
482
|
+
config = get_tool_config("brave", Config)
|
|
483
|
+
print(config.timeout) # typed as float
|
|
484
|
+
|
|
485
|
+
# Without schema (raw dict)
|
|
486
|
+
raw = get_tool_config("brave")
|
|
487
|
+
print(raw.get("timeout", 60.0))
|
|
488
|
+
"""
|
|
489
|
+
# Get raw config values for this pack
|
|
490
|
+
raw_config = _get_raw_config(pack)
|
|
491
|
+
|
|
492
|
+
# Expand ${VAR} patterns at point of use (runtime expansion)
|
|
493
|
+
expanded_config: dict[str, Any] = _expand_vars_recursive(raw_config)
|
|
1186
494
|
|
|
1187
|
-
|
|
495
|
+
if schema is None:
|
|
496
|
+
return expanded_config
|
|
497
|
+
|
|
498
|
+
# Validate and return typed config instance
|
|
499
|
+
try:
|
|
500
|
+
return schema.model_validate(expanded_config)
|
|
501
|
+
except Exception:
|
|
502
|
+
# If validation fails, return defaults from schema
|
|
503
|
+
return schema()
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _get_raw_config(pack: str) -> dict[str, Any]:
|
|
507
|
+
"""Get raw config dict for a pack from loaded configuration.
|
|
508
|
+
|
|
509
|
+
This function handles both typed tools.X fields and extra fields
|
|
510
|
+
allowed via model_config. It supports:
|
|
511
|
+
1. Typed tools.X fields (e.g., tools.stats)
|
|
512
|
+
2. Extra fields for tool packs (e.g., tools.brave)
|
|
1188
513
|
|
|
1189
514
|
Args:
|
|
1190
|
-
|
|
1191
|
-
Ignored after config is cached unless reload=True.
|
|
1192
|
-
reload: Force reload configuration from disk. Use sparingly - primarily
|
|
1193
|
-
intended for testing. In production, restart the process to reload.
|
|
515
|
+
pack: Pack name (e.g., "brave", "ground")
|
|
1194
516
|
|
|
1195
517
|
Returns:
|
|
1196
|
-
|
|
518
|
+
Raw config dict for the pack, or empty dict if not configured
|
|
1197
519
|
"""
|
|
1198
|
-
|
|
520
|
+
try:
|
|
521
|
+
config = get_config()
|
|
522
|
+
except Exception:
|
|
523
|
+
# Config not loaded yet - return empty dict
|
|
524
|
+
return {}
|
|
525
|
+
|
|
526
|
+
# Get the tools section
|
|
527
|
+
tools = config.tools
|
|
528
|
+
|
|
529
|
+
# First check for typed attribute (e.g., tools.stats)
|
|
530
|
+
if hasattr(tools, pack):
|
|
531
|
+
pack_config = getattr(tools, pack)
|
|
532
|
+
if hasattr(pack_config, "model_dump"):
|
|
533
|
+
result: dict[str, Any] = pack_config.model_dump()
|
|
534
|
+
return result
|
|
535
|
+
# Handle raw dict from extra fields
|
|
536
|
+
if isinstance(pack_config, dict):
|
|
537
|
+
return pack_config
|
|
538
|
+
return {}
|
|
539
|
+
|
|
540
|
+
# Check model_extra for dynamically allowed fields
|
|
541
|
+
if hasattr(tools, "model_extra") and tools.model_extra:
|
|
542
|
+
extra = tools.model_extra
|
|
543
|
+
if pack in extra:
|
|
544
|
+
pack_data = extra[pack]
|
|
545
|
+
if isinstance(pack_data, dict):
|
|
546
|
+
return pack_data
|
|
547
|
+
return {}
|
|
548
|
+
|
|
549
|
+
return {}
|
|
550
|
+
|
|
1199
551
|
|
|
552
|
+
def reset() -> None:
|
|
553
|
+
"""Clear config cache for reload.
|
|
554
|
+
|
|
555
|
+
Use this as part of the config reload flow to force config to be
|
|
556
|
+
reloaded from disk on next access.
|
|
557
|
+
"""
|
|
558
|
+
global _config
|
|
1200
559
|
with _config_lock:
|
|
1201
|
-
|
|
1202
|
-
_config = load_config(config_path)
|
|
1203
|
-
return _config
|
|
560
|
+
_config = None
|