harness-detect 0.2.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.
@@ -0,0 +1,680 @@
1
+ """Detect installed LLM harnesses and resolve their config/state paths.
2
+
3
+ This is a Python port of the ``@auron-labs/harness-detect`` TypeScript package,
4
+ the Go package at ``github.com/auron/harness-detect/packages/golang/harnessdetect``,
5
+ and the ``harness-detect`` Rust crate. It shares the same harness registry
6
+ (``data/harnesses.json``) and exposes the same API surface, adapted to
7
+ idiomatic Python naming.
8
+
9
+ Detection is data-driven: a harness counts as **installed** when a matching
10
+ executable is found on ``PATH`` **or** one or more of its known
11
+ config/state/cache/install/project paths exist on disk. All harness-specific
12
+ behavior lives in the registry JSON; this module contains no harness-specific
13
+ code paths.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import re
21
+ import sys
22
+ from dataclasses import dataclass, field
23
+ from importlib import resources
24
+ from pathlib import Path
25
+ from typing import Optional
26
+
27
+ __all__ = [
28
+ "CheckOptions",
29
+ "HarnessCheckResult",
30
+ "HarnessDefinition",
31
+ "HarnessEnvVar",
32
+ "HarnessError",
33
+ "HarnessInstallation",
34
+ "HarnessMatrix",
35
+ "HarnessPathSpec",
36
+ "HarnessRootDef",
37
+ "HarnessSupport",
38
+ "HarnessSupportArea",
39
+ "HarnessSupportPath",
40
+ "HarnessSupportRecord",
41
+ "HarnessSupportScope",
42
+ "ResolvedHarnessPath",
43
+ "check_harness",
44
+ "detect_harnesses",
45
+ "detect_installed_harnesses",
46
+ "get_raw_harness_data",
47
+ "get_harness_matrix",
48
+ "get_harness_support",
49
+ "list_harnesses",
50
+ "list_harness_support",
51
+ ]
52
+
53
+ __version__ = "0.2.0"
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Registry loading (read at load time, mirroring the TypeScript approach)
58
+ # ---------------------------------------------------------------------------
59
+
60
+
61
+ def _load_matrix() -> HarnessMatrix:
62
+ """Load and parse the bundled registry JSON."""
63
+ with (
64
+ resources.files("harness_detect.data")
65
+ .joinpath("harnesses.json")
66
+ .open("r", encoding="utf-8") as f
67
+ ):
68
+ raw = json.load(f)
69
+ return HarnessMatrix(
70
+ version=raw["version"],
71
+ harnesses=tuple(HarnessDefinition.from_dict(h) for h in raw["harnesses"]),
72
+ )
73
+
74
+
75
+ _MATRIX: Optional[HarnessMatrix] = None
76
+
77
+
78
+ def _matrix() -> HarnessMatrix:
79
+ global _MATRIX
80
+ if _MATRIX is None:
81
+ _MATRIX = _load_matrix()
82
+ return _MATRIX
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # Types
87
+ # ---------------------------------------------------------------------------
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class HarnessEnvVar:
92
+ """A harness-relevant environment variable (documentation only)."""
93
+
94
+ name: str
95
+ description: str
96
+
97
+ @classmethod
98
+ def from_dict(cls, d: dict) -> "HarnessEnvVar":
99
+ return cls(name=d["name"], description=d["description"])
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class HarnessPathSpec:
104
+ """One path template to check for a harness."""
105
+
106
+ id: str
107
+ category: str
108
+ kind: str
109
+ template: str
110
+ platforms: tuple[str, ...] = ()
111
+
112
+ @classmethod
113
+ def from_dict(cls, d: dict) -> "HarnessPathSpec":
114
+ return cls(
115
+ id=d["id"],
116
+ category=d["category"],
117
+ kind=d["kind"],
118
+ template=d["template"],
119
+ platforms=tuple(d.get("platforms", [])),
120
+ )
121
+
122
+
123
+ @dataclass(frozen=True)
124
+ class HarnessSupportPath:
125
+ """One support-related path template."""
126
+
127
+ id: str
128
+ kind: str
129
+ template: str
130
+ platforms: tuple[str, ...] = ()
131
+ description: str = ""
132
+
133
+ @classmethod
134
+ def from_dict(cls, d: dict) -> "HarnessSupportPath":
135
+ return cls(
136
+ id=d["id"],
137
+ kind=d["kind"],
138
+ template=d["template"],
139
+ platforms=tuple(d.get("platforms", [])),
140
+ description=d.get("description", ""),
141
+ )
142
+
143
+
144
+ @dataclass(frozen=True)
145
+ class HarnessSupportScope:
146
+ """One support capability leaf for one scope."""
147
+
148
+ status: str
149
+ paths: tuple[HarnessSupportPath, ...]
150
+ sources: tuple[str, ...]
151
+ confidence: str
152
+ notes: str = ""
153
+
154
+ @classmethod
155
+ def from_dict(cls, d: dict) -> "HarnessSupportScope":
156
+ return cls(
157
+ status=d["status"],
158
+ paths=tuple(HarnessSupportPath.from_dict(p) for p in d.get("paths", [])),
159
+ sources=tuple(d.get("sources", [])),
160
+ confidence=d["confidence"],
161
+ notes=d.get("notes", ""),
162
+ )
163
+
164
+
165
+ @dataclass(frozen=True)
166
+ class HarnessSupportArea:
167
+ """One support capability area."""
168
+
169
+ global_: HarnessSupportScope
170
+ local: HarnessSupportScope
171
+
172
+ @classmethod
173
+ def from_dict(cls, d: dict) -> "HarnessSupportArea":
174
+ return cls(
175
+ global_=HarnessSupportScope.from_dict(d["global"]),
176
+ local=HarnessSupportScope.from_dict(d["local"]),
177
+ )
178
+
179
+
180
+ @dataclass(frozen=True)
181
+ class HarnessSupport:
182
+ """Support metadata for a harness."""
183
+
184
+ config: HarnessSupportArea
185
+ skills: HarnessSupportArea
186
+ commands: HarnessSupportArea
187
+ agents: HarnessSupportArea
188
+ dot_agents: HarnessSupportArea
189
+
190
+ @classmethod
191
+ def from_dict(cls, d: dict) -> "HarnessSupport":
192
+ return cls(
193
+ config=HarnessSupportArea.from_dict(d["config"]),
194
+ skills=HarnessSupportArea.from_dict(d["skills"]),
195
+ commands=HarnessSupportArea.from_dict(d["commands"]),
196
+ agents=HarnessSupportArea.from_dict(d["agents"]),
197
+ dot_agents=HarnessSupportArea.from_dict(d["dotAgents"]),
198
+ )
199
+
200
+
201
+ @dataclass(frozen=True)
202
+ class HarnessRootDef:
203
+ """A derived template variable declared by a harness.
204
+
205
+ Roots are resolved in declaration order before path templates, allowing
206
+ later roots to reference earlier ones.
207
+ """
208
+
209
+ name: str
210
+ env: str = ""
211
+ use: str = ""
212
+ fallback: str = ""
213
+
214
+ @classmethod
215
+ def from_dict(cls, d: dict) -> "HarnessRootDef":
216
+ return cls(
217
+ name=d["name"],
218
+ env=d.get("env", ""),
219
+ use=d.get("use", ""),
220
+ fallback=d["fallback"],
221
+ )
222
+
223
+
224
+ @dataclass(frozen=True)
225
+ class HarnessInstallation:
226
+ """One documented installation method."""
227
+
228
+ method: str
229
+ package: str = ""
230
+ command: str = ""
231
+ url: str = ""
232
+ marketplace: str = ""
233
+ id: str = ""
234
+ platforms: tuple[str, ...] = ()
235
+ notes: str = ""
236
+
237
+ @classmethod
238
+ def from_dict(cls, d: dict) -> "HarnessInstallation":
239
+ return cls(
240
+ method=d["method"],
241
+ package=d.get("package", ""),
242
+ command=d.get("command", ""),
243
+ url=d.get("url", ""),
244
+ marketplace=d.get("marketplace", ""),
245
+ id=d.get("id", ""),
246
+ platforms=tuple(d.get("platforms", [])),
247
+ notes=d.get("notes", ""),
248
+ )
249
+
250
+
251
+ @dataclass(frozen=True)
252
+ class HarnessDefinition:
253
+ """A single LLM harness definition."""
254
+
255
+ key: str
256
+ name: str
257
+ aliases: tuple[str, ...]
258
+ executables: tuple[str, ...]
259
+ installations: tuple[HarnessInstallation, ...]
260
+ paths: tuple[HarnessPathSpec, ...]
261
+ roots: tuple[HarnessRootDef, ...]
262
+ support: HarnessSupport
263
+ env: tuple[HarnessEnvVar, ...]
264
+ sources: tuple[str, ...]
265
+
266
+ @classmethod
267
+ def from_dict(cls, d: dict) -> "HarnessDefinition":
268
+ return cls(
269
+ key=d["key"],
270
+ name=d["name"],
271
+ aliases=tuple(d.get("aliases", [])),
272
+ executables=tuple(d.get("executables", [])),
273
+ installations=tuple(
274
+ HarnessInstallation.from_dict(i) for i in d.get("installations", [])
275
+ ),
276
+ paths=tuple(HarnessPathSpec.from_dict(p) for p in d.get("paths", [])),
277
+ roots=tuple(HarnessRootDef.from_dict(r) for r in d.get("roots", [])),
278
+ support=HarnessSupport.from_dict(d["support"]),
279
+ env=tuple(HarnessEnvVar.from_dict(e) for e in d.get("env", [])),
280
+ sources=tuple(d.get("sources", [])),
281
+ )
282
+
283
+
284
+ @dataclass(frozen=True)
285
+ class HarnessMatrix:
286
+ """The top-level registry document."""
287
+
288
+ version: int
289
+ harnesses: tuple[HarnessDefinition, ...]
290
+
291
+
292
+ @dataclass(frozen=True)
293
+ class HarnessSupportRecord:
294
+ """One harness support entry."""
295
+
296
+ key: str
297
+ name: str
298
+ support: HarnessSupport
299
+
300
+
301
+ @dataclass(frozen=True)
302
+ class ResolvedHarnessPath:
303
+ """A resolved path entry: the original spec plus the resolved path and
304
+ whether it exists on disk."""
305
+
306
+ id: str
307
+ category: str
308
+ kind: str
309
+ template: str
310
+ platforms: tuple[str, ...]
311
+ path: Optional[str]
312
+ exists: bool
313
+
314
+
315
+ @dataclass
316
+ class CheckOptions:
317
+ """Options passed to :func:`check_harness` and :func:`detect_harnesses`.
318
+
319
+ When ``env`` is ``None`` the real process environment is used. When
320
+ provided, it **replaces** the process environment entirely (defaults are
321
+ still filled in for ``HOME``, ``XDG_*``, ``TMPDIR``, ``CWD``).
322
+ """
323
+
324
+ env: Optional[dict[str, str]] = None
325
+ cwd: Optional[str] = None
326
+
327
+
328
+ @dataclass
329
+ class HarnessCheckResult:
330
+ """The result of checking a single harness."""
331
+
332
+ key: str
333
+ name: str
334
+ installed: bool
335
+ executable_path: Optional[str]
336
+ harness: HarnessDefinition
337
+ paths: list[ResolvedHarnessPath] = field(default_factory=list)
338
+ matched_paths: list[ResolvedHarnessPath] = field(default_factory=list)
339
+ reasons: list[str] = field(default_factory=list)
340
+
341
+
342
+ class HarnessError(Exception):
343
+ """Raised when a harness key or alias is not found."""
344
+
345
+
346
+ # ---------------------------------------------------------------------------
347
+ # Public API
348
+ # ---------------------------------------------------------------------------
349
+
350
+
351
+ def get_raw_harness_data() -> HarnessMatrix:
352
+ """Return the full registry (immutable)."""
353
+ return _matrix()
354
+
355
+
356
+ def get_harness_matrix() -> HarnessMatrix:
357
+ """Return the full registry (immutable)."""
358
+ return get_raw_harness_data()
359
+
360
+
361
+ def list_harnesses() -> list[HarnessDefinition]:
362
+ """Return the list of harness definitions (immutable)."""
363
+ return list(_matrix().harnesses)
364
+
365
+
366
+ def get_harness_support(input: str) -> HarnessSupportRecord:
367
+ """Return support metadata for one harness by key or alias."""
368
+ harness = _find_harness_definition(input)
369
+ if harness is None:
370
+ raise HarnessError(f"Unknown harness: {input}")
371
+ return HarnessSupportRecord(
372
+ key=harness.key,
373
+ name=harness.name,
374
+ support=harness.support,
375
+ )
376
+
377
+
378
+ def list_harness_support() -> list[HarnessSupportRecord]:
379
+ """Return support metadata for all harnesses."""
380
+ return [
381
+ HarnessSupportRecord(key=h.key, name=h.name, support=h.support)
382
+ for h in _matrix().harnesses
383
+ ]
384
+
385
+
386
+ def check_harness(
387
+ input: str, options: Optional[CheckOptions] = None
388
+ ) -> HarnessCheckResult:
389
+ """Check a single harness by key or alias (case-insensitive, trimmed).
390
+
391
+ Raises :class:`HarnessError` with the message ``"Unknown harness: <input>"``
392
+ if the key/alias is not found.
393
+ """
394
+ opts = options or CheckOptions()
395
+ harness = _find_harness_definition(input)
396
+ if harness is None:
397
+ raise HarnessError(f"Unknown harness: {input}")
398
+
399
+ base_env = _with_defaults(opts.env, opts.cwd)
400
+ env = _resolve_harness_roots(harness, base_env)
401
+ executable_path = _find_executable(harness.executables, env)
402
+ paths = _resolve_paths(harness, env)
403
+ matched_paths = [p for p in paths if p.exists]
404
+ reasons: list[str] = []
405
+
406
+ if executable_path is not None:
407
+ reasons.append(f"executable:{os.path.basename(executable_path)}")
408
+
409
+ for entry in matched_paths:
410
+ reasons.append(f"{entry.category}:{entry.id}")
411
+
412
+ return HarnessCheckResult(
413
+ key=harness.key,
414
+ name=harness.name,
415
+ installed=executable_path is not None or bool(matched_paths),
416
+ executable_path=executable_path,
417
+ harness=harness,
418
+ paths=paths,
419
+ matched_paths=matched_paths,
420
+ reasons=reasons,
421
+ )
422
+
423
+
424
+ def detect_harnesses(
425
+ options: Optional[CheckOptions] = None,
426
+ ) -> list[HarnessCheckResult]:
427
+ """Check every harness in the registry, returning one result per entry."""
428
+ opts = options or CheckOptions()
429
+ return [check_harness(h.key, opts) for h in _matrix().harnesses]
430
+
431
+
432
+ def detect_installed_harnesses(
433
+ options: Optional[CheckOptions] = None,
434
+ ) -> list[HarnessCheckResult]:
435
+ """Return the subset of :func:`detect_harnesses` results whose ``installed``
436
+ field is ``True``."""
437
+ return [r for r in detect_harnesses(options) if r.installed]
438
+
439
+
440
+ # ---------------------------------------------------------------------------
441
+ # Internal: lookup
442
+ # ---------------------------------------------------------------------------
443
+
444
+
445
+ def _find_harness_definition(input: str) -> Optional[HarnessDefinition]:
446
+ key = input.strip().lower()
447
+ for harness in _matrix().harnesses:
448
+ if harness.key.lower() == key:
449
+ return harness
450
+ if any(a.lower() == key for a in harness.aliases):
451
+ return harness
452
+ return None
453
+
454
+
455
+ # ---------------------------------------------------------------------------
456
+ # Internal: environment defaults
457
+ # ---------------------------------------------------------------------------
458
+
459
+
460
+ def _with_defaults(
461
+ env: Optional[dict[str, str]],
462
+ cwd: Optional[str],
463
+ ) -> dict[str, str]:
464
+ """Compute the universal base-variable map (HOME, XDG_*, TMPDIR, CWD)
465
+ plus any caller-supplied env vars. Harness-specific derived roots are
466
+ resolved separately by :func:`_resolve_harness_roots`.
467
+ """
468
+ out: dict[str, str] = dict(env) if env is not None else dict(os.environ)
469
+
470
+ home = out.get("HOME", "") or os.path.expanduser("~") or "/"
471
+ userprofile = out.get("USERPROFILE", "") or home
472
+
473
+ xdg_config_home = out.get("XDG_CONFIG_HOME", "") or os.path.join(home, ".config")
474
+ xdg_data_home = out.get("XDG_DATA_HOME", "") or os.path.join(
475
+ home, ".local", "share"
476
+ )
477
+ xdg_state_home = out.get("XDG_STATE_HOME", "") or os.path.join(
478
+ home, ".local", "state"
479
+ )
480
+ xdg_cache_home = out.get("XDG_CACHE_HOME", "") or os.path.join(home, ".cache")
481
+
482
+ if cwd:
483
+ cwd_val = cwd
484
+ else:
485
+ cwd_val = os.getcwd() or "."
486
+
487
+ tmpdir = out.get("TMPDIR", "") or tempfile_gettempdir()
488
+
489
+ out["HOME"] = home
490
+ out["USERPROFILE"] = userprofile
491
+ out["XDG_CONFIG_HOME"] = xdg_config_home
492
+ out["XDG_DATA_HOME"] = xdg_data_home
493
+ out["XDG_STATE_HOME"] = xdg_state_home
494
+ out["XDG_CACHE_HOME"] = xdg_cache_home
495
+ out["TMPDIR"] = tmpdir
496
+ out["CWD"] = cwd_val
497
+
498
+ return out
499
+
500
+
501
+ def tempfile_gettempdir() -> str:
502
+ import tempfile
503
+
504
+ return tempfile.gettempdir()
505
+
506
+
507
+ # ---------------------------------------------------------------------------
508
+ # Internal: harness root resolution
509
+ # ---------------------------------------------------------------------------
510
+
511
+
512
+ def _resolve_harness_roots(
513
+ harness: HarnessDefinition,
514
+ base_env: dict[str, str],
515
+ ) -> dict[str, str]:
516
+ """Resolve the harness-specific derived template variables declared in
517
+ ``harness.roots``. Resolution order follows declaration order so that later
518
+ roots can reference earlier roots in their ``fallback`` and ``use``
519
+ templates.
520
+ """
521
+ out = dict(base_env)
522
+
523
+ for root in harness.roots:
524
+ env_val = base_env.get(root.env, "") if root.env else ""
525
+ env_val_nonempty = env_val if env_val else ""
526
+
527
+ if root.env and env_val_nonempty:
528
+ if root.use:
529
+ # Resolve "use" against base env + resolved roots + the env var itself.
530
+ tmp = dict(out)
531
+ tmp[root.env] = env_val_nonempty
532
+ value = _resolve_template(root.use, tmp)
533
+ else:
534
+ value = env_val_nonempty
535
+ else:
536
+ value = _resolve_template(root.fallback, out)
537
+
538
+ if value is not None and value != "":
539
+ out[root.name] = os.path.normpath(value)
540
+
541
+ return out
542
+
543
+
544
+ # ---------------------------------------------------------------------------
545
+ # Internal: template resolution
546
+ # ---------------------------------------------------------------------------
547
+
548
+ _TEMPLATE_VAR = re.compile(r"\$\{([^}]+)\}")
549
+
550
+
551
+ def _resolve_template(template: str, env: dict[str, str]) -> Optional[str]:
552
+ """Replace all ``${VAR}`` placeholders with values from ``env``.
553
+
554
+ If any placeholder value is missing or empty, the **entire template
555
+ resolves to ``None``** (matching the TypeScript ``null`` / Go empty-string /
556
+ Rust ``None`` semantics). The resolved string is normalized via
557
+ :func:`os.path.normpath`.
558
+ """
559
+ if not template:
560
+ return None
561
+
562
+ unresolved = False
563
+
564
+ def repl(m: re.Match[str]) -> str:
565
+ nonlocal unresolved
566
+ name = m.group(1)
567
+ value = env.get(name)
568
+ if value is None or value == "":
569
+ unresolved = True
570
+ return ""
571
+ return value
572
+
573
+ resolved = _TEMPLATE_VAR.sub(repl, template)
574
+
575
+ if unresolved:
576
+ return None
577
+
578
+ return os.path.normpath(resolved)
579
+
580
+
581
+ # ---------------------------------------------------------------------------
582
+ # Internal: platform + filesystem checks
583
+ # ---------------------------------------------------------------------------
584
+
585
+
586
+ def _current_platform() -> str:
587
+ """Return the current platform identifier using the Node.js convention
588
+ (``darwin``, ``linux``, ``win32``, ...) so it matches the registry's
589
+ ``platforms[]`` values.
590
+ """
591
+ if sys.platform == "darwin":
592
+ return "darwin"
593
+ if sys.platform.startswith("linux"):
594
+ return "linux"
595
+ if sys.platform == "win32":
596
+ return "win32"
597
+ if sys.platform.startswith("freebsd"):
598
+ return "freebsd"
599
+ return sys.platform
600
+
601
+
602
+ def _platform_matches(platforms: tuple[str, ...]) -> bool:
603
+ if not platforms:
604
+ return True
605
+ plat = _current_platform()
606
+ return plat in platforms
607
+
608
+
609
+ def _path_type_matches(kind: str, candidate_path: str) -> bool:
610
+ p = Path(candidate_path)
611
+ try:
612
+ if kind == "dir":
613
+ return p.is_dir()
614
+ return p.is_file()
615
+ except OSError:
616
+ return False
617
+
618
+
619
+ def _executable_file_matches(candidate_path: str) -> bool:
620
+ if not _path_type_matches("file", candidate_path):
621
+ return False
622
+ if sys.platform == "win32":
623
+ return True
624
+ # Non-Windows: check the executable bit (any of owner/group/other).
625
+ try:
626
+ st = os.stat(candidate_path)
627
+ except OSError:
628
+ return False
629
+ return bool(st.st_mode & 0o111)
630
+
631
+
632
+ def _resolve_paths(
633
+ harness: HarnessDefinition,
634
+ env: dict[str, str],
635
+ ) -> list[ResolvedHarnessPath]:
636
+ out: list[ResolvedHarnessPath] = []
637
+ for entry in harness.paths:
638
+ if not _platform_matches(entry.platforms):
639
+ continue
640
+ resolved = _resolve_template(entry.template, env)
641
+ exists = bool(resolved) and _path_type_matches(entry.kind, resolved)
642
+ out.append(
643
+ ResolvedHarnessPath(
644
+ id=entry.id,
645
+ category=entry.category,
646
+ kind=entry.kind,
647
+ template=entry.template,
648
+ platforms=entry.platforms,
649
+ path=resolved,
650
+ exists=exists,
651
+ )
652
+ )
653
+ return out
654
+
655
+
656
+ def _find_executable(
657
+ executables: tuple[str, ...],
658
+ env: dict[str, str],
659
+ ) -> Optional[str]:
660
+ if not executables:
661
+ return None
662
+
663
+ path_value = env.get("PATH", "")
664
+ delimiter = ";" if sys.platform == "win32" else os.pathsep
665
+ path_parts = [p for p in path_value.split(delimiter) if p]
666
+
667
+ if sys.platform == "win32":
668
+ pathext = env.get("PATHEXT", ".EXE;.CMD;.BAT;.COM")
669
+ exts = pathext.split(";")
670
+ else:
671
+ exts = [""]
672
+
673
+ for executable in executables:
674
+ for d in path_parts:
675
+ for ext in exts:
676
+ candidate = os.path.join(d, executable + ext)
677
+ if _executable_file_matches(candidate):
678
+ return os.path.normpath(candidate)
679
+
680
+ return None