openmd-cli 0.1.0__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 (177) hide show
  1. openmd/__init__.py +3 -0
  2. openmd/__main__.py +5 -0
  3. openmd/adapters/__init__.py +1 -0
  4. openmd/adapters/base.py +95 -0
  5. openmd/adapters/bilibili/__init__.py +11 -0
  6. openmd/adapters/bilibili/adapter.py +104 -0
  7. openmd/adapters/bilibili/discovery.py +33 -0
  8. openmd/adapters/douyin/__init__.py +11 -0
  9. openmd/adapters/douyin/adapter.py +117 -0
  10. openmd/adapters/douyin/discovery.py +36 -0
  11. openmd/adapters/douyin/sidecar.py +77 -0
  12. openmd/adapters/generic_media/__init__.py +14 -0
  13. openmd/adapters/high_privacy.py +77 -0
  14. openmd/adapters/local_audio/__init__.py +14 -0
  15. openmd/adapters/local_audio/adapter.py +169 -0
  16. openmd/adapters/local_audio/asr.py +58 -0
  17. openmd/adapters/local_audio/backends/__init__.py +1 -0
  18. openmd/adapters/local_audio/backends/auto.py +177 -0
  19. openmd/adapters/local_audio/backends/faster_whisper.py +285 -0
  20. openmd/adapters/local_audio/backends/funasr_sense_voice.py +14 -0
  21. openmd/adapters/local_audio/backends/qwen3_asr.py +254 -0
  22. openmd/adapters/opencli.py +770 -0
  23. openmd/adapters/pdf/__init__.py +11 -0
  24. openmd/adapters/pdf/adapter.py +153 -0
  25. openmd/adapters/pdf/backends/__init__.py +1 -0
  26. openmd/adapters/pdf/backends/markitdown.py +165 -0
  27. openmd/adapters/pdf/parser.py +56 -0
  28. openmd/adapters/qq_group/__init__.py +11 -0
  29. openmd/adapters/qq_group/adapter.py +289 -0
  30. openmd/adapters/qq_group/parsers/__init__.py +1 -0
  31. openmd/adapters/qq_group/parsers/html.py +60 -0
  32. openmd/adapters/qq_group/parsers/json.py +71 -0
  33. openmd/adapters/registry.py +149 -0
  34. openmd/adapters/screenshot/__init__.py +11 -0
  35. openmd/adapters/screenshot/adapter.py +154 -0
  36. openmd/adapters/screenshot/backends/__init__.py +1 -0
  37. openmd/adapters/screenshot/backends/paddle.py +15 -0
  38. openmd/adapters/screenshot/backends/rapidocr.py +176 -0
  39. openmd/adapters/screenshot/backends/tesseract.py +15 -0
  40. openmd/adapters/screenshot/ocr.py +84 -0
  41. openmd/adapters/wechat_group/__init__.py +11 -0
  42. openmd/adapters/wechat_group/adapter.py +160 -0
  43. openmd/adapters/wechat_group/wx_cli.py +268 -0
  44. openmd/adapters/wechat_moments/__init__.py +11 -0
  45. openmd/adapters/wechat_moments/adapter.py +167 -0
  46. openmd/adapters/wechat_moments/wx_cli.py +265 -0
  47. openmd/adapters/weixin/__init__.py +11 -0
  48. openmd/adapters/weixin/adapter.py +148 -0
  49. openmd/adapters/weixin/discovery.py +510 -0
  50. openmd/adapters/weixin/report.py +278 -0
  51. openmd/adapters/xiaohongshu.py +158 -0
  52. openmd/backends/__init__.py +1 -0
  53. openmd/backends/base.py +204 -0
  54. openmd/backends/bilibili.py +1480 -0
  55. openmd/backends/bilibili_wbi.py +277 -0
  56. openmd/backends/discourse.py +426 -0
  57. openmd/backends/douyin.py +868 -0
  58. openmd/backends/douyin_detail.py +124 -0
  59. openmd/backends/douyin_providers.py +602 -0
  60. openmd/backends/executor.py +546 -0
  61. openmd/backends/feishu.py +861 -0
  62. openmd/backends/generic_media.py +77 -0
  63. openmd/backends/interactive.py +60 -0
  64. openmd/backends/ocr.py +41 -0
  65. openmd/backends/opencli.py +82 -0
  66. openmd/backends/pdf.py +122 -0
  67. openmd/backends/registry.py +234 -0
  68. openmd/backends/screenshot.py +79 -0
  69. openmd/backends/video_metadata.py +167 -0
  70. openmd/backends/web_article.py +363 -0
  71. openmd/backends/wechat_group.py +35 -0
  72. openmd/backends/wechat_moments.py +35 -0
  73. openmd/backends/weixin.py +767 -0
  74. openmd/backends/x.py +455 -0
  75. openmd/backends/xiaoe.py +940 -0
  76. openmd/backends/xiaohongshu.py +911 -0
  77. openmd/backends/xiaohongshu_providers.py +876 -0
  78. openmd/backends/xiaoyuzhou.py +1017 -0
  79. openmd/backends/youtube.py +280 -0
  80. openmd/backends/youtube_discovery.py +1245 -0
  81. openmd/backends/youtube_transcript.py +213 -0
  82. openmd/browser_extension/background.js +231 -0
  83. openmd/browser_extension/icons/icon-128.png +0 -0
  84. openmd/browser_extension/icons/icon-16.png +0 -0
  85. openmd/browser_extension/icons/icon-32.png +0 -0
  86. openmd/browser_extension/icons/icon-48.png +0 -0
  87. openmd/browser_extension/icons/icon-512.png +0 -0
  88. openmd/browser_extension/idle.html +6 -0
  89. openmd/browser_extension/manifest.json +43 -0
  90. openmd/browser_extension/options.html +42 -0
  91. openmd/browser_extension/options.js +117 -0
  92. openmd/browser_extension/popup.css +71 -0
  93. openmd/browser_extension/popup.html +17 -0
  94. openmd/browser_extension/popup.js +59 -0
  95. openmd/browser_extension/vendor/opencli-bridge/LICENSE +190 -0
  96. openmd/browser_extension/vendor/opencli-bridge/VENDOR.md +31 -0
  97. openmd/browser_extension/vendor/opencli-bridge/background.js +2742 -0
  98. openmd/browser_host.py +503 -0
  99. openmd/browser_setup.py +106 -0
  100. openmd/cli.py +2012 -0
  101. openmd/console.py +32 -0
  102. openmd/core/__init__.py +1 -0
  103. openmd/core/capabilities.py +162 -0
  104. openmd/core/content_validation.py +323 -0
  105. openmd/core/coverage.py +163 -0
  106. openmd/core/detector.py +247 -0
  107. openmd/core/doctor.py +104 -0
  108. openmd/core/execution_policy.py +164 -0
  109. openmd/core/filename.py +64 -0
  110. openmd/core/input_validation.py +91 -0
  111. openmd/core/local_config.py +96 -0
  112. openmd/core/manifest.py +156 -0
  113. openmd/core/models.py +168 -0
  114. openmd/core/opencli_output.py +34 -0
  115. openmd/core/operation_contract.py +60 -0
  116. openmd/core/output.py +194 -0
  117. openmd/core/package_contract.py +335 -0
  118. openmd/core/package_validator.py +489 -0
  119. openmd/core/package_writer.py +666 -0
  120. openmd/core/planner.py +345 -0
  121. openmd/core/preferences.py +61 -0
  122. openmd/core/readable.py +129 -0
  123. openmd/core/receipt.py +285 -0
  124. openmd/core/renderer.py +185 -0
  125. openmd/core/runlog.py +208 -0
  126. openmd/core/secrets.py +313 -0
  127. openmd/doctor_view.py +68 -0
  128. openmd/interactive.py +453 -0
  129. openmd/ocr/__init__.py +1 -0
  130. openmd/ocr/breakers.py +84 -0
  131. openmd/ocr/config.py +54 -0
  132. openmd/ocr/documents.py +522 -0
  133. openmd/ocr/endpoint.py +72 -0
  134. openmd/ocr/layout.py +92 -0
  135. openmd/ocr/layout_filter.py +118 -0
  136. openmd/ocr/layout_worker.py +89 -0
  137. openmd/ocr/native_math.py +220 -0
  138. openmd/ocr/office.py +305 -0
  139. openmd/ocr/ollama.py +200 -0
  140. openmd/ocr/otsl.py +51 -0
  141. openmd/ocr/ovis-prompt.txt +1 -0
  142. openmd/ocr/pipeline.py +384 -0
  143. openmd/ocr/settings.json +25 -0
  144. openmd/ocr/tiling.py +631 -0
  145. openmd/post_video.py +154 -0
  146. openmd/probe.py +397 -0
  147. openmd/routing/__init__.py +1 -0
  148. openmd/routing/cards/builtin.json +620 -0
  149. openmd/routing/cards/defuddle.json +70 -0
  150. openmd/routing/cards/x-tweet-fetcher.json +71 -0
  151. openmd/routing/cards/xyz-dl.json +60 -0
  152. openmd/routing/cards/yutto.json +68 -0
  153. openmd/routing/entry_shim.py +71 -0
  154. openmd/routing/lifecycle.py +459 -0
  155. openmd/routing/loader.py +138 -0
  156. openmd/routing/providers.py +158 -0
  157. openmd/routing/readers.py +108 -0
  158. openmd/routing/routes.json +504 -0
  159. openmd/routing/runner.py +192 -0
  160. openmd/routing/view.py +40 -0
  161. openmd/runtime/__init__.py +1 -0
  162. openmd/runtime/_windows_job.py +234 -0
  163. openmd/runtime/asr.py +217 -0
  164. openmd/runtime/asr_worker.py +119 -0
  165. openmd/runtime/gpu_lock.py +81 -0
  166. openmd/runtime/gpu_quarantine.py +85 -0
  167. openmd/runtime/interpreters.py +407 -0
  168. openmd/runtime/managed_tools.py +204 -0
  169. openmd/runtime/models.py +202 -0
  170. openmd/runtime/services.py +175 -0
  171. openmd/runtime/tempdirs.py +227 -0
  172. openmd_cli-0.1.0.dist-info/METADATA +168 -0
  173. openmd_cli-0.1.0.dist-info/RECORD +177 -0
  174. openmd_cli-0.1.0.dist-info/WHEEL +5 -0
  175. openmd_cli-0.1.0.dist-info/entry_points.txt +2 -0
  176. openmd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  177. openmd_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,504 @@
1
+ {
2
+ "schema": "openmd.routes/v1",
3
+ "version": "2026-11-10.1",
4
+ "routes": [
5
+ {
6
+ "source": "bilibili",
7
+ "capability": "transcript",
8
+ "chain": [
9
+ "opencli.bilibili.subtitle",
10
+ "bilibili-harvest.core",
11
+ "yt-dlp.bilibili",
12
+ "asr.fallback"
13
+ ]
14
+ },
15
+ {
16
+ "source": "bilibili",
17
+ "capability": "media",
18
+ "chain": [
19
+ "yutto.bilibili.media-asr"
20
+ ]
21
+ },
22
+ {
23
+ "source": "youtube",
24
+ "capability": "transcript",
25
+ "chain": [
26
+ "opencli.youtube.transcript",
27
+ "youtube-transcript-api",
28
+ "yt-dlp.youtube",
29
+ "asr.fallback"
30
+ ]
31
+ },
32
+ {
33
+ "source": "weixin",
34
+ "capability": "content",
35
+ "chain": [
36
+ "opencli.weixin.download",
37
+ "defuddle.web.content",
38
+ "weixin.article.local",
39
+ "opencli.web.read"
40
+ ]
41
+ },
42
+ {
43
+ "source": "wechat_group",
44
+ "capability": "content",
45
+ "chain": [
46
+ "opencli.wx.history"
47
+ ]
48
+ },
49
+ {
50
+ "source": "wechat_moments",
51
+ "capability": "content",
52
+ "chain": [
53
+ "opencli.wx.sns-feed"
54
+ ]
55
+ },
56
+ {
57
+ "source": "qq_group",
58
+ "capability": "content",
59
+ "chain": [
60
+ "qq_group.export"
61
+ ]
62
+ },
63
+ {
64
+ "source": "xiaoyuzhou",
65
+ "capability": "transcript",
66
+ "chain": [
67
+ "opencli.xiaoyuzhou.transcript"
68
+ ]
69
+ },
70
+ {
71
+ "source": "xiaoyuzhou",
72
+ "capability": "media",
73
+ "chain": [
74
+ "xyz-dl.xiaoyuzhou.media-asr"
75
+ ]
76
+ },
77
+ {
78
+ "source": "douyin",
79
+ "capability": "content",
80
+ "chain": [
81
+ "douyin.transcript"
82
+ ]
83
+ },
84
+ {
85
+ "source": "tiktok",
86
+ "capability": "content",
87
+ "chain": [
88
+ "opencli.tiktok.user"
89
+ ]
90
+ },
91
+ {
92
+ "source": "xiaohongshu",
93
+ "capability": "content",
94
+ "chain": [
95
+ "xiaohongshu.composite"
96
+ ]
97
+ },
98
+ {
99
+ "source": "xiaoe",
100
+ "capability": "transcript",
101
+ "chain": [
102
+ "opencli.xiaoe.transcript",
103
+ "opencli.xiaoe.content"
104
+ ]
105
+ },
106
+ {
107
+ "source": "x",
108
+ "capability": "content",
109
+ "chain": [
110
+ "x-tweet-fetcher.x.content",
111
+ "opencli.twitter.thread"
112
+ ]
113
+ },
114
+ {
115
+ "source": "web_article",
116
+ "capability": "content",
117
+ "chain": [
118
+ "web_article.discourse",
119
+ "defuddle.web.content",
120
+ "web_article.trafilatura",
121
+ "opencli.web.read"
122
+ ]
123
+ },
124
+ {
125
+ "source": "feishu",
126
+ "capability": "content",
127
+ "chain": [
128
+ "lark-cli.docs.fetch",
129
+ "lark-cli.drive.export-markdown",
130
+ "lark-cli.markdown.fetch",
131
+ "manual.markdown-export"
132
+ ]
133
+ },
134
+ {
135
+ "source": "generic_media",
136
+ "capability": "transcript",
137
+ "chain": [
138
+ "asr.fallback"
139
+ ]
140
+ },
141
+ {
142
+ "source": "pdf",
143
+ "capability": "content",
144
+ "chain": [
145
+ "pdf.markitdown",
146
+ "pdf.textlayer",
147
+ "ocr.chain"
148
+ ]
149
+ },
150
+ {
151
+ "source": "screenshot",
152
+ "capability": "content",
153
+ "chain": [
154
+ "ocr.chain",
155
+ "ocr.rapidocr",
156
+ "ocr.tesseract"
157
+ ]
158
+ }
159
+ ],
160
+ "capabilities": [
161
+ {
162
+ "source_id": "bilibili",
163
+ "tier": "A",
164
+ "state": "partial",
165
+ "input_kinds": [
166
+ "single",
167
+ "collection"
168
+ ],
169
+ "source_exhausted_kinds": [
170
+ "collection"
171
+ ],
172
+ "profiles": [
173
+ "media"
174
+ ],
175
+ "route_source": "bilibili",
176
+ "runtime_binding": "route"
177
+ },
178
+ {
179
+ "source_id": "youtube",
180
+ "tier": "A",
181
+ "state": "partial",
182
+ "input_kinds": [
183
+ "single",
184
+ "collection"
185
+ ],
186
+ "source_exhausted_kinds": [
187
+ "collection"
188
+ ],
189
+ "profiles": [
190
+ "media"
191
+ ],
192
+ "route_source": "youtube",
193
+ "runtime_binding": "route"
194
+ },
195
+ {
196
+ "source_id": "weixin",
197
+ "tier": "A",
198
+ "state": "partial",
199
+ "input_kinds": [
200
+ "single",
201
+ "collection"
202
+ ],
203
+ "source_exhausted_kinds": [
204
+ "collection"
205
+ ],
206
+ "profiles": [
207
+ "article"
208
+ ],
209
+ "route_source": "weixin",
210
+ "runtime_binding": "route"
211
+ },
212
+ {
213
+ "source_id": "xiaoyuzhou",
214
+ "tier": "A",
215
+ "state": "partial",
216
+ "input_kinds": [
217
+ "single",
218
+ "collection"
219
+ ],
220
+ "source_exhausted_kinds": [
221
+ "collection"
222
+ ],
223
+ "profiles": [
224
+ "media"
225
+ ],
226
+ "route_source": "xiaoyuzhou",
227
+ "runtime_binding": "route"
228
+ },
229
+ {
230
+ "source_id": "douyin",
231
+ "tier": "A",
232
+ "state": "partial",
233
+ "input_kinds": [
234
+ "single",
235
+ "collection"
236
+ ],
237
+ "source_exhausted_kinds": [
238
+ "collection"
239
+ ],
240
+ "profiles": [
241
+ "media"
242
+ ],
243
+ "route_source": "douyin",
244
+ "runtime_binding": "route"
245
+ },
246
+ {
247
+ "source_id": "tiktok",
248
+ "tier": "C",
249
+ "state": "paused",
250
+ "input_kinds": [
251
+ "single"
252
+ ],
253
+ "source_exhausted_kinds": [],
254
+ "profiles": [
255
+ "media"
256
+ ],
257
+ "route_source": "tiktok",
258
+ "runtime_binding": null
259
+ },
260
+ {
261
+ "source_id": "xiaohongshu",
262
+ "tier": "A",
263
+ "state": "partial",
264
+ "input_kinds": [
265
+ "single",
266
+ "list"
267
+ ],
268
+ "source_exhausted_kinds": [
269
+ "list"
270
+ ],
271
+ "profiles": [
272
+ "article",
273
+ "media"
274
+ ],
275
+ "route_source": "xiaohongshu",
276
+ "runtime_binding": "route"
277
+ },
278
+ {
279
+ "source_id": "xiaohongshu_creator",
280
+ "tier": "B",
281
+ "state": "blocked",
282
+ "input_kinds": [
283
+ "collection"
284
+ ],
285
+ "source_exhausted_kinds": [],
286
+ "profiles": [
287
+ "article",
288
+ "media"
289
+ ],
290
+ "route_source": "xiaohongshu",
291
+ "runtime_binding": null
292
+ },
293
+ {
294
+ "source_id": "xiaoe",
295
+ "tier": "A",
296
+ "state": "partial",
297
+ "input_kinds": [
298
+ "single",
299
+ "collection"
300
+ ],
301
+ "source_exhausted_kinds": [
302
+ "collection"
303
+ ],
304
+ "profiles": [
305
+ "media"
306
+ ],
307
+ "route_source": "xiaoe",
308
+ "runtime_binding": "route"
309
+ },
310
+ {
311
+ "source_id": "x",
312
+ "tier": "B",
313
+ "state": "partial",
314
+ "input_kinds": [
315
+ "single",
316
+ "list"
317
+ ],
318
+ "source_exhausted_kinds": [],
319
+ "profiles": [
320
+ "article"
321
+ ],
322
+ "route_source": "x",
323
+ "runtime_binding": "route"
324
+ },
325
+ {
326
+ "source_id": "web_article",
327
+ "tier": "B",
328
+ "state": "ready",
329
+ "input_kinds": [
330
+ "single",
331
+ "list"
332
+ ],
333
+ "source_exhausted_kinds": [],
334
+ "profiles": [
335
+ "article"
336
+ ],
337
+ "route_source": "web_article",
338
+ "runtime_binding": "route"
339
+ },
340
+ {
341
+ "source_id": "feishu",
342
+ "tier": "B",
343
+ "state": "partial",
344
+ "input_kinds": [
345
+ "single",
346
+ "list"
347
+ ],
348
+ "source_exhausted_kinds": [],
349
+ "profiles": [
350
+ "article",
351
+ "document"
352
+ ],
353
+ "route_source": "feishu",
354
+ "runtime_binding": "route"
355
+ },
356
+ {
357
+ "source_id": "generic_media",
358
+ "tier": "B",
359
+ "state": "partial",
360
+ "input_kinds": [
361
+ "single",
362
+ "list"
363
+ ],
364
+ "source_exhausted_kinds": [],
365
+ "profiles": [
366
+ "media"
367
+ ],
368
+ "route_source": "generic_media",
369
+ "runtime_binding": null
370
+ },
371
+ {
372
+ "source_id": "local_audio",
373
+ "tier": "A",
374
+ "state": "ready",
375
+ "input_kinds": [
376
+ "file",
377
+ "folder"
378
+ ],
379
+ "source_exhausted_kinds": [
380
+ "folder"
381
+ ],
382
+ "profiles": [
383
+ "media"
384
+ ],
385
+ "planned_provider": "asr.auto",
386
+ "route_source": "local_audio",
387
+ "runtime_binding": "asr.auto"
388
+ },
389
+ {
390
+ "source_id": "local_video",
391
+ "tier": "B",
392
+ "state": "partial",
393
+ "input_kinds": [
394
+ "file",
395
+ "folder"
396
+ ],
397
+ "source_exhausted_kinds": [
398
+ "folder"
399
+ ],
400
+ "profiles": [
401
+ "media"
402
+ ],
403
+ "planned_provider": "asr.fallback",
404
+ "route_source": "local_video",
405
+ "runtime_binding": "asr.auto"
406
+ },
407
+ {
408
+ "source_id": "tutorial_video",
409
+ "tier": "C",
410
+ "state": "experimental",
411
+ "input_kinds": [
412
+ "file",
413
+ "single"
414
+ ],
415
+ "source_exhausted_kinds": [],
416
+ "profiles": [
417
+ "media"
418
+ ],
419
+ "planned_provider": "asr.fallback",
420
+ "route_source": "tutorial_video",
421
+ "runtime_binding": "asr.auto"
422
+ },
423
+ {
424
+ "source_id": "pdf_office",
425
+ "tier": "A",
426
+ "state": "partial",
427
+ "input_kinds": [
428
+ "file",
429
+ "folder"
430
+ ],
431
+ "source_exhausted_kinds": [
432
+ "folder"
433
+ ],
434
+ "profiles": [
435
+ "document"
436
+ ],
437
+ "route_source": "pdf",
438
+ "runtime_binding": "route"
439
+ },
440
+ {
441
+ "source_id": "screenshot",
442
+ "tier": "B",
443
+ "state": "partial",
444
+ "input_kinds": [
445
+ "file",
446
+ "folder"
447
+ ],
448
+ "source_exhausted_kinds": [
449
+ "folder"
450
+ ],
451
+ "profiles": [
452
+ "document"
453
+ ],
454
+ "route_source": "screenshot",
455
+ "runtime_binding": "route"
456
+ },
457
+ {
458
+ "source_id": "wechat_moments",
459
+ "tier": "B",
460
+ "state": "partial",
461
+ "input_kinds": [
462
+ "collection"
463
+ ],
464
+ "source_exhausted_kinds": [
465
+ "collection"
466
+ ],
467
+ "profiles": [
468
+ "conversation"
469
+ ],
470
+ "route_source": "wechat_moments",
471
+ "runtime_binding": null
472
+ },
473
+ {
474
+ "source_id": "wechat_group",
475
+ "tier": "B",
476
+ "state": "partial",
477
+ "input_kinds": [
478
+ "collection"
479
+ ],
480
+ "source_exhausted_kinds": [
481
+ "collection"
482
+ ],
483
+ "profiles": [
484
+ "conversation"
485
+ ],
486
+ "route_source": "wechat_group",
487
+ "runtime_binding": null
488
+ },
489
+ {
490
+ "source_id": "qq_group",
491
+ "tier": "B",
492
+ "state": "paused",
493
+ "input_kinds": [
494
+ "collection"
495
+ ],
496
+ "source_exhausted_kinds": [],
497
+ "profiles": [
498
+ "conversation"
499
+ ],
500
+ "route_source": "qq_group",
501
+ "runtime_binding": null
502
+ }
503
+ ]
504
+ }
@@ -0,0 +1,192 @@
1
+ """Safe argv execution and local launcher resolution for tool cards."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import importlib.util
7
+ import os
8
+ import re
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ import time
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any
16
+ from urllib.parse import urlparse
17
+
18
+ from openmd.core.local_config import load_local_config
19
+ from openmd.core.secrets import sanitize_error_summary
20
+ from openmd.runtime.interpreters import owned_popen, wait_interruptibly, terminate_quietly as _kill_tree
21
+
22
+
23
+ class ToolError(ValueError):
24
+ def __init__(self, code: str, detail: str = "") -> None:
25
+ self.code = code
26
+ super().__init__(detail or code)
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Launcher:
31
+ argv: tuple[str, ...]
32
+ origin: str
33
+ entry_path: Path
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class ToolRun:
38
+ exit_code: int
39
+ seconds: float
40
+ stdout: str
41
+ stderr: str
42
+ files: dict[str, dict[str, Any]]
43
+
44
+
45
+ def _executable(value: str) -> str:
46
+ resolved = shutil.which(value) or (str(Path(value).expanduser().resolve()) if Path(value).expanduser().is_file() else None)
47
+ if not resolved:
48
+ raise ToolError("tool_not_installed", "Executable not found; run openmd doctor --profile tools.")
49
+ if Path(resolved).suffix.lower() in {".cmd", ".bat"}:
50
+ raise ToolError("tool_launcher_unsafe", "Batch launchers cannot safely carry URLs; configure a native executable or Node entry.")
51
+ return resolved
52
+
53
+
54
+ def resolve_launcher(card: dict[str, Any]) -> Launcher:
55
+ tool_id = card["id"]
56
+ config = load_local_config().get("tools", {}).get(tool_id, {})
57
+ if not isinstance(config, dict) or set(config) - {"python", "paths", "root", "node", "script_root"}:
58
+ raise ToolError("tool_location_invalid")
59
+ config = dict(config)
60
+ prefix = "OPENMD_TOOL_" + re.sub(r"[^A-Z0-9]", "_", tool_id.upper())
61
+ origin = "local_config" if config else "which"
62
+ for key in ("python", "root", "node", "script_root"):
63
+ if os.getenv(prefix + "_" + key.upper()):
64
+ config[key] = os.environ[prefix + "_" + key.upper()]
65
+ origin = "env"
66
+ launcher = card.get("launcher", {})
67
+ if card["kind"] == "command" and launcher.get("node_entry"):
68
+ root = config.get("root")
69
+ if root:
70
+ entry = Path(root).expanduser() / launcher["node_entry"]
71
+ else:
72
+ which = next((shutil.which(x) for x in launcher.get("which", []) if shutil.which(x)), None)
73
+ if not which:
74
+ raise ToolError("tool_not_installed")
75
+ entry = Path(which).parent / "node_modules" / launcher["node_entry"]
76
+ if not entry.is_file():
77
+ raise ToolError("tool_not_installed", "Node entry not found; configure tool root and run openmd doctor --profile tools.")
78
+ node = _executable(config.get("node", "node"))
79
+ if launcher.get("node_api"):
80
+ import json
81
+
82
+ # Library-only options are declarative; invoke the unchanged CLI
83
+ # after configuring its shared exported parser. All values are argv.
84
+ api = launcher["node_api"]
85
+ api_path = ((Path(root) if root else entry.parents[2]) / api["module"]).resolve()
86
+ if not api_path.is_file():
87
+ raise ToolError("tool_not_installed")
88
+ code = "const [entry,apiPath,name,options,...args]=process.argv.slice(1);const api=require(apiPath),original=api[name],overrides=JSON.parse(options);api[name]=function(...a){a[a.length-1]={...a[a.length-1],...overrides};return original.apply(this,a)};require(entry).createProgram().parse([process.execPath,entry,...args]);"
89
+ return Launcher((node, "-e", code, str(entry.resolve()), str(api_path), api["export"], json.dumps(api["options"])), origin, entry.resolve())
90
+ return Launcher((node, str(entry.resolve())), origin, entry.resolve())
91
+ if card["kind"] in {"python_entry", "python_script"}:
92
+ if not config:
93
+ raise ToolError("tool_not_installed", "Configure the isolated Python interpreter and dependency paths; direct console scripts cannot enforce this card's TLS and credential boundary.")
94
+ python = _executable(config.get("python", sys.executable))
95
+ paths = config.get("paths", [])
96
+ if not isinstance(paths, list) or not all(isinstance(p, str) and Path(p).is_dir() for p in paths):
97
+ raise ToolError("tool_not_installed", "Isolated Python dependency directory is missing.")
98
+ args = [python, "-I", "-X", "utf8", str(Path(__file__).with_name("entry_shim.py").resolve())]
99
+ for path in paths:
100
+ args.extend(("--path", str(Path(path).resolve())))
101
+ if card["kind"] == "python_script":
102
+ root = config.get("script_root")
103
+ if not isinstance(root, str):
104
+ raise ToolError("tool_not_installed")
105
+ entry = Path(root).expanduser() / launcher["script"]
106
+ if not entry.is_file():
107
+ raise ToolError("tool_not_installed")
108
+ args.extend(("--script", str(entry.resolve())))
109
+ else:
110
+ module = launcher["entry"].split(":", 1)[0]
111
+ relative = Path(*module.split("."))
112
+ entries = [Path(p) / relative for p in paths]
113
+ entry = next((q.with_suffix(".py") for q in entries if q.with_suffix(".py").is_file()), None)
114
+ if entry is None:
115
+ entry = next((q / "__init__.py" for q in entries if (q / "__init__.py").is_file()), None)
116
+ if entry is None and not paths:
117
+ spec = importlib.util.find_spec(module.split(".")[0])
118
+ entry = Path(spec.origin) if spec and spec.origin else None
119
+ if entry is None:
120
+ raise ToolError("tool_not_installed", "Python entry not installed; run openmd doctor --profile tools.")
121
+ args.extend(("--entry", launcher["entry"]))
122
+ return Launcher(tuple([*args, "--"]), origin, entry.resolve())
123
+ raise ToolError("tool_launcher_invalid")
124
+
125
+
126
+ def validate_url(url: str, capability: dict[str, Any]) -> None:
127
+ parsed = urlparse(url)
128
+ if parsed.scheme.lower() not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password or any(ord(c) < 32 for c in url):
129
+ raise ToolError("tool_input_invalid", "Tool requires an HTTP(S) source URL without embedded credentials.")
130
+ if capability.get("url_hosts") and parsed.hostname.lower() not in capability["url_hosts"]:
131
+ raise ToolError("tool_input_unsupported")
132
+ if capability.get("url_path_pattern") and not re.fullmatch(capability["url_path_pattern"], parsed.path):
133
+ raise ToolError("tool_input_unsupported", "This card supports individual public items only.")
134
+
135
+
136
+ def build_argv(launcher: Launcher, capability: dict[str, Any], *, url: str, item_id: str | None, workdir: Path) -> list[str]:
137
+ validate_url(url, capability)
138
+ values = {"url": url, "id": item_id or url.rstrip("/").split("/")[-1], "workdir": str(workdir.resolve())}
139
+ arguments: list[str] = []
140
+ for template in capability["argv"]:
141
+ if any(name not in values for name in re.findall(r"\{([^{}]+)\}", template)):
142
+ raise ToolError("tool_placeholder_invalid")
143
+ argument = template.format_map(values)
144
+ arguments.append(argument)
145
+ return [*launcher.argv, *arguments]
146
+
147
+
148
+ def isolated_environment(workdir: Path) -> dict[str, str]:
149
+ allowed = {"PATH", "SYSTEMROOT", "WINDIR", "TEMP", "TMP", "LANG", "LC_ALL", "SSL_CERT_FILE", "SSL_CERT_DIR", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY"}
150
+ env = {k: v for k, v in os.environ.items() if k.upper() in allowed}
151
+ home = workdir / ".home"
152
+ home.mkdir(exist_ok=True)
153
+ for key in ("HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "XDG_CONFIG_HOME"):
154
+ env[key] = str(home)
155
+ env["PYTHONIOENCODING"] = "utf-8"
156
+ env["PYTHONUTF8"] = "1"
157
+ return env
158
+
159
+
160
+ def run_tool(card: dict[str, Any], capability: dict[str, Any], item, workdir: Path) -> ToolRun:
161
+ launcher = resolve_launcher(card)
162
+ argv = build_argv(launcher, capability, url=item.url, item_id=item.id, workdir=workdir)
163
+ stdout_path, stderr_path = workdir / ".stdout", workdir / ".stderr"
164
+ started = time.monotonic()
165
+ try:
166
+ with stdout_path.open("wb") as out, stderr_path.open("wb") as err:
167
+ process = owned_popen(argv, cwd=workdir, env=isolated_environment(workdir), stdin=subprocess.DEVNULL, stdout=out, stderr=err, shell=False, start_new_session=os.name != "nt", creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0)
168
+ try:
169
+ wait_interruptibly(process, capability["timeout_s"])
170
+ except subprocess.TimeoutExpired as exc:
171
+ _kill_tree(process)
172
+ raise ToolError("tool_timeout", "Command timed out; its owned process tree was terminated.") from exc
173
+ except BaseException:
174
+ _kill_tree(process)
175
+ raise
176
+ if stdout_path.stat().st_size > 16 * 1024 * 1024 or stderr_path.stat().st_size > 16 * 1024 * 1024:
177
+ raise ToolError("tool_output_too_large")
178
+ stdout = stdout_path.read_text(encoding="utf-8", errors="replace")
179
+ stderr = stderr_path.read_text(encoding="utf-8", errors="replace")
180
+ artifacts: dict[str, dict[str, Any]] = {}
181
+ for path in workdir.rglob("*"):
182
+ if path.is_file() and path.name not in {".stdout", ".stderr", ".openmd-owner.json"} and ".home" not in path.relative_to(workdir).parts:
183
+ if not path.resolve().is_relative_to(workdir.resolve()):
184
+ raise ToolError("tool_artifact_path_invalid")
185
+ digest = hashlib.sha256()
186
+ with path.open("rb") as stream:
187
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
188
+ digest.update(chunk)
189
+ artifacts[path.relative_to(workdir).as_posix()] = {"bytes": path.stat().st_size, "sha256": digest.hexdigest()}
190
+ return ToolRun(process.returncode, round(time.monotonic() - started, 3), stdout, stderr, artifacts)
191
+ except OSError as exc:
192
+ raise ToolError("tool_launch_failed", sanitize_error_summary(exc) or "Tool launch failed.") from exc