katalyst-taxonomy-cli 2.0.1a1__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 (51) hide show
  1. katalyst_taxonomy_cli/__init__.py +14 -0
  2. katalyst_taxonomy_cli/__main__.py +12 -0
  3. katalyst_taxonomy_cli/_wiring.py +248 -0
  4. katalyst_taxonomy_cli/app.py +550 -0
  5. katalyst_taxonomy_cli/commands/__init__.py +1 -0
  6. katalyst_taxonomy_cli/commands/api.py +104 -0
  7. katalyst_taxonomy_cli/commands/backend.py +96 -0
  8. katalyst_taxonomy_cli/commands/bundle.py +106 -0
  9. katalyst_taxonomy_cli/commands/clone.py +68 -0
  10. katalyst_taxonomy_cli/commands/config.py +748 -0
  11. katalyst_taxonomy_cli/commands/config_filters.py +159 -0
  12. katalyst_taxonomy_cli/commands/config_home.py +179 -0
  13. katalyst_taxonomy_cli/commands/config_plugins.py +180 -0
  14. katalyst_taxonomy_cli/commands/config_profile.py +604 -0
  15. katalyst_taxonomy_cli/commands/config_schemas.py +257 -0
  16. katalyst_taxonomy_cli/commands/config_sources.py +141 -0
  17. katalyst_taxonomy_cli/commands/core.py +468 -0
  18. katalyst_taxonomy_cli/commands/create.py +145 -0
  19. katalyst_taxonomy_cli/commands/deps.py +48 -0
  20. katalyst_taxonomy_cli/commands/diff.py +63 -0
  21. katalyst_taxonomy_cli/commands/drafts.py +63 -0
  22. katalyst_taxonomy_cli/commands/export.py +186 -0
  23. katalyst_taxonomy_cli/commands/init.py +84 -0
  24. katalyst_taxonomy_cli/commands/kinds.py +302 -0
  25. katalyst_taxonomy_cli/commands/lifecycle.py +127 -0
  26. katalyst_taxonomy_cli/commands/lint.py +256 -0
  27. katalyst_taxonomy_cli/commands/plugin.py +148 -0
  28. katalyst_taxonomy_cli/commands/promote.py +95 -0
  29. katalyst_taxonomy_cli/commands/query.py +187 -0
  30. katalyst_taxonomy_cli/commands/relate.py +137 -0
  31. katalyst_taxonomy_cli/commands/relocate.py +129 -0
  32. katalyst_taxonomy_cli/commands/remove.py +95 -0
  33. katalyst_taxonomy_cli/commands/resolve.py +48 -0
  34. katalyst_taxonomy_cli/commands/rollback.py +51 -0
  35. katalyst_taxonomy_cli/commands/self_update.py +530 -0
  36. katalyst_taxonomy_cli/commands/show.py +33 -0
  37. katalyst_taxonomy_cli/commands/status.py +107 -0
  38. katalyst_taxonomy_cli/commands/sync.py +60 -0
  39. katalyst_taxonomy_cli/commands/update.py +112 -0
  40. katalyst_taxonomy_cli/commands/versions.py +54 -0
  41. katalyst_taxonomy_cli/context.py +145 -0
  42. katalyst_taxonomy_cli/discovery.py +157 -0
  43. katalyst_taxonomy_cli/fields.py +70 -0
  44. katalyst_taxonomy_cli/install.py +330 -0
  45. katalyst_taxonomy_cli/options.py +20 -0
  46. katalyst_taxonomy_cli/output.py +92 -0
  47. katalyst_taxonomy_cli/py.typed +0 -0
  48. katalyst_taxonomy_cli-2.0.1a1.dist-info/METADATA +12 -0
  49. katalyst_taxonomy_cli-2.0.1a1.dist-info/RECORD +51 -0
  50. katalyst_taxonomy_cli-2.0.1a1.dist-info/WHEEL +4 -0
  51. katalyst_taxonomy_cli-2.0.1a1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,748 @@
1
+ """``kata config`` — inspect and switch resolved configuration (#212, #213).
2
+
3
+ There is one configuration file, ``<user config home>/config.yaml``. Every command
4
+ here works from any directory — none needs a repository:
5
+
6
+ ``kata config path``
7
+ The active home (its name, which rule chose it, and its config, state and cache
8
+ directories), where the config file is, and where each of the other (layered)
9
+ files lives.
10
+ ``kata config show``
11
+ The profile-resolved configuration.
12
+ ``kata config sources``
13
+ Which file every resolved key came from.
14
+ ``kata config profile ...``
15
+ List, create, change, delete and activate profiles and the backends each declares
16
+ (:mod:`.config_profile`).
17
+ ``kata config sync`` / ``kata config status``
18
+ Fetch and pin the profiles sourced from elsewhere, and report their pins
19
+ (:mod:`.config_sources`).
20
+
21
+ The group deliberately sits at the top level (``kata config ...``, not
22
+ ``kata tax config ...``) because config resolution is what decides where ``tax``
23
+ operates — it cannot live inside the thing it configures.
24
+
25
+ Structure for follow-on issues: every subcommand is an independent
26
+ ``@config.command`` that calls a module-level ``build_*_payload`` helper and then
27
+ renders. Adding ``explain`` (#214) means adding a command plus a payload builder —
28
+ no restructuring, and the payload builders are importable, which is how the MCP
29
+ ``config_show`` tool stays byte-identical to the CLI.
30
+
31
+ JSON shapes are the contract in
32
+ ``stack-tests/features/cli/31_kata_config_discovery.feature`` and
33
+ ``32_kata_config_profiles.feature``. Extend them additively.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ from pathlib import Path
39
+ from typing import Any
40
+
41
+ import click
42
+ from katalyst_taxonomy.config.profiles import UnknownProfileError
43
+ from katalyst_taxonomy.config.resolution import ConfigResolver
44
+ from katalyst_taxonomy.domain.config_context import ConfigContext
45
+ from katalyst_taxonomy.domain.config_files import (
46
+ CONFIG_FILE,
47
+ ConfigFileSpec,
48
+ MergeStrategy,
49
+ config_file_specs,
50
+ spec_for,
51
+ )
52
+ from katalyst_taxonomy.domain.config_layers import MISSING, lookup_dotted
53
+ from katalyst_taxonomy.services.layered_config import LayeredConfigService
54
+
55
+ from ..output import ConsoleOutput, OutputPort
56
+
57
+ __all__ = [
58
+ "build_explain_payload",
59
+ "build_path_payload",
60
+ "build_permissions_payload",
61
+ "build_show_payload",
62
+ "build_sources_payload",
63
+ "config",
64
+ "resolve_profile",
65
+ ]
66
+
67
+ _FORMAT_CHOICE = click.Choice(["text", "json"])
68
+
69
+ _PROFILE_OPTION = click.option(
70
+ "--profile",
71
+ "profile",
72
+ default=None,
73
+ help="Profile to resolve. Overrides KATALYST_PROFILE and spec.activeProfile.",
74
+ )
75
+
76
+
77
+ def resolve_profile(explicit: str | None) -> str | None:
78
+ """Resolve ``--profile`` for a ``kata config`` subcommand (#222 §3).
79
+
80
+ Falls back to the root group's ``kata --profile``, which is where #222 moved the
81
+ flag so that ``kata config`` — the command whose entire job is reporting which
82
+ configuration is in effect — can be asked about a profile at all. The
83
+ subcommand's own flag still wins, being the more specific statement.
84
+
85
+ Reads the live Click context rather than taking it as a parameter so that every
86
+ subcommand needs one call and none of them needs a ``@click.pass_context``.
87
+ """
88
+ if explicit is not None:
89
+ return explicit
90
+ from ..app import root_profile
91
+
92
+ ctx = click.get_current_context(silent=True)
93
+ return root_profile(ctx) if ctx is not None else None
94
+
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # Composition
98
+ # ---------------------------------------------------------------------------
99
+ def _service() -> LayeredConfigService:
100
+ """Build the layered config service against the real filesystem."""
101
+ from katalyst_taxonomy.adapters.filesystem.config import FilesystemConfigAdapter
102
+
103
+ return LayeredConfigService(FilesystemConfigAdapter())
104
+
105
+
106
+ def _resolver() -> ConfigResolver:
107
+ """Build the (read-only) config resolver against the real filesystem."""
108
+ from katalyst_taxonomy.config.backends import config_resolver
109
+
110
+ return config_resolver()
111
+
112
+
113
+ def repository_root(backend: str | None = None) -> tuple[str | None, Path | None]:
114
+ """The repository whose copies of the layered files count, as ``(name, root)``.
115
+
116
+ Named by ``--backend``; otherwise the profile's only filesystem backend, else the
117
+ one the current directory is inside. With neither, ``(None, None)`` — config
118
+ commands work anywhere, and the files then layer the invocation directory.
119
+
120
+ Raises:
121
+ ValueError: If *backend* names no filesystem backend the profile reads.
122
+ """
123
+ from katalyst_taxonomy.config.targets import resolve_repository_for
124
+
125
+ if backend:
126
+ target = resolve_repository_for(backend, cwd=Path.cwd())
127
+ return target.name, target.root
128
+ try:
129
+ target = resolve_repository_for(None, cwd=Path.cwd())
130
+ except Exception: # noqa: BLE001 - no single repository: the invocation directory
131
+ return None, None
132
+ return target.name, target.root
133
+
134
+
135
+ def resolve_context(backend: str | None = None) -> ConfigContext:
136
+ """Resolve the config home and the repository the layered files are read from.
137
+
138
+ Never fails outside a repository: the configuration does not live in one.
139
+ """
140
+ return _service().context(explicit_path=repository_root(backend)[1])
141
+
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # Payload builders — shared with the MCP config_show tool
145
+ # ---------------------------------------------------------------------------
146
+ def _optional_str(value: Path | None) -> str | None:
147
+ return str(value) if value is not None else None
148
+
149
+
150
+ def build_path_payload(*, backend: str | None = None) -> dict[str, Any]:
151
+ """Build the ``kata config path`` payload.
152
+
153
+ Returns:
154
+ The active home (``home``: name, kind, root, the three directories and the
155
+ rule that chose it), the config file and whether it exists, the repository the command would work on (``repository``,
156
+ ``repoRoot`` — ``null`` when there is no single one), the working directory,
157
+ and a per-file table.
158
+ """
159
+ service = _service()
160
+ name, root = repository_root(backend)
161
+ ctx = service.context(explicit_path=root)
162
+ layers = service.layer_paths(ctx, specs=all_specs())
163
+
164
+ return {
165
+ "repository": name,
166
+ "repoRoot": _optional_str(root),
167
+ "repoRootFound": root is not None,
168
+ "workingDirectory": str(Path.cwd().resolve()),
169
+ "userConfigHome": str(ctx.user_config_home),
170
+ "home": ctx.home.to_dict() if ctx.home is not None else None,
171
+ "configFile": str(ctx.config_file),
172
+ "configExists": ctx.config_file.is_file(),
173
+ "files": [
174
+ {
175
+ "key": entry.key,
176
+ "strategy": entry.strategy.value,
177
+ "description": entry.description,
178
+ "repoPath": _optional_str(entry.repo_path),
179
+ "repoPresent": entry.repo_present,
180
+ "userPath": str(entry.user_path),
181
+ "userPresent": entry.user_present,
182
+ }
183
+ for entry in layers
184
+ ],
185
+ }
186
+
187
+
188
+ def build_show_payload(
189
+ *,
190
+ backend: str | None = None,
191
+ file: str = CONFIG_FILE.key,
192
+ key: str | None = None,
193
+ profile: str | None = None,
194
+ ) -> dict[str, Any]:
195
+ """Build the ``kata config show`` payload.
196
+
197
+ For a deep-merged file the merged mapping is spread at the top level — so
198
+ ``$.spec.outputFormat`` addresses the resolved config directly. The config file
199
+ reports ``configFile``; a layered file reports its ``repoConfig``/``userConfig``
200
+ paths. For a ``by-name`` file the merged stream is under ``documents``. For a text
201
+ file it is ``text``.
202
+
203
+ For the ``KatalystConfig`` document, ``spec`` is the declared view with
204
+ ``activeProfile`` set to the profile actually in force. ``profile`` names it.
205
+
206
+ Args:
207
+ backend: The repository whose copies of the layered files count.
208
+ file: Which file to show. Defaults to the ``KatalystConfig`` document.
209
+ key: Optional dotted key to narrow to.
210
+ profile: Explicit profile (the ``--profile`` flag).
211
+
212
+ Raises:
213
+ ValueError: If *file* is not a layered config file, or the configuration
214
+ is malformed.
215
+ UnknownProfileError: If the profile cannot be selected.
216
+ """
217
+ spec = spec_for(file, all_specs())
218
+ service = _service()
219
+ ctx = resolve_context(backend)
220
+
221
+ payload: dict[str, Any]
222
+ if spec.strategy is MergeStrategy.BY_NAME:
223
+ named = service.resolve_named(spec, ctx)
224
+ payload = {"documents": [dict(doc) for doc in named.documents]}
225
+ repo_path, user_path = named.repo_path, named.user_path
226
+ elif spec.strategy is MergeStrategy.CONCATENATE:
227
+ text = service.resolve_text(spec, ctx)
228
+ payload = {"text": text.text, "origins": list(text.origins)}
229
+ repo_path, user_path = text.repo_path, text.user_path
230
+ else:
231
+ merged = service.resolve_mapping(spec, ctx)
232
+ payload = dict(merged.data)
233
+ repo_path, user_path = merged.repo_path, merged.user_path
234
+ if spec.key == CONFIG_FILE.key:
235
+ # Inspecting an incomplete configuration must still show it — this is the
236
+ # command someone runs to find out what is missing. So a document that
237
+ # does not resolve yet reports why, instead of failing.
238
+ from katalyst_taxonomy.config.resolution import NoBackendConfiguredError
239
+
240
+ try:
241
+ payload.update(_resolver().resolve(ctx=ctx, profile=profile).profile_view())
242
+ payload["configured"] = True
243
+ except NoBackendConfiguredError as exc:
244
+ payload["configured"] = False
245
+ payload["reason"] = str(exc)
246
+ # Literal credentials are never shown; `${VAR}` references are, as written.
247
+ from katalyst_taxonomy.config.remote import redact_config_document
248
+
249
+ payload = redact_config_document(payload)
250
+ if key is not None:
251
+ value = lookup_dotted(payload, key)
252
+ payload = {
253
+ "key": key,
254
+ "value": None if value is MISSING else value,
255
+ "present": value is not MISSING,
256
+ }
257
+
258
+ payload["file"] = spec.key
259
+ payload.update(_file_paths(spec, ctx, repo_path, user_path))
260
+ return payload
261
+
262
+
263
+ def _file_paths(
264
+ spec: ConfigFileSpec,
265
+ ctx: ConfigContext,
266
+ repo_path: Path | None,
267
+ user_path: Path | None,
268
+ ) -> dict[str, Any]:
269
+ """Where *spec* was read from: ``configFile`` for config.yaml, both layers otherwise."""
270
+ if spec.key == CONFIG_FILE.key:
271
+ return {"configFile": str(ctx.config_file), "configExists": user_path is not None}
272
+ return {"repoConfig": _optional_str(repo_path), "userConfig": _optional_str(user_path)}
273
+
274
+
275
+ def build_explain_payload(
276
+ address: str,
277
+ *,
278
+ profile: str | None = None,
279
+ ) -> dict[str, Any]:
280
+ """Build the ``kata config explain`` payload (#214).
281
+
282
+ Delegates wholesale to
283
+ :func:`katalyst_taxonomy.services.routing_explain.explain_payload` rather than
284
+ assembling anything here, which is what lets the MCP ``config_explain`` tool
285
+ be byte-identical without ``mcp/`` importing ``cli/``.
286
+
287
+ Raises:
288
+ UnknownBackendError: If a named backend is undeclared.
289
+ UnknownProfileError: If the profile cannot be selected.
290
+ ValueError: If the configuration is malformed.
291
+ """
292
+ from katalyst_taxonomy.services.routing_explain import explain_payload
293
+
294
+ return explain_payload(address, base_path=Path.cwd(), profile=profile)
295
+
296
+
297
+ def build_permissions_payload(
298
+ *,
299
+ fqtn: str | None = None,
300
+ operation: str | None = None,
301
+ profile: str | None = None,
302
+ ) -> dict[str, Any]:
303
+ """Build the ``kata config permissions`` payload (#216).
304
+
305
+ Fail-closed resolution that cannot explain itself is undebuggable, and
306
+ most-specific-wins-then-deny-wins is not something anyone should have to
307
+ simulate in their head. This reports the *effective* access for the active
308
+ profile and **which grant produced it**, plus every declared grant with the file
309
+ that declared it.
310
+
311
+ Delegates the whole computation to
312
+ :meth:`katalyst_taxonomy.config.permissions.PermissionPolicy.report`, so the
313
+ report is produced by the same object the port gate enforces with. An
314
+ explanation resolved by a second code path is not an explanation.
315
+
316
+ Args:
317
+ fqtn: Optional target FQTN. Omitted, the report is store-wide.
318
+ operation: Optional ``r``/``w``/``x`` (or ``read``/``write``/``execute``) to
319
+ report a single bit's verdict under ``allowed``.
320
+ profile: Explicit profile (the ``--profile`` flag).
321
+
322
+ Raises:
323
+ ValueError: If *operation* is not a recognized operation.
324
+ UnknownProfileError: If the profile cannot be selected.
325
+ """
326
+ from katalyst_taxonomy.config.backends import load_permission_policy
327
+
328
+ policy = load_permission_policy(None, profile=profile)
329
+ from katalyst_taxonomy.services.permissions import report_target
330
+
331
+ target = report_target(None, fqtn or "", profile=profile)
332
+ payload = policy.report(target)
333
+ if operation is not None:
334
+ resolved = _operation(operation)
335
+ payload["operation"] = str(resolved)
336
+ payload["allowed"] = policy.allows(resolved, target)
337
+ return payload
338
+
339
+
340
+ def _operation(value: str): # noqa: ANN202 - Operation, imported lazily
341
+ """Parse the ``--op`` flag, accepting a single bit or the full word.
342
+
343
+ Raises:
344
+ ValueError: If *value* names no operation.
345
+ """
346
+ from katalyst_taxonomy.domain.permissions import Operation
347
+
348
+ aliases = {"r": Operation.READ, "w": Operation.WRITE, "x": Operation.EXECUTE}
349
+ lowered = value.strip().lower()
350
+ if lowered in aliases:
351
+ return aliases[lowered]
352
+ for operation in Operation:
353
+ if lowered == str(operation):
354
+ return operation
355
+ valid = ", ".join(f"{op.value} ({op.value[0]})" for op in Operation)
356
+ msg = f"Unknown operation {value!r}. Expected one of: {valid}."
357
+ raise ValueError(msg)
358
+
359
+
360
+ def build_sources_payload(
361
+ *,
362
+ backend: str | None = None,
363
+ file: str = CONFIG_FILE.key,
364
+ ) -> dict[str, Any]:
365
+ """Build the ``kata config sources`` payload.
366
+
367
+ ``sources`` maps every resolved dotted key to the absolute path of the file
368
+ that set it — for config.yaml, always the one config file. ``layers`` lists the
369
+ participating files strongest-first with their numeric priority.
370
+ """
371
+ spec = spec_for(file, all_specs())
372
+ service = _service()
373
+ ctx = resolve_context(backend)
374
+
375
+ if spec.strategy is MergeStrategy.BY_NAME:
376
+ named = service.resolve_named(spec, ctx)
377
+ provenance = named.provenance
378
+ presence = named.layers
379
+ elif spec.strategy is MergeStrategy.CONCATENATE:
380
+ text = service.resolve_text(spec, ctx)
381
+ provenance = {f"{index}": origin for index, origin in enumerate(text.origins)}
382
+ presence = text.layers
383
+ else:
384
+ merged = service.resolve_mapping(spec, ctx)
385
+ provenance = merged.provenance
386
+ presence = merged.layers
387
+
388
+ return {
389
+ "file": spec.key,
390
+ "repoRoot": str(ctx.repo_root),
391
+ "userConfigHome": str(ctx.user_config_home),
392
+ "sources": dict(sorted(provenance.items())),
393
+ "layers": [
394
+ {
395
+ "layer": entry.layer.label,
396
+ "priority": entry.layer.priority,
397
+ "path": _optional_str(entry.path),
398
+ "present": entry.present,
399
+ }
400
+ for entry in presence
401
+ ],
402
+ }
403
+
404
+
405
+ # ---------------------------------------------------------------------------
406
+ # Command group
407
+ # ---------------------------------------------------------------------------
408
+ @click.group("config")
409
+ @click.pass_context
410
+ def config(ctx: click.Context) -> None:
411
+ """Inspect and edit the Katalyst configuration.
412
+
413
+ There is one configuration file, config.yaml in the active home (kata --home or
414
+ KATALYST_HOME: 'default', a name under ~/.katalyst/homes, or an absolute path; see
415
+ 'kata config home'). Each profile in it declares
416
+ the backends it reads; a repository is a filesystem backend of a profile. Select a
417
+ profile with --profile or KATALYST_PROFILE; change them with 'kata config profile'.
418
+ """
419
+ # A development install needs a home chosen (Q1), except to manage homes.
420
+ if ctx.invoked_subcommand != "home":
421
+ from ..install import refuse_default_home_for_development
422
+
423
+ refuse_default_home_for_development()
424
+
425
+
426
+ def _output() -> OutputPort:
427
+ return ConsoleOutput()
428
+
429
+
430
+ def _fail(message: str) -> None:
431
+ raise click.ClickException(message)
432
+
433
+
434
+ _ORIGIN_LABELS = {"flag": "--home", "env": "KATALYST_HOME", "default": "no home selected"}
435
+
436
+
437
+ def _home_label(home: dict[str, Any]) -> str:
438
+ """``dev (~/.katalyst/homes/dev)``, a path home's path, or ``default``."""
439
+ name = str(home.get("name") or "default")
440
+ if home.get("kind") == "named" and home.get("root"):
441
+ return f"{name} ({home['root']})"
442
+ return name
443
+
444
+
445
+ @config.command("path")
446
+ @click.option(
447
+ "--backend",
448
+ default=None,
449
+ help="Repository (filesystem backend) whose copies of the layered files count.",
450
+ )
451
+ @click.option("--format", "fmt", type=_FORMAT_CHOICE, default="text")
452
+ def config_path(backend: str | None, fmt: str) -> None:
453
+ """Show where the config file is, and which repository a command would work on."""
454
+ out = _output()
455
+ try:
456
+ payload = build_path_payload(backend=backend)
457
+ except ValueError as exc:
458
+ _fail(str(exc))
459
+ return
460
+
461
+ if fmt == "json":
462
+ out.json_out(payload)
463
+ return
464
+
465
+ home: dict[str, Any] = payload["home"] or {}
466
+ out.key_value(
467
+ [
468
+ ("home", f"{_home_label(home)} ({_ORIGIN_LABELS.get(str(home.get('origin')), '-')})"),
469
+ (
470
+ "config file",
471
+ payload["configFile"] + ("" if payload["configExists"] else " (missing)"),
472
+ ),
473
+ ("config dir", payload["userConfigHome"]),
474
+ ("state dir", str(home.get("stateDir") or "-")),
475
+ ("cache dir", str(home.get("cacheDir") or "-")),
476
+ (
477
+ "repository",
478
+ f"{payload['repository']} ({payload['repoRoot']})"
479
+ if payload["repoRootFound"]
480
+ else "(none — pass --backend NAME)",
481
+ ),
482
+ ]
483
+ )
484
+ out.heading("Layered files")
485
+ out.table(
486
+ ["file", "strategy", "repository", "user"],
487
+ [
488
+ [
489
+ entry["key"],
490
+ entry["strategy"],
491
+ entry["repoPath"] if entry["repoPresent"] else "-",
492
+ entry["userPath"] if entry["userPresent"] else "-",
493
+ ]
494
+ for entry in payload["files"]
495
+ ],
496
+ )
497
+
498
+
499
+ @config.command("show")
500
+ @click.option(
501
+ "--backend",
502
+ default=None,
503
+ help="Repository (filesystem backend) whose copies of the layered files count.",
504
+ )
505
+ @click.option("--file", "file_key", default=CONFIG_FILE.key, help="Layered file to show.")
506
+ @click.option("--key", default=None, help="Dotted key to narrow to, e.g. spec.outputFormat.")
507
+ @_PROFILE_OPTION
508
+ @click.option("--format", "fmt", type=_FORMAT_CHOICE, default="text")
509
+ def config_show(
510
+ backend: str | None,
511
+ file_key: str,
512
+ key: str | None,
513
+ profile: str | None,
514
+ fmt: str,
515
+ ) -> None:
516
+ """Show the profile-resolved configuration."""
517
+ out = _output()
518
+ try:
519
+ payload = build_show_payload(
520
+ backend=backend,
521
+ file=file_key,
522
+ key=key,
523
+ profile=resolve_profile(profile),
524
+ )
525
+ except (UnknownProfileError, ValueError) as exc:
526
+ _fail(str(exc))
527
+ return
528
+
529
+ if fmt == "json":
530
+ out.json_out(payload)
531
+ return
532
+
533
+ import yaml
534
+
535
+ out.echo(yaml.safe_dump(payload, sort_keys=False, default_flow_style=False).rstrip())
536
+
537
+
538
+ @config.command("sources")
539
+ @click.option(
540
+ "--backend",
541
+ default=None,
542
+ help="Repository (filesystem backend) whose copies of the layered files count.",
543
+ )
544
+ @click.option("--file", "file_key", default=CONFIG_FILE.key, help="Layered file to inspect.")
545
+ @click.option("--format", "fmt", type=_FORMAT_CHOICE, default="text")
546
+ def config_sources(backend: str | None, file_key: str, fmt: str) -> None:
547
+ """Show which file each resolved key came from, in precedence order."""
548
+ out = _output()
549
+ try:
550
+ payload = build_sources_payload(backend=backend, file=file_key)
551
+ except ValueError as exc:
552
+ _fail(str(exc))
553
+ return
554
+
555
+ if fmt == "json":
556
+ out.json_out(payload)
557
+ return
558
+
559
+ sources: dict[str, str] = payload["sources"]
560
+ if not sources:
561
+ out.info(f"No configuration resolved for {payload['file']!r}.")
562
+ else:
563
+ out.table(["key", "source"], [[key, value] for key, value in sources.items()])
564
+
565
+ out.heading("Layers (strongest first)")
566
+ out.table(
567
+ ["layer", "priority", "path", "present"],
568
+ [
569
+ [
570
+ entry["layer"],
571
+ str(entry["priority"]),
572
+ entry["path"] or "-",
573
+ "yes" if entry["present"] else "no",
574
+ ]
575
+ for entry in payload["layers"]
576
+ ],
577
+ )
578
+
579
+
580
+ def all_specs() -> tuple[ConfigFileSpec, ...]:
581
+ """Every layered config file: the runtime's own and each installed plugin's."""
582
+ from katalyst_taxonomy.plugins.loader import plugin_context
583
+
584
+ return config_file_specs(plugin_context().config_files)
585
+
586
+
587
+ # ---------------------------------------------------------------------------
588
+ # Addressing
589
+ # ---------------------------------------------------------------------------
590
+ @config.command("explain")
591
+ @click.argument("address")
592
+ @_PROFILE_OPTION
593
+ @click.option("--format", "fmt", type=_FORMAT_CHOICE, default="text")
594
+ def config_explain(address: str, profile: str | None, fmt: str) -> None:
595
+ """Explain what ADDRESS names, and whether it is there.
596
+
597
+ ADDRESS may carry a ``#kind`` selector — ``filesystem:payments.backend#stack`` — for a
598
+ path two kinds both claim; without one, such a path fails naming the selectors.
599
+
600
+ ADDRESS is ``backend:fqtn``. Reports whether that backend is one the profile reads,
601
+ and whether it holds a node there and of what kind. An unprefixed address is refused
602
+ with the backends that do hold the name.
603
+
604
+ Pure resolution — it reads the repository's own documents to learn each node's
605
+ kind and wing, and never opens a connection to a remote store. An unknown
606
+ backend name fails and lists the declared ones; it is never a silent fallback.
607
+ """
608
+ out = _output()
609
+ try:
610
+ payload = build_explain_payload(address, profile=resolve_profile(profile))
611
+ except (UnknownProfileError, ValueError) as exc:
612
+ _fail(str(exc))
613
+ return
614
+
615
+ if fmt == "json":
616
+ out.json_out(payload)
617
+ return
618
+
619
+ out.key_value(
620
+ [
621
+ ("address", payload["address"]),
622
+ ("backend", payload["backend"]),
623
+ ("fqtn", payload["fqtn"]),
624
+ ("exists", "yes" if payload["exists"] else "no"),
625
+ ("kind", payload["kind"] or "(unknown)"),
626
+ ("tier", payload["tier"]),
627
+ ("profile reads", ", ".join(payload["backends"]) or "-"),
628
+ ("profile", payload["profile"] or "(none)"),
629
+ ]
630
+ )
631
+
632
+
633
+ # ---------------------------------------------------------------------------
634
+ # Permissions (#216)
635
+ # ---------------------------------------------------------------------------
636
+ @config.command("permissions")
637
+ @click.option("--fqtn", default=None, help="Target FQTN. Omitted, the report is store-wide.")
638
+ @click.option("--op", "operation", default=None, help="Operation to check: r, w, x.")
639
+ @_PROFILE_OPTION
640
+ @click.option("--format", "fmt", type=_FORMAT_CHOICE, default="text")
641
+ def config_permissions(
642
+ fqtn: str | None,
643
+ operation: str | None,
644
+ profile: str | None,
645
+ fmt: str,
646
+ ) -> None:
647
+ """Report the effective access for a target, and which backend grant decided.
648
+
649
+ Rules live on backends: the target's backend's grants decide, in every profile.
650
+ Resolution is most-specific-wins — any ``fqtn`` match outranks a ``kind`` or
651
+ ``wing`` match, and more literal dot-segments outrank fewer — with the more
652
+ restrictive grant winning at equal specificity. An unmatched target falls
653
+ through to the backend's ``permissions.default``, and a ``permissions`` block that
654
+ declares no ``default`` denies.
655
+
656
+ A backend with no ``permissions`` block reports ``rwx`` from no grant
657
+ (``decidedBy: unrestricted``): that is the back-compat guarantee, not an omission.
658
+ """
659
+ out = _output()
660
+ try:
661
+ payload = build_permissions_payload(
662
+ fqtn=fqtn,
663
+ operation=operation,
664
+ profile=resolve_profile(profile),
665
+ )
666
+ except (UnknownProfileError, ValueError) as exc:
667
+ _fail(str(exc))
668
+ return
669
+
670
+ if fmt == "json":
671
+ out.json_out(payload)
672
+ return
673
+
674
+ rows = [
675
+ ("profile", payload["profile"] or "(none)"),
676
+ ("fqtn", payload["fqtn"] or "(store-wide)"),
677
+ ("backend", payload["backend"] or "-"),
678
+ ("access", payload["access"]),
679
+ ("decided by", payload["decidedBy"]),
680
+ ("grant", payload["grant"] or "-"),
681
+ ("default", payload["default"] or "(none declared)"),
682
+ ("source", payload["source"] or "-"),
683
+ ]
684
+ if "operation" in payload:
685
+ rows.append((payload["operation"], "allowed" if payload["allowed"] else "DENIED"))
686
+ out.key_value(rows)
687
+
688
+ backends: list[dict[str, Any]] = payload["backendRules"]
689
+ if not backends:
690
+ out.info("No backend declares permission rules; every writable backend is rwx.")
691
+ return
692
+ out.heading("Backend grants")
693
+ out.table(
694
+ ["backend", "grant", "access", "match", "source"],
695
+ [
696
+ [
697
+ rules["backend"],
698
+ entry["name"] or "-",
699
+ entry["access"],
700
+ ", ".join(f"{key}={value}" for key, value in entry["match"].items()) or "*",
701
+ entry["source"] or "-",
702
+ ]
703
+ for rules in backends
704
+ for entry in rules["grants"]
705
+ ]
706
+ + [
707
+ [rules["backend"], "(default)", rules["default"], "*", rules["source"] or "-"]
708
+ for rules in backends
709
+ if rules["default"] is not None
710
+ ],
711
+ )
712
+
713
+
714
+ # ---------------------------------------------------------------------------
715
+ # Subcommands contributed by sibling modules
716
+ # ---------------------------------------------------------------------------
717
+ # `schemas` lives in its own module so that the command's name and payload
718
+ # builder stay in one place (see #218). Attaching here, at import time, keeps
719
+ # `kata config` the single discovery point for every subcommand.
720
+ from .config_schemas import attach as _attach_schemas # noqa: E402
721
+
722
+ _attach_schemas(config)
723
+
724
+ from .config_plugins import attach as _attach_plugins # noqa: E402
725
+
726
+ _attach_plugins(config)
727
+
728
+ # `filter` (#217) follows the same pattern: its own module, one line here. It
729
+ # reports whether a node is visible under the active profile's include/exclude
730
+ # clauses and which clause decided.
731
+ from .config_filters import attach as _attach_filters # noqa: E402
732
+
733
+ _attach_filters(config)
734
+
735
+ # `profile` — creating and changing profiles and their backends.
736
+ from .config_profile import attach as _attach_profile # noqa: E402
737
+
738
+ _attach_profile(config)
739
+
740
+ # `sync` and `status` — profiles sourced from elsewhere, fetched and pinned.
741
+ from .config_sources import attach as _attach_sources # noqa: E402
742
+
743
+ _attach_sources(config)
744
+
745
+ # `home` — the named homes `kata --home` selects (#257).
746
+ from .config_home import attach as _attach_home # noqa: E402
747
+
748
+ _attach_home(config)