onetool-mcp 1.0.0b1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. bench/__init__.py +5 -0
  2. bench/cli.py +69 -0
  3. bench/harness/__init__.py +66 -0
  4. bench/harness/client.py +692 -0
  5. bench/harness/config.py +397 -0
  6. bench/harness/csv_writer.py +109 -0
  7. bench/harness/evaluate.py +512 -0
  8. bench/harness/metrics.py +283 -0
  9. bench/harness/runner.py +899 -0
  10. bench/py.typed +0 -0
  11. bench/reporter.py +629 -0
  12. bench/run.py +487 -0
  13. bench/secrets.py +101 -0
  14. bench/utils.py +16 -0
  15. onetool/__init__.py +4 -0
  16. onetool/cli.py +391 -0
  17. onetool/py.typed +0 -0
  18. onetool_mcp-1.0.0b1.dist-info/METADATA +163 -0
  19. onetool_mcp-1.0.0b1.dist-info/RECORD +132 -0
  20. onetool_mcp-1.0.0b1.dist-info/WHEEL +4 -0
  21. onetool_mcp-1.0.0b1.dist-info/entry_points.txt +3 -0
  22. onetool_mcp-1.0.0b1.dist-info/licenses/LICENSE.txt +687 -0
  23. onetool_mcp-1.0.0b1.dist-info/licenses/NOTICE.txt +64 -0
  24. ot/__init__.py +37 -0
  25. ot/__main__.py +6 -0
  26. ot/_cli.py +107 -0
  27. ot/_tui.py +53 -0
  28. ot/config/__init__.py +46 -0
  29. ot/config/defaults/bench.yaml +4 -0
  30. ot/config/defaults/diagram-templates/api-flow.mmd +33 -0
  31. ot/config/defaults/diagram-templates/c4-context.puml +30 -0
  32. ot/config/defaults/diagram-templates/class-diagram.mmd +87 -0
  33. ot/config/defaults/diagram-templates/feature-mindmap.mmd +70 -0
  34. ot/config/defaults/diagram-templates/microservices.d2 +81 -0
  35. ot/config/defaults/diagram-templates/project-gantt.mmd +37 -0
  36. ot/config/defaults/diagram-templates/state-machine.mmd +42 -0
  37. ot/config/defaults/onetool.yaml +25 -0
  38. ot/config/defaults/prompts.yaml +97 -0
  39. ot/config/defaults/servers.yaml +7 -0
  40. ot/config/defaults/snippets.yaml +4 -0
  41. ot/config/defaults/tool_templates/__init__.py +7 -0
  42. ot/config/defaults/tool_templates/extension.py +52 -0
  43. ot/config/defaults/tool_templates/isolated.py +61 -0
  44. ot/config/dynamic.py +121 -0
  45. ot/config/global_templates/__init__.py +2 -0
  46. ot/config/global_templates/bench-secrets-template.yaml +6 -0
  47. ot/config/global_templates/bench.yaml +9 -0
  48. ot/config/global_templates/onetool.yaml +27 -0
  49. ot/config/global_templates/secrets-template.yaml +44 -0
  50. ot/config/global_templates/servers.yaml +18 -0
  51. ot/config/global_templates/snippets.yaml +235 -0
  52. ot/config/loader.py +1087 -0
  53. ot/config/mcp.py +145 -0
  54. ot/config/secrets.py +190 -0
  55. ot/config/tool_config.py +125 -0
  56. ot/decorators.py +116 -0
  57. ot/executor/__init__.py +35 -0
  58. ot/executor/base.py +16 -0
  59. ot/executor/fence_processor.py +83 -0
  60. ot/executor/linter.py +142 -0
  61. ot/executor/pack_proxy.py +260 -0
  62. ot/executor/param_resolver.py +140 -0
  63. ot/executor/pep723.py +288 -0
  64. ot/executor/result_store.py +369 -0
  65. ot/executor/runner.py +496 -0
  66. ot/executor/simple.py +163 -0
  67. ot/executor/tool_loader.py +396 -0
  68. ot/executor/validator.py +398 -0
  69. ot/executor/worker_pool.py +388 -0
  70. ot/executor/worker_proxy.py +189 -0
  71. ot/http_client.py +145 -0
  72. ot/logging/__init__.py +37 -0
  73. ot/logging/config.py +315 -0
  74. ot/logging/entry.py +213 -0
  75. ot/logging/format.py +188 -0
  76. ot/logging/span.py +349 -0
  77. ot/meta.py +1555 -0
  78. ot/paths.py +453 -0
  79. ot/prompts.py +218 -0
  80. ot/proxy/__init__.py +21 -0
  81. ot/proxy/manager.py +396 -0
  82. ot/py.typed +0 -0
  83. ot/registry/__init__.py +189 -0
  84. ot/registry/models.py +57 -0
  85. ot/registry/parser.py +269 -0
  86. ot/registry/registry.py +413 -0
  87. ot/server.py +315 -0
  88. ot/shortcuts/__init__.py +15 -0
  89. ot/shortcuts/aliases.py +87 -0
  90. ot/shortcuts/snippets.py +258 -0
  91. ot/stats/__init__.py +35 -0
  92. ot/stats/html.py +250 -0
  93. ot/stats/jsonl_writer.py +283 -0
  94. ot/stats/reader.py +354 -0
  95. ot/stats/timing.py +57 -0
  96. ot/support.py +63 -0
  97. ot/tools.py +114 -0
  98. ot/utils/__init__.py +81 -0
  99. ot/utils/batch.py +161 -0
  100. ot/utils/cache.py +120 -0
  101. ot/utils/deps.py +403 -0
  102. ot/utils/exceptions.py +23 -0
  103. ot/utils/factory.py +179 -0
  104. ot/utils/format.py +65 -0
  105. ot/utils/http.py +202 -0
  106. ot/utils/platform.py +45 -0
  107. ot/utils/sanitize.py +130 -0
  108. ot/utils/truncate.py +69 -0
  109. ot_tools/__init__.py +4 -0
  110. ot_tools/_convert/__init__.py +12 -0
  111. ot_tools/_convert/excel.py +279 -0
  112. ot_tools/_convert/pdf.py +254 -0
  113. ot_tools/_convert/powerpoint.py +268 -0
  114. ot_tools/_convert/utils.py +358 -0
  115. ot_tools/_convert/word.py +283 -0
  116. ot_tools/brave_search.py +604 -0
  117. ot_tools/code_search.py +736 -0
  118. ot_tools/context7.py +495 -0
  119. ot_tools/convert.py +614 -0
  120. ot_tools/db.py +415 -0
  121. ot_tools/diagram.py +1604 -0
  122. ot_tools/diagram.yaml +167 -0
  123. ot_tools/excel.py +1372 -0
  124. ot_tools/file.py +1348 -0
  125. ot_tools/firecrawl.py +732 -0
  126. ot_tools/grounding_search.py +646 -0
  127. ot_tools/package.py +604 -0
  128. ot_tools/py.typed +0 -0
  129. ot_tools/ripgrep.py +544 -0
  130. ot_tools/scaffold.py +471 -0
  131. ot_tools/transform.py +213 -0
  132. ot_tools/web_fetch.py +384 -0
ot_tools/diagram.py ADDED
@@ -0,0 +1,1604 @@
1
+ """Diagram generation tools using Kroki as the rendering backend.
2
+
3
+ Provides a two-stage pipeline for creating diagrams:
4
+ 1. Generate source - creates diagram source code for review
5
+ 2. Render diagram - renders source via Kroki to SVG/PNG/PDF
6
+
7
+ Supports 28+ diagram types through Kroki, with focus providers:
8
+ - Mermaid: flowcharts, sequences, state diagrams, Gantt, mindmaps
9
+ - PlantUML: UML diagrams, C4 architecture
10
+ - D2: modern architecture diagrams with auto-layout
11
+
12
+ Reference: https://kroki.io/
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ # Pack for dot notation: diagram.generate_source(), diagram.render(), etc.
18
+ pack = "diagram"
19
+
20
+ __all__ = [
21
+ "batch_render",
22
+ "generate_source",
23
+ "get_diagram_instructions",
24
+ "get_diagram_policy",
25
+ "get_output_config",
26
+ "get_playground_url",
27
+ "get_render_status",
28
+ "get_template",
29
+ "list_providers",
30
+ "render_diagram",
31
+ "render_directory",
32
+ ]
33
+
34
+ import base64
35
+ import re
36
+ import threading
37
+ import time
38
+ import uuid
39
+ import zlib
40
+ from concurrent.futures import ThreadPoolExecutor, as_completed
41
+ from datetime import datetime
42
+ from pathlib import Path
43
+ from typing import Any, Literal
44
+
45
+ import httpx
46
+ from pydantic import BaseModel, Field
47
+
48
+ from ot.config import get_tool_config
49
+ from ot.logging import LogSpan
50
+ from ot.paths import get_effective_cwd, get_project_dir
51
+ from ot.utils import truncate
52
+
53
+ # ==================== Configuration Classes ====================
54
+
55
+
56
+ class BackendConfig(BaseModel):
57
+ """Kroki backend settings."""
58
+
59
+ type: Literal["kroki"] = Field(
60
+ default="kroki",
61
+ description="Backend type (only kroki supported)",
62
+ )
63
+ remote_url: str = Field(
64
+ default="https://kroki.io",
65
+ description="Remote Kroki service URL",
66
+ )
67
+ self_hosted_url: str = Field(
68
+ default="http://localhost:8000",
69
+ description="Self-hosted Kroki URL",
70
+ )
71
+ prefer: Literal["remote", "self_hosted", "auto"] = Field(
72
+ default="remote",
73
+ description="Preferred backend: remote, self_hosted, or auto",
74
+ )
75
+ timeout: float = Field(
76
+ default=30.0,
77
+ ge=1.0,
78
+ le=120.0,
79
+ description="Request timeout in seconds",
80
+ )
81
+
82
+
83
+ class PolicyConfig(BaseModel):
84
+ """Policy rules for diagram generation."""
85
+
86
+ rules: str = Field(
87
+ default="""\
88
+ NEVER use ASCII art or text-based diagrams in markdown.
89
+ Use the diagram tools for all visual representations.
90
+ Save output as SVG and reference in markdown.
91
+ Always generate source first, then render.""",
92
+ description="Policy rules for LLM guidance",
93
+ )
94
+ preferred_format: Literal["svg", "png", "pdf"] = Field(
95
+ default="svg",
96
+ description="Default output format",
97
+ )
98
+ preferred_providers: list[str] = Field(
99
+ default_factory=lambda: ["mermaid", "d2", "plantuml"],
100
+ description="Preferred diagram providers in order",
101
+ )
102
+
103
+
104
+ class OutputConfig(BaseModel):
105
+ """Output settings for generated diagrams."""
106
+
107
+ dir: str = Field(
108
+ default="diagrams",
109
+ description="Output directory for rendered diagrams (relative to project dir)",
110
+ )
111
+ naming: str = Field(
112
+ default="{provider}_{name}_{timestamp}",
113
+ description="Filename pattern (supports {provider}, {name}, {timestamp})",
114
+ )
115
+ default_format: Literal["svg", "png", "pdf"] = Field(
116
+ default="svg",
117
+ description="Default output format",
118
+ )
119
+ save_source: bool = Field(
120
+ default=True,
121
+ description="Save diagram source alongside rendered output",
122
+ )
123
+
124
+
125
+ class ProviderInstructions(BaseModel):
126
+ """Instructions for a specific diagram provider."""
127
+
128
+ when_to_use: str = Field(
129
+ default="",
130
+ description="Guidance on when to use this provider",
131
+ )
132
+ style_tips: str = Field(
133
+ default="",
134
+ description="Style and syntax tips",
135
+ )
136
+ syntax_guide: str = Field(
137
+ default="",
138
+ description="Link to syntax documentation",
139
+ )
140
+ example: str = Field(
141
+ default="",
142
+ description="Example diagram source",
143
+ )
144
+
145
+
146
+ class TemplateRef(BaseModel):
147
+ """Reference to a diagram template file."""
148
+
149
+ provider: str = Field(
150
+ ...,
151
+ description="Diagram provider (mermaid, plantuml, d2)",
152
+ )
153
+ diagram_type: str = Field(
154
+ ...,
155
+ description="Type of diagram (sequence, flowchart, etc.)",
156
+ )
157
+ description: str = Field(
158
+ default="",
159
+ description="Template description",
160
+ )
161
+ file: str = Field(
162
+ ...,
163
+ description="Path to template file (relative to config)",
164
+ )
165
+
166
+
167
+ class Config(BaseModel):
168
+ """Pack configuration - discovered by registry."""
169
+
170
+ backend: BackendConfig = Field(
171
+ default_factory=BackendConfig,
172
+ description="Kroki backend settings",
173
+ )
174
+ policy: PolicyConfig = Field(
175
+ default_factory=PolicyConfig,
176
+ description="Policy rules for diagram generation",
177
+ )
178
+ output: OutputConfig = Field(
179
+ default_factory=OutputConfig,
180
+ description="Output settings",
181
+ )
182
+ instructions: dict[str, ProviderInstructions] = Field(
183
+ default_factory=dict,
184
+ description="Provider-specific instructions",
185
+ )
186
+ templates: dict[str, TemplateRef] = Field(
187
+ default_factory=dict,
188
+ description="Named template references",
189
+ )
190
+
191
+
192
+ def _get_config() -> Config:
193
+ """Get diagram pack configuration."""
194
+ return get_tool_config("diagram", Config)
195
+
196
+
197
+ # Shared HTTP client for connection pooling
198
+ _http_client = httpx.Client(
199
+ timeout=30.0,
200
+ limits=httpx.Limits(
201
+ max_keepalive_connections=20,
202
+ max_connections=100,
203
+ keepalive_expiry=30.0,
204
+ ),
205
+ )
206
+
207
+
208
+ # ==================== Constants ====================
209
+
210
+ # Kroki-supported diagram providers
211
+ KROKI_PROVIDERS = [
212
+ "actdiag",
213
+ "blockdiag",
214
+ "bpmn",
215
+ "bytefield",
216
+ "c4plantuml",
217
+ "d2",
218
+ "dbml",
219
+ "ditaa",
220
+ "erd",
221
+ "excalidraw",
222
+ "graphviz",
223
+ "mermaid",
224
+ "nomnoml",
225
+ "nwdiag",
226
+ "packetdiag",
227
+ "pikchr",
228
+ "plantuml",
229
+ "rackdiag",
230
+ "seqdiag",
231
+ "structurizr",
232
+ "svgbob",
233
+ "symbolator",
234
+ "tikz",
235
+ "umlet",
236
+ "vega",
237
+ "vegalite",
238
+ "wavedrom",
239
+ "wireviz",
240
+ ]
241
+
242
+ # Focus providers with full guidance
243
+ FOCUS_PROVIDERS = ["mermaid", "plantuml", "d2"]
244
+
245
+ # File extensions for diagram sources
246
+ PROVIDER_EXTENSIONS = {
247
+ "mermaid": ".mmd",
248
+ "plantuml": ".puml",
249
+ "d2": ".d2",
250
+ "graphviz": ".dot",
251
+ "ditaa": ".ditaa",
252
+ "erd": ".erd",
253
+ "nomnoml": ".nomnoml",
254
+ "svgbob": ".bob",
255
+ "vega": ".vg.json",
256
+ "vegalite": ".vl.json",
257
+ }
258
+
259
+ # Reverse mapping: extension -> provider (for file inference)
260
+ EXTENSION_TO_PROVIDER = {v: k for k, v in PROVIDER_EXTENSIONS.items()}
261
+
262
+ # Set of all known diagram extensions (for directory scanning)
263
+ DIAGRAM_EXTENSIONS = frozenset(PROVIDER_EXTENSIONS.values())
264
+
265
+ # Output format MIME types
266
+ FORMAT_MIME_TYPES = {
267
+ "svg": "image/svg+xml",
268
+ "png": "image/png",
269
+ "pdf": "application/pdf",
270
+ "jpeg": "image/jpeg",
271
+ }
272
+
273
+ # Async task storage for batch operations
274
+ _render_tasks: dict[str, dict[str, Any]] = {}
275
+ _render_tasks_lock = threading.Lock()
276
+
277
+ # Cached backend URL to avoid redundant health checks
278
+ # TTL-based invalidation: cache expires after _CACHE_TTL_SECONDS
279
+ _CACHE_TTL_SECONDS = 300 # 5 minutes
280
+ _cached_backend: dict[str, Any] = {"url": None, "is_self_hosted": None, "timestamp": 0.0}
281
+
282
+
283
+ # ==================== Path Resolution ====================
284
+
285
+
286
+ def _resolve_project_path(path_str: str) -> Path:
287
+ """Resolve a path relative to the project directory (OT_CWD).
288
+
289
+ Args:
290
+ path_str: Path string (relative, absolute, or with ~)
291
+
292
+ Returns:
293
+ Resolved absolute Path
294
+ """
295
+ p = Path(path_str).expanduser()
296
+ if p.is_absolute():
297
+ return p
298
+ return (get_effective_cwd() / p).resolve()
299
+
300
+
301
+ def _resolve_config_path(path_str: str) -> Path:
302
+ """Resolve a path relative to the config directory (.onetool/).
303
+
304
+ Args:
305
+ path_str: Path string (relative, absolute, or with ~)
306
+
307
+ Returns:
308
+ Resolved absolute Path
309
+ """
310
+ p = Path(path_str).expanduser()
311
+ if p.is_absolute():
312
+ return p
313
+ # Try project-level .onetool first, fall back to global
314
+ project_ot = get_project_dir()
315
+ if project_ot:
316
+ return (project_ot / p).resolve()
317
+ # Fall back to project root if no .onetool dir
318
+ return (get_effective_cwd() / p).resolve()
319
+
320
+
321
+ def _resolve_output_dir(output_dir: str | None) -> Path:
322
+ """Resolve the output directory for diagrams.
323
+
324
+ Output is always relative to the project directory (OT_CWD), not the config
325
+ directory. This ensures diagrams are saved where the user expects them.
326
+
327
+ Path resolution:
328
+ - Relative paths: resolved relative to project directory (OT_CWD)
329
+ - Absolute paths: used as-is
330
+ - ~ paths: expanded to home directory
331
+
332
+ Args:
333
+ output_dir: Output directory path, or None to use config default.
334
+
335
+ Returns:
336
+ Resolved absolute Path to output directory (created if needed).
337
+ """
338
+ if output_dir is None:
339
+ output_dir = _get_config().output.dir
340
+
341
+ path = _resolve_project_path(output_dir)
342
+ path.mkdir(parents=True, exist_ok=True)
343
+ return path
344
+
345
+
346
+ # ==================== Encoding Utilities ====================
347
+
348
+
349
+ def encode_source(source: str) -> str:
350
+ """Encode diagram source for GET URL requests.
351
+
352
+ Uses deflate compression + base64url encoding as required by Kroki.
353
+
354
+ Args:
355
+ source: The diagram source code.
356
+
357
+ Returns:
358
+ Encoded string suitable for Kroki GET URLs.
359
+ """
360
+ compressed = zlib.compress(source.encode("utf-8"), level=9)
361
+ return base64.urlsafe_b64encode(compressed).decode("ascii")
362
+
363
+
364
+ def _decode_source(encoded: str) -> str:
365
+ """Decode diagram source from GET URL encoding.
366
+
367
+ Args:
368
+ encoded: The encoded diagram source.
369
+
370
+ Returns:
371
+ Original diagram source code.
372
+ """
373
+ compressed = base64.urlsafe_b64decode(encoded)
374
+ return zlib.decompress(compressed).decode("utf-8")
375
+
376
+
377
+ def _encode_plantuml(source: str) -> str:
378
+ """Encode diagram source using PlantUML-specific encoding.
379
+
380
+ PlantUML uses a custom alphabet for URL encoding.
381
+
382
+ Args:
383
+ source: The PlantUML diagram source.
384
+
385
+ Returns:
386
+ PlantUML-encoded string for plantuml.com URLs.
387
+ """
388
+ # PlantUML's custom base64 alphabet
389
+ alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_"
390
+
391
+ compressed = zlib.compress(source.encode("utf-8"), level=9)[2:-4]
392
+ result = []
393
+
394
+ for i in range(0, len(compressed), 3):
395
+ chunk = compressed[i : i + 3]
396
+ if len(chunk) == 3:
397
+ b1, b2, b3 = chunk
398
+ result.append(alphabet[b1 >> 2])
399
+ result.append(alphabet[((b1 & 0x3) << 4) | (b2 >> 4)])
400
+ result.append(alphabet[((b2 & 0xF) << 2) | (b3 >> 6)])
401
+ result.append(alphabet[b3 & 0x3F])
402
+ elif len(chunk) == 2:
403
+ b1, b2 = chunk
404
+ result.append(alphabet[b1 >> 2])
405
+ result.append(alphabet[((b1 & 0x3) << 4) | (b2 >> 4)])
406
+ result.append(alphabet[(b2 & 0xF) << 2])
407
+ elif len(chunk) == 1:
408
+ b1 = chunk[0]
409
+ result.append(alphabet[b1 >> 2])
410
+ result.append(alphabet[(b1 & 0x3) << 4])
411
+
412
+ return "".join(result)
413
+
414
+
415
+ # ==================== Kroki Client Utilities ====================
416
+
417
+
418
+ def _get_kroki_url() -> str:
419
+ """Get the appropriate Kroki URL based on configuration.
420
+
421
+ Uses cached result with TTL to avoid redundant health checks in batch
422
+ operations while allowing recovery if a backend goes down.
423
+
424
+ Returns:
425
+ The Kroki base URL (remote or self-hosted).
426
+ """
427
+ # Return cached URL if available and not expired
428
+ now = time.monotonic()
429
+ if (
430
+ _cached_backend["url"] is not None
431
+ and now - _cached_backend["timestamp"] < _CACHE_TTL_SECONDS
432
+ ):
433
+ return _cached_backend["url"]
434
+
435
+ config = _get_config()
436
+ prefer = config.backend.prefer
437
+ remote_url = config.backend.remote_url
438
+ self_hosted_url = config.backend.self_hosted_url
439
+
440
+ if prefer == "self_hosted":
441
+ _cached_backend["url"] = self_hosted_url
442
+ _cached_backend["is_self_hosted"] = True
443
+ elif prefer == "auto":
444
+ # Try self-hosted first, fall back to remote
445
+ try:
446
+ resp = _http_client.get(f"{self_hosted_url}/health", timeout=2.0)
447
+ if resp.status_code == 200:
448
+ _cached_backend["url"] = self_hosted_url
449
+ _cached_backend["is_self_hosted"] = True
450
+ else:
451
+ _cached_backend["url"] = remote_url
452
+ _cached_backend["is_self_hosted"] = False
453
+ except Exception:
454
+ _cached_backend["url"] = remote_url
455
+ _cached_backend["is_self_hosted"] = False
456
+ else:
457
+ _cached_backend["url"] = remote_url
458
+ _cached_backend["is_self_hosted"] = False
459
+
460
+ _cached_backend["timestamp"] = now
461
+ return _cached_backend["url"]
462
+
463
+
464
+ def _is_self_hosted() -> bool:
465
+ """Check if using self-hosted Kroki backend.
466
+
467
+ Uses cached result from _get_kroki_url() to avoid redundant health checks.
468
+ """
469
+ # Ensure cache is populated
470
+ if _cached_backend["is_self_hosted"] is None:
471
+ _get_kroki_url()
472
+ return _cached_backend["is_self_hosted"] or False
473
+
474
+
475
+ def _render_via_kroki(
476
+ source: str, provider: str, output_format: str = "svg", timeout: float = 30.0
477
+ ) -> bytes:
478
+ """Render diagram source via Kroki HTTP API.
479
+
480
+ Uses POST for rendering to handle large diagrams without URL limits.
481
+
482
+ Args:
483
+ source: The diagram source code.
484
+ provider: The diagram provider (mermaid, plantuml, d2, etc.).
485
+ output_format: Output format (svg, png, pdf).
486
+ timeout: Request timeout in seconds.
487
+
488
+ Returns:
489
+ Rendered diagram as bytes.
490
+
491
+ Raises:
492
+ Exception: If rendering fails.
493
+ """
494
+ kroki_url = _get_kroki_url()
495
+ url = f"{kroki_url}/{provider}/{output_format}"
496
+
497
+ with LogSpan(span="diagram.kroki", provider=provider, format=output_format, url=url) as span:
498
+ resp = _http_client.post(
499
+ url,
500
+ content=source.encode("utf-8"),
501
+ headers={"Content-Type": "text/plain"},
502
+ timeout=timeout,
503
+ )
504
+
505
+ span.add(status=resp.status_code)
506
+
507
+ if resp.status_code != 200:
508
+ error_msg = resp.text[:500] if resp.text else f"HTTP {resp.status_code}"
509
+ span.add(error=error_msg)
510
+ raise Exception(f"Kroki render failed: {error_msg}")
511
+
512
+ span.add(responseLen=len(resp.content))
513
+ return resp.content
514
+
515
+
516
+ def _get_kroki_get_url(source: str, provider: str, output_format: str = "svg") -> str:
517
+ """Generate a Kroki GET URL for sharing.
518
+
519
+ Args:
520
+ source: The diagram source code.
521
+ provider: The diagram provider.
522
+ output_format: Output format.
523
+
524
+ Returns:
525
+ Shareable Kroki GET URL.
526
+ """
527
+ encoded = encode_source(source)
528
+ kroki_url = _get_kroki_url()
529
+ return f"{kroki_url}/{provider}/{output_format}/{encoded}"
530
+
531
+
532
+ # ==================== Playground URL Generators ====================
533
+
534
+
535
+ def _get_mermaid_playground_url(source: str) -> str:
536
+ """Generate Mermaid Live Editor URL.
537
+
538
+ Args:
539
+ source: Mermaid diagram source.
540
+
541
+ Returns:
542
+ Mermaid Live Editor URL.
543
+ """
544
+ # Mermaid.live uses pako (deflate + base64)
545
+ encoded = encode_source(source)
546
+ return f"https://mermaid.live/edit#pako:{encoded}"
547
+
548
+
549
+ def _get_plantuml_playground_url(source: str) -> str:
550
+ """Generate PlantUML web server URL.
551
+
552
+ Args:
553
+ source: PlantUML diagram source.
554
+
555
+ Returns:
556
+ PlantUML playground URL.
557
+ """
558
+ encoded = _encode_plantuml(source)
559
+ return f"https://www.plantuml.com/plantuml/uml/{encoded}"
560
+
561
+
562
+ def _get_d2_playground_url(source: str) -> str:
563
+ """Generate D2 playground URL.
564
+
565
+ Args:
566
+ source: D2 diagram source.
567
+
568
+ Returns:
569
+ D2 playground URL.
570
+ """
571
+ # D2 playground uses base64url encoding
572
+ encoded = base64.urlsafe_b64encode(source.encode("utf-8")).decode("ascii")
573
+ return f"https://play.d2lang.com/?script={encoded}"
574
+
575
+
576
+ # ==================== Validation Utilities ====================
577
+
578
+
579
+ def _validate_provider(provider: str) -> None:
580
+ """Validate that the provider is supported by Kroki.
581
+
582
+ Args:
583
+ provider: The diagram provider name.
584
+
585
+ Raises:
586
+ ValueError: If provider is not supported.
587
+ """
588
+ if provider not in KROKI_PROVIDERS:
589
+ raise ValueError(
590
+ f"Unknown provider '{provider}'. "
591
+ f"Supported: {', '.join(FOCUS_PROVIDERS)} (focus), "
592
+ f"plus {len(KROKI_PROVIDERS) - len(FOCUS_PROVIDERS)} others."
593
+ )
594
+
595
+
596
+ def _validate_format(output_format: str) -> None:
597
+ """Validate that the output format is supported.
598
+
599
+ Args:
600
+ output_format: The output format.
601
+
602
+ Raises:
603
+ ValueError: If format is not supported.
604
+ """
605
+ if output_format not in FORMAT_MIME_TYPES:
606
+ raise ValueError(
607
+ f"Unknown format '{output_format}'. "
608
+ f"Supported: {', '.join(FORMAT_MIME_TYPES.keys())}"
609
+ )
610
+
611
+
612
+ def _basic_source_validation(source: str, provider: str) -> list[str]:
613
+ """Perform basic validation of diagram source.
614
+
615
+ Returns warnings/errors that don't prevent rendering but may cause issues.
616
+
617
+ Args:
618
+ source: The diagram source code.
619
+ provider: The diagram provider.
620
+
621
+ Returns:
622
+ List of warning messages (empty if no issues).
623
+ """
624
+ warnings: list[str] = []
625
+
626
+ # Check for common Mermaid issues
627
+ if provider == "mermaid":
628
+ # Check for quoted aliases in sequence diagrams (common mistake)
629
+ if "sequenceDiagram" in source and re.search(
630
+ r'participant\s+\w+\s+as\s+"[^"]+"', source
631
+ ):
632
+ warnings.append(
633
+ "Mermaid sequence diagrams: quotes after 'as' appear literally. "
634
+ "Use: participant ID as Display Name (no quotes)"
635
+ )
636
+ # Check for spaces in node IDs
637
+ if re.search(r'\b\w+\s+\w+\["', source):
638
+ warnings.append(
639
+ "Mermaid: node IDs should not contain spaces. "
640
+ 'Use ID["Label with spaces"] format.'
641
+ )
642
+
643
+ # Check for PlantUML markers
644
+ if provider == "plantuml" and not source.strip().startswith("@start"):
645
+ warnings.append(
646
+ "PlantUML source should start with @startuml, @startmindmap, etc."
647
+ )
648
+
649
+ # Check for D2 issues - D2 is pretty forgiving, just check for obvious issues
650
+ if provider == "d2" and source.strip().startswith("@"):
651
+ warnings.append("D2 doesn't use @ markers. This looks like PlantUML syntax.")
652
+
653
+ return warnings
654
+
655
+
656
+ # ==================== File Utilities ====================
657
+
658
+
659
+ def _get_source_extension(provider: str) -> str:
660
+ """Get the file extension for a diagram provider.
661
+
662
+ Args:
663
+ provider: The diagram provider.
664
+
665
+ Returns:
666
+ File extension including the dot.
667
+ """
668
+ return PROVIDER_EXTENSIONS.get(provider, f".{provider}")
669
+
670
+
671
+ def _generate_filename(
672
+ name: str, provider: str, output_format: str, is_source: bool = False
673
+ ) -> str:
674
+ """Generate a filename based on the configured naming pattern.
675
+
676
+ Args:
677
+ name: Base name for the file.
678
+ provider: The diagram provider.
679
+ output_format: Output format for rendered files.
680
+ is_source: Whether this is a source file (not rendered).
681
+
682
+ Returns:
683
+ Generated filename.
684
+ """
685
+ naming_pattern = _get_config().output.naming
686
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
687
+
688
+ filename = naming_pattern.format(
689
+ provider=provider,
690
+ name=name,
691
+ timestamp=timestamp,
692
+ )
693
+
694
+ if is_source:
695
+ return filename + _get_source_extension(provider)
696
+ else:
697
+ return filename + f".{output_format}"
698
+
699
+
700
+ # ==================== Core Tools ====================
701
+
702
+
703
+ def generate_source(
704
+ *,
705
+ source: str,
706
+ provider: Literal[
707
+ "mermaid", "plantuml", "d2", "graphviz", "ditaa", "erd", "nomnoml", "svgbob"
708
+ ]
709
+ | str,
710
+ name: str,
711
+ output_dir: str | None = None,
712
+ validate: bool = True,
713
+ ) -> str:
714
+ """Save diagram source code to a file for review before rendering.
715
+
716
+ This is the first stage of the two-stage pipeline. The source can be
717
+ reviewed and edited before calling render_diagram().
718
+
719
+ Args:
720
+ source: The diagram source code.
721
+ provider: Diagram provider (mermaid, plantuml, d2, etc.).
722
+ name: Base name for the file (used in filename generation).
723
+ output_dir: Output directory (defaults to config tools.diagram.output.dir).
724
+ validate: Perform basic source validation (default: True).
725
+
726
+ Returns:
727
+ Result message with file path and any validation warnings.
728
+
729
+ Example:
730
+ result = diagram.generate_source(
731
+ source=\"\"\"
732
+ sequenceDiagram
733
+ participant C as Client
734
+ participant S as Server
735
+ C->>S: Request
736
+ S-->>C: Response
737
+ \"\"\",
738
+ provider="mermaid",
739
+ name="api-flow"
740
+ )
741
+ """
742
+ with LogSpan(span="diagram.generate_source", provider=provider, diagram_name=name) as s:
743
+ try:
744
+ _validate_provider(provider)
745
+
746
+ # Get output directory (resolved relative to project directory)
747
+ output_path = _resolve_output_dir(output_dir)
748
+
749
+ # Generate filename and write source
750
+ filename = _generate_filename(name, provider, "", is_source=True)
751
+ file_path = output_path / filename
752
+
753
+ file_path.write_text(source)
754
+
755
+ # Validation warnings
756
+ warnings: list[str] = []
757
+ if validate:
758
+ warnings = _basic_source_validation(source, provider)
759
+
760
+ # Get playground URL for debugging
761
+ playground_url = get_playground_url(source=source, provider=provider)
762
+
763
+ result_parts = [
764
+ f"Source saved: {file_path}",
765
+ f"Provider: {provider}",
766
+ f"Lines: {len(source.splitlines())}",
767
+ ]
768
+
769
+ if playground_url:
770
+ result_parts.append(f"Playground: {playground_url}")
771
+
772
+ if warnings:
773
+ result_parts.append("\nWarnings:")
774
+ result_parts.extend(f" - {w}" for w in warnings)
775
+
776
+ s.add(path=str(file_path), warnings=len(warnings))
777
+ return "\n".join(result_parts)
778
+
779
+ except Exception as e:
780
+ s.add(error=str(e))
781
+ return f"Error generating source: {e}"
782
+
783
+
784
+ def render_diagram(
785
+ *,
786
+ source: str | None = None,
787
+ source_file: str | None = None,
788
+ provider: str | None = None,
789
+ name: str | None = None,
790
+ output_format: Literal["svg", "png", "pdf"] = "svg",
791
+ output_dir: str | None = None,
792
+ save_source: bool | None = None,
793
+ async_mode: bool = False,
794
+ ) -> str:
795
+ """Render a diagram from source code or file via Kroki.
796
+
797
+ This is the second stage of the two-stage pipeline. Renders the diagram
798
+ and saves the output file.
799
+
800
+ Args:
801
+ source: The diagram source code (mutually exclusive with source_file).
802
+ source_file: Path to source file (mutually exclusive with source).
803
+ provider: Diagram provider (required if source given, inferred from file).
804
+ name: Base name for output file (defaults to source filename).
805
+ output_format: Output format (svg, png, pdf). Default: svg.
806
+ output_dir: Output directory (defaults to config).
807
+ save_source: Save source alongside output (defaults to config).
808
+ async_mode: Return immediately with task ID for status polling.
809
+
810
+ Returns:
811
+ If sync: Result message with output file path.
812
+ If async: Task ID for polling with get_render_status().
813
+
814
+ Example:
815
+ # From source
816
+ result = diagram.render_diagram(
817
+ source=\"\"\"
818
+ sequenceDiagram
819
+ C->>S: Request
820
+ \"\"\",
821
+ provider="mermaid",
822
+ name="sequence"
823
+ )
824
+
825
+ # From file
826
+ result = diagram.render_diagram(
827
+ source_file="../diagrams/mermaid_api-flow.mmd"
828
+ )
829
+ """
830
+ with LogSpan(
831
+ span="diagram.render_diagram",
832
+ provider=provider,
833
+ diagram_name=name,
834
+ format=output_format,
835
+ async_mode=async_mode,
836
+ ) as s:
837
+ try:
838
+ # Validate inputs
839
+ if source is None and source_file is None:
840
+ raise ValueError("Either source or source_file must be provided")
841
+ if source is not None and source_file is not None:
842
+ raise ValueError("Cannot provide both source and source_file")
843
+
844
+ _validate_format(output_format)
845
+
846
+ # Load source from file if needed
847
+ if source_file is not None:
848
+ file_path = Path(source_file)
849
+ if not file_path.exists():
850
+ raise FileNotFoundError(f"Source file not found: {source_file}")
851
+
852
+ source = file_path.read_text()
853
+
854
+ # Infer provider from extension
855
+ if provider is None:
856
+ ext = file_path.suffix.lower()
857
+ provider = EXTENSION_TO_PROVIDER.get(ext)
858
+ if provider is None:
859
+ raise ValueError(
860
+ f"Cannot infer provider from extension '{ext}'. "
861
+ "Please specify provider explicitly."
862
+ )
863
+
864
+ # Use filename as name if not provided
865
+ if name is None:
866
+ name = file_path.stem
867
+
868
+ if provider is None:
869
+ raise ValueError("Provider must be specified when using source")
870
+ if name is None:
871
+ name = "diagram"
872
+
873
+ _validate_provider(provider)
874
+
875
+ # Get output directory (resolved relative to project directory)
876
+ output_path = _resolve_output_dir(output_dir)
877
+
878
+ # Generate output filename
879
+ output_filename = _generate_filename(name, provider, output_format)
880
+ output_file = output_path / output_filename
881
+
882
+ # Handle async mode
883
+ if async_mode:
884
+ task_id = f"render-{uuid.uuid4().hex[:8]}"
885
+ timeout = _get_config().backend.timeout
886
+
887
+ with _render_tasks_lock:
888
+ _render_tasks[task_id] = {
889
+ "status": "running",
890
+ "source": source,
891
+ "provider": provider,
892
+ "output_format": output_format,
893
+ "output_file": str(output_file),
894
+ "started_at": datetime.now().isoformat(),
895
+ }
896
+
897
+ def _do_async_render() -> None:
898
+ """Background thread worker for async rendering."""
899
+ try:
900
+ rendered = _render_via_kroki(
901
+ source, provider, output_format, timeout
902
+ )
903
+ output_file.write_bytes(rendered)
904
+
905
+ with _render_tasks_lock:
906
+ _render_tasks[task_id]["status"] = "completed"
907
+ _render_tasks[task_id]["completed_at"] = (
908
+ datetime.now().isoformat()
909
+ )
910
+ except Exception as e:
911
+ with _render_tasks_lock:
912
+ _render_tasks[task_id]["status"] = "failed"
913
+ _render_tasks[task_id]["error"] = str(e)
914
+
915
+ # Start rendering in background thread
916
+ thread = threading.Thread(target=_do_async_render, daemon=True)
917
+ thread.start()
918
+
919
+ s.add(task_id=task_id)
920
+ return f"Rendering started. Task ID: {task_id}"
921
+
922
+ # Synchronous rendering
923
+ timeout = _get_config().backend.timeout
924
+ rendered = _render_via_kroki(source, provider, output_format, timeout)
925
+
926
+ # Save output
927
+ output_file.write_bytes(rendered)
928
+
929
+ # Optionally save source alongside
930
+ if save_source is None:
931
+ save_source = _get_config().output.save_source
932
+
933
+ source_saved = ""
934
+ if save_source and source_file is None:
935
+ source_filename = _generate_filename(name, provider, "", is_source=True)
936
+ source_file_path = output_path / source_filename
937
+ source_file_path.write_text(source)
938
+ source_saved = f"\nSource saved: {source_file_path}"
939
+
940
+ # Get share URL
941
+ share_url = _get_kroki_get_url(source, provider, output_format)
942
+
943
+ s.add(output=str(output_file), size=len(rendered), format=output_format)
944
+
945
+ return (
946
+ f"Rendered: {output_file}\n"
947
+ f"Size: {len(rendered):,} bytes\n"
948
+ f"Format: {output_format.upper()}{source_saved}\n"
949
+ f"Share URL: {truncate(share_url, 100)}"
950
+ )
951
+
952
+ except Exception as e:
953
+ s.add(error=str(e))
954
+ return f"Error rendering diagram: {e}"
955
+
956
+
957
+ def get_render_status(*, task_id: str) -> str:
958
+ """Check the status of an async render task.
959
+
960
+ Args:
961
+ task_id: The task ID returned by render_diagram(async_mode=True).
962
+
963
+ Returns:
964
+ JSON-formatted status including progress and output file path.
965
+
966
+ Example:
967
+ status = diagram.get_render_status(task_id="render-abc123")
968
+ """
969
+ with LogSpan(span="diagram.get_render_status", task_id=task_id) as s:
970
+ with _render_tasks_lock:
971
+ task = _render_tasks.get(task_id)
972
+ if task is None:
973
+ s.add(error="not_found")
974
+ return f"Task not found: {task_id}"
975
+ # Copy task data under lock to avoid races
976
+ task = dict(task)
977
+
978
+ status = task.get("status", "unknown")
979
+ result_parts = [
980
+ f"Task: {task_id}",
981
+ f"Status: {status}",
982
+ ]
983
+
984
+ if status == "completed":
985
+ result_parts.append(f"Output: {task.get('output_file')}")
986
+ if task.get("completed_at"):
987
+ result_parts.append(f"Completed: {task.get('completed_at')}")
988
+ elif status == "failed":
989
+ result_parts.append(f"Error: {task.get('error')}")
990
+ elif status == "running":
991
+ result_parts.append(f"Started: {task.get('started_at')}")
992
+
993
+ s.add(status=status)
994
+ return "\n".join(result_parts)
995
+
996
+
997
+ # ==================== Batch Operations (Self-Hosted Only) ====================
998
+
999
+
1000
+ def _render_single_diagram(
1001
+ item: dict[str, str],
1002
+ output_format: str,
1003
+ output_path: Path,
1004
+ timeout: float,
1005
+ ) -> dict[str, Any]:
1006
+ """Render a single diagram - helper for concurrent batch processing.
1007
+
1008
+ Args:
1009
+ item: Dict with 'source', 'provider', 'name' keys.
1010
+ output_format: Output format (svg, png, pdf).
1011
+ output_path: Directory to save output.
1012
+ timeout: Request timeout.
1013
+
1014
+ Returns:
1015
+ Result dict with name, status, file/error.
1016
+ """
1017
+ source = item.get("source", "")
1018
+ provider = item.get("provider", "mermaid")
1019
+ name = item.get("name", "diagram")
1020
+
1021
+ try:
1022
+ _validate_provider(provider)
1023
+ _validate_format(output_format)
1024
+
1025
+ rendered = _render_via_kroki(source, provider, output_format, timeout)
1026
+
1027
+ output_filename = _generate_filename(name, provider, output_format)
1028
+ output_file = output_path / output_filename
1029
+ output_file.write_bytes(rendered)
1030
+
1031
+ return {"name": name, "status": "success", "file": str(output_file)}
1032
+ except Exception as e:
1033
+ return {"name": name, "status": "failed", "error": str(e)}
1034
+
1035
+
1036
+ def batch_render(
1037
+ *,
1038
+ sources: list[dict[str, str]],
1039
+ output_format: Literal["svg", "png", "pdf"] = "svg",
1040
+ output_dir: str | None = None,
1041
+ max_concurrent: int = 5,
1042
+ ) -> str:
1043
+ """Render multiple diagrams concurrently. Self-hosted Kroki only.
1044
+
1045
+ This operation requires a self-hosted Kroki instance to avoid
1046
+ overloading the public kroki.io service.
1047
+
1048
+ Args:
1049
+ sources: List of dicts with 'source', 'provider', 'name' keys.
1050
+ output_format: Output format for all diagrams.
1051
+ output_dir: Output directory.
1052
+ max_concurrent: Maximum concurrent render requests (default: 5).
1053
+
1054
+ Returns:
1055
+ Task ID for polling status with get_render_status().
1056
+
1057
+ Example:
1058
+ result = diagram.batch_render(
1059
+ sources=[
1060
+ {"source": "...", "provider": "mermaid", "name": "diagram1"},
1061
+ {"source": "...", "provider": "d2", "name": "diagram2"},
1062
+ ]
1063
+ )
1064
+ """
1065
+ with LogSpan(span="diagram.batch_render", count=len(sources), format=output_format) as s:
1066
+ # Check for self-hosted requirement
1067
+ if not _is_self_hosted():
1068
+ s.add(error="requires_self_hosted")
1069
+ return (
1070
+ "Error: batch_render requires self-hosted Kroki.\n"
1071
+ "The public kroki.io service has rate limits.\n"
1072
+ "Run: onetool diagram setup --self-hosted"
1073
+ )
1074
+
1075
+ task_id = f"batch-{uuid.uuid4().hex[:8]}"
1076
+
1077
+ with _render_tasks_lock:
1078
+ _render_tasks[task_id] = {
1079
+ "status": "running",
1080
+ "type": "batch",
1081
+ "total": len(sources),
1082
+ "completed": 0,
1083
+ "failed": 0,
1084
+ "results": [],
1085
+ "started_at": datetime.now().isoformat(),
1086
+ }
1087
+
1088
+ # Get output directory (resolved relative to project directory)
1089
+ output_path = _resolve_output_dir(output_dir)
1090
+
1091
+ timeout = _get_config().backend.timeout
1092
+
1093
+ # Process sources concurrently using thread pool
1094
+ with ThreadPoolExecutor(max_workers=max_concurrent) as executor:
1095
+ futures = {
1096
+ executor.submit(
1097
+ _render_single_diagram, item, output_format, output_path, timeout
1098
+ ): item
1099
+ for item in sources
1100
+ }
1101
+
1102
+ for future in as_completed(futures):
1103
+ result = future.result()
1104
+ with _render_tasks_lock:
1105
+ _render_tasks[task_id]["results"].append(result)
1106
+ if result["status"] == "success":
1107
+ _render_tasks[task_id]["completed"] += 1
1108
+ else:
1109
+ _render_tasks[task_id]["failed"] += 1
1110
+
1111
+ with _render_tasks_lock:
1112
+ _render_tasks[task_id]["status"] = "completed"
1113
+ _render_tasks[task_id]["completed_at"] = datetime.now().isoformat()
1114
+
1115
+ completed = _render_tasks[task_id]["completed"]
1116
+ failed = _render_tasks[task_id]["failed"]
1117
+
1118
+ s.add(task_id=task_id, completed=completed, failed=failed)
1119
+ return (
1120
+ f"Batch complete. Task ID: {task_id}\n"
1121
+ f"Completed: {completed}/{len(sources)}\n"
1122
+ f"Failed: {failed}"
1123
+ )
1124
+
1125
+
1126
+ def _render_file(
1127
+ source_file: Path,
1128
+ provider: str,
1129
+ output_format: str,
1130
+ output_path: Path,
1131
+ timeout: float,
1132
+ ) -> dict[str, Any]:
1133
+ """Render a diagram from file - reads file just-in-time to save memory.
1134
+
1135
+ Args:
1136
+ source_file: Path to the source file.
1137
+ provider: Diagram provider.
1138
+ output_format: Output format (svg, png, pdf).
1139
+ output_path: Directory to save output.
1140
+ timeout: Request timeout.
1141
+
1142
+ Returns:
1143
+ Result dict with name, status, file/error.
1144
+ """
1145
+ name = source_file.stem
1146
+ try:
1147
+ # Read file just-in-time (not pre-loaded)
1148
+ source = source_file.read_text()
1149
+
1150
+ _validate_provider(provider)
1151
+ _validate_format(output_format)
1152
+
1153
+ rendered = _render_via_kroki(source, provider, output_format, timeout)
1154
+
1155
+ output_filename = _generate_filename(name, provider, output_format)
1156
+ output_file = output_path / output_filename
1157
+ output_file.write_bytes(rendered)
1158
+
1159
+ return {"name": name, "status": "success", "file": str(output_file)}
1160
+ except Exception as e:
1161
+ return {"name": name, "status": "failed", "error": str(e)}
1162
+
1163
+
1164
+ def render_directory(
1165
+ *,
1166
+ directory: str,
1167
+ output_format: Literal["svg", "png", "pdf"] = "svg",
1168
+ output_dir: str | None = None,
1169
+ recursive: bool = False,
1170
+ pattern: str = "*",
1171
+ max_concurrent: int = 5,
1172
+ ) -> str:
1173
+ """Discover and render all diagram source files in a directory.
1174
+
1175
+ Self-hosted Kroki only. Files are read just-in-time to minimise memory usage.
1176
+
1177
+ Args:
1178
+ directory: Directory containing diagram source files.
1179
+ output_format: Output format for all diagrams.
1180
+ output_dir: Output directory (defaults to same as source).
1181
+ recursive: Search subdirectories.
1182
+ pattern: Glob pattern to match files (e.g., "*.mmd").
1183
+ max_concurrent: Maximum concurrent render requests (default: 5).
1184
+
1185
+ Returns:
1186
+ Summary of rendered files.
1187
+
1188
+ Example:
1189
+ result = diagram.render_directory(
1190
+ directory="../diagrams/source",
1191
+ output_format="svg",
1192
+ recursive=True
1193
+ )
1194
+ """
1195
+ with LogSpan(span="diagram.render_directory", directory=directory, pattern=pattern) as s:
1196
+ if not _is_self_hosted():
1197
+ s.add(error="requires_self_hosted")
1198
+ return (
1199
+ "Error: render_directory requires self-hosted Kroki.\n"
1200
+ "Run: onetool diagram setup --self-hosted"
1201
+ )
1202
+
1203
+ dir_path = Path(directory)
1204
+ if not dir_path.exists():
1205
+ s.add(error="dir_not_found")
1206
+ return f"Error: Directory not found: {directory}"
1207
+
1208
+ # Find source files (just paths, don't read content yet)
1209
+ if recursive:
1210
+ files = list(dir_path.rglob(pattern))
1211
+ else:
1212
+ files = list(dir_path.glob(pattern))
1213
+
1214
+ # Filter to known diagram extensions and pair with provider
1215
+ file_provider_pairs: list[tuple[Path, str]] = []
1216
+ for f in files:
1217
+ if f.suffix in DIAGRAM_EXTENSIONS:
1218
+ provider = EXTENSION_TO_PROVIDER.get(f.suffix)
1219
+ if provider:
1220
+ file_provider_pairs.append((f, provider))
1221
+
1222
+ if not file_provider_pairs:
1223
+ s.add(found=0)
1224
+ return f"No diagram source files found in {directory}"
1225
+
1226
+ s.add(found=len(file_provider_pairs))
1227
+
1228
+ # Get output directory (defaults to source directory if not specified)
1229
+ if output_dir is not None:
1230
+ output_path = _resolve_output_dir(output_dir)
1231
+ else:
1232
+ output_path = dir_path
1233
+ output_path.mkdir(parents=True, exist_ok=True)
1234
+
1235
+ timeout = _get_config().backend.timeout
1236
+
1237
+ # Process files concurrently - files are read just-in-time
1238
+ completed = 0
1239
+ failed = 0
1240
+ results: list[dict[str, Any]] = []
1241
+
1242
+ with ThreadPoolExecutor(max_workers=max_concurrent) as executor:
1243
+ futures = {
1244
+ executor.submit(
1245
+ _render_file,
1246
+ source_file,
1247
+ provider,
1248
+ output_format,
1249
+ output_path,
1250
+ timeout,
1251
+ ): source_file
1252
+ for source_file, provider in file_provider_pairs
1253
+ }
1254
+
1255
+ for future in as_completed(futures):
1256
+ result = future.result()
1257
+ results.append(result)
1258
+ if result["status"] == "success":
1259
+ completed += 1
1260
+ else:
1261
+ failed += 1
1262
+
1263
+ s.add(completed=completed, failed=failed)
1264
+ return (
1265
+ f"Directory render complete.\n"
1266
+ f"Completed: {completed}/{len(file_provider_pairs)}\n"
1267
+ f"Failed: {failed}"
1268
+ )
1269
+
1270
+
1271
+ # ==================== Configuration Tools ====================
1272
+
1273
+
1274
+ def get_diagram_policy() -> str:
1275
+ """Get the diagram policy rules from configuration.
1276
+
1277
+ Returns policy rules that guide LLM diagram generation behaviour.
1278
+
1279
+ Returns:
1280
+ Policy rules as formatted text.
1281
+
1282
+ Example:
1283
+ policy = diagram.get_diagram_policy()
1284
+ """
1285
+ with LogSpan(span="diagram.get_diagram_policy") as s:
1286
+ config = _get_config()
1287
+ policy = config.policy
1288
+
1289
+ result_parts = [
1290
+ "Diagram Policy",
1291
+ "=" * 40,
1292
+ "",
1293
+ "Rules:",
1294
+ policy.rules,
1295
+ "",
1296
+ f"Preferred format: {policy.preferred_format}",
1297
+ f"Preferred providers: {', '.join(policy.preferred_providers)}",
1298
+ ]
1299
+
1300
+ s.add(source="config")
1301
+ return "\n".join(result_parts)
1302
+
1303
+
1304
+ def get_diagram_instructions(
1305
+ *, provider: Literal["mermaid", "plantuml", "d2"] | str | None = None
1306
+ ) -> str:
1307
+ """Get provider-specific diagram instructions.
1308
+
1309
+ Returns guidance on when to use the provider, style tips,
1310
+ syntax guides, and examples.
1311
+
1312
+ Args:
1313
+ provider: Specific provider to get instructions for.
1314
+ If None, returns instructions for all focus providers.
1315
+
1316
+ Returns:
1317
+ Formatted instructions text.
1318
+
1319
+ Example:
1320
+ # All providers
1321
+ instructions = diagram.get_diagram_instructions()
1322
+
1323
+ # Specific provider
1324
+ mermaid_guide = diagram.get_diagram_instructions(provider="mermaid")
1325
+ """
1326
+ with LogSpan(span="diagram.get_diagram_instructions", provider=provider) as s:
1327
+ config = _get_config()
1328
+ # Convert Pydantic models to dicts for backward compatibility
1329
+ instructions = {k: v.model_dump() for k, v in config.instructions.items()} if config.instructions else {}
1330
+
1331
+ # If no config, use defaults
1332
+ if not instructions:
1333
+ instructions = _get_default_instructions()
1334
+
1335
+ if provider is not None:
1336
+ if provider not in instructions:
1337
+ s.add(found=False)
1338
+ return f"No instructions found for provider: {provider}"
1339
+
1340
+ instr = instructions[provider]
1341
+ result = _format_provider_instructions(provider, instr)
1342
+ s.add(found=True)
1343
+ return result
1344
+
1345
+ # Return all focus provider instructions
1346
+ result_parts = ["Diagram Provider Instructions", "=" * 40]
1347
+
1348
+ for p in FOCUS_PROVIDERS:
1349
+ if p in instructions:
1350
+ result_parts.append("")
1351
+ result_parts.append(_format_provider_instructions(p, instructions[p]))
1352
+
1353
+ s.add(providers=len([p for p in FOCUS_PROVIDERS if p in instructions]))
1354
+ return "\n".join(result_parts)
1355
+
1356
+
1357
+ def _get_default_instructions() -> dict[str, Any]:
1358
+ """Get default instructions when not configured."""
1359
+ return {
1360
+ "mermaid": {
1361
+ "when_to_use": (
1362
+ "Flowcharts and decision trees\n"
1363
+ "Sequence diagrams for API flows\n"
1364
+ "Class diagrams for data models\n"
1365
+ "State diagrams for workflows"
1366
+ ),
1367
+ "style_tips": (
1368
+ "Use subgraphs to group related nodes\n"
1369
+ "Keep flowcharts top-to-bottom (TD) for readability\n"
1370
+ "QUOTING: NO quotes after 'as' in sequence diagrams"
1371
+ ),
1372
+ "syntax_guide": "https://mermaid.js.org/syntax/",
1373
+ "example": (
1374
+ "sequenceDiagram\n"
1375
+ " participant C as Client\n"
1376
+ " participant S as Server\n"
1377
+ " C->>S: Request\n"
1378
+ " S-->>C: Response"
1379
+ ),
1380
+ },
1381
+ "plantuml": {
1382
+ "when_to_use": (
1383
+ "Complex UML diagrams\n"
1384
+ "C4 architecture diagrams\n"
1385
+ "Detailed sequence diagrams with notes"
1386
+ ),
1387
+ "style_tips": (
1388
+ "Use skinparam for consistent theming\n"
1389
+ "QUOTING: Quote display names BEFORE 'as'"
1390
+ ),
1391
+ "syntax_guide": "https://plantuml.com/",
1392
+ },
1393
+ "d2": {
1394
+ "when_to_use": (
1395
+ "Clean architecture diagrams\n"
1396
+ "System context diagrams\n"
1397
+ "Hand-drawn style (sketch mode)"
1398
+ ),
1399
+ "style_tips": (
1400
+ "Use containers for logical grouping\n"
1401
+ "D2 auto-layouts well\n"
1402
+ "QUOTING: Always quote labels after colon"
1403
+ ),
1404
+ "syntax_guide": "https://d2lang.com/tour/intro",
1405
+ },
1406
+ }
1407
+
1408
+
1409
+ def _format_provider_instructions(provider: str, instr: dict[str, Any]) -> str:
1410
+ """Format instructions for a single provider."""
1411
+ parts = [f"## {provider.upper()}"]
1412
+
1413
+ if instr.get("when_to_use"):
1414
+ parts.append("\nWhen to use:")
1415
+ parts.append(instr["when_to_use"])
1416
+
1417
+ if instr.get("style_tips"):
1418
+ parts.append("\nStyle tips:")
1419
+ parts.append(instr["style_tips"])
1420
+
1421
+ if instr.get("syntax_guide"):
1422
+ parts.append(f"\nSyntax guide: {instr['syntax_guide']}")
1423
+
1424
+ if instr.get("example"):
1425
+ parts.append("\nExample:")
1426
+ parts.append("```")
1427
+ parts.append(instr["example"])
1428
+ parts.append("```")
1429
+
1430
+ return "\n".join(parts)
1431
+
1432
+
1433
+ def get_output_config() -> str:
1434
+ """Get diagram output configuration settings.
1435
+
1436
+ Returns:
1437
+ Formatted output configuration.
1438
+
1439
+ Example:
1440
+ config = diagram.get_output_config()
1441
+ """
1442
+ with LogSpan(span="diagram.get_output_config") as s:
1443
+ config = _get_config()
1444
+ output = config.output
1445
+
1446
+ result = (
1447
+ "Diagram Output Configuration\n"
1448
+ + "=" * 40 + "\n\n"
1449
+ + f"Output directory: {output.dir}\n"
1450
+ + f"Naming pattern: {output.naming}\n"
1451
+ + f"Default format: {output.default_format}\n"
1452
+ + f"Save source: {output.save_source}"
1453
+ )
1454
+
1455
+ s.add(dir=output.dir, format=output.default_format)
1456
+ return result
1457
+
1458
+
1459
+ def get_template(*, name: str) -> str:
1460
+ """Load a diagram template by name.
1461
+
1462
+ Templates are defined in the diagram config and provide
1463
+ starting points for common diagram types.
1464
+
1465
+ Args:
1466
+ name: Template name as configured in onetool.yaml.
1467
+
1468
+ Returns:
1469
+ Template source code with metadata.
1470
+
1471
+ Example:
1472
+ # First configure templates in onetool.yaml:
1473
+ # tools:
1474
+ # diagram:
1475
+ # templates:
1476
+ # api-flow:
1477
+ # provider: mermaid
1478
+ # diagram_type: sequence
1479
+ # description: API flow template
1480
+ # file: templates/api-flow.mmd
1481
+ #
1482
+ # Then load it:
1483
+ template = diagram.get_template(name="api-flow")
1484
+ """
1485
+ with LogSpan(span="diagram.get_template", template_name=name) as s:
1486
+ config = _get_config()
1487
+ templates = config.templates
1488
+
1489
+ if name not in templates:
1490
+ s.add(found=False)
1491
+ available = ", ".join(templates.keys()) if templates else "none configured"
1492
+ return f"Template not found: {name}\nAvailable: {available}"
1493
+
1494
+ template = templates[name]
1495
+ file_path = template.file
1496
+
1497
+ # Try to load the template file
1498
+ if file_path:
1499
+ # Resolve relative to config directory (.onetool/)
1500
+ path = _resolve_config_path(file_path)
1501
+
1502
+ if path.exists():
1503
+ source = path.read_text()
1504
+ s.add(found=True, lines=len(source.splitlines()))
1505
+ return (
1506
+ f"Template: {name}\n"
1507
+ f"Provider: {template.provider}\n"
1508
+ f"Type: {template.diagram_type}\n"
1509
+ f"Description: {template.description}\n"
1510
+ f"\n--- Source ---\n{source}"
1511
+ )
1512
+
1513
+ s.add(found=False, reason="file_not_found")
1514
+ return f"Template '{name}' configured but file not found: {file_path}"
1515
+
1516
+
1517
+ def list_providers(*, focus_only: bool = False) -> str:
1518
+ """List all available diagram providers.
1519
+
1520
+ Args:
1521
+ focus_only: Only list focus providers with full guidance
1522
+ (mermaid, plantuml, d2).
1523
+
1524
+ Returns:
1525
+ Formatted list of providers.
1526
+
1527
+ Example:
1528
+ # All providers
1529
+ providers = diagram.list_providers()
1530
+
1531
+ # Focus providers only
1532
+ focus = diagram.list_providers(focus_only=True)
1533
+ """
1534
+ with LogSpan(span="diagram.list_providers", focus_only=focus_only) as s:
1535
+ if focus_only:
1536
+ result = (
1537
+ "Focus Providers (with full guidance)\n"
1538
+ "=" * 40 + "\n\n"
1539
+ "- mermaid: Flowcharts, sequences, state, Gantt, mindmaps\n"
1540
+ "- plantuml: UML diagrams, C4 architecture\n"
1541
+ "- d2: Modern architecture diagrams with auto-layout\n"
1542
+ "\nUse diagram.get_diagram_instructions(provider='...') for details."
1543
+ )
1544
+ s.add(count=len(FOCUS_PROVIDERS))
1545
+ return result
1546
+
1547
+ result_parts = [
1548
+ "All Kroki Providers",
1549
+ "=" * 40,
1550
+ "",
1551
+ "Focus providers (with guidance):",
1552
+ ]
1553
+ result_parts.extend(f" - {p}" for p in FOCUS_PROVIDERS)
1554
+
1555
+ result_parts.append("\nOther providers:")
1556
+ other = [p for p in KROKI_PROVIDERS if p not in FOCUS_PROVIDERS]
1557
+ result_parts.extend(f" - {p}" for p in sorted(other))
1558
+
1559
+ result_parts.append(f"\nTotal: {len(KROKI_PROVIDERS)} providers")
1560
+
1561
+ s.add(count=len(KROKI_PROVIDERS))
1562
+ return "\n".join(result_parts)
1563
+
1564
+
1565
+ # ==================== Utility Tools ====================
1566
+
1567
+
1568
+ def get_playground_url(
1569
+ *,
1570
+ source: str,
1571
+ provider: Literal["mermaid", "plantuml", "d2"] | str,
1572
+ ) -> str:
1573
+ """Generate a playground URL for interactive editing.
1574
+
1575
+ Playground URLs allow editing diagrams in the browser before
1576
+ saving the final source.
1577
+
1578
+ Args:
1579
+ source: The diagram source code.
1580
+ provider: The diagram provider.
1581
+
1582
+ Returns:
1583
+ Playground URL or message if not supported.
1584
+
1585
+ Example:
1586
+ url = diagram.get_playground_url(
1587
+ source="sequenceDiagram\\n A->>B: Hello",
1588
+ provider="mermaid"
1589
+ )
1590
+ """
1591
+ with LogSpan(span="diagram.get_playground_url", provider=provider) as s:
1592
+ if provider == "mermaid":
1593
+ url = _get_mermaid_playground_url(source)
1594
+ elif provider == "plantuml":
1595
+ url = _get_plantuml_playground_url(source)
1596
+ elif provider == "d2":
1597
+ url = _get_d2_playground_url(source)
1598
+ else:
1599
+ s.add(supported=False)
1600
+ # Fall back to Kroki GET URL
1601
+ url = _get_kroki_get_url(source, provider, "svg")
1602
+
1603
+ s.add(supported=True, url_length=len(url))
1604
+ return url