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/models.py ADDED
@@ -0,0 +1,735 @@
1
+ """Pydantic models for OneTool configuration with embedded defaults.
2
+
3
+ All default values are embedded directly in model definitions (single source of truth).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+ from typing import Any, Literal
10
+
11
+ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator
12
+
13
+ # ==================== Snippet Models ====================
14
+
15
+
16
+ class SnippetParam(BaseModel):
17
+ """Parameter definition for a snippet."""
18
+
19
+ required: bool = Field(
20
+ default=True, description="Whether this parameter is required"
21
+ )
22
+ default: Any = Field(default=None, description="Default value if not provided")
23
+ description: str = Field(default="", description="Description of the parameter")
24
+
25
+
26
+ class SnippetDef(BaseModel):
27
+ """Definition of a reusable snippet template."""
28
+
29
+ description: str = Field(
30
+ default="", description="Description of what this snippet does"
31
+ )
32
+ params: dict[str, SnippetParam] = Field(
33
+ default_factory=dict, description="Parameter definitions"
34
+ )
35
+ body: str = Field(
36
+ ..., description="Jinja2 template body that expands to Python code"
37
+ )
38
+
39
+
40
+ # ==================== Transform Configuration ====================
41
+
42
+
43
+ class TransformConfig(BaseModel):
44
+ """Configuration for the transform() tool."""
45
+
46
+ model: str = Field(default="", description="Model for code generation")
47
+ base_url: str = Field(default="", description="Base URL for OpenAI-compatible API")
48
+ max_tokens: int = Field(default=4096, description="Max output tokens")
49
+
50
+
51
+ # ==================== Security Configuration ====================
52
+
53
+
54
+ class BuiltinsConfig(BaseModel):
55
+ """Builtins allowlist configuration."""
56
+
57
+ allow: list[str] = Field(
58
+ default_factory=lambda: [
59
+ # Types
60
+ "bool",
61
+ "bytes",
62
+ "dict",
63
+ "float",
64
+ "frozenset",
65
+ "int",
66
+ "list",
67
+ "set",
68
+ "str",
69
+ "tuple",
70
+ "type",
71
+ # Functions
72
+ "abs",
73
+ "all",
74
+ "any",
75
+ "ascii",
76
+ "callable",
77
+ "chr",
78
+ "delattr",
79
+ "dir",
80
+ "divmod",
81
+ "enumerate",
82
+ "filter",
83
+ "format",
84
+ "getattr",
85
+ "hasattr",
86
+ "hash",
87
+ "id",
88
+ "isinstance",
89
+ "issubclass",
90
+ "iter",
91
+ "len",
92
+ "map",
93
+ "max",
94
+ "min",
95
+ "next",
96
+ "ord",
97
+ "pow",
98
+ "print",
99
+ "range",
100
+ "repr",
101
+ "reversed",
102
+ "round",
103
+ "setattr",
104
+ "slice",
105
+ "sorted",
106
+ "sum",
107
+ "vars",
108
+ "zip",
109
+ # Exceptions
110
+ "*Error",
111
+ "*Exception",
112
+ "StopIteration",
113
+ ],
114
+ description="Allowed builtin functions and types",
115
+ )
116
+
117
+
118
+ class ImportsConfig(BaseModel):
119
+ """Imports allowlist configuration."""
120
+
121
+ allow: list[str] = Field(
122
+ default_factory=lambda: [
123
+ # Note: pathlib intentionally excluded - use file.* tools instead for sandboxed filesystem access
124
+ "abc",
125
+ "array",
126
+ "base64",
127
+ "bisect",
128
+ "calendar",
129
+ "collections",
130
+ "copy",
131
+ "csv",
132
+ "dataclasses",
133
+ "datetime",
134
+ "decimal",
135
+ "difflib",
136
+ "enum",
137
+ "fractions",
138
+ "functools",
139
+ "hashlib",
140
+ "heapq",
141
+ "html",
142
+ "html.parser",
143
+ "itertools",
144
+ "json",
145
+ "math",
146
+ "operator",
147
+ "random",
148
+ "re",
149
+ "statistics",
150
+ "string",
151
+ "textwrap",
152
+ "time",
153
+ "types",
154
+ "typing",
155
+ "urllib.parse",
156
+ "uuid",
157
+ "zoneinfo",
158
+ ],
159
+ description="Allowed import modules",
160
+ )
161
+ warn: list[str] = Field(
162
+ default_factory=lambda: ["yaml"],
163
+ description="Imports that trigger warnings but are allowed",
164
+ )
165
+
166
+
167
+ class CallsConfig(BaseModel):
168
+ """Qualified calls configuration."""
169
+
170
+ allow: list[str] = Field(
171
+ default_factory=list,
172
+ description="Allowed qualified function calls (e.g., 'json.loads')",
173
+ )
174
+ block: list[str] = Field(
175
+ default_factory=list,
176
+ description="Blocked qualified function calls (e.g., 'pickle.*')",
177
+ )
178
+ warn: list[str] = Field(
179
+ default_factory=list,
180
+ description="Qualified calls that trigger warnings",
181
+ )
182
+
183
+
184
+ class DundersConfig(BaseModel):
185
+ """Magic variable (dunder) configuration."""
186
+
187
+ allow: list[str] = Field(
188
+ default_factory=lambda: ["__format__", "__sanitize__"],
189
+ description="Allowed magic variables (e.g., '__format__')",
190
+ )
191
+
192
+
193
+ class OutputSanitizationConfig(BaseModel):
194
+ """Output sanitization configuration for prompt injection protection.
195
+
196
+ Protects against indirect prompt injection by sanitizing tool outputs
197
+ that may contain malicious payloads from external content.
198
+
199
+ Three-layer defense:
200
+ 1. Trigger sanitization: Replace __ot, mcp__onetool patterns
201
+ 2. Tag sanitization: Remove <external-content-*> patterns
202
+ 3. GUID-tagged boundaries: Wrap content in unpredictable tags
203
+ """
204
+
205
+ enabled: bool = Field(
206
+ default=True,
207
+ description="Global toggle for output sanitization",
208
+ )
209
+
210
+
211
+ class SecurityConfig(BaseModel):
212
+ """Code validation security configuration.
213
+
214
+ Allowlist-based security model: block everything by default, explicitly
215
+ allow what's safe. Tool namespaces (ot.*, brave.*, etc.) are auto-allowed.
216
+
217
+ Configuration structure:
218
+ security:
219
+ builtins:
220
+ allow: [str, int, list, ...]
221
+ imports:
222
+ allow: [json, re, math, ...]
223
+ warn: [yaml]
224
+ calls:
225
+ block: [pickle.*, yaml.load]
226
+ warn: [random.seed]
227
+ dunders:
228
+ allow: [__format__, __sanitize__]
229
+
230
+ Patterns support fnmatch wildcards:
231
+ - '*' matches any characters (e.g., 'subprocess.*' matches 'subprocess.run')
232
+ - '?' matches a single character
233
+ - '[seq]' matches any character in seq
234
+ """
235
+
236
+ # Cached frozensets (computed lazily on first access)
237
+ _allowed_builtins: frozenset[str] | None = PrivateAttr(default=None)
238
+ _allowed_imports: frozenset[str] | None = PrivateAttr(default=None)
239
+ _warned_imports: frozenset[str] | None = PrivateAttr(default=None)
240
+ _blocked_calls: frozenset[str] | None = PrivateAttr(default=None)
241
+ _warned_calls: frozenset[str] | None = PrivateAttr(default=None)
242
+ _allowed_calls: frozenset[str] | None = PrivateAttr(default=None)
243
+ _allowed_dunders: frozenset[str] | None = PrivateAttr(default=None)
244
+
245
+ validate_code: bool = Field(
246
+ default=True,
247
+ description="Enable AST-based code validation before execution",
248
+ )
249
+
250
+ enabled: bool = Field(
251
+ default=True,
252
+ description="Enable security pattern checking (requires validate_code)",
253
+ )
254
+
255
+ builtins: BuiltinsConfig = Field(
256
+ default_factory=BuiltinsConfig,
257
+ description="Builtins allowlist configuration",
258
+ )
259
+
260
+ imports: ImportsConfig = Field(
261
+ default_factory=ImportsConfig,
262
+ description="Imports allowlist configuration",
263
+ )
264
+
265
+ calls: CallsConfig = Field(
266
+ default_factory=CallsConfig,
267
+ description="Qualified calls configuration",
268
+ )
269
+
270
+ dunders: DundersConfig = Field(
271
+ default_factory=DundersConfig,
272
+ description="Magic variable (dunder) configuration",
273
+ )
274
+
275
+ sanitize: OutputSanitizationConfig = Field(
276
+ default_factory=OutputSanitizationConfig,
277
+ description="Output sanitization for prompt injection protection",
278
+ )
279
+
280
+ def get_allowed_builtins(self) -> frozenset[str]:
281
+ """Get the set of allowed builtins (cached)."""
282
+ if self._allowed_builtins is None:
283
+ self._allowed_builtins = frozenset(self.builtins.allow)
284
+ return self._allowed_builtins
285
+
286
+ def get_allowed_imports(self) -> frozenset[str]:
287
+ """Get the set of allowed imports (cached)."""
288
+ if self._allowed_imports is None:
289
+ self._allowed_imports = frozenset(self.imports.allow)
290
+ return self._allowed_imports
291
+
292
+ def get_warned_imports(self) -> frozenset[str]:
293
+ """Get the set of imports that trigger warnings (cached)."""
294
+ if self._warned_imports is None:
295
+ self._warned_imports = frozenset(self.imports.warn)
296
+ return self._warned_imports
297
+
298
+ def get_blocked_calls(self) -> frozenset[str]:
299
+ """Get the set of blocked qualified calls (cached)."""
300
+ if self._blocked_calls is None:
301
+ self._blocked_calls = frozenset(self.calls.block)
302
+ return self._blocked_calls
303
+
304
+ def get_warned_calls(self) -> frozenset[str]:
305
+ """Get the set of qualified calls that trigger warnings (cached)."""
306
+ if self._warned_calls is None:
307
+ self._warned_calls = frozenset(self.calls.warn)
308
+ return self._warned_calls
309
+
310
+ def get_allowed_calls(self) -> frozenset[str]:
311
+ """Get the set of explicitly allowed qualified calls (cached)."""
312
+ if self._allowed_calls is None:
313
+ self._allowed_calls = frozenset(self.calls.allow)
314
+ return self._allowed_calls
315
+
316
+ def get_allowed_dunders(self) -> frozenset[str]:
317
+ """Get the set of allowed magic variables (cached)."""
318
+ if self._allowed_dunders is None:
319
+ self._allowed_dunders = frozenset(self.dunders.allow)
320
+ return self._allowed_dunders
321
+
322
+
323
+ # ==================== Output Configuration ====================
324
+
325
+
326
+ class OutputConfig(BaseModel):
327
+ """Large output handling configuration.
328
+
329
+ When tool outputs exceed max_inline_size, they are stored to disk
330
+ and a summary with a query handle is returned instead.
331
+ """
332
+
333
+ max_inline_size: int = Field(
334
+ default=50000,
335
+ ge=0,
336
+ description="Max output size in bytes before storing to disk. Set to 0 to disable.",
337
+ )
338
+ result_store_dir: str = Field(
339
+ default="tmp",
340
+ description="Directory for result files (relative to .onetool/)",
341
+ )
342
+ result_ttl: int = Field(
343
+ default=3600,
344
+ ge=0,
345
+ description="Time-to-live in seconds for stored results (0 = no expiry)",
346
+ )
347
+ preview_lines: int = Field(
348
+ default=10,
349
+ ge=0,
350
+ description="Number of preview lines to include in summary",
351
+ )
352
+
353
+
354
+ # ==================== Stats Configuration ====================
355
+
356
+
357
+ class StatsConfig(BaseModel):
358
+ """Runtime statistics collection configuration."""
359
+
360
+ enabled: bool = Field(
361
+ default=True,
362
+ description="Enable statistics collection",
363
+ )
364
+ persist_dir: str = Field(
365
+ default="stats",
366
+ description="Directory for stats files (relative to .onetool/)",
367
+ )
368
+ persist_path: str = Field(
369
+ default="stats.jsonl",
370
+ description="Filename for stats persistence (within persist_dir)",
371
+ )
372
+ flush_interval_seconds: int = Field(
373
+ default=30,
374
+ ge=1,
375
+ le=300,
376
+ description="Interval in seconds between flushing stats to disk",
377
+ )
378
+ context_per_call: int = Field(
379
+ default=30000,
380
+ ge=0,
381
+ description="Estimated context tokens saved per consolidated tool call",
382
+ )
383
+ time_overhead_per_call_ms: int = Field(
384
+ default=4000,
385
+ ge=0,
386
+ description="Estimated time overhead in ms saved per consolidated tool call",
387
+ )
388
+ model: str = Field(
389
+ default="anthropic/claude-opus-4.5",
390
+ description="Model for cost estimation (e.g., anthropic/claude-opus-4.5)",
391
+ )
392
+ cost_per_million_input_tokens: float = Field(
393
+ default=15.0,
394
+ ge=0,
395
+ description="Cost in USD per million input tokens",
396
+ )
397
+ cost_per_million_output_tokens: float = Field(
398
+ default=75.0,
399
+ ge=0,
400
+ description="Cost in USD per million output tokens",
401
+ )
402
+ chars_per_token: float = Field(
403
+ default=4.0,
404
+ ge=1.0,
405
+ description="Average characters per token for estimation",
406
+ )
407
+
408
+
409
+ # ==================== Message Configuration ====================
410
+
411
+
412
+ class MsgTopicConfig(BaseModel):
413
+ """Topic-to-file mapping for message routing."""
414
+
415
+ pattern: str = Field(
416
+ ...,
417
+ description="Glob-style topic pattern (e.g., 'status:*', 'doc:*')",
418
+ )
419
+ file: str = Field(
420
+ ...,
421
+ description="File path for messages matching this pattern (supports ~ and ${VAR})",
422
+ )
423
+
424
+
425
+ class MsgConfig(BaseModel):
426
+ """Message tool configuration."""
427
+
428
+ topics: list[MsgTopicConfig] = Field(
429
+ default_factory=list,
430
+ description="Topic patterns mapped to output files (first match wins)",
431
+ )
432
+
433
+
434
+ # ==================== MCP Server Configuration ====================
435
+
436
+
437
+ class AuthConfig(BaseModel):
438
+ """Authentication configuration for MCP servers."""
439
+
440
+ type: Literal["oauth", "bearer"] = Field(
441
+ description="Authentication type",
442
+ )
443
+ scopes: list[str] = Field(
444
+ default_factory=list,
445
+ description="OAuth scopes (for type=oauth)",
446
+ )
447
+ token: str | None = Field(
448
+ default=None,
449
+ description="Bearer token (for type=bearer, supports ${VAR} expansion)",
450
+ )
451
+
452
+
453
+ class McpServerConfig(BaseModel):
454
+ """Configuration for an MCP server connection.
455
+
456
+ Compatible with bench ServerConfig format, with additional
457
+ `enabled` field for toggling servers without removing config.
458
+ """
459
+
460
+ type: Literal["http", "stdio"] = Field(description="Server connection type")
461
+ enabled: bool = Field(default=True, description="Whether this server is enabled")
462
+ url: str | None = Field(default=None, description="URL for HTTP servers")
463
+ headers: dict[str, str] = Field(
464
+ default_factory=dict, description="Headers for HTTP servers"
465
+ )
466
+ command: str | None = Field(default=None, description="Command for stdio servers")
467
+ args: list[str] = Field(
468
+ default_factory=list, description="Arguments for stdio command"
469
+ )
470
+ env: dict[str, str] = Field(
471
+ default_factory=dict, description="Environment variables for stdio servers"
472
+ )
473
+ timeout: int = Field(default=30, description="Connection timeout in seconds")
474
+ auth: AuthConfig | None = Field(
475
+ default=None,
476
+ description="Authentication configuration for HTTP servers",
477
+ )
478
+ instructions: str | None = Field(
479
+ default=None,
480
+ description="Agent instructions for using this server's tools (surfaced in MCP instructions)",
481
+ )
482
+
483
+
484
+ # ==================== Tools Configuration ====================
485
+
486
+
487
+ class ToolsConfig(BaseModel):
488
+ """Aggregated tool configurations.
489
+
490
+ Core configs (msg, stats) are typed fields. Tool-specific configs
491
+ (brave, ground, etc.) are allowed as extra fields and accessed via
492
+ get_tool_config() with schemas defined in tool files.
493
+ """
494
+
495
+ model_config = ConfigDict(extra="allow")
496
+
497
+ # Core configs - always available
498
+ msg: MsgConfig = Field(default_factory=MsgConfig)
499
+ stats: StatsConfig = Field(default_factory=StatsConfig)
500
+
501
+
502
+ # ==================== Root Configuration ====================
503
+
504
+
505
+ class OneToolConfig(BaseModel):
506
+ """Root configuration for OneTool (global-only, V2)."""
507
+
508
+ # Private attribute to track config file location (not serialized)
509
+ _config_dir: Path | None = PrivateAttr(default=None)
510
+
511
+ version: int = Field(
512
+ default=1,
513
+ description="Config schema version for migration support",
514
+ )
515
+
516
+ include: list[str] = Field(
517
+ default_factory=list,
518
+ description="Files to deep-merge into config (processed before validation)",
519
+ )
520
+
521
+ # Root-level environment variables for subprocesses
522
+ env: dict[str, str] = Field(
523
+ default_factory=dict,
524
+ description="Shared environment variables for all MCP servers",
525
+ )
526
+
527
+ transform: TransformConfig = Field(
528
+ default_factory=TransformConfig, description="transform() tool configuration"
529
+ )
530
+
531
+ alias: dict[str, str] = Field(
532
+ default_factory=dict,
533
+ description="Short alias names mapping to full function names (e.g., ws -> brave.web_search)",
534
+ )
535
+
536
+ snippets: dict[str, SnippetDef] = Field(
537
+ default_factory=dict,
538
+ description="Reusable snippet templates with Jinja2 variable substitution",
539
+ )
540
+
541
+ servers: dict[str, McpServerConfig] = Field(
542
+ default_factory=dict,
543
+ description="External MCP servers to proxy through OneTool",
544
+ )
545
+
546
+ tools: ToolsConfig = Field(
547
+ default_factory=ToolsConfig,
548
+ description="Tool-specific configuration (timeouts, limits, etc.)",
549
+ )
550
+
551
+ security: SecurityConfig = Field(
552
+ default_factory=SecurityConfig,
553
+ description="Code validation and security pattern configuration",
554
+ )
555
+
556
+ stats: StatsConfig = Field(
557
+ default_factory=StatsConfig,
558
+ description="Runtime statistics collection configuration",
559
+ )
560
+
561
+ output: OutputConfig = Field(
562
+ default_factory=OutputConfig,
563
+ description="Large output handling configuration",
564
+ )
565
+
566
+ tools_dir: list[str] = Field(
567
+ default_factory=lambda: ["tools/*.py"],
568
+ description="Glob patterns for tool discovery (relative to .onetool/, or absolute)",
569
+ )
570
+ secrets_file: str = Field(
571
+ default="config/secrets.yaml",
572
+ description="Path to secrets file (relative to .onetool/, or absolute)",
573
+ )
574
+ prompts: dict[str, Any] | None = Field(
575
+ default=None,
576
+ description="Inline prompts config (can also be loaded via include:)",
577
+ )
578
+
579
+ log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = Field(
580
+ default="INFO", description="Logging level"
581
+ )
582
+ log_dir: str = Field(
583
+ default="logs",
584
+ description="Directory for log files (relative to .onetool/)",
585
+ )
586
+ compact_max_length: int = Field(
587
+ default=120, description="Max value length in compact console output"
588
+ )
589
+ log_verbose: bool = Field(
590
+ default=False,
591
+ description="Disable log truncation for debugging (full values in output)",
592
+ )
593
+ debug_tracebacks: bool = Field(
594
+ default=False,
595
+ description="Show verbose tracebacks with local variables on errors",
596
+ )
597
+
598
+ @field_validator("snippets", "servers", "alias", mode="before")
599
+ @classmethod
600
+ def empty_dict_if_none(cls, v: Any) -> Any:
601
+ """Convert None to empty dict for dict-type fields.
602
+
603
+ This handles YAML files where the key exists but all values are commented out,
604
+ which YAML parses as None instead of an empty dict.
605
+ """
606
+ return {} if v is None else v
607
+
608
+ def get_tool_files(self) -> list[Path]:
609
+ """Get list of tool files matching configured glob patterns.
610
+
611
+ Pattern resolution (all relative to OT_DIR .onetool/):
612
+ - Absolute paths: used as-is
613
+ - ~ paths: expanded to home directory
614
+ - Relative patterns: resolved relative to OT_DIR (.onetool/)
615
+
616
+ Returns:
617
+ List of Path objects for tool files
618
+ """
619
+ import glob
620
+
621
+ from ot.paths import get_global_dir
622
+
623
+ tool_files: list[Path] = []
624
+
625
+ # Determine OT_DIR for resolving patterns
626
+ if self._config_dir is not None:
627
+ # _config_dir is the config/ subdirectory, go up to .onetool/
628
+ ot_dir = self._config_dir.parent
629
+ else:
630
+ # Fallback: global .onetool
631
+ ot_dir = get_global_dir()
632
+
633
+ for pattern in self.tools_dir:
634
+ # Expand ~ first
635
+ expanded = Path(pattern).expanduser()
636
+
637
+ # Determine resolved pattern for globbing
638
+ if expanded.is_absolute():
639
+ # Absolute pattern - use as-is
640
+ resolved_pattern = str(expanded)
641
+ else:
642
+ # All relative patterns resolve against OT_DIR
643
+ resolved_pattern = str(ot_dir / pattern)
644
+
645
+ # Use glob.glob() for cross-platform compatibility
646
+ for match in glob.glob(resolved_pattern, recursive=True): # noqa: PTH207
647
+ path = Path(match)
648
+ if path.is_file() and path.suffix == ".py":
649
+ tool_files.append(path)
650
+
651
+ return sorted(set(tool_files))
652
+
653
+ def _resolve_onetool_relative_path(self, path_str: str) -> Path:
654
+ """Resolve a path relative to the .onetool directory.
655
+
656
+ Handles:
657
+ - Absolute paths: returned as-is
658
+ - ~ expansion: expanded to home directory
659
+ - Relative paths: resolved relative to .onetool/ directory (parent of config/)
660
+
661
+ Note: Does NOT expand ${VAR} - use ~/path instead of ${HOME}/path.
662
+
663
+ Args:
664
+ path_str: Path string to resolve
665
+
666
+ Returns:
667
+ Resolved absolute Path
668
+ """
669
+ from ot.paths import get_global_dir
670
+
671
+ # Only expand ~ (no ${VAR} expansion)
672
+ path = Path(path_str).expanduser()
673
+
674
+ # If absolute after expansion, use as-is
675
+ if path.is_absolute():
676
+ return path
677
+
678
+ # Resolve relative to .onetool/ directory (parent of config/)
679
+ if self._config_dir is not None:
680
+ # _config_dir is the config/ subdirectory, go up to .onetool/
681
+ onetool_dir = self._config_dir.parent
682
+ return (onetool_dir / path).resolve()
683
+
684
+ # Fallback: resolve relative to global .onetool/
685
+ return (get_global_dir() / path).resolve()
686
+
687
+ def get_secrets_file_path(self) -> Path:
688
+ """Get the resolved path to the secrets configuration file.
689
+
690
+ Path is resolved relative to .onetool/ directory.
691
+
692
+ Returns:
693
+ Absolute Path to secrets file
694
+ """
695
+ return self._resolve_onetool_relative_path(self.secrets_file)
696
+
697
+ def get_log_dir_path(self) -> Path:
698
+ """Get the resolved path to the log directory.
699
+
700
+ Path is resolved relative to the .onetool/ directory.
701
+
702
+ Returns:
703
+ Absolute Path to log directory
704
+ """
705
+ return self._resolve_onetool_relative_path(self.log_dir)
706
+
707
+ def get_stats_dir_path(self) -> Path:
708
+ """Get the resolved path to the stats directory.
709
+
710
+ Path is resolved relative to the .onetool/ directory.
711
+
712
+ Returns:
713
+ Absolute Path to stats directory
714
+ """
715
+ return self._resolve_onetool_relative_path(self.stats.persist_dir)
716
+
717
+ def get_stats_file_path(self) -> Path:
718
+ """Get the resolved path to the stats JSONL file.
719
+
720
+ Stats file is stored in the stats directory.
721
+
722
+ Returns:
723
+ Absolute Path to stats file
724
+ """
725
+ return self.get_stats_dir_path() / self.stats.persist_path
726
+
727
+ def get_result_store_path(self) -> Path:
728
+ """Get the resolved path to the result store directory.
729
+
730
+ Path is resolved relative to the .onetool/ directory.
731
+
732
+ Returns:
733
+ Absolute Path to result store directory
734
+ """
735
+ return self._resolve_onetool_relative_path(self.output.result_store_dir)