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.
Files changed (34) hide show
  1. onetool/cli.py +2 -0
  2. {onetool_mcp-1.0.0rc2.dist-info → onetool_mcp-1.0.0rc3.dist-info}/METADATA +26 -33
  3. {onetool_mcp-1.0.0rc2.dist-info → onetool_mcp-1.0.0rc3.dist-info}/RECORD +31 -33
  4. ot/config/__init__.py +90 -48
  5. ot/config/global_templates/__init__.py +2 -2
  6. ot/config/global_templates/diagram-templates/api-flow.mmd +33 -33
  7. ot/config/global_templates/diagram-templates/c4-context.puml +30 -30
  8. ot/config/global_templates/diagram-templates/class-diagram.mmd +87 -87
  9. ot/config/global_templates/diagram-templates/feature-mindmap.mmd +70 -70
  10. ot/config/global_templates/diagram-templates/microservices.d2 +81 -81
  11. ot/config/global_templates/diagram-templates/project-gantt.mmd +37 -37
  12. ot/config/global_templates/diagram-templates/state-machine.mmd +42 -42
  13. ot/config/global_templates/diagram.yaml +167 -167
  14. ot/config/global_templates/onetool.yaml +2 -0
  15. ot/config/global_templates/prompts.yaml +102 -102
  16. ot/config/global_templates/security.yaml +1 -4
  17. ot/config/global_templates/servers.yaml +1 -1
  18. ot/config/global_templates/tool_templates/__init__.py +7 -7
  19. ot/config/loader.py +226 -869
  20. ot/config/models.py +735 -0
  21. ot/config/secrets.py +243 -192
  22. ot/executor/tool_loader.py +10 -1
  23. ot/executor/validator.py +11 -1
  24. ot/meta.py +338 -33
  25. ot/prompts.py +228 -218
  26. ot/proxy/manager.py +168 -8
  27. ot/registry/__init__.py +199 -189
  28. ot/config/dynamic.py +0 -121
  29. ot/config/mcp.py +0 -149
  30. ot/config/tool_config.py +0 -125
  31. {onetool_mcp-1.0.0rc2.dist-info → onetool_mcp-1.0.0rc3.dist-info}/WHEEL +0 -0
  32. {onetool_mcp-1.0.0rc2.dist-info → onetool_mcp-1.0.0rc3.dist-info}/entry_points.txt +0 -0
  33. {onetool_mcp-1.0.0rc2.dist-info → onetool_mcp-1.0.0rc3.dist-info}/licenses/LICENSE.txt +0 -0
  34. {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
- - src/ot_tools/*.py
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, Literal
29
+ from typing import Any, TypeVar
29
30
 
30
31
  import yaml
31
32
  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_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 default locations.
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. cwd/.onetool/config/onetool.yaml
742
- 4. ~/.onetool/config/onetool.yaml
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
- cwd = get_effective_cwd()
759
- project_config = cwd / ".onetool" / CONFIG_SUBDIR / "onetool.yaml"
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 = get_config_dir(get_global_dir()) / "onetool.yaml"
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 _expand_secrets_recursive(data: Any) -> Any:
798
- """Recursively expand ${VAR} from secrets.yaml in config data.
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 secrets expanded.
121
+ Data with variables expanded.
805
122
  """
806
123
  if isinstance(data, dict):
807
- return {k: _expand_secrets_recursive(v) for k, v in data.items()}
124
+ return {k: _expand_vars_recursive(v) for k, v in data.items()}
808
125
  elif isinstance(data, list):
809
- return [_expand_secrets_recursive(v) for v in data]
810
- elif isinstance(data, str):
811
- return expand_secrets(data)
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 _validate_version(data: dict[str, Any], config_path: Path) -> None:
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 using two-tier fallback.
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 (searched in two-tier order)
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) for the config
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
- # Tier 1: ot_dir (project .onetool/ or current OT_DIR)
889
- tier1 = (ot_dir / include_path).resolve()
890
- if tier1.exists():
891
- logger.debug(f"Include resolved (ot_dir): {tier1}")
892
- return tier1
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.debug(f"Include not found: {include_path_str}")
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 _load_includes(
943
- data: dict[str, Any], ot_dir: Path, seen_paths: set[Path] | None = None
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 uses two-tier fallback:
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
- seen_paths: Set of already-processed paths (for circular detection)
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 seen_paths is None:
963
- seen_paths = set()
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
- # Use two-tier resolution (ot_dir -> global)
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
- logger.warning(f"Include file not found: {include_path_str}")
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 OT_DIR for all nested includes)
994
- new_seen = seen_paths | {include_path}
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. cwd/.onetool/onetool.yaml (project config)
1068
- 3. ~/.onetool/onetool.yaml (global config)
1069
- 4. ConfigNotFoundError (requires 'onetool init')
332
+ 2. ~/.onetool/config/onetool.yaml (global config only)
333
+ 3. ConfigNotFoundError (requires 'onetool init')
1070
334
 
1071
- Inheritance (controlled by 'inherit' field in your config):
335
+ No project-level configuration or inheritance is supported in V2.
1072
336
 
1073
- 'global' (default):
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
- 'none':
1079
- No base config. Your config is standalone.
1080
- Use for fully self-contained configurations.
339
+ # ~/.onetool/config/onetool.yaml
340
+ version: 1
1081
341
 
1082
- Example minimal project config using global inheritance::
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
- - ./tools/*.py
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 = _load_includes(expanded_data, ot_dir)
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
- # Load and merge base config for inheritance
1126
- base_config = _load_base_config(inherit, resolved_path)
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(merged_data, resolved_path)
1133
- _remove_legacy_fields(merged_data)
380
+ _validate_version(flattened_data)
1134
381
 
1135
382
  try:
1136
- config = OneToolConfig.model_validate(merged_data)
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 = resolved_path.parent.resolve()
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 get_config(
1180
- config_path: Path | str | None = None, reload: bool = False
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
- Returns a cached config instance. On first call, loads config from disk.
1185
- Subsequent calls return the cached instance unless reload=True.
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
- Thread-safety: Protected by lock for safe concurrent access.
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
- config_path: Path to config file (only used on first load or reload).
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
- OneToolConfig instance (same instance on subsequent calls)
518
+ Raw config dict for the pack, or empty dict if not configured
1197
519
  """
1198
- global _config
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
- if _config is None or reload:
1202
- _config = load_config(config_path)
1203
- return _config
560
+ _config = None