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.
- katalyst_taxonomy_cli/__init__.py +14 -0
- katalyst_taxonomy_cli/__main__.py +12 -0
- katalyst_taxonomy_cli/_wiring.py +248 -0
- katalyst_taxonomy_cli/app.py +550 -0
- katalyst_taxonomy_cli/commands/__init__.py +1 -0
- katalyst_taxonomy_cli/commands/api.py +104 -0
- katalyst_taxonomy_cli/commands/backend.py +96 -0
- katalyst_taxonomy_cli/commands/bundle.py +106 -0
- katalyst_taxonomy_cli/commands/clone.py +68 -0
- katalyst_taxonomy_cli/commands/config.py +748 -0
- katalyst_taxonomy_cli/commands/config_filters.py +159 -0
- katalyst_taxonomy_cli/commands/config_home.py +179 -0
- katalyst_taxonomy_cli/commands/config_plugins.py +180 -0
- katalyst_taxonomy_cli/commands/config_profile.py +604 -0
- katalyst_taxonomy_cli/commands/config_schemas.py +257 -0
- katalyst_taxonomy_cli/commands/config_sources.py +141 -0
- katalyst_taxonomy_cli/commands/core.py +468 -0
- katalyst_taxonomy_cli/commands/create.py +145 -0
- katalyst_taxonomy_cli/commands/deps.py +48 -0
- katalyst_taxonomy_cli/commands/diff.py +63 -0
- katalyst_taxonomy_cli/commands/drafts.py +63 -0
- katalyst_taxonomy_cli/commands/export.py +186 -0
- katalyst_taxonomy_cli/commands/init.py +84 -0
- katalyst_taxonomy_cli/commands/kinds.py +302 -0
- katalyst_taxonomy_cli/commands/lifecycle.py +127 -0
- katalyst_taxonomy_cli/commands/lint.py +256 -0
- katalyst_taxonomy_cli/commands/plugin.py +148 -0
- katalyst_taxonomy_cli/commands/promote.py +95 -0
- katalyst_taxonomy_cli/commands/query.py +187 -0
- katalyst_taxonomy_cli/commands/relate.py +137 -0
- katalyst_taxonomy_cli/commands/relocate.py +129 -0
- katalyst_taxonomy_cli/commands/remove.py +95 -0
- katalyst_taxonomy_cli/commands/resolve.py +48 -0
- katalyst_taxonomy_cli/commands/rollback.py +51 -0
- katalyst_taxonomy_cli/commands/self_update.py +530 -0
- katalyst_taxonomy_cli/commands/show.py +33 -0
- katalyst_taxonomy_cli/commands/status.py +107 -0
- katalyst_taxonomy_cli/commands/sync.py +60 -0
- katalyst_taxonomy_cli/commands/update.py +112 -0
- katalyst_taxonomy_cli/commands/versions.py +54 -0
- katalyst_taxonomy_cli/context.py +145 -0
- katalyst_taxonomy_cli/discovery.py +157 -0
- katalyst_taxonomy_cli/fields.py +70 -0
- katalyst_taxonomy_cli/install.py +330 -0
- katalyst_taxonomy_cli/options.py +20 -0
- katalyst_taxonomy_cli/output.py +92 -0
- katalyst_taxonomy_cli/py.typed +0 -0
- katalyst_taxonomy_cli-2.0.1a1.dist-info/METADATA +12 -0
- katalyst_taxonomy_cli-2.0.1a1.dist-info/RECORD +51 -0
- katalyst_taxonomy_cli-2.0.1a1.dist-info/WHEEL +4 -0
- katalyst_taxonomy_cli-2.0.1a1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""``kata config filter`` — is this node visible, and which clause decided (#217).
|
|
2
|
+
|
|
3
|
+
Include/exclude resolution is invisible by nature: nothing in an FQTN says which
|
|
4
|
+
clause claimed it, and "``exclude`` wins over every ``include``" is not something
|
|
5
|
+
anyone should have to simulate in their head against a config file. This command is
|
|
6
|
+
the inspection surface, and it answers from the *same*
|
|
7
|
+
:class:`~katalyst_taxonomy.config.filters.FilterPolicy` object the read paths
|
|
8
|
+
narrow with — a verdict resolved by a second code path is not an explanation of
|
|
9
|
+
the first.
|
|
10
|
+
|
|
11
|
+
Deliberately a **separate module** from :mod:`.config`, following the pattern
|
|
12
|
+
``config_schemas`` and ``config_plugins`` established: it is added to the group by
|
|
13
|
+
one line there, so this command lands without touching the file that owns
|
|
14
|
+
``path``/``show``/``sources``/``profiles``/``use``/``explain``/``permissions``.
|
|
15
|
+
|
|
16
|
+
The JSON shape is the contract in
|
|
17
|
+
``stack-tests/features/cli/35_kata_config_filters.feature``. Extend it additively.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import click
|
|
26
|
+
from katalyst_taxonomy.config.profiles import UnknownProfileError
|
|
27
|
+
|
|
28
|
+
from ..output import ConsoleOutput, OutputPort
|
|
29
|
+
from .config import resolve_profile
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"attach",
|
|
33
|
+
"build_filter_payload",
|
|
34
|
+
"filter_command",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
_FORMAT_CHOICE = click.Choice(["text", "json"])
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def build_filter_payload(
|
|
41
|
+
fqtn: str = "",
|
|
42
|
+
*,
|
|
43
|
+
profile: str | None = None,
|
|
44
|
+
) -> dict[str, Any]:
|
|
45
|
+
"""Build the ``kata config filter`` payload.
|
|
46
|
+
|
|
47
|
+
Delegates wholesale to
|
|
48
|
+
:func:`katalyst_taxonomy.services.filters.filter_payload` rather than
|
|
49
|
+
assembling anything here, which is what lets the MCP ``config_filter`` tool be
|
|
50
|
+
byte-identical without ``mcp/`` importing ``cli/``.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
UnknownProfileError: If the profile cannot be selected.
|
|
54
|
+
ValueError: If the configuration is malformed, if ``filters.include`` was
|
|
55
|
+
declared as an empty list, or if a clause declares ``match.backend``.
|
|
56
|
+
"""
|
|
57
|
+
from katalyst_taxonomy.services.filters import filter_payload
|
|
58
|
+
|
|
59
|
+
return filter_payload(fqtn, base_path=Path.cwd(), profile=profile)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _output() -> OutputPort:
|
|
63
|
+
return ConsoleOutput()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@click.command("filter")
|
|
67
|
+
@click.option(
|
|
68
|
+
"--fqtn",
|
|
69
|
+
default=None,
|
|
70
|
+
help="Node FQTN to decide. Omitted, only the declared clauses are listed.",
|
|
71
|
+
)
|
|
72
|
+
@click.option(
|
|
73
|
+
"--profile",
|
|
74
|
+
"profile",
|
|
75
|
+
default=None,
|
|
76
|
+
help="Profile to resolve. Overrides KATALYST_PROFILE and spec.activeProfile.",
|
|
77
|
+
)
|
|
78
|
+
@click.option("--format", "fmt", type=_FORMAT_CHOICE, default="text")
|
|
79
|
+
def filter_command(
|
|
80
|
+
fqtn: str | None,
|
|
81
|
+
profile: str | None,
|
|
82
|
+
fmt: str,
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Report whether an FQTN is visible under its backend's filters, and why.
|
|
85
|
+
|
|
86
|
+
Filters live on backends and apply in every profile that reads them. Resolution is: criteria AND within a clause, clauses OR across a list, and
|
|
87
|
+
``exclude`` always beats ``include``. An absent ``include`` list admits
|
|
88
|
+
everything; ``include: []`` is refused outright, because an empty list cannot
|
|
89
|
+
be told apart from an omitted one.
|
|
90
|
+
|
|
91
|
+
A backend with no ``filters`` block reports every node as included with a null
|
|
92
|
+
clause and ``clauseType: unfiltered`` — that is the back-compat guarantee, not
|
|
93
|
+
an omission.
|
|
94
|
+
|
|
95
|
+
Pure resolution: it reads the repository's own documents to learn each node's
|
|
96
|
+
kind, wing and labels, and never opens a connection to a remote store. An FQTN
|
|
97
|
+
with no node behind it still resolves — the criteria that need facts simply
|
|
98
|
+
cannot match, which is fail-closed.
|
|
99
|
+
"""
|
|
100
|
+
out = _output()
|
|
101
|
+
try:
|
|
102
|
+
payload = build_filter_payload(fqtn or "", profile=resolve_profile(profile))
|
|
103
|
+
except (UnknownProfileError, ValueError) as exc:
|
|
104
|
+
raise click.ClickException(str(exc)) from exc
|
|
105
|
+
|
|
106
|
+
if fmt == "json":
|
|
107
|
+
out.json_out(payload)
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
_render_text(out, payload, asked=bool(fqtn))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _render_text(out: OutputPort, payload: dict[str, Any], *, asked: bool) -> None:
|
|
114
|
+
"""Render the human view: the verdict, then every declared clause."""
|
|
115
|
+
if asked:
|
|
116
|
+
out.key_value(
|
|
117
|
+
[
|
|
118
|
+
("fqtn", payload["fqtn"]),
|
|
119
|
+
("backend", payload["backend"] or "-"),
|
|
120
|
+
("visible", "yes" if payload["included"] else "NO"),
|
|
121
|
+
("decided by", payload["clauseType"]),
|
|
122
|
+
("clause", payload["clause"] or "-"),
|
|
123
|
+
("matched on", payload["matchedOn"] or "-"),
|
|
124
|
+
("profile", payload["profile"] or "(none)"),
|
|
125
|
+
("source", payload["source"] or "-"),
|
|
126
|
+
]
|
|
127
|
+
)
|
|
128
|
+
else:
|
|
129
|
+
out.key_value([("profile", payload["profile"] or "(none)")])
|
|
130
|
+
|
|
131
|
+
clauses: list[dict[str, Any]] = payload["backendClauses"]
|
|
132
|
+
if not clauses:
|
|
133
|
+
out.info("No backend declares filter clauses — every node is visible.")
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
out.heading("Backend clauses")
|
|
137
|
+
out.table(
|
|
138
|
+
["backend", "type", "clause", "match", "source"],
|
|
139
|
+
[
|
|
140
|
+
[
|
|
141
|
+
str(entry["backend"]),
|
|
142
|
+
str(entry["type"]),
|
|
143
|
+
str(entry["name"] or "-"),
|
|
144
|
+
", ".join(f"{key}={value}" for key, value in entry["match"].items()) or "*",
|
|
145
|
+
str(entry["source"] or "-"),
|
|
146
|
+
]
|
|
147
|
+
for entry in clauses
|
|
148
|
+
],
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def attach(group: click.Group) -> None:
|
|
153
|
+
"""Add ``filter`` to the ``kata config`` group.
|
|
154
|
+
|
|
155
|
+
A function rather than a bare ``add_command`` call so registration is one line
|
|
156
|
+
in ``commands/config.py`` and this module stays the only place that knows the
|
|
157
|
+
command's name.
|
|
158
|
+
"""
|
|
159
|
+
group.add_command(filter_command)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""``kata config home`` — list, create and remove named Katalyst homes (#257).
|
|
2
|
+
|
|
3
|
+
A home is the config, state and cache a process uses (:mod:`katalyst_taxonomy.domain.homes`).
|
|
4
|
+
``kata --home NAME`` or ``KATALYST_HOME=NAME`` selects one; a named home must exist first::
|
|
5
|
+
|
|
6
|
+
kata config home list
|
|
7
|
+
kata config home create NAME [--from HOME]
|
|
8
|
+
kata config home rm NAME [--yes]
|
|
9
|
+
|
|
10
|
+
Path homes (an absolute ``KATALYST_HOME``, for tests and CI) are never listed: they are
|
|
11
|
+
wherever their caller put them.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import click
|
|
22
|
+
from katalyst_taxonomy.adapters.filesystem.homes import (
|
|
23
|
+
create_home,
|
|
24
|
+
home_for,
|
|
25
|
+
named_homes,
|
|
26
|
+
)
|
|
27
|
+
from katalyst_taxonomy.domain.homes import (
|
|
28
|
+
DEFAULT_HOME,
|
|
29
|
+
Home,
|
|
30
|
+
HomeError,
|
|
31
|
+
named_home,
|
|
32
|
+
resolve_home,
|
|
33
|
+
)
|
|
34
|
+
from katalyst_taxonomy.domain.paths import CONFIG_FILE_NAME
|
|
35
|
+
|
|
36
|
+
from ..output import ConsoleOutput
|
|
37
|
+
|
|
38
|
+
__all__ = ["attach", "home"]
|
|
39
|
+
|
|
40
|
+
_FORMAT_CHOICE = click.Choice(["text", "json"])
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
#: The running API's state record in a home's state directory (one API per home), as
|
|
44
|
+
#: ``katalyst_taxonomy_mcp.rest.state`` writes it.
|
|
45
|
+
_API_RECORD = "server.json"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _running_api(home: Home) -> dict[str, Any] | None:
|
|
49
|
+
"""The API whose state record is in *home*, when its process is alive."""
|
|
50
|
+
try:
|
|
51
|
+
record = json.loads((home.state_dir / _API_RECORD).read_text(encoding="utf-8"))
|
|
52
|
+
pid, host, port = int(record["pid"]), str(record["host"]), int(record["port"])
|
|
53
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
54
|
+
return None
|
|
55
|
+
try:
|
|
56
|
+
os.kill(pid, 0)
|
|
57
|
+
except OSError:
|
|
58
|
+
return None
|
|
59
|
+
return {"pid": pid, "url": f"http://{host}:{port}"}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _same(a: Home, b: Home) -> bool:
|
|
63
|
+
return a.kind == b.kind and a.root == b.root
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def build_list_payload() -> dict[str, Any]:
|
|
67
|
+
"""Every listed home: the default one, then ``~/.katalyst/homes/*``."""
|
|
68
|
+
active = resolve_home()
|
|
69
|
+
homes = [home_for(DEFAULT_HOME), *named_homes()]
|
|
70
|
+
return {
|
|
71
|
+
"active": active.to_dict(),
|
|
72
|
+
"homes": [
|
|
73
|
+
{
|
|
74
|
+
"name": home.name,
|
|
75
|
+
"kind": home.kind.value,
|
|
76
|
+
"root": None if home.root is None else str(home.root),
|
|
77
|
+
"active": _same(home, active),
|
|
78
|
+
"api": _running_api(home),
|
|
79
|
+
}
|
|
80
|
+
for home in homes
|
|
81
|
+
],
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@click.group("home")
|
|
86
|
+
def home() -> None:
|
|
87
|
+
"""List, create and remove named homes (~/.katalyst/homes/NAME).
|
|
88
|
+
|
|
89
|
+
Select one with 'kata --home NAME' or KATALYST_HOME=NAME. 'default' is the XDG
|
|
90
|
+
locations (~/.config/katalyst, ~/.local/state/katalyst, ~/.cache/katalyst).
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@home.command("list")
|
|
95
|
+
@click.option("--format", "fmt", type=_FORMAT_CHOICE, default="text")
|
|
96
|
+
def home_list(fmt: str) -> None:
|
|
97
|
+
"""List the default home and every named home, marking the active one."""
|
|
98
|
+
payload = build_list_payload()
|
|
99
|
+
out = ConsoleOutput()
|
|
100
|
+
if fmt == "json":
|
|
101
|
+
out.json_out(payload)
|
|
102
|
+
return
|
|
103
|
+
out.table(
|
|
104
|
+
["", "home", "root", "api"],
|
|
105
|
+
[
|
|
106
|
+
[
|
|
107
|
+
"*" if entry["active"] else "",
|
|
108
|
+
entry["name"],
|
|
109
|
+
entry["root"] or "(XDG locations)",
|
|
110
|
+
entry["api"]["url"] if entry["api"] else "-",
|
|
111
|
+
]
|
|
112
|
+
for entry in payload["homes"]
|
|
113
|
+
],
|
|
114
|
+
)
|
|
115
|
+
active = payload["active"]
|
|
116
|
+
if active["kind"] == "path":
|
|
117
|
+
out.info(f"Active: {active['name']} (a path home, not listed)")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@home.command("create")
|
|
121
|
+
@click.argument("name")
|
|
122
|
+
@click.option(
|
|
123
|
+
"--from",
|
|
124
|
+
"source",
|
|
125
|
+
default=None,
|
|
126
|
+
metavar="HOME",
|
|
127
|
+
help="Copy config.yaml (only) from this home: 'default', a name, or an absolute path.",
|
|
128
|
+
)
|
|
129
|
+
def home_create(name: str, source: str | None) -> None:
|
|
130
|
+
"""Create the named home NAME, empty or with another home's config.yaml."""
|
|
131
|
+
try:
|
|
132
|
+
origin = home_for(source) if source is not None else None
|
|
133
|
+
created = create_home(name)
|
|
134
|
+
except HomeError as exc:
|
|
135
|
+
raise click.ClickException(str(exc)) from exc
|
|
136
|
+
copied = ""
|
|
137
|
+
if origin is not None:
|
|
138
|
+
config_file = origin.config_dir / CONFIG_FILE_NAME
|
|
139
|
+
if config_file.is_file():
|
|
140
|
+
shutil.copy2(config_file, created.config_dir / CONFIG_FILE_NAME)
|
|
141
|
+
copied = f", with config.yaml from {origin.name}"
|
|
142
|
+
ConsoleOutput().success(f"Created home {created.label}{copied}")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@home.command("rm")
|
|
146
|
+
@click.argument("name")
|
|
147
|
+
@click.option("--yes", is_flag=True, help="Do not ask before deleting.")
|
|
148
|
+
def home_rm(name: str, yes: bool) -> None:
|
|
149
|
+
"""Delete the named home NAME: its config, state and cache.
|
|
150
|
+
|
|
151
|
+
Refuses the default home, the active home, and a home whose API is running.
|
|
152
|
+
"""
|
|
153
|
+
try:
|
|
154
|
+
target = named_home(name)
|
|
155
|
+
except HomeError as exc:
|
|
156
|
+
raise click.ClickException(str(exc)) from exc
|
|
157
|
+
root = target.root
|
|
158
|
+
if root is None or not root.is_dir():
|
|
159
|
+
raise click.ClickException(f"home {name!r} does not exist")
|
|
160
|
+
if _same(target, resolve_home()):
|
|
161
|
+
raise click.ClickException(f"home {name!r} is the active home; select another first")
|
|
162
|
+
api = _running_api(target)
|
|
163
|
+
if api is not None:
|
|
164
|
+
raise click.ClickException(
|
|
165
|
+
f"home {name!r} has a running API ({api['url']}, pid {api['pid']}); stop it first"
|
|
166
|
+
)
|
|
167
|
+
out = ConsoleOutput()
|
|
168
|
+
out.info(f"This deletes {root} and everything in it:")
|
|
169
|
+
for entry in sorted(root.iterdir()):
|
|
170
|
+
out.info(f" {entry.name}{'/' if entry.is_dir() else ''}")
|
|
171
|
+
if not yes and not click.confirm(f"Delete home {name!r}?", default=False):
|
|
172
|
+
raise click.ClickException("not deleted")
|
|
173
|
+
shutil.rmtree(root)
|
|
174
|
+
out.success(f"Deleted home {name!r}")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def attach(group: click.Group) -> None:
|
|
178
|
+
"""Add ``home`` to the ``kata config`` group."""
|
|
179
|
+
group.add_command(home)
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""``kata config plugins`` — which plugins ran, in what order, with what config (#219).
|
|
2
|
+
|
|
3
|
+
Deliberately a **separate module** from :mod:`.config`: it is added to the
|
|
4
|
+
``config`` group by one line there, so this command can land without touching the
|
|
5
|
+
file that owns ``path``/``show``/``sources``/``profiles``/``use``.
|
|
6
|
+
|
|
7
|
+
Register it with a single line at the end of ``commands/config.py``::
|
|
8
|
+
|
|
9
|
+
from .config_plugins import attach as _attach_plugins; _attach_plugins(config)
|
|
10
|
+
|
|
11
|
+
The JSON shape is the contract in
|
|
12
|
+
``stack-tests/features/cli/37_kata_config_plugins.feature``. Extend it additively.
|
|
13
|
+
|
|
14
|
+
The command is a *report*, but not a read-only one in the interesting sense:
|
|
15
|
+
building the payload **activates the plugins**, so a plugin that raises during
|
|
16
|
+
``register()`` or a ``spec.plugins`` entry naming something that is not installed
|
|
17
|
+
fails this command. That is the point — ``kata config plugins`` is where you find
|
|
18
|
+
out, and a report that said "activated: true" without having activated anything
|
|
19
|
+
would be exactly the illusion this issue exists to remove.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import click
|
|
27
|
+
from katalyst_taxonomy.config.profiles import UnknownProfileError
|
|
28
|
+
from katalyst_taxonomy.plugins.errors import PluginError
|
|
29
|
+
|
|
30
|
+
from ..output import ConsoleOutput, OutputPort
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"attach",
|
|
34
|
+
"build_plugins_payload",
|
|
35
|
+
"plugins",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
_FORMAT_CHOICE = click.Choice(["text", "json"])
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_plugins_payload(
|
|
42
|
+
*,
|
|
43
|
+
profile: str | None = None,
|
|
44
|
+
) -> dict[str, Any]:
|
|
45
|
+
"""Build the ``kata config plugins`` payload.
|
|
46
|
+
|
|
47
|
+
Resolves the configuration, activates plugins under the resulting selection,
|
|
48
|
+
then reports what actually happened. Activation is process-memoised, so when
|
|
49
|
+
this runs inside a process that already activated — it does not today, since
|
|
50
|
+
``config`` is not under ``tax``, but a future embedding might — the report
|
|
51
|
+
describes that activation rather than performing a second one.
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
UnknownProfileError: If the profile cannot be selected.
|
|
55
|
+
ValueError: If the configuration is malformed.
|
|
56
|
+
PluginError: If a declared plugin is unknown, cannot be loaded, or raised
|
|
57
|
+
during ``register()``.
|
|
58
|
+
"""
|
|
59
|
+
from katalyst_engine.events.bus import EventBus
|
|
60
|
+
from katalyst_taxonomy.config.backends import config_resolver
|
|
61
|
+
from katalyst_taxonomy.config.plugins import selection_from_config
|
|
62
|
+
from katalyst_taxonomy.plugins.activation import activate_once, plugins_payload
|
|
63
|
+
from katalyst_taxonomy.plugins.startup import plugin_schema_registry
|
|
64
|
+
|
|
65
|
+
effective = config_resolver().resolve(profile=profile)
|
|
66
|
+
selection = selection_from_config(effective.config, effective.selection.profile)
|
|
67
|
+
|
|
68
|
+
activation = activate_once(
|
|
69
|
+
selection=selection,
|
|
70
|
+
schema_registry=plugin_schema_registry(),
|
|
71
|
+
event_bus=EventBus(),
|
|
72
|
+
)
|
|
73
|
+
return plugins_payload(
|
|
74
|
+
activation,
|
|
75
|
+
profile=effective.profile_name,
|
|
76
|
+
config_origin=effective.origin.value,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _output() -> OutputPort:
|
|
81
|
+
return ConsoleOutput()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@click.command("plugins")
|
|
85
|
+
@click.option(
|
|
86
|
+
"--profile",
|
|
87
|
+
"profile",
|
|
88
|
+
default=None,
|
|
89
|
+
help="Profile to resolve. Overrides KATALYST_PROFILE and spec.activeProfile.",
|
|
90
|
+
)
|
|
91
|
+
@click.option("--format", "fmt", type=_FORMAT_CHOICE, default="text")
|
|
92
|
+
def plugins(profile: str | None, fmt: str) -> None:
|
|
93
|
+
"""List plugins, whether they activated, their order, and their config.
|
|
94
|
+
|
|
95
|
+
Every discovered plugin is listed even when configuration disables it — a
|
|
96
|
+
report that hid a disabled plugin would make "why is this plugin not doing
|
|
97
|
+
anything" unanswerable, which is the opposite of what this command is for.
|
|
98
|
+
|
|
99
|
+
``activated`` means the plugin's ``register()`` was called and returned.
|
|
100
|
+
"""
|
|
101
|
+
# Imported here, not at module scope: ``commands/config.py`` attaches this
|
|
102
|
+
# command at the end of its own module body, so a module-level
|
|
103
|
+
# ``from .config import resolve_profile`` makes importing *this* module first
|
|
104
|
+
# fail with a partially-initialised-module ImportError. Deferring mirrors
|
|
105
|
+
# ``resolve_profile`` itself, which defers ``from ..app import root_profile``
|
|
106
|
+
# for the same reason.
|
|
107
|
+
from .config import resolve_profile
|
|
108
|
+
|
|
109
|
+
out = _output()
|
|
110
|
+
try:
|
|
111
|
+
payload = build_plugins_payload(profile=resolve_profile(profile))
|
|
112
|
+
except (
|
|
113
|
+
UnknownProfileError,
|
|
114
|
+
PluginError,
|
|
115
|
+
ValueError,
|
|
116
|
+
) as exc:
|
|
117
|
+
raise click.ClickException(str(exc)) from exc
|
|
118
|
+
|
|
119
|
+
if fmt == "json":
|
|
120
|
+
out.json_out(payload)
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
_render_text(out, payload)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _render_text(out: OutputPort, payload: dict[str, Any]) -> None:
|
|
127
|
+
"""Render the human view: the resolved context, then plugins, then the order."""
|
|
128
|
+
out.key_value(
|
|
129
|
+
[
|
|
130
|
+
("profile", payload["profile"] or "(none)"),
|
|
131
|
+
("config origin", payload["configOrigin"] or "-"),
|
|
132
|
+
("plugins declared", "yes" if payload["declared"] else "no"),
|
|
133
|
+
]
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
out.heading("Plugins")
|
|
137
|
+
entries: list[dict[str, Any]] = payload["plugins"]
|
|
138
|
+
if not entries:
|
|
139
|
+
out.info("No plugins discovered.")
|
|
140
|
+
else:
|
|
141
|
+
out.table(
|
|
142
|
+
["", "plugin", "version", "priority", "capabilities", "note"],
|
|
143
|
+
[
|
|
144
|
+
[
|
|
145
|
+
"*" if entry["activated"] else "x",
|
|
146
|
+
str(entry["name"]),
|
|
147
|
+
str(entry["version"] or "-"),
|
|
148
|
+
"-" if entry["priority"] is None else str(entry["priority"]),
|
|
149
|
+
", ".join(entry["capabilities"]) or "-",
|
|
150
|
+
str(entry["reason"] or entry["entryPoint"] or "-"),
|
|
151
|
+
]
|
|
152
|
+
for entry in entries
|
|
153
|
+
],
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
order: list[str] = payload["activationOrder"]
|
|
157
|
+
out.heading("Activation order")
|
|
158
|
+
out.echo(" → ".join(order) if order else "(nothing activated)")
|
|
159
|
+
|
|
160
|
+
configured = [entry for entry in entries if entry["config"]]
|
|
161
|
+
if configured:
|
|
162
|
+
out.heading("Configuration")
|
|
163
|
+
out.table(
|
|
164
|
+
["plugin", "key", "value"],
|
|
165
|
+
[
|
|
166
|
+
[str(entry["name"]), str(key), str(value)]
|
|
167
|
+
for entry in configured
|
|
168
|
+
for key, value in sorted(entry["config"].items())
|
|
169
|
+
],
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def attach(group: click.Group) -> None:
|
|
174
|
+
"""Add ``plugins`` to the ``kata config`` group.
|
|
175
|
+
|
|
176
|
+
A function rather than a bare ``add_command`` call so registration is one line
|
|
177
|
+
in ``commands/config.py`` and this module stays the only place that knows the
|
|
178
|
+
command's name.
|
|
179
|
+
"""
|
|
180
|
+
group.add_command(plugins)
|