dazzlecmd-lib 0.8.55__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 (85) hide show
  1. dazzlecmd_lib/__init__.py +61 -0
  2. dazzlecmd_lib/_version.py +92 -0
  3. dazzlecmd_lib/aggregator_config.py +453 -0
  4. dazzlecmd_lib/cli_helpers.py +180 -0
  5. dazzlecmd_lib/colors.py +228 -0
  6. dazzlecmd_lib/conditions.py +211 -0
  7. dazzlecmd_lib/config.py +194 -0
  8. dazzlecmd_lib/contexts.py +1198 -0
  9. dazzlecmd_lib/continuum.py +22 -0
  10. dazzlecmd_lib/core/__init__.py +124 -0
  11. dazzlecmd_lib/core/links/__init__.py +195 -0
  12. dazzlecmd_lib/core/links/_detect.py +711 -0
  13. dazzlecmd_lib/core/safedel/__init__.py +79 -0
  14. dazzlecmd_lib/core/safedel/_classifier.py +331 -0
  15. dazzlecmd_lib/core/safedel/_platform.py +508 -0
  16. dazzlecmd_lib/core/safedel/_recover.py +658 -0
  17. dazzlecmd_lib/core/safedel/_store.py +565 -0
  18. dazzlecmd_lib/core/safedel/_timepattern.py +390 -0
  19. dazzlecmd_lib/core/safedel/_volumes.py +449 -0
  20. dazzlecmd_lib/core/safedel/_zones.py +350 -0
  21. dazzlecmd_lib/default_meta_commands.py +2359 -0
  22. dazzlecmd_lib/engine.py +2076 -0
  23. dazzlecmd_lib/entity.py +468 -0
  24. dazzlecmd_lib/loader.py +546 -0
  25. dazzlecmd_lib/meta_command_registry.py +265 -0
  26. dazzlecmd_lib/mode.py +1566 -0
  27. dazzlecmd_lib/paths.py +140 -0
  28. dazzlecmd_lib/platform_detect.py +253 -0
  29. dazzlecmd_lib/platform_resolve.py +146 -0
  30. dazzlecmd_lib/registry.py +1379 -0
  31. dazzlecmd_lib/reserved.py +62 -0
  32. dazzlecmd_lib/resolution_context.py +73 -0
  33. dazzlecmd_lib/resolution_trace.py +92 -0
  34. dazzlecmd_lib/schema_version.py +63 -0
  35. dazzlecmd_lib/setup_resolve.py +238 -0
  36. dazzlecmd_lib/states.py +322 -0
  37. dazzlecmd_lib/templates/__with__/docker-deploy/Dockerfile.tmpl +11 -0
  38. dazzlecmd_lib/templates/__with__/docker-test/Dockerfile.test.tmpl +10 -0
  39. dazzlecmd_lib/templates/__with__/docker-test/docker-compose.test.yml.tmpl +7 -0
  40. dazzlecmd_lib/templates/aggregator/.gitignore.tmpl +9 -0
  41. dazzlecmd_lib/templates/aggregator/README.md.tmpl +30 -0
  42. dazzlecmd_lib/templates/aggregator/aggregator.json.tmpl +19 -0
  43. dazzlecmd_lib/templates/aggregator/pyproject.toml.tmpl +29 -0
  44. dazzlecmd_lib/templates/aggregator/src/{name_underscore}/__init__.py.tmpl +3 -0
  45. dazzlecmd_lib/templates/aggregator/src/{name_underscore}/_version.py.tmpl +7 -0
  46. dazzlecmd_lib/templates/aggregator/src/{name_underscore}/cli.py.tmpl +42 -0
  47. dazzlecmd_lib/templates/aggregator/tests/test_cli_smoke.py.tmpl +17 -0
  48. dazzlecmd_lib/templates/bash/.dazzlecmd.json.tmpl +25 -0
  49. dazzlecmd_lib/templates/bash/{name}.sh.tmpl +10 -0
  50. dazzlecmd_lib/templates/binary/.dazzlecmd.json.tmpl +25 -0
  51. dazzlecmd_lib/templates/binary/README.md.tmpl +59 -0
  52. dazzlecmd_lib/templates/c_cpp/.dazzlecmd.json.tmpl +29 -0
  53. dazzlecmd_lib/templates/c_cpp/Makefile.tmpl +11 -0
  54. dazzlecmd_lib/templates/c_cpp/main.c.tmpl +15 -0
  55. dazzlecmd_lib/templates/cmd/.dazzlecmd.json.tmpl +25 -0
  56. dazzlecmd_lib/templates/cmd/{name}.cmd.tmpl +11 -0
  57. dazzlecmd_lib/templates/docker/.dazzlecmd.json.tmpl +29 -0
  58. dazzlecmd_lib/templates/docker/Dockerfile.tmpl +12 -0
  59. dazzlecmd_lib/templates/generic/.dazzlecmd.json.tmpl +25 -0
  60. dazzlecmd_lib/templates/generic/README.md.tmpl +67 -0
  61. dazzlecmd_lib/templates/node/.dazzlecmd.json.tmpl +28 -0
  62. dazzlecmd_lib/templates/node/index.js.tmpl +8 -0
  63. dazzlecmd_lib/templates/node/package.json.tmpl +13 -0
  64. dazzlecmd_lib/templates/powershell/.dazzlecmd.json.tmpl +25 -0
  65. dazzlecmd_lib/templates/powershell/{name}.ps1.tmpl +12 -0
  66. dazzlecmd_lib/templates/python/.dazzlecmd.json.tmpl +25 -0
  67. dazzlecmd_lib/templates/python/__full__/.dazzlecmd.json.tmpl +30 -0
  68. dazzlecmd_lib/templates/python/__full__/README.md.tmpl +32 -0
  69. dazzlecmd_lib/templates/python/__full__/dz_setup.py.tmpl +235 -0
  70. dazzlecmd_lib/templates/python/__full__/requirements.txt.tmpl +7 -0
  71. dazzlecmd_lib/templates/python/__full__/tests/test_{name_underscore}.py.tmpl +18 -0
  72. dazzlecmd_lib/templates/python/{name_underscore}.py.tmpl +19 -0
  73. dazzlecmd_lib/templates/repokit_fallback/CONTRIBUTING.md.tmpl +9 -0
  74. dazzlecmd_lib/templates/repokit_fallback/LICENSE.tmpl +22 -0
  75. dazzlecmd_lib/templates/rust/.dazzlecmd.json.tmpl +29 -0
  76. dazzlecmd_lib/templates/rust/Cargo.toml.tmpl +11 -0
  77. dazzlecmd_lib/templates/rust/src/main.rs.tmpl +9 -0
  78. dazzlecmd_lib/templates.py +197 -0
  79. dazzlecmd_lib/testing.py +82 -0
  80. dazzlecmd_lib/user_overrides.py +131 -0
  81. dazzlecmd_lib-0.8.55.dist-info/METADATA +117 -0
  82. dazzlecmd_lib-0.8.55.dist-info/RECORD +85 -0
  83. dazzlecmd_lib-0.8.55.dist-info/WHEEL +5 -0
  84. dazzlecmd_lib-0.8.55.dist-info/licenses/LICENSE +674 -0
  85. dazzlecmd_lib-0.8.55.dist-info/top_level.txt +1 -0
@@ -0,0 +1,61 @@
1
+ """dazzlecmd-lib -- Engine library for dazzlecmd-pattern tool aggregators.
2
+
3
+ Build your own dz-pattern CLI in ~10 lines:
4
+
5
+ from dazzlecmd_lib import AggregatorEngine
6
+
7
+ def main():
8
+ engine = AggregatorEngine(
9
+ name="my-tools",
10
+ command="mt",
11
+ tools_dir="tools",
12
+ manifest=".mt.json",
13
+ version_info=("1.0", "1.0.0_main_1"),
14
+ )
15
+ return engine.run()
16
+
17
+ That gets you default ``mt list``, ``mt info <tool>``, ``mt kit``,
18
+ ``mt version``, ``mt tree``, ``mt setup``. Customize via:
19
+
20
+ engine.meta_registry.register("mycmd", parser_factory, handler)
21
+ engine.meta_registry.override("list", handler=my_custom_list)
22
+ engine.meta_registry.unregister("tree")
23
+
24
+ Public API:
25
+ - AggregatorEngine: configurable CLI tool aggregator
26
+ - FQCNIndex: dual-index lookup for Fully Qualified Collection Names
27
+ - RunnerRegistry: extensible runtime dispatch (runtime types)
28
+ - MetaCommandRegistry: per-engine meta-command registry
29
+ - cli_helpers: argparse scaffolding helpers for escape-hatch paths
30
+ - default_meta_commands: stock list/info/kit/version/tree/setup
31
+ - ConfigManager: per-aggregator config reading/writing
32
+ - CircularDependencyError, FQCNCollisionError: exception types
33
+ """
34
+
35
+ from dazzlecmd_lib._version import __version__
36
+
37
+ # Core engine + FQCN
38
+ from dazzlecmd_lib.engine import (
39
+ AggregatorEngine,
40
+ FQCNIndex,
41
+ FQCNCollisionError,
42
+ CircularDependencyError,
43
+ )
44
+
45
+ # Runtime dispatch
46
+ from dazzlecmd_lib.registry import RunnerRegistry
47
+
48
+ # Config + meta-command machinery
49
+ from dazzlecmd_lib.config import ConfigManager
50
+ from dazzlecmd_lib.meta_command_registry import (
51
+ MetaCommandRegistry,
52
+ MetaCommandAlreadyRegisteredError,
53
+ MetaCommandNotRegisteredError,
54
+ RegistryLockedError,
55
+ )
56
+
57
+ # CLI helpers + defaults (available as modules; not re-exported at top level
58
+ # to keep the namespace clean. Import them explicitly:
59
+ # from dazzlecmd_lib import cli_helpers, default_meta_commands)
60
+ from dazzlecmd_lib import cli_helpers
61
+ from dazzlecmd_lib import default_meta_commands
@@ -0,0 +1,92 @@
1
+ """
2
+ Version information for dazzlecmd-lib.
3
+
4
+ This file is the canonical source for version numbers.
5
+ The __version__ string is automatically updated by git hooks
6
+ with build metadata (branch, build number, date, commit hash).
7
+
8
+ Format: MAJOR.MINOR.PATCH[-PHASE]_BRANCH_BUILD-YYYYMMDD-COMMITHASH
9
+ Example: 0.1.0_main_1-20260101-a1b2c3d4
10
+
11
+ Version levels:
12
+ PROJECT_PHASE: Global project maturity (prealpha -> alpha -> beta -> stable).
13
+ Changes rarely, when the overall project hits a threshold.
14
+ PHASE: Per-MINOR feature set maturity (alpha -> beta -> "" for stable).
15
+ Drops when a MINOR's feature set is complete.
16
+ """
17
+
18
+ # Version components - edit these for version bumps
19
+ MAJOR = 0
20
+ MINOR = 8
21
+ PATCH = 55
22
+ PHASE = "" # Per-MINOR feature set: None, "alpha", "beta", "rc1", etc.
23
+
24
+ # Project-level phase (independent of version phase)
25
+ PROJECT_PHASE = "" # "prealpha", "alpha", "beta", "stable", or ""
26
+
27
+ # Auto-updated by git hooks - do not edit manually
28
+ __version__ = "0.8.55_main_5-20260624-f9cdb549"
29
+ __app_name__ = "dazzlecmd-lib"
30
+
31
+
32
+ def get_version():
33
+ """Return the full version string including branch and build info."""
34
+ return __version__
35
+
36
+
37
+ def get_base_version():
38
+ """Return the semantic version string (MAJOR.MINOR.PATCH[-PHASE])."""
39
+ if "_" in __version__:
40
+ return __version__.split("_")[0]
41
+ base = f"{MAJOR}.{MINOR}.{PATCH}"
42
+ if PHASE:
43
+ base = f"{base}-{PHASE}"
44
+ return base
45
+
46
+
47
+ def get_display_version():
48
+ """Return a human-friendly version string with project phase.
49
+
50
+ Example: 'PREALPHA 0.1.0-alpha' or 'BETA 0.5.1' or '1.0.0'
51
+ """
52
+ base = get_base_version()
53
+ if PROJECT_PHASE and PROJECT_PHASE != "stable":
54
+ return f"{PROJECT_PHASE.upper()} {base}"
55
+ return base
56
+
57
+
58
+ def get_pip_version():
59
+ """
60
+ Return PEP 440 compliant version for pip/setuptools.
61
+
62
+ Converts our version format to PEP 440:
63
+ - Main branch: 0.1.0_main_3-20260404-hash -> 0.1.0
64
+ - Dev branch: 0.1.0_dev_3-20260404-hash -> 0.1.0.dev3
65
+ - Alpha: 0.1.0-alpha_main_3 -> 0.1.0a0
66
+ """
67
+ base = f"{MAJOR}.{MINOR}.{PATCH}"
68
+
69
+ # Map phase to PEP 440 pre-release segment
70
+ phase_map = {"alpha": "a0", "beta": "b0"}
71
+ if PHASE:
72
+ base += phase_map.get(PHASE, PHASE)
73
+
74
+ if "_" not in __version__:
75
+ return base
76
+
77
+ parts = __version__.split("_")
78
+ branch = parts[1] if len(parts) > 1 else "unknown"
79
+
80
+ if branch == "main":
81
+ return base
82
+ else:
83
+ build_info = "_".join(parts[2:]) if len(parts) > 2 else ""
84
+ build_num = build_info.split("-")[0] if "-" in build_info else "0"
85
+ return f"{base}.dev{build_num}"
86
+
87
+
88
+ # For convenience in imports
89
+ VERSION = get_version()
90
+ BASE_VERSION = get_base_version()
91
+ PIP_VERSION = get_pip_version()
92
+ DISPLAY_VERSION = get_display_version()
@@ -0,0 +1,453 @@
1
+ """Declarative aggregator configuration via ``aggregator.json``.
2
+
3
+ Every dazzlecmd-lib consumer (dazzlecmd, wtf-windows, amdead, etc.) declares
4
+ its identity and layout in an ``aggregator.json`` file at its project root.
5
+ The library reads this file to construct an ``AggregatorEngine`` with the
6
+ right parameters, replacing the previous pattern of hand-coded
7
+ ``AggregatorEngine(name=..., command=..., tools_dir=..., ...)`` calls in
8
+ each aggregator's main module.
9
+
10
+ The file is **required** -- no backward-compat fallback. An aggregator
11
+ without ``aggregator.json`` at its project root cannot construct an engine
12
+ via the canonical ``AggregatorEngine.from_project(project_root)`` path.
13
+
14
+ Schema (v1)::
15
+
16
+ {
17
+ "_schema_version": 1,
18
+ "name": "dazzlecmd",
19
+ "command": "dz",
20
+ "description": "one-line description",
21
+ "tools_dir": "projects",
22
+ "kits_dir": "kits",
23
+ "manifest_name": ".dazzlecmd.json",
24
+ "enabled_meta_commands": ["list", "info", "kit", "tree", "setup",
25
+ "version", "add", "mode", "new"],
26
+ "extra_reserved_commands": ["find", "git", ...],
27
+ "schema": {
28
+ "remote_url_paths": ["source.url", "lifecycle.remote"],
29
+ "lifecycle_path": "lifecycle"
30
+ },
31
+ "discovery": {
32
+ "tool_patterns": ["${tools_dir}/*/*"],
33
+ "scan_hidden": false
34
+ }
35
+ }
36
+
37
+ Field semantics:
38
+
39
+ - ``_schema_version``: integer; ``1`` for this format. Forward-compat
40
+ hook -- future library versions can migrate older files.
41
+ - ``name``: human-readable aggregator name (appears in ``--help``).
42
+ - ``command``: CLI command name (``dz``, ``wtf``, ``amdead``). Substituted
43
+ into user-facing strings (no more hardcoded ``"dz"``).
44
+ - ``description``: one-line description for ``--help``.
45
+ - ``tools_dir``: relative directory name where tool projects live. Replaces
46
+ the hardcoded ``"projects/"`` literals throughout the codebase
47
+ (issue #37 BLOCKERs F2/F3/F4/F8).
48
+ - ``kits_dir``: relative directory name for kit registry pointers.
49
+ - ``manifest_name``: per-tool manifest filename
50
+ (``.dazzlecmd.json`` / ``.wtf.json`` / ``.amdead.json`` / ...).
51
+ - ``enabled_meta_commands``: list of meta-command names this aggregator
52
+ registers as CLI subcommands. Subset of ``DEFAULT_RESERVED_COMMANDS``.
53
+ Defaults to ``DEFAULT_META_COMMANDS_USER`` when omitted.
54
+ - ``extra_reserved_commands``: additional names reserved beyond
55
+ ``DEFAULT_RESERVED_COMMANDS``. Use sparingly -- only for names that
56
+ the aggregator wants to keep available as future meta-commands but
57
+ isn't using yet. Existing tools' names should NOT appear here (the
58
+ library would silently skip them during discovery; pre-v0.7.51 had
59
+ this regression in the dazzlecmd fixture).
60
+ - ``schema.remote_url_paths``: ordered list of dotted paths the library
61
+ tries when resolving a tool's remote URL. Each entry is a fallback.
62
+ Replaces hardcoded ``project["source"]["url"]`` / ``project["lifecycle"]["remote"]``
63
+ (BLOCKER F7 schema decoupling).
64
+ - ``schema.lifecycle_path``: dotted path for the lifecycle metadata block.
65
+ - ``discovery.tool_patterns``: list of glob patterns for finding tools
66
+ beyond the standard ``<tools_dir>/<ns>/<tool>`` layout. ``${tools_dir}``
67
+ is interpolated from the same JSON.
68
+ - ``discovery.scan_hidden``: whether ``.dotdirs`` are scanned (default
69
+ ``false``).
70
+
71
+ Subprocess environment contract
72
+ -------------------------------
73
+
74
+ The library injects the following environment variables before invoking
75
+ any tool subprocess. Tool scripts (PowerShell, bash, Python, etc.) can
76
+ read these to adapt branding strings, log paths, and behavior to the
77
+ host aggregator without each aggregator hand-rolling its own bridge:
78
+
79
+ - ``DZ_APP_NAME``: the engine's ``name`` field (e.g., ``"dazzlecmd"``,
80
+ ``"wtf-windows"``, ``"amdead"``). Reflects engine IDENTITY, not
81
+ per-invocation context. Set at every dispatch site.
82
+ - ``DZ_COMMAND``: the engine's ``command`` field (e.g., ``"dz"``,
83
+ ``"wtf"``, ``"amdead"``). Same semantics as ``DZ_APP_NAME``.
84
+ - ``DZ_CANONICAL_FQCN``: canonical FQCN of the dispatched tool (e.g.,
85
+ ``"core:rn"``). Set only when a ``ResolutionContext`` is supplied to
86
+ the dispatcher. Tools writing persistent state (caches, logs,
87
+ checkpoints) MUST key on this to avoid divergent state across
88
+ alias-vs-canonical-vs-short-name invocation paths (v0.7.28).
89
+ - ``DZ_INVOKED_FQCN``: what the user actually typed (alias, short name,
90
+ or canonical). Equal to ``DZ_CANONICAL_FQCN`` for canonical
91
+ invocations. Same gating as ``DZ_CANONICAL_FQCN``.
92
+
93
+ All four vars are restored to their pre-dispatch values in a
94
+ ``finally`` block after the subprocess completes, so dz's own process
95
+ environment is not permanently modified.
96
+
97
+ Nested-aggregator behavior: when one aggregator embeds another as a kit
98
+ (e.g., dazzlecmd embeds wtf-windows so ``dz wtf:locked`` dispatches a
99
+ wtf tool), ``DZ_APP_NAME`` and ``DZ_COMMAND`` reflect the **ROOT
100
+ ENGINE** (``"dazzlecmd"`` / ``"dz"``), not the kit's owning aggregator.
101
+ This matches the user's experience (they typed ``dz``, not ``wtf``) but
102
+ tool authors should not assume their original aggregator's identity is
103
+ present in these vars at runtime.
104
+
105
+ A ``DZ_PROJECT_ROOT`` env var was considered (would expose the
106
+ aggregator's project_root absolute path so tools can resolve log
107
+ destinations etc. relative to it) but is deferred to a follow-up issue.
108
+ It introduces tool-to-aggregator filesystem coupling that warrants a
109
+ separate design pass. Tools needing the project root today can read
110
+ ``DZ_APP_NAME`` and look the project up via ``find_aggregator_root()``
111
+ or via configuration.
112
+ """
113
+
114
+ from __future__ import annotations
115
+
116
+ import json
117
+ import os
118
+ import warnings
119
+ from typing import List, Optional, Set, Tuple
120
+
121
+ from pydantic import BaseModel, ConfigDict
122
+
123
+ from dazzlecmd_lib.reserved import (
124
+ DEFAULT_META_COMMANDS_USER,
125
+ DEFAULT_RESERVED_COMMANDS,
126
+ )
127
+
128
+
129
+ AGGREGATOR_CONFIG_FILENAME = "aggregator.json"
130
+ CURRENT_SCHEMA_VERSION = 1
131
+
132
+ # Defaults referenced by BOTH the model fields and the parse helpers, so the
133
+ # parse helpers don't reach into model internals (the old code read
134
+ # ``__dataclass_fields__[...].default``, which Pydantic models don't expose).
135
+ _DEFAULT_REMOTE_URL_PATHS = ("source.url", "lifecycle.remote")
136
+ _DEFAULT_LIFECYCLE_PATH = "lifecycle"
137
+ _DEFAULT_TOOL_PATTERNS = ("${tools_dir}/*/*",)
138
+
139
+
140
+ class AggregatorConfigError(Exception):
141
+ """Raised when ``aggregator.json`` is missing, malformed, or invalid."""
142
+
143
+
144
+ def find_aggregator_root(start_path=None, max_depth=12):
145
+ """Walk up from ``start_path`` to find a directory with ``aggregator.json``.
146
+
147
+ The first ancestor (including ``start_path`` itself) containing the
148
+ canonical marker file is the project root. This is the new
149
+ discovery strategy (Phase 3.5 T1-M): the presence of
150
+ ``aggregator.json`` itself defines the project root, instead of
151
+ requiring tools_dir + kits_dir hardcoded knowledge to find it.
152
+
153
+ **Entry points MUST pass an explicit ``start_path`` anchored to their
154
+ own package location** -- typically
155
+ ``os.path.dirname(os.path.abspath(__file__))`` of the aggregator's
156
+ ``cli`` module. This pins the aggregator's identity to *which package
157
+ it is*, not *where it is invoked from*. An aggregator that calls this
158
+ bare (relying on the cwd default) will impersonate whatever other
159
+ aggregator the user happens to be standing in: running ``dz`` from
160
+ inside a ``wtf-windows`` checkout would load wtf's ``aggregator.json``
161
+ and ``dz`` would become ``wtf``. The package anchor avoids that
162
+ because the entry point's ``__file__`` is fixed at install time.
163
+
164
+ When ``start_path`` is ``None`` the walk starts from ``os.getcwd()``.
165
+ This "find from the current directory" behavior is for tests and
166
+ ad-hoc tooling that genuinely want cwd-relative discovery -- NOT for
167
+ production entry points. (Earlier revisions also fell back to this
168
+ module's own ``__file__``; that was removed because the library lives
169
+ co-located with dazzlecmd in dev mode, so the fallback made every
170
+ aggregator that called this bare resolve to dazzlecmd.)
171
+
172
+ Args:
173
+ start_path: Directory to start the walk from. Production entry
174
+ points pass their package's ``__file__`` directory. Defaults
175
+ to ``os.getcwd()`` (tests / ad-hoc cwd-relative discovery).
176
+ max_depth: Maximum number of parent directories to walk before
177
+ giving up. Defaults to 12 (deep enough for any sane layout,
178
+ shallow enough to terminate quickly when the marker is
179
+ absent).
180
+
181
+ Returns:
182
+ Absolute path to the project root, or ``None`` if no
183
+ ``aggregator.json`` was found within ``max_depth`` ancestors of
184
+ the starting point.
185
+ """
186
+ if start_path is None:
187
+ start_path = os.getcwd()
188
+ current = os.path.abspath(start_path)
189
+ for _ in range(max_depth + 1):
190
+ if os.path.isfile(os.path.join(current, AGGREGATOR_CONFIG_FILENAME)):
191
+ return current
192
+ parent = os.path.dirname(current)
193
+ if parent == current:
194
+ return None
195
+ current = parent
196
+ return None
197
+
198
+
199
+ class AggregatorSchema(BaseModel):
200
+ """How the engine reads tool-manifest values.
201
+
202
+ Decouples library code from any single manifest format
203
+ (.dazzlecmd.json vs .wtf.json vs .amdead.json).
204
+ """
205
+
206
+ model_config = ConfigDict(frozen=True)
207
+
208
+ remote_url_paths: Tuple[str, ...] = _DEFAULT_REMOTE_URL_PATHS
209
+ lifecycle_path: str = _DEFAULT_LIFECYCLE_PATH
210
+
211
+
212
+ class AggregatorDiscovery(BaseModel):
213
+ """Glob patterns + flags for finding tools beyond the standard layout."""
214
+
215
+ model_config = ConfigDict(frozen=True)
216
+
217
+ tool_patterns: Tuple[str, ...] = _DEFAULT_TOOL_PATTERNS
218
+ scan_hidden: bool = False
219
+
220
+
221
+ # The ``schema`` field name deliberately shadows Pydantic's deprecated
222
+ # ``BaseModel.schema()`` (callers use ``config.schema`` throughout). Suppress
223
+ # the one benign class-definition UserWarning so it never reaches a ``dz``
224
+ # user's stderr (which would also break the byte-identical output baseline).
225
+ # The model functions correctly -- verified empirically on pydantic 2.11.7.
226
+ with warnings.catch_warnings():
227
+ warnings.filterwarnings(
228
+ "ignore",
229
+ message=r'Field name "schema".*shadows an attribute.*',
230
+ category=UserWarning,
231
+ )
232
+
233
+ class AggregatorConfig(BaseModel):
234
+ """Parsed ``aggregator.json``.
235
+
236
+ Constructed by ``load_aggregator_config(project_root)``. Frozen so it
237
+ can be safely passed around as engine state.
238
+ """
239
+
240
+ model_config = ConfigDict(frozen=True)
241
+
242
+ project_root: str
243
+ schema_version: int
244
+ name: str
245
+ command: str
246
+ description: str
247
+ tools_dir: str
248
+ kits_dir: str
249
+ manifest_name: str
250
+ enabled_meta_commands: frozenset
251
+ reserved_commands: frozenset
252
+ schema: AggregatorSchema
253
+ discovery: AggregatorDiscovery
254
+ # Reserved slot (v0.8.0): the "skin" half of same-bones -- per-aggregator
255
+ # presentation/projection config (visibility frames, render hints). Read
256
+ # but not yet consumed; the grouping/ungrouping projection layer that
257
+ # interprets it lands in a later release. Kept as a plain dict so the
258
+ # eventual schema can evolve without a config-format migration.
259
+ presentation: Optional[dict] = None
260
+
261
+ def resolved_discovery_patterns(self) -> Tuple[str, ...]:
262
+ """Return ``discovery.tool_patterns`` with ``${tools_dir}`` expanded."""
263
+ return tuple(
264
+ pattern.replace("${tools_dir}", self.tools_dir)
265
+ for pattern in self.discovery.tool_patterns
266
+ )
267
+
268
+
269
+ def _require(data: dict, key: str, source: str) -> object:
270
+ if key not in data:
271
+ raise AggregatorConfigError(
272
+ f"{source}: required key '{key}' missing"
273
+ )
274
+ return data[key]
275
+
276
+
277
+ def _str_field(data: dict, key: str, source: str) -> str:
278
+ value = _require(data, key, source)
279
+ if not isinstance(value, str) or not value.strip():
280
+ raise AggregatorConfigError(
281
+ f"{source}: '{key}' must be a non-empty string (got {value!r})"
282
+ )
283
+ return value
284
+
285
+
286
+ def _optional_str(data: dict, key: str, default: str) -> str:
287
+ value = data.get(key, default)
288
+ if not isinstance(value, str):
289
+ raise AggregatorConfigError(
290
+ f"'{key}' must be a string (got {value!r})"
291
+ )
292
+ return value
293
+
294
+
295
+ def _optional_list_of_str(data: dict, key: str, default: List[str],
296
+ source: str) -> List[str]:
297
+ value = data.get(key, default)
298
+ if not isinstance(value, list):
299
+ raise AggregatorConfigError(
300
+ f"{source}: '{key}' must be a list (got {type(value).__name__})"
301
+ )
302
+ for item in value:
303
+ if not isinstance(item, str):
304
+ raise AggregatorConfigError(
305
+ f"{source}: '{key}' entries must be strings (got {item!r})"
306
+ )
307
+ return list(value)
308
+
309
+
310
+ def _parse_schema(data: dict, source: str) -> AggregatorSchema:
311
+ block = data.get("schema") or {}
312
+ if not isinstance(block, dict):
313
+ raise AggregatorConfigError(
314
+ f"{source}: 'schema' must be an object (got {type(block).__name__})"
315
+ )
316
+ remote_paths = _optional_list_of_str(
317
+ block, "remote_url_paths",
318
+ list(_DEFAULT_REMOTE_URL_PATHS),
319
+ f"{source}: schema",
320
+ )
321
+ lifecycle_path = _optional_str(
322
+ block, "lifecycle_path",
323
+ _DEFAULT_LIFECYCLE_PATH,
324
+ )
325
+ return AggregatorSchema(
326
+ remote_url_paths=tuple(remote_paths),
327
+ lifecycle_path=lifecycle_path,
328
+ )
329
+
330
+
331
+ def _parse_discovery(data: dict, source: str) -> AggregatorDiscovery:
332
+ block = data.get("discovery") or {}
333
+ if not isinstance(block, dict):
334
+ raise AggregatorConfigError(
335
+ f"{source}: 'discovery' must be an object (got {type(block).__name__})"
336
+ )
337
+ patterns = _optional_list_of_str(
338
+ block, "tool_patterns",
339
+ list(_DEFAULT_TOOL_PATTERNS),
340
+ f"{source}: discovery",
341
+ )
342
+ scan_hidden = block.get("scan_hidden", False)
343
+ if not isinstance(scan_hidden, bool):
344
+ raise AggregatorConfigError(
345
+ f"{source}: discovery.scan_hidden must be a boolean "
346
+ f"(got {scan_hidden!r})"
347
+ )
348
+ return AggregatorDiscovery(
349
+ tool_patterns=tuple(patterns),
350
+ scan_hidden=scan_hidden,
351
+ )
352
+
353
+
354
+ def load_aggregator_config(project_root: str) -> AggregatorConfig:
355
+ """Load and validate ``aggregator.json`` from ``project_root``.
356
+
357
+ Raises ``AggregatorConfigError`` if the file is missing, unreadable,
358
+ not valid JSON, has an unknown ``_schema_version``, or fails field
359
+ validation.
360
+ """
361
+ project_root = os.path.abspath(project_root)
362
+ config_path = os.path.join(project_root, AGGREGATOR_CONFIG_FILENAME)
363
+ source = f"{config_path}"
364
+
365
+ if not os.path.isfile(config_path):
366
+ raise AggregatorConfigError(
367
+ f"aggregator.json not found at {config_path}. "
368
+ f"Every dazzlecmd-lib aggregator must declare an aggregator.json "
369
+ f"at its project root. See docs/guides/aggregator-config.md for "
370
+ f"the schema."
371
+ )
372
+
373
+ try:
374
+ with open(config_path, "r", encoding="utf-8") as f:
375
+ data = json.load(f)
376
+ except OSError as exc:
377
+ raise AggregatorConfigError(
378
+ f"Could not read {source}: {exc}"
379
+ ) from exc
380
+ except json.JSONDecodeError as exc:
381
+ raise AggregatorConfigError(
382
+ f"{source} is not valid JSON: {exc}"
383
+ ) from exc
384
+
385
+ if not isinstance(data, dict):
386
+ raise AggregatorConfigError(
387
+ f"{source}: top-level value must be a JSON object "
388
+ f"(got {type(data).__name__})"
389
+ )
390
+
391
+ schema_version = data.get("_schema_version", 1)
392
+ if not isinstance(schema_version, int):
393
+ raise AggregatorConfigError(
394
+ f"{source}: '_schema_version' must be an integer "
395
+ f"(got {schema_version!r})"
396
+ )
397
+ if schema_version != CURRENT_SCHEMA_VERSION:
398
+ raise AggregatorConfigError(
399
+ f"{source}: unsupported _schema_version {schema_version}; "
400
+ f"this library supports version {CURRENT_SCHEMA_VERSION}"
401
+ )
402
+
403
+ name = _str_field(data, "name", source)
404
+ command = _str_field(data, "command", source)
405
+ tools_dir = _str_field(data, "tools_dir", source)
406
+ kits_dir = _str_field(data, "kits_dir", source)
407
+ manifest_name = _str_field(data, "manifest_name", source)
408
+ description = _optional_str(data, "description", f"{name} - tool aggregator")
409
+
410
+ enabled_list = _optional_list_of_str(
411
+ data, "enabled_meta_commands",
412
+ list(DEFAULT_META_COMMANDS_USER),
413
+ source,
414
+ )
415
+ enabled_meta_commands = frozenset(enabled_list)
416
+ unknown = enabled_meta_commands - DEFAULT_RESERVED_COMMANDS
417
+ if unknown:
418
+ raise AggregatorConfigError(
419
+ f"{source}: 'enabled_meta_commands' contains names not in the "
420
+ f"reserved set: {sorted(unknown)}. Allowed: "
421
+ f"{sorted(DEFAULT_RESERVED_COMMANDS)}"
422
+ )
423
+
424
+ extra_reserved_list = _optional_list_of_str(
425
+ data, "extra_reserved_commands", [], source,
426
+ )
427
+ reserved_commands = DEFAULT_RESERVED_COMMANDS | frozenset(extra_reserved_list)
428
+
429
+ schema = _parse_schema(data, source)
430
+ discovery = _parse_discovery(data, source)
431
+
432
+ presentation = data.get("presentation")
433
+ if presentation is not None and not isinstance(presentation, dict):
434
+ raise AggregatorConfigError(
435
+ f"{source}: 'presentation' must be an object "
436
+ f"(got {type(presentation).__name__})"
437
+ )
438
+
439
+ return AggregatorConfig(
440
+ project_root=project_root,
441
+ schema_version=schema_version,
442
+ name=name,
443
+ command=command,
444
+ description=description,
445
+ tools_dir=tools_dir,
446
+ kits_dir=kits_dir,
447
+ manifest_name=manifest_name,
448
+ enabled_meta_commands=enabled_meta_commands,
449
+ reserved_commands=reserved_commands,
450
+ schema=schema,
451
+ discovery=discovery,
452
+ presentation=presentation,
453
+ )