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,257 @@
|
|
|
1
|
+
"""``kata config schemas`` — which bundle provides what, and is it on (#218).
|
|
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_schemas import attach as _attach_schemas; _attach_schemas(config)
|
|
10
|
+
|
|
11
|
+
The JSON shape is the contract in
|
|
12
|
+
``stack-tests/features/cli/36_kata_config_schemas.feature``. Extend it additively.
|
|
13
|
+
|
|
14
|
+
The command is a *report*, but it is not a read-only one in the interesting sense:
|
|
15
|
+
building the payload builds the registry, so a version pin that is not satisfied,
|
|
16
|
+
two bundles claiming one kind, or a declared bundle that cannot be parsed all fail
|
|
17
|
+
the command. That is the point — ``kata config schemas`` is where you find out.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import click
|
|
25
|
+
from katalyst_engine.core.errors import EngineConfigurationError
|
|
26
|
+
from katalyst_taxonomy.config.profiles import UnknownProfileError
|
|
27
|
+
|
|
28
|
+
from ..output import ConsoleOutput, OutputPort
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"attach",
|
|
32
|
+
"build_schemas_payload",
|
|
33
|
+
"schemas",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
_FORMAT_CHOICE = click.Choice(["text", "json"])
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_schemas_payload(
|
|
40
|
+
*,
|
|
41
|
+
profile: str | None = None,
|
|
42
|
+
) -> dict[str, Any]:
|
|
43
|
+
"""Build the ``kata config schemas`` payload.
|
|
44
|
+
|
|
45
|
+
Resolves the configuration, installs the resulting selection into the process
|
|
46
|
+
so the registry is built under it, then composes the report from registry
|
|
47
|
+
provenance plus the one config-aware discovery factory.
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
UnknownProfileError: If the profile cannot be selected.
|
|
51
|
+
ValueError: If the configuration is malformed.
|
|
52
|
+
EngineConfigurationError: If a version pin is unsatisfied, a declared
|
|
53
|
+
bundle's manifest cannot be read, or two bundles claim the same kind
|
|
54
|
+
or the same install destination.
|
|
55
|
+
RuntimeError: If a declared bundle cannot be loaded.
|
|
56
|
+
"""
|
|
57
|
+
from katalyst_taxonomy.adapters.filesystem.real import RealFilesystem
|
|
58
|
+
from katalyst_taxonomy.adapters.orchestration import template_bundles
|
|
59
|
+
from katalyst_taxonomy.config.backends import config_resolver
|
|
60
|
+
from katalyst_taxonomy.config.schemas import selection_from_config
|
|
61
|
+
from katalyst_taxonomy.engine import bridge
|
|
62
|
+
from katalyst_taxonomy.services.schema_gate import check_declared_bundles
|
|
63
|
+
from katalyst_taxonomy.services.schema_report import schemas_payload
|
|
64
|
+
from katalyst_taxonomy.sources.template_resolver import TemplateResolver
|
|
65
|
+
|
|
66
|
+
effective = config_resolver().resolve(profile=profile)
|
|
67
|
+
selection = selection_from_config(effective.config, effective.selection.profile)
|
|
68
|
+
|
|
69
|
+
bridge.configure_schema_bundles(selection)
|
|
70
|
+
try:
|
|
71
|
+
# Probing the declared sources is what makes an unreadable manifest and an
|
|
72
|
+
# install-target collision visible. It is opt-in per caller because it is
|
|
73
|
+
# I/O, and ``kata config schemas`` is the caller that exists to pay for it.
|
|
74
|
+
declared = check_declared_bundles(
|
|
75
|
+
selection, repo_root=None, templates=template_bundles(), probe_sources=True
|
|
76
|
+
)
|
|
77
|
+
factory = bridge.bundle_discovery_factory()
|
|
78
|
+
registry = bridge.get_default_registry()
|
|
79
|
+
resolver = TemplateResolver()
|
|
80
|
+
candidates = factory.candidates()
|
|
81
|
+
return schemas_payload(
|
|
82
|
+
selection=selection,
|
|
83
|
+
registry=registry,
|
|
84
|
+
bundle_names={
|
|
85
|
+
candidate.name: candidate.discovered.entry_point for candidate in candidates
|
|
86
|
+
},
|
|
87
|
+
distributions={
|
|
88
|
+
candidate.name: candidate.distribution
|
|
89
|
+
for candidate in candidates
|
|
90
|
+
if candidate.distribution
|
|
91
|
+
},
|
|
92
|
+
declared=declared,
|
|
93
|
+
scaffold_providers=resolver.scaffold_template_providers(),
|
|
94
|
+
repo_root=None,
|
|
95
|
+
filesystem=RealFilesystem(),
|
|
96
|
+
profile=effective.profile_name,
|
|
97
|
+
config_origin=effective.origin.value,
|
|
98
|
+
)
|
|
99
|
+
finally:
|
|
100
|
+
bridge.configure_schema_bundles(None)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _output() -> OutputPort:
|
|
104
|
+
return ConsoleOutput()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@click.command("schemas")
|
|
108
|
+
@click.option(
|
|
109
|
+
"--profile",
|
|
110
|
+
"profile",
|
|
111
|
+
default=None,
|
|
112
|
+
help="Profile to resolve. Overrides KATALYST_PROFILE and spec.activeProfile.",
|
|
113
|
+
)
|
|
114
|
+
@click.option("--format", "fmt", type=_FORMAT_CHOICE, default="text")
|
|
115
|
+
def schemas(profile: str | None, fmt: str) -> None:
|
|
116
|
+
"""List schema bundles, the kinds they provide, and which are enabled.
|
|
117
|
+
|
|
118
|
+
Every kind is attributed to the bundle that registered it — both by the name
|
|
119
|
+
``spec.schemas`` uses to select it and by the distribution you install it from
|
|
120
|
+
— and every declared bundle is listed even when the active profile excludes
|
|
121
|
+
it, because a report that hid a disabled bundle would make "why is my kind
|
|
122
|
+
missing" unanswerable. Variants the profile excludes move to
|
|
123
|
+
``disabledScaffoldVariants``, with the reason, so ``scaffoldVariants`` means
|
|
124
|
+
"what you can actually scaffold".
|
|
125
|
+
|
|
126
|
+
Fails, rather than reporting, when the configuration cannot be honoured: an
|
|
127
|
+
unsatisfied version pin, a declared bundle whose manifest cannot be read, or
|
|
128
|
+
two bundles claiming one kind or one shared install destination.
|
|
129
|
+
"""
|
|
130
|
+
out = _output()
|
|
131
|
+
try:
|
|
132
|
+
payload = build_schemas_payload(profile=_selected_profile(profile))
|
|
133
|
+
except (
|
|
134
|
+
UnknownProfileError,
|
|
135
|
+
EngineConfigurationError,
|
|
136
|
+
RuntimeError,
|
|
137
|
+
ValueError,
|
|
138
|
+
) as exc:
|
|
139
|
+
raise click.ClickException(str(exc)) from exc
|
|
140
|
+
|
|
141
|
+
if fmt == "json":
|
|
142
|
+
out.json_out(payload)
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
_render_text(out, payload)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _render_text(out: OutputPort, payload: dict[str, Any]) -> None:
|
|
149
|
+
"""Render the human view: bundles, then kinds, then variants, then conflicts."""
|
|
150
|
+
out.key_value(
|
|
151
|
+
[
|
|
152
|
+
("profile", payload["profile"] or "(none)"),
|
|
153
|
+
("config origin", payload["configOrigin"] or "-"),
|
|
154
|
+
("schemas declared", "yes" if payload["declared"] else "no"),
|
|
155
|
+
]
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
out.heading("Bundles")
|
|
159
|
+
bundles: list[dict[str, Any]] = payload["bundles"]
|
|
160
|
+
if not bundles:
|
|
161
|
+
out.info("No schema bundles discovered.")
|
|
162
|
+
else:
|
|
163
|
+
out.table(
|
|
164
|
+
["", "bundle", "distribution", "version", "kinds", "note"],
|
|
165
|
+
[
|
|
166
|
+
[
|
|
167
|
+
"*" if entry["enabled"] else "x",
|
|
168
|
+
str(entry["name"]),
|
|
169
|
+
str(entry.get("distribution") or "-"),
|
|
170
|
+
str(entry.get("resolvedVersion") or entry["version"] or "-"),
|
|
171
|
+
str(len(entry["kinds"])),
|
|
172
|
+
str(
|
|
173
|
+
entry["reason"] or entry.get("sourceWarning") or entry["entryPoint"] or "-"
|
|
174
|
+
),
|
|
175
|
+
]
|
|
176
|
+
for entry in bundles
|
|
177
|
+
],
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
out.heading("Kinds")
|
|
181
|
+
out.table(
|
|
182
|
+
["kind", "bundle", "distribution", "wing"],
|
|
183
|
+
[
|
|
184
|
+
[
|
|
185
|
+
str(entry["kind"]),
|
|
186
|
+
str(entry["bundle"] or "-"),
|
|
187
|
+
str(entry.get("distribution") or "-"),
|
|
188
|
+
str(entry["wing"] or "-"),
|
|
189
|
+
]
|
|
190
|
+
for entry in payload["kinds"]
|
|
191
|
+
],
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
variants: list[dict[str, Any]] = payload["scaffoldVariants"]
|
|
195
|
+
if variants:
|
|
196
|
+
out.heading("Scaffold variants")
|
|
197
|
+
out.table(
|
|
198
|
+
["variant", "node type", "bundle", "origin"],
|
|
199
|
+
[
|
|
200
|
+
[
|
|
201
|
+
str(entry["name"]),
|
|
202
|
+
str(entry["nodeType"]),
|
|
203
|
+
str(entry["bundle"]),
|
|
204
|
+
str(entry["origin"]),
|
|
205
|
+
]
|
|
206
|
+
for entry in variants
|
|
207
|
+
],
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# Rendered as its own section rather than as ``x`` rows above, for the same
|
|
211
|
+
# reason the payload splits the two keys: a reader scanning "what can I
|
|
212
|
+
# scaffold?" should not have to filter, and a reader asking "why is my variant
|
|
213
|
+
# missing?" needs the reason, which only this section has room for.
|
|
214
|
+
disabled: list[dict[str, Any]] = payload.get("disabledScaffoldVariants", [])
|
|
215
|
+
if disabled:
|
|
216
|
+
out.heading("Disabled scaffold variants")
|
|
217
|
+
out.table(
|
|
218
|
+
["variant", "node type", "bundle", "reason"],
|
|
219
|
+
[
|
|
220
|
+
[
|
|
221
|
+
str(entry["name"]),
|
|
222
|
+
str(entry["nodeType"]),
|
|
223
|
+
str(entry["bundle"]),
|
|
224
|
+
str(entry["reason"] or "-"),
|
|
225
|
+
]
|
|
226
|
+
for entry in disabled
|
|
227
|
+
],
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
conflicts: list[dict[str, Any]] = payload["conflicts"]
|
|
231
|
+
if conflicts:
|
|
232
|
+
out.heading("Conflicts")
|
|
233
|
+
out.table(
|
|
234
|
+
["scope", "key", "providers"],
|
|
235
|
+
[
|
|
236
|
+
[str(entry["scope"]), str(entry["key"]), ", ".join(entry["providers"])]
|
|
237
|
+
for entry in conflicts
|
|
238
|
+
],
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def attach(group: click.Group) -> None:
|
|
243
|
+
"""Add ``schemas`` to the ``kata config`` group.
|
|
244
|
+
|
|
245
|
+
A function rather than a bare ``add_command`` call so registration is one line
|
|
246
|
+
in ``commands/config.py`` and this module stays the only place that knows the
|
|
247
|
+
command's name.
|
|
248
|
+
"""
|
|
249
|
+
group.add_command(schemas)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _selected_profile(explicit: str | None) -> str | None:
|
|
253
|
+
# Imported here: ``config`` attaches this module's command at import time, so a
|
|
254
|
+
# module-level import back into it is a cycle whenever this module loads first.
|
|
255
|
+
from .config import resolve_profile
|
|
256
|
+
|
|
257
|
+
return resolve_profile(explicit)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""``kata config sync`` and ``kata config status`` — profiles sourced from elsewhere.
|
|
2
|
+
|
|
3
|
+
A profile may name a ``source`` (a ``git:``/``github:`` repository holding a config file,
|
|
4
|
+
or another kata API) instead of declaring its backends. These commands are thin shells
|
|
5
|
+
over :class:`~katalyst_taxonomy.services.config_sources.ConfigSourceService`, the same
|
|
6
|
+
service the MCP ``config_sync``/``config_status`` tools and REST ``/config/sources`` call::
|
|
7
|
+
|
|
8
|
+
kata config sync [PROFILE...] fetch each sourced profile and pin it
|
|
9
|
+
kata config status [--offline] each sourced profile's source, pin and last fetch,
|
|
10
|
+
and whether the source is ahead of the pin
|
|
11
|
+
|
|
12
|
+
The pin and the fetched profile are kept in the state home, never in config.yaml; every
|
|
13
|
+
other command reads the pinned copy.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import TYPE_CHECKING, Any
|
|
19
|
+
|
|
20
|
+
import click
|
|
21
|
+
|
|
22
|
+
from ..output import ConsoleOutput, OutputPort
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
25
|
+
from katalyst_taxonomy.services.config_sources import SourceStatus, SourceSync
|
|
26
|
+
|
|
27
|
+
__all__ = ["attach"]
|
|
28
|
+
|
|
29
|
+
_FORMAT_CHOICE = click.Choice(["text", "json"])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _output() -> OutputPort:
|
|
33
|
+
return ConsoleOutput()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _short(pin: str | None) -> str:
|
|
37
|
+
"""A commit sha or content hash shortened to 12 characters; an ETag as it is."""
|
|
38
|
+
if not pin:
|
|
39
|
+
return "-"
|
|
40
|
+
if pin.startswith("sha256:"):
|
|
41
|
+
return pin[: len("sha256:") + 12]
|
|
42
|
+
return pin[:12] if len(pin) in (40, 64) else pin
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@click.command("sync")
|
|
46
|
+
@click.argument("names", nargs=-1, metavar="[PROFILE]...")
|
|
47
|
+
@click.option("--format", "fmt", type=_FORMAT_CHOICE, default="text")
|
|
48
|
+
def config_sync(names: tuple[str, ...], fmt: str) -> None:
|
|
49
|
+
"""Fetch every sourced profile (or each PROFILE) and pin it.
|
|
50
|
+
|
|
51
|
+
A git source is pinned to its commit, a kata API to its ETag or content hash. Each
|
|
52
|
+
repository a sourced profile's filesystem backends name is found under
|
|
53
|
+
spec.workspace by its remote URL, or cloned there. Exits 1 if any fetch failed; a
|
|
54
|
+
failed one keeps its previous pin.
|
|
55
|
+
"""
|
|
56
|
+
from katalyst_taxonomy.adapters.orchestration import sync_config_sources
|
|
57
|
+
|
|
58
|
+
out = _output()
|
|
59
|
+
try:
|
|
60
|
+
results: list[SourceSync] = sync_config_sources(names)
|
|
61
|
+
except ValueError as exc:
|
|
62
|
+
raise click.ClickException(str(exc)) from exc
|
|
63
|
+
if fmt == "json":
|
|
64
|
+
out.json_out({"profiles": [r.as_payload() for r in results]})
|
|
65
|
+
elif not results:
|
|
66
|
+
out.info("No profile is sourced from elsewhere; nothing to sync.")
|
|
67
|
+
for result in results if fmt == "text" else ():
|
|
68
|
+
if not result.ok:
|
|
69
|
+
out.warning(f"{result.profile}: {result.error}")
|
|
70
|
+
continue
|
|
71
|
+
change = (
|
|
72
|
+
f"pinned {_short(result.pin)}"
|
|
73
|
+
if not result.previous
|
|
74
|
+
else f"{_short(result.previous)} -> {_short(result.pin)}"
|
|
75
|
+
if result.changed
|
|
76
|
+
else f"unchanged at {_short(result.pin)}"
|
|
77
|
+
)
|
|
78
|
+
out.success(f"{result.profile}: {result.source} {change}")
|
|
79
|
+
for clone in result.clones:
|
|
80
|
+
how = "cloned to" if clone.cloned else "using"
|
|
81
|
+
out.info(f" {clone.repository}: {how} {clone.path}")
|
|
82
|
+
if any(not r.ok for r in results):
|
|
83
|
+
raise SystemExit(1)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@click.command("status")
|
|
87
|
+
@click.option(
|
|
88
|
+
"--offline",
|
|
89
|
+
is_flag=True,
|
|
90
|
+
help="Do not ask the sources whether they have moved; report the pins only.",
|
|
91
|
+
)
|
|
92
|
+
@click.option("--format", "fmt", type=_FORMAT_CHOICE, default="text")
|
|
93
|
+
def config_status(offline: bool, fmt: str) -> None:
|
|
94
|
+
"""Each sourced profile: its source, pin and last fetch, and whether the source is ahead."""
|
|
95
|
+
from katalyst_taxonomy.adapters.orchestration import config_source_status
|
|
96
|
+
|
|
97
|
+
out = _output()
|
|
98
|
+
try:
|
|
99
|
+
rows: list[SourceStatus] = config_source_status(check=not offline)
|
|
100
|
+
except ValueError as exc:
|
|
101
|
+
raise click.ClickException(str(exc)) from exc
|
|
102
|
+
if fmt == "json":
|
|
103
|
+
out.json_out({"profiles": [r.as_payload() for r in rows]})
|
|
104
|
+
return
|
|
105
|
+
if not rows:
|
|
106
|
+
out.info("No profile is sourced from elsewhere.")
|
|
107
|
+
return
|
|
108
|
+
out.table(
|
|
109
|
+
["profile", "source", "pin", "fetched", "upstream"],
|
|
110
|
+
[
|
|
111
|
+
[
|
|
112
|
+
row.profile
|
|
113
|
+
if row.source_profile == row.profile
|
|
114
|
+
else f"{row.profile} ({row.source_profile})",
|
|
115
|
+
row.source,
|
|
116
|
+
_short(row.pin) if row.pin else "never fetched",
|
|
117
|
+
row.fetched_at or "-",
|
|
118
|
+
_upstream(row, offline),
|
|
119
|
+
]
|
|
120
|
+
for row in rows
|
|
121
|
+
],
|
|
122
|
+
)
|
|
123
|
+
for row in rows:
|
|
124
|
+
if row.error:
|
|
125
|
+
out.warning(f"{row.profile}: {row.error}")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _upstream(row: Any, offline: bool) -> str:
|
|
129
|
+
if offline:
|
|
130
|
+
return "not checked"
|
|
131
|
+
if row.behind is None:
|
|
132
|
+
return "unknown"
|
|
133
|
+
if row.behind:
|
|
134
|
+
return f"behind ({_short(row.latest)}) — run 'kata config sync {row.profile}'"
|
|
135
|
+
return "current"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def attach(group: click.Group) -> None:
|
|
139
|
+
"""Add ``sync`` and ``status`` to the ``kata config`` group."""
|
|
140
|
+
group.add_command(config_sync)
|
|
141
|
+
group.add_command(config_status)
|