katalyst-taxonomy-cli 2.0.1a1__tar.gz

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 (83) hide show
  1. katalyst_taxonomy_cli-2.0.1a1/.gitignore +123 -0
  2. katalyst_taxonomy_cli-2.0.1a1/PKG-INFO +12 -0
  3. katalyst_taxonomy_cli-2.0.1a1/pyproject.toml +31 -0
  4. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/__init__.py +14 -0
  5. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/__main__.py +12 -0
  6. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/_wiring.py +248 -0
  7. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/app.py +550 -0
  8. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/commands/__init__.py +1 -0
  9. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/commands/api.py +104 -0
  10. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/commands/backend.py +96 -0
  11. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/commands/bundle.py +106 -0
  12. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/commands/clone.py +68 -0
  13. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/commands/config.py +748 -0
  14. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/commands/config_filters.py +159 -0
  15. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/commands/config_home.py +179 -0
  16. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/commands/config_plugins.py +180 -0
  17. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/commands/config_profile.py +604 -0
  18. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/commands/config_schemas.py +257 -0
  19. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/commands/config_sources.py +141 -0
  20. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/commands/core.py +468 -0
  21. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/commands/create.py +145 -0
  22. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/commands/deps.py +48 -0
  23. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/commands/diff.py +63 -0
  24. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/commands/drafts.py +63 -0
  25. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/commands/export.py +186 -0
  26. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/commands/init.py +84 -0
  27. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/commands/kinds.py +302 -0
  28. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/commands/lifecycle.py +127 -0
  29. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/commands/lint.py +256 -0
  30. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/commands/plugin.py +148 -0
  31. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/commands/promote.py +95 -0
  32. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/commands/query.py +187 -0
  33. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/commands/relate.py +137 -0
  34. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/commands/relocate.py +129 -0
  35. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/commands/remove.py +95 -0
  36. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/commands/resolve.py +48 -0
  37. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/commands/rollback.py +51 -0
  38. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/commands/self_update.py +530 -0
  39. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/commands/show.py +33 -0
  40. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/commands/status.py +107 -0
  41. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/commands/sync.py +60 -0
  42. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/commands/update.py +112 -0
  43. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/commands/versions.py +54 -0
  44. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/context.py +145 -0
  45. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/discovery.py +157 -0
  46. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/fields.py +70 -0
  47. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/install.py +330 -0
  48. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/options.py +20 -0
  49. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/output.py +92 -0
  50. katalyst_taxonomy_cli-2.0.1a1/src/katalyst_taxonomy_cli/py.typed +0 -0
  51. katalyst_taxonomy_cli-2.0.1a1/tests/conftest.py +65 -0
  52. katalyst_taxonomy_cli-2.0.1a1/tests/test_add.py +251 -0
  53. katalyst_taxonomy_cli-2.0.1a1/tests/test_add_bundle.py +144 -0
  54. katalyst_taxonomy_cli-2.0.1a1/tests/test_api_command.py +48 -0
  55. katalyst_taxonomy_cli-2.0.1a1/tests/test_cli_version.py +30 -0
  56. katalyst_taxonomy_cli-2.0.1a1/tests/test_clone.py +219 -0
  57. katalyst_taxonomy_cli-2.0.1a1/tests/test_command_signatures.py +153 -0
  58. katalyst_taxonomy_cli-2.0.1a1/tests/test_config_command.py +445 -0
  59. katalyst_taxonomy_cli-2.0.1a1/tests/test_config_explain.py +476 -0
  60. katalyst_taxonomy_cli-2.0.1a1/tests/test_config_home.py +133 -0
  61. katalyst_taxonomy_cli-2.0.1a1/tests/test_config_plugins_command.py +399 -0
  62. katalyst_taxonomy_cli-2.0.1a1/tests/test_config_profiles.py +480 -0
  63. katalyst_taxonomy_cli-2.0.1a1/tests/test_config_schemas_command.py +305 -0
  64. katalyst_taxonomy_cli-2.0.1a1/tests/test_config_sources.py +157 -0
  65. katalyst_taxonomy_cli-2.0.1a1/tests/test_core_schema.py +95 -0
  66. katalyst_taxonomy_cli-2.0.1a1/tests/test_create.py +582 -0
  67. katalyst_taxonomy_cli-2.0.1a1/tests/test_create_typed_and_variants.py +203 -0
  68. katalyst_taxonomy_cli-2.0.1a1/tests/test_deps.py +181 -0
  69. katalyst_taxonomy_cli-2.0.1a1/tests/test_git_backend_cli.py +118 -0
  70. katalyst_taxonomy_cli-2.0.1a1/tests/test_github_backend_cli.py +140 -0
  71. katalyst_taxonomy_cli-2.0.1a1/tests/test_install.py +292 -0
  72. katalyst_taxonomy_cli-2.0.1a1/tests/test_kinds.py +98 -0
  73. katalyst_taxonomy_cli-2.0.1a1/tests/test_kinds_plugin_settings.py +70 -0
  74. katalyst_taxonomy_cli-2.0.1a1/tests/test_lint_output.py +85 -0
  75. katalyst_taxonomy_cli-2.0.1a1/tests/test_lint_strict.py +127 -0
  76. katalyst_taxonomy_cli-2.0.1a1/tests/test_output.py +105 -0
  77. katalyst_taxonomy_cli-2.0.1a1/tests/test_plugin_command.py +113 -0
  78. katalyst_taxonomy_cli-2.0.1a1/tests/test_plugin_gating.py +204 -0
  79. katalyst_taxonomy_cli-2.0.1a1/tests/test_relate_update.py +254 -0
  80. katalyst_taxonomy_cli-2.0.1a1/tests/test_relocate.py +162 -0
  81. katalyst_taxonomy_cli-2.0.1a1/tests/test_remove.py +137 -0
  82. katalyst_taxonomy_cli-2.0.1a1/tests/test_repository_targets.py +202 -0
  83. katalyst_taxonomy_cli-2.0.1a1/tests/test_self_update.py +452 -0
@@ -0,0 +1,123 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ # Not a Python build directory: Bard's SPA keeps shared helpers (utils.ts, download.ts) in
19
+ # src/lib, and ignoring it left a fresh clone unable to build or test the SPA.
20
+ !plugins/bard/web/src/lib/
21
+ lib64/
22
+ parts/
23
+ sdist/
24
+ var/
25
+ wheels/
26
+ *.egg-info/
27
+ .installed.cfg
28
+ *.egg
29
+
30
+ # PyInstaller
31
+ *.manifest
32
+ *.spec
33
+
34
+ # Installer logs
35
+ pip-log.txt
36
+ pip-delete-this-directory.txt
37
+
38
+ # Unit test / coverage reports
39
+ htmlcov/
40
+ .tox/
41
+ .nox/
42
+ .coverage
43
+ .coverage.*
44
+ .cache
45
+ nosetests.xml
46
+ coverage.xml
47
+ *.cover
48
+ *.py,cover
49
+ .hypothesis/
50
+ .pytest_cache/
51
+
52
+ # Translations
53
+ *.mo
54
+ *.pot
55
+
56
+ # Environments
57
+ .env
58
+ .venv
59
+ env/
60
+ venv/
61
+ ENV/
62
+ env.bak/
63
+ venv.bak/
64
+
65
+ # Spyder project settings
66
+ .spyderproject
67
+ .spyproject
68
+
69
+ # Rope project settings
70
+ .ropeproject
71
+
72
+ # mkdocs documentation
73
+ /site
74
+
75
+ # mypy / pyright
76
+ .mypy_cache/
77
+ .dmypy.json
78
+ dmypy.json
79
+ .pyright/
80
+
81
+ # Ruff
82
+ .ruff_cache/
83
+
84
+ # IDE
85
+ .idea/
86
+ .vscode/
87
+ *.swp
88
+ *.swo
89
+ *~
90
+
91
+ # OS
92
+ .DS_Store
93
+ Thumbs.db
94
+
95
+ # Local configuration
96
+ *.local
97
+
98
+ # Browser/Playwright state files
99
+ browser-state-*.json
100
+
101
+ # Agent harness scratch (run exhaust, never the design bundle)
102
+ .opencode/sessions/
103
+ .opencode/analysis/
104
+ .opencode/package-lock.json
105
+
106
+ # Build artifacts
107
+ site/
108
+ stack-tests/cucumber-report/
109
+ stack-tests/test-results/
110
+
111
+ # Generated artifacts (rebuild with `just generate-schemas`)
112
+ taxonomy/src/katalyst_taxonomy/engine/generated/
113
+ .worktrees/
114
+ .plan-74-75-76-77.md
115
+ .HANDOFF-scaffolding-wiring.md
116
+ .HANDOFF-2-cicd-migration.md
117
+ .HANDOFF-action-execution-fixes.md
118
+ .HANDOFF-bdd-coverage.md
119
+ .HANDOFF-*.md
120
+ docs/superpowers/
121
+
122
+ # Node dependencies — installed, never committed
123
+ node_modules/
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.5
2
+ Name: katalyst-taxonomy-cli
3
+ Version: 2.0.1a1
4
+ Summary: CLI for katalyst-taxonomy — the kata command
5
+ Author: eSimplicity
6
+ License: Proprietary
7
+ Requires-Python: >=3.12
8
+ Requires-Dist: click>=8.1
9
+ Requires-Dist: katalyst-core<1,>=0.1
10
+ Requires-Dist: katalyst-engine<6,>=5
11
+ Requires-Dist: katalyst-taxonomy-mcp<5,>=4
12
+ Requires-Dist: katalyst-taxonomy<6,>=5
@@ -0,0 +1,31 @@
1
+ [project]
2
+ name = "katalyst-taxonomy-cli"
3
+ version = "2.0.1a1"
4
+ description = "CLI for katalyst-taxonomy — the kata command"
5
+ license = { text = "Proprietary" }
6
+ requires-python = ">=3.12"
7
+ authors = [
8
+ { name = "eSimplicity" }
9
+ ]
10
+ dependencies = [
11
+ # Bounded: unbounded, this resolved against the published katalyst-taxonomy 3.x and
12
+ # katalyst-taxonomy-mcp 3.2.0, which predate the config-rooted runtime the CLI is.
13
+ "katalyst-taxonomy>=5,<6",
14
+ "katalyst-taxonomy-mcp>=4,<5",
15
+ "katalyst-engine>=5,<6",
16
+ "click>=8.1",
17
+ "katalyst-core>=0.1,<1",
18
+ ]
19
+
20
+ [project.scripts]
21
+ kata = "katalyst_taxonomy_cli.__main__:main"
22
+
23
+ [build-system]
24
+ requires = ["hatchling"]
25
+ build-backend = "hatchling.build"
26
+
27
+ [tool.hatch.build.targets.wheel]
28
+ packages = ["src/katalyst_taxonomy_cli"]
29
+
30
+ [tool.hatch.build.targets.wheel.sources]
31
+ "src" = ""
@@ -0,0 +1,14 @@
1
+ """Katalyst CLI package."""
2
+
3
+ from importlib.metadata import PackageNotFoundError
4
+ from importlib.metadata import version as _dist_version
5
+
6
+ #: This package's own distribution: the one ``kata`` runs from.
7
+ DISTRIBUTION = "katalyst-taxonomy-cli"
8
+
9
+ try:
10
+ __version__ = _dist_version(DISTRIBUTION)
11
+ except PackageNotFoundError: # pragma: no cover - source tree without an install
12
+ __version__ = "0.0.0+unknown"
13
+
14
+ __all__ = ["DISTRIBUTION", "__version__"]
@@ -0,0 +1,12 @@
1
+ """Katalyst CLI — entry point for the `kata` command."""
2
+
3
+ from .app import cli
4
+
5
+
6
+ def main() -> None:
7
+ """Entry point registered as `kata` script."""
8
+ cli()
9
+
10
+
11
+ if __name__ == "__main__":
12
+ main()
@@ -0,0 +1,248 @@
1
+ """Internal wiring — factory functions for service instantiation.
2
+
3
+ This is the CLI's composition root for services that need adapter wiring.
4
+ Commands should never import adapters directly — they get services from here.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING
12
+
13
+ import click
14
+
15
+ if TYPE_CHECKING: # pragma: no cover - typing only
16
+ from katalyst_taxonomy.config.backends import BackendRegistry
17
+ from katalyst_taxonomy.ports.backend import PromotableBackend, TaxonomyBackend
18
+
19
+ from .context import CliContext
20
+
21
+
22
+ def create_bundle_service(): # noqa: ANN201
23
+ """Wire up BundleService with the concrete bundle sources."""
24
+ from katalyst_taxonomy.services.bundle_service import BundleService
25
+ from katalyst_taxonomy.sources.bundle_sources import default_bundle_sources
26
+
27
+ return BundleService(sources=list(default_bundle_sources()))
28
+
29
+
30
+ def create_plugin_install_service(): # noqa: ANN201
31
+ """Wire up PluginInstallService, recording in the user config file."""
32
+ from katalyst_taxonomy.services.plugin_install import plugin_install_service
33
+
34
+ return plugin_install_service()
35
+
36
+
37
+ def create_sync_service(name: str, root: Path, *, profile: str | None = None): # noqa: ANN201
38
+ """Wire up SyncService with concrete adapters.
39
+
40
+ The source is one repository — the filesystem backend *name*, rooted at *root* —
41
+ because sync's job is "push what is on disk". It comes from the registry, so it is
42
+ named (the nodes it syncs carry their real backend) and subject to the active
43
+ profile's ``r`` bit (#216). Targets come from the registry and are gated on ``w``
44
+ by construction.
45
+ """
46
+ from katalyst_taxonomy.adapters.databricks.sync import DatabricksAppender
47
+ from katalyst_taxonomy.config.backends import load_backends
48
+ from katalyst_taxonomy.services.sync import SyncService
49
+
50
+ registry = load_backends(root, profile=profile)
51
+ return SyncService(
52
+ source=registry.get(name),
53
+ registry=registry,
54
+ base_path=root,
55
+ appenders=(DatabricksAppender(),),
56
+ )
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Backend resolution
61
+ # ---------------------------------------------------------------------------
62
+ @dataclass(frozen=True, slots=True)
63
+ class ResolvedBackend:
64
+ """The one backend a command operates on.
65
+
66
+ Attributes:
67
+ registry: The registry, so a command can still address a second backend.
68
+ name: The backend the command named.
69
+ backend: The constructed backend.
70
+ fqtn: The address with its ``backend:`` prefix and ``#kind`` selector stripped, or
71
+ ``""`` when the command supplied no address. This is what a command passes
72
+ downstream.
73
+ selector: The ``#kind`` narrowing, or ``None``.
74
+ """
75
+
76
+ registry: BackendRegistry
77
+ name: str
78
+ backend: TaxonomyBackend
79
+ fqtn: str = ""
80
+ selector: str | None = None
81
+
82
+ @property
83
+ def address(self) -> str:
84
+ """The FQTN with its ``#kind`` selector, as the services resolve it."""
85
+ return f"{self.fqtn}#{self.selector}" if self.selector else self.fqtn
86
+
87
+
88
+ @dataclass(frozen=True, slots=True)
89
+ class ResolvedRead:
90
+ """The backends a listing reads, and the address it was narrowed to.
91
+
92
+ Attributes:
93
+ registry: The registry.
94
+ sources: ``(name, backend)`` pairs — one when the command named a backend, otherwise
95
+ every backend the active profile lists.
96
+ fqtn: The address's FQTN with its prefix and selector stripped, or ``""``.
97
+ selector: The ``#kind`` narrowing, or ``None``.
98
+ """
99
+
100
+ registry: BackendRegistry
101
+ sources: tuple[tuple[str, TaxonomyBackend], ...]
102
+ fqtn: str = ""
103
+ selector: str | None = None
104
+
105
+
106
+ def load_registry(ctx: CliContext) -> BackendRegistry:
107
+ """Load the backend registry for *ctx*, honoring the active profile."""
108
+ from katalyst_taxonomy.config.backends import load_backends
109
+
110
+ return load_backends(ctx.cwd, profile=ctx.profile)
111
+
112
+
113
+ def _registry(ctx: CliContext) -> BackendRegistry:
114
+ try:
115
+ return load_registry(ctx)
116
+ except Exception as exc: # noqa: BLE001 - config errors are user errors, not tracebacks
117
+ raise click.ClickException(str(exc)) from exc
118
+
119
+
120
+ def _named(registry: BackendRegistry, target: str | None, address: str) -> tuple[str | None, str]:
121
+ """The backend named by *address* or *target*, and the bare FQTN.
122
+
123
+ A prefix and ``--backend`` that disagree is an error rather than one silently
124
+ winning: the command was told two different stores.
125
+ """
126
+ if address:
127
+ parsed = registry.address(address)
128
+ if target and target != parsed.backend:
129
+ raise click.ClickException(
130
+ f"{address!r} names backend {parsed.backend!r} but --backend is {target!r}."
131
+ )
132
+ return parsed.backend, parsed.fqtn
133
+ if target:
134
+ if target not in registry.profile_backends():
135
+ from katalyst_taxonomy.config.routing import UnknownBackendError
136
+
137
+ raise UnknownBackendError(target, registry.profile_backends())
138
+ return target, ""
139
+ return None, ""
140
+
141
+
142
+ def resolve_backend(
143
+ ctx: CliContext,
144
+ target: str | None = None,
145
+ *,
146
+ address: str | None = None,
147
+ ) -> ResolvedBackend:
148
+ """The single backend a command writes to or operates on. It must be named.
149
+
150
+ Named by the address's ``backend:`` prefix, or by ``--backend``. Neither is an error
151
+ listing the profile's backends: there is no default, so nothing is chosen for the
152
+ caller.
153
+
154
+ Raises:
155
+ click.ClickException: If the configuration is invalid, nothing names a backend, the
156
+ address is unprefixed, or the backend named is not one the profile reads.
157
+ """
158
+ from katalyst_taxonomy.config.routing import BackendAddressError
159
+ from katalyst_taxonomy.engine.qualification import split_kind_selector
160
+
161
+ registry = _registry(ctx)
162
+ routable, selector = split_kind_selector(address or "")
163
+ try:
164
+ name, fqtn = _named(registry, target, routable)
165
+ if name is None:
166
+ reads = ", ".join(registry.profile_backends())
167
+ raise click.ClickException(
168
+ f"Name the backend to use with --backend. This profile reads: {reads}."
169
+ )
170
+ backend = registry.get(name)
171
+ except BackendAddressError as exc:
172
+ raise click.ClickException(str(exc)) from exc
173
+ return ResolvedBackend(
174
+ registry=registry, name=name, backend=backend, fqtn=fqtn, selector=selector
175
+ )
176
+
177
+
178
+ def resolve_read(
179
+ ctx: CliContext,
180
+ target: str | None = None,
181
+ *,
182
+ address: str | None = None,
183
+ ) -> ResolvedRead:
184
+ """The backends a listing reads: the one named, or all of the profile's.
185
+
186
+ An address still has to carry a prefix — an unprefixed name is an error, not a search
187
+ across backends.
188
+
189
+ Raises:
190
+ click.ClickException: If the configuration is invalid, the address is unprefixed,
191
+ or a named backend is unknown or unavailable.
192
+ """
193
+ from katalyst_taxonomy.config.routing import BackendAddressError
194
+ from katalyst_taxonomy.engine.qualification import split_kind_selector
195
+
196
+ registry = _registry(ctx)
197
+ routable, selector = split_kind_selector(address or "")
198
+ try:
199
+ name, fqtn = _named(registry, target, routable)
200
+ sources = registry.read_set(name)
201
+ except BackendAddressError as exc:
202
+ raise click.ClickException(str(exc)) from exc
203
+ return ResolvedRead(registry=registry, sources=sources, fqtn=fqtn, selector=selector)
204
+
205
+
206
+ def resolve_promotable_backend(
207
+ ctx: CliContext,
208
+ target: str | None = None,
209
+ *,
210
+ address: str | None = None,
211
+ capability: str = "promotion workflows",
212
+ operation: str | None = None,
213
+ ) -> tuple[str, PromotableBackend, str]:
214
+ """Resolve a backend that must satisfy :class:`PromotableBackend`.
215
+
216
+ A separate function rather than a ``requires=`` parameter on
217
+ :func:`resolve_backend` so the return type is *narrowed* — the five promotion
218
+ commands then call ``backend.promote_node`` and friends with no ``isinstance``
219
+ dance and no loss of type information, which a generic ``type[Any]`` guard
220
+ would have thrown away.
221
+
222
+ Args:
223
+ ctx: The CLI context.
224
+ target: Explicit ``--backend`` name.
225
+ address: A ``backend:fqtn`` the command operates on. Its prefix names the backend.
226
+ capability: Operation name used in the error message.
227
+ operation: When the command *changes* the backend (``promote``, ``rollback``),
228
+ its name — a read-only backend is then refused with ``ReadOnlyBackendError``
229
+ rather than "does not support promotion workflows", which is true but not
230
+ the reason.
231
+
232
+ Returns:
233
+ The resolved name, the narrowed backend, and the bare FQTN (``""`` without one).
234
+
235
+ Raises:
236
+ SystemExit: With status 1 if the backend does not support *capability*.
237
+ """
238
+ from katalyst_taxonomy.ports.backend import PromotableBackend as _Promotable
239
+
240
+ resolved = resolve_backend(ctx, target, address=address)
241
+ if operation is not None:
242
+ from katalyst_taxonomy.ports.backend import require_writable
243
+
244
+ require_writable(resolved.backend, operation, target=resolved.fqtn)
245
+ if not isinstance(resolved.backend, _Promotable):
246
+ ctx.output.echo(f"Error: Backend '{resolved.name}' does not support {capability}.")
247
+ raise SystemExit(1)
248
+ return resolved.name, resolved.backend, resolved.fqtn