kctl-lib 0.8.2__tar.gz → 0.9.0__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.
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/PKG-INFO +1 -1
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/pyproject.toml +1 -1
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/__init__.py +9 -1
- kctl_lib-0.9.0/src/kctl_lib/dry_run.py +76 -0
- kctl_lib-0.9.0/src/kctl_lib/error_handler.py +140 -0
- kctl_lib-0.9.0/src/kctl_lib/introspection.py +108 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/skill_generator.py +23 -3
- kctl_lib-0.9.0/tests/test_dry_run.py +76 -0
- kctl_lib-0.9.0/tests/test_error_handler.py +129 -0
- kctl_lib-0.9.0/tests/test_introspection.py +117 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_skill_generator.py +108 -6
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/.gitignore +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/README.md +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/_migration.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/api_client.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/async_api_client.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/callbacks.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/cli/__init__.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/cli/commands/__init__.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/cli/commands/current.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/cli/commands/list_cmd.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/cli/commands/migrate.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/cli/commands/show.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/cli/commands/validate.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/cli/main.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/completions.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/config.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/docker.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/doctor_base.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/exceptions.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/git_ops.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/history.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/logging_utils.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/monitor_base.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/output.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/plugins.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/progress.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/py.typed +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/runner.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/schemas.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/secrets.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/self_update.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/ssh.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/ssh_tunnel.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/testing.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/tui.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/tui.tcss +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/validate.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/conftest.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/fixtures/clean_config.yaml +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/fixtures/dirty_config.yaml +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_api_client.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_async_api_client.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_callbacks.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_cli_current.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_cli_list.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_cli_migrate.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_cli_show.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_cli_smoke.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_cli_validate.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_completions.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_config.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_docker.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_doctor_base.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_exceptions.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_git_ops.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_history.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_inheritance.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_logging_utils.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_mask_secret_fields.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_migration.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_monitor_base.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_output.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_plugins.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_profile_banner.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_progress.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_runner.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_schemas.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_secrets.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_self_update.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_ssh.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_ssh_tunnel.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_tui.py +0 -0
- {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_validate.py +0 -0
|
@@ -24,13 +24,15 @@ Public API:
|
|
|
24
24
|
- tui: Textual TUI (add_tui_command)
|
|
25
25
|
"""
|
|
26
26
|
|
|
27
|
-
__version__ = "0.
|
|
27
|
+
__version__ = "0.9.0"
|
|
28
28
|
|
|
29
29
|
from kctl_lib.api_client import APIClient
|
|
30
30
|
from kctl_lib.async_api_client import AsyncAPIClient
|
|
31
31
|
from kctl_lib.callbacks import AppContextBase
|
|
32
32
|
from kctl_lib.docker import DockerManager
|
|
33
33
|
from kctl_lib.doctor_base import CheckResult, DoctorCheck, run_doctor
|
|
34
|
+
from kctl_lib.dry_run import with_dry_run
|
|
35
|
+
from kctl_lib.error_handler import cli_entrypoint
|
|
34
36
|
from kctl_lib.exceptions import (
|
|
35
37
|
APIError,
|
|
36
38
|
AppNotFoundError,
|
|
@@ -43,6 +45,7 @@ from kctl_lib.exceptions import (
|
|
|
43
45
|
NotFoundError,
|
|
44
46
|
ValidationError,
|
|
45
47
|
)
|
|
48
|
+
from kctl_lib.introspection import dump_command_list, dump_command_tree, register_introspection_commands
|
|
46
49
|
from kctl_lib.output import Output
|
|
47
50
|
from kctl_lib.progress import ItemProgress, LogCollector, PerfTimer, StepResult, StepTracker
|
|
48
51
|
from kctl_lib.validate import Issue
|
|
@@ -56,6 +59,7 @@ __all__ = [
|
|
|
56
59
|
"AuthenticationError",
|
|
57
60
|
"CheckResult",
|
|
58
61
|
"CommandError",
|
|
62
|
+
"cli_entrypoint",
|
|
59
63
|
"ConfigError",
|
|
60
64
|
"ConnectionError",
|
|
61
65
|
"DockerError",
|
|
@@ -71,7 +75,11 @@ __all__ = [
|
|
|
71
75
|
"StepResult",
|
|
72
76
|
"StepTracker",
|
|
73
77
|
"ValidationError",
|
|
78
|
+
"dump_command_list",
|
|
79
|
+
"dump_command_tree",
|
|
80
|
+
"register_introspection_commands",
|
|
74
81
|
"run_doctor",
|
|
82
|
+
"with_dry_run",
|
|
75
83
|
]
|
|
76
84
|
|
|
77
85
|
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""--explain dry-run support for Typer commands.
|
|
2
|
+
|
|
3
|
+
Decorator that adds an --explain flag to a Typer command. When passed,
|
|
4
|
+
prints the action plan and exits 0 without invoking the wrapped function.
|
|
5
|
+
Useful for AI agents and humans previewing destructive operations.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
@app.command()
|
|
9
|
+
@with_dry_run(summary="Create DNS record name={name} content={content}")
|
|
10
|
+
def create(name: str, content: str) -> None:
|
|
11
|
+
...
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import functools
|
|
17
|
+
import inspect
|
|
18
|
+
import typing
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import typer
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def with_dry_run(summary: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
26
|
+
"""Decorate a Typer command so `--explain` prints the action and exits 0.
|
|
27
|
+
|
|
28
|
+
The summary string supports named placeholders matching the wrapped
|
|
29
|
+
function's parameter names (e.g. "Create domain {name} with TTL {ttl}").
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
33
|
+
# Resolve forward-referenced annotations (PEP 563 / ``from __future__
|
|
34
|
+
# import annotations``) so Typer can see Annotated metadata like
|
|
35
|
+
# ``typer.Option("--type")``. Without this, annotations remain as
|
|
36
|
+
# raw strings and Typer treats every parameter as a positional
|
|
37
|
+
# argument.
|
|
38
|
+
try:
|
|
39
|
+
resolved_hints = typing.get_type_hints(func, include_extras=True)
|
|
40
|
+
except Exception:
|
|
41
|
+
resolved_hints = {}
|
|
42
|
+
|
|
43
|
+
sig = inspect.signature(func)
|
|
44
|
+
resolved_params = []
|
|
45
|
+
for name, param in sig.parameters.items():
|
|
46
|
+
if name in resolved_hints:
|
|
47
|
+
resolved_params.append(param.replace(annotation=resolved_hints[name]))
|
|
48
|
+
else:
|
|
49
|
+
resolved_params.append(param)
|
|
50
|
+
# Extend signature so Typer registers --explain as a Boolean flag
|
|
51
|
+
new_params = resolved_params + [
|
|
52
|
+
inspect.Parameter(
|
|
53
|
+
"explain",
|
|
54
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
55
|
+
default=typer.Option(False, "--explain", help="Show what would happen and exit."),
|
|
56
|
+
annotation=bool,
|
|
57
|
+
)
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
@functools.wraps(func)
|
|
61
|
+
def wrapper(*args: Any, explain: bool = False, **kwargs: Any) -> Any:
|
|
62
|
+
if explain:
|
|
63
|
+
bound = sig.bind_partial(*args, **kwargs)
|
|
64
|
+
bound.apply_defaults()
|
|
65
|
+
try:
|
|
66
|
+
rendered = summary.format(**bound.arguments)
|
|
67
|
+
except KeyError as exc:
|
|
68
|
+
raise typer.BadParameter(f"with_dry_run summary references unknown parameter {exc}") from exc
|
|
69
|
+
typer.echo(f"[explain] {rendered}")
|
|
70
|
+
raise typer.Exit(0)
|
|
71
|
+
return func(*args, **kwargs)
|
|
72
|
+
|
|
73
|
+
wrapper.__signature__ = sig.replace(parameters=new_params) # type: ignore[attr-defined]
|
|
74
|
+
return wrapper
|
|
75
|
+
|
|
76
|
+
return decorator
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Wrap Typer apps so KctlError subclasses become user-facing messages.
|
|
2
|
+
|
|
3
|
+
We translate :class:`KctlError` raised inside a command body into a clean
|
|
4
|
+
one-line message (or JSON when ``--json`` is requested) instead of a Python
|
|
5
|
+
traceback. Both the real CLI entry point and ``CliRunner.invoke`` end up
|
|
6
|
+
calling the command's wrapped callback, so wrapping each registered command
|
|
7
|
+
callback (and the root callback) is sufficient:
|
|
8
|
+
|
|
9
|
+
* Production path: console script -> ``app()`` -> Typer's ``__call__`` ->
|
|
10
|
+
click -> invokes the command function (which IS wrapped).
|
|
11
|
+
* Test path: ``CliRunner.invoke`` -> ``typer.main.get_command`` -> click ->
|
|
12
|
+
invokes the command function (which IS wrapped).
|
|
13
|
+
|
|
14
|
+
An earlier iteration also assigned ``app.__call__ = wrapped_call`` thinking
|
|
15
|
+
this would intercept ``app()``; that was a misunderstanding of Python's
|
|
16
|
+
special method lookup, which goes through ``type(obj).__call__`` and ignores
|
|
17
|
+
the instance dict. The instance-level assignment was dead code and has been
|
|
18
|
+
removed.
|
|
19
|
+
|
|
20
|
+
``cli_entrypoint`` is idempotent: wrapping the same app twice is a no-op,
|
|
21
|
+
and wrapping a callback that's already wrapped is skipped.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import functools
|
|
27
|
+
import json
|
|
28
|
+
import sys
|
|
29
|
+
from collections.abc import Callable
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
import click
|
|
33
|
+
import typer
|
|
34
|
+
|
|
35
|
+
from kctl_lib.exceptions import KctlError
|
|
36
|
+
|
|
37
|
+
_JSON_PARAM_NAME = "json" # Host callbacks must name their --json parameter `json` for JSON-mode error output to work
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _wants_json() -> bool:
|
|
41
|
+
# Assumes -j is reserved globally for --json per kctl-lib CLI convention
|
|
42
|
+
if "--json" in sys.argv or "-j" in sys.argv:
|
|
43
|
+
return True
|
|
44
|
+
# When invoked via click (incl. ``CliRunner.invoke``) ``sys.argv`` isn't
|
|
45
|
+
# rewritten, so consult the active click context. ``cli_entrypoint``
|
|
46
|
+
# registers a root callback that accepts ``--json`` for this purpose.
|
|
47
|
+
try:
|
|
48
|
+
ctx = click.get_current_context(silent=True)
|
|
49
|
+
except RuntimeError:
|
|
50
|
+
ctx = None
|
|
51
|
+
if ctx is not None:
|
|
52
|
+
params = ctx.find_root().params
|
|
53
|
+
if params.get(_JSON_PARAM_NAME):
|
|
54
|
+
return True
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _emit_error(exc: KctlError) -> None:
|
|
59
|
+
name = exc.__class__.__name__
|
|
60
|
+
message = str(exc)
|
|
61
|
+
if _wants_json():
|
|
62
|
+
sys.stdout.write(json.dumps({"error": name, "message": message}) + "\n")
|
|
63
|
+
else:
|
|
64
|
+
sys.stderr.write(f"❌ {name}: {message}\n")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _wrap_callback(callback: Callable[..., Any]) -> Callable[..., Any]:
|
|
68
|
+
"""Wrap a single command/callback so KctlError -> clean message + Exit(1)."""
|
|
69
|
+
|
|
70
|
+
if getattr(callback, "_kctl_wrapped", False):
|
|
71
|
+
return callback
|
|
72
|
+
|
|
73
|
+
@functools.wraps(callback)
|
|
74
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
75
|
+
try:
|
|
76
|
+
return callback(*args, **kwargs)
|
|
77
|
+
except KctlError as exc:
|
|
78
|
+
_emit_error(exc)
|
|
79
|
+
raise typer.Exit(1) from exc
|
|
80
|
+
|
|
81
|
+
wrapper._kctl_wrapped = True # type: ignore[attr-defined]
|
|
82
|
+
return wrapper
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def cli_entrypoint(app: typer.Typer) -> typer.Typer:
|
|
86
|
+
"""Wrap a Typer app so :class:`KctlError` subclasses become clean messages.
|
|
87
|
+
|
|
88
|
+
Idempotent -- wrapping the same app twice is a no-op.
|
|
89
|
+
|
|
90
|
+
## Usage
|
|
91
|
+
|
|
92
|
+
Call ``cli_entrypoint(app)`` AFTER registering all commands and any
|
|
93
|
+
``@app.callback()``. Calling it earlier may cause the root callback
|
|
94
|
+
installation to be overwritten by a later ``@app.callback()`` decoration.
|
|
95
|
+
"""
|
|
96
|
+
if getattr(app, "_kctl_wrapped", False):
|
|
97
|
+
return app
|
|
98
|
+
|
|
99
|
+
# Wrap each registered command callback so CliRunner-based invocation
|
|
100
|
+
# (and any direct click-command invocation) also benefits from the
|
|
101
|
+
# KctlError translation.
|
|
102
|
+
for cmd_info in app.registered_commands:
|
|
103
|
+
if cmd_info.callback is not None:
|
|
104
|
+
cmd_info.callback = _wrap_callback(cmd_info.callback)
|
|
105
|
+
|
|
106
|
+
# Wrap the app callback too, if one is registered.
|
|
107
|
+
if app.registered_callback is not None and app.registered_callback.callback is not None:
|
|
108
|
+
app.registered_callback.callback = _wrap_callback(app.registered_callback.callback)
|
|
109
|
+
|
|
110
|
+
# Typer flattens single-command apps unless a callback exists, which would
|
|
111
|
+
# cause ``runner.invoke(app, ["cmd_name"])`` to be rejected as an unexpected
|
|
112
|
+
# extra argument. Register a no-op callback if none is set so the
|
|
113
|
+
# subcommand structure is preserved regardless of command count.
|
|
114
|
+
#
|
|
115
|
+
# The callback declares ``--json`` so click parses it as a root-level flag
|
|
116
|
+
# even when ``sys.argv`` isn't rewritten (e.g. under ``CliRunner.invoke``).
|
|
117
|
+
# The value is read back via :func:`_wants_json`.
|
|
118
|
+
if app.registered_callback is None:
|
|
119
|
+
|
|
120
|
+
@app.callback()
|
|
121
|
+
def _kctl_root_callback(
|
|
122
|
+
json: bool = typer.Option( # noqa: A002 -- click param name is "json"
|
|
123
|
+
False,
|
|
124
|
+
"--json",
|
|
125
|
+
"-j",
|
|
126
|
+
help="Emit machine-readable JSON output.",
|
|
127
|
+
),
|
|
128
|
+
) -> None:
|
|
129
|
+
"""No-op root callback installed by cli_entrypoint."""
|
|
130
|
+
|
|
131
|
+
# NOTE: We intentionally do NOT assign ``app.__call__ = ...``. Python's
|
|
132
|
+
# special method lookup bypasses the instance dict and goes through
|
|
133
|
+
# ``type(app).__call__``, so an instance-level assignment is dead code.
|
|
134
|
+
# Wrapping each command callback (and the root callback) is sufficient
|
|
135
|
+
# because both the production path (console script -> Typer.__call__ ->
|
|
136
|
+
# click -> command function) and the test path (CliRunner.invoke ->
|
|
137
|
+
# typer.main.get_command -> click -> command function) end up calling
|
|
138
|
+
# the wrapped callback.
|
|
139
|
+
app._kctl_wrapped = True # type: ignore[attr-defined]
|
|
140
|
+
return app
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Machine-readable Typer command introspection for AI agents.
|
|
2
|
+
|
|
3
|
+
Exposes `kctl-<x> commands tree --json` (full hierarchy) and
|
|
4
|
+
`kctl-<x> commands list --json` (flat leaf paths) so an AI agent can
|
|
5
|
+
discover the full command surface in one structured call instead of
|
|
6
|
+
walking N `--help` invocations.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
from click import Command, Group
|
|
16
|
+
|
|
17
|
+
_ROOT_NAME = "root"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _normalize_name(name: str) -> str:
|
|
21
|
+
"""Strip Typer's trailing dash artifact from underscore-suffixed function names.
|
|
22
|
+
|
|
23
|
+
Typer converts a function named ``list_`` (used to avoid shadowing the
|
|
24
|
+
Python builtin) to a command named ``list-``. From an AI agent's
|
|
25
|
+
perspective the canonical command name is ``list``, so we drop the
|
|
26
|
+
trailing dash for non-root nodes.
|
|
27
|
+
"""
|
|
28
|
+
if name != _ROOT_NAME and name.endswith("-"):
|
|
29
|
+
return name.rstrip("-")
|
|
30
|
+
return name
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _walk_click_command(name: str, cmd: Command) -> dict[str, Any]:
|
|
34
|
+
node: dict[str, Any] = {
|
|
35
|
+
"name": _normalize_name(name),
|
|
36
|
+
"help": (cmd.help or "").strip(),
|
|
37
|
+
}
|
|
38
|
+
if isinstance(cmd, Group):
|
|
39
|
+
groups: list[dict[str, Any]] = []
|
|
40
|
+
commands: list[dict[str, Any]] = []
|
|
41
|
+
for sub_name, sub in cmd.commands.items():
|
|
42
|
+
child = _walk_click_command(sub_name, sub)
|
|
43
|
+
(groups if isinstance(sub, Group) else commands).append(child)
|
|
44
|
+
node["groups"] = groups
|
|
45
|
+
node["commands"] = commands
|
|
46
|
+
else:
|
|
47
|
+
node["params"] = [{"name": p.name, "type": getattr(p.type, "name", str(p.type))} for p in cmd.params]
|
|
48
|
+
return node
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def dump_command_tree(app: typer.Typer) -> dict[str, Any]:
|
|
52
|
+
"""Return the full command hierarchy of a Typer app as a dict.
|
|
53
|
+
|
|
54
|
+
Root node always has 'groups' and 'commands' keys, even for single-command
|
|
55
|
+
apps (where the root would otherwise be a leaf Command, not a Group).
|
|
56
|
+
"""
|
|
57
|
+
click_app = typer.main.get_command(app)
|
|
58
|
+
root = _walk_click_command(_ROOT_NAME, click_app)
|
|
59
|
+
root.setdefault("groups", [])
|
|
60
|
+
root.setdefault("commands", [])
|
|
61
|
+
return root
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _flatten(node: dict[str, Any], prefix: str = "") -> list[str]:
|
|
65
|
+
paths: list[str] = []
|
|
66
|
+
name = node["name"]
|
|
67
|
+
qualified = "" if name == _ROOT_NAME else (f"{prefix} {name}".strip() if prefix else name)
|
|
68
|
+
for cmd in node.get("commands", []):
|
|
69
|
+
leaf = f"{qualified} {cmd['name']}".strip()
|
|
70
|
+
paths.append(leaf)
|
|
71
|
+
for group in node.get("groups", []):
|
|
72
|
+
paths.extend(_flatten(group, qualified))
|
|
73
|
+
return paths
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def dump_command_list(app: typer.Typer) -> list[str]:
|
|
77
|
+
"""Flat list of all leaf commands as 'group verb' strings, sorted."""
|
|
78
|
+
return sorted(_flatten(dump_command_tree(app)))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def register_introspection_commands(app: typer.Typer) -> None:
|
|
82
|
+
"""Add a `commands` group to the app for AI-friendly discovery.
|
|
83
|
+
|
|
84
|
+
Idempotent: calling twice does not re-register.
|
|
85
|
+
"""
|
|
86
|
+
if getattr(app, "_kctl_introspection_registered", False):
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
commands_app = typer.Typer(help="Machine-readable command discovery (for AI agents).")
|
|
90
|
+
|
|
91
|
+
@commands_app.command("tree")
|
|
92
|
+
def tree_cmd() -> None:
|
|
93
|
+
"""Dump the full command tree as JSON."""
|
|
94
|
+
payload = dump_command_tree(app)
|
|
95
|
+
typer.echo(json.dumps(payload, indent=2))
|
|
96
|
+
|
|
97
|
+
@commands_app.command("list")
|
|
98
|
+
def list_cmd(json_flag: bool = typer.Option(True, "--json/--no-json")) -> None:
|
|
99
|
+
"""List all leaf commands as 'group verb' strings."""
|
|
100
|
+
paths = dump_command_list(app)
|
|
101
|
+
if json_flag:
|
|
102
|
+
typer.echo(json.dumps(paths))
|
|
103
|
+
else:
|
|
104
|
+
for p in paths:
|
|
105
|
+
typer.echo(p)
|
|
106
|
+
|
|
107
|
+
app.add_typer(commands_app, name="commands")
|
|
108
|
+
app._kctl_introspection_registered = True # type: ignore[attr-defined]
|
|
@@ -245,13 +245,24 @@ def generate_skill(
|
|
|
245
245
|
|
|
246
246
|
lines: list[str] = []
|
|
247
247
|
|
|
248
|
+
# Slim frontmatter description (<= 200 chars). The full trigger keyword
|
|
249
|
+
# list now lives in the body ## Triggers section to keep the per-session
|
|
250
|
+
# token cost of loading all skills into Claude's picker low.
|
|
251
|
+
short_description = (
|
|
252
|
+
f"{description} ({len(groups)} groups, ~{total_commands} commands). "
|
|
253
|
+
f"MUST use for ANY {cli_name} operation. "
|
|
254
|
+
f"See SKILL.md ## Triggers for keywords."
|
|
255
|
+
)
|
|
256
|
+
if len(short_description) > 200:
|
|
257
|
+
raise ValueError(
|
|
258
|
+
f"SKILL.md description must be <=200 chars, got {len(short_description)}: {short_description!r}"
|
|
259
|
+
)
|
|
260
|
+
|
|
248
261
|
# Frontmatter
|
|
249
262
|
lines.append("---")
|
|
250
263
|
lines.append(f"name: {skill_name}")
|
|
251
264
|
lines.append("description: >")
|
|
252
|
-
lines.append(f" {
|
|
253
|
-
lines.append(f" MUST use for ANY {cli_name} operation.")
|
|
254
|
-
lines.append(f" Triggers on: {triggers}.")
|
|
265
|
+
lines.append(f" {short_description}")
|
|
255
266
|
lines.append(f" Auto-generated: {datetime.now(UTC).strftime('%Y-%m-%d')}")
|
|
256
267
|
lines.append(f" registry_hash: {registry_hash}")
|
|
257
268
|
lines.append("---")
|
|
@@ -287,6 +298,15 @@ def generate_skill(
|
|
|
287
298
|
lines.append("| `--version`, `-V` | Show version |")
|
|
288
299
|
lines.append("")
|
|
289
300
|
|
|
301
|
+
# Triggers — full keyword list moved out of frontmatter so the skill
|
|
302
|
+
# picker only loads the slim description by default.
|
|
303
|
+
lines.append("## Triggers")
|
|
304
|
+
lines.append("")
|
|
305
|
+
lines.append("Auto-invoke this skill when the user references any of these keywords:")
|
|
306
|
+
lines.append("")
|
|
307
|
+
lines.append(triggers)
|
|
308
|
+
lines.append("")
|
|
309
|
+
|
|
290
310
|
# Command reference
|
|
291
311
|
lines.append("## Command Reference")
|
|
292
312
|
lines.append("")
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Tests for with_dry_run decorator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from typer.testing import CliRunner
|
|
7
|
+
|
|
8
|
+
from kctl_lib.dry_run import with_dry_run
|
|
9
|
+
|
|
10
|
+
SIDE_EFFECT: list[str] = []
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _build_app() -> typer.Typer:
|
|
14
|
+
app = typer.Typer()
|
|
15
|
+
SIDE_EFFECT.clear()
|
|
16
|
+
|
|
17
|
+
# Callback forces Typer to treat the app as a command group even though
|
|
18
|
+
# only one subcommand is registered. Without it, Typer collapses a
|
|
19
|
+
# single-command app and the leading "create" arg gets consumed as a name.
|
|
20
|
+
@app.callback()
|
|
21
|
+
def _main() -> None:
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
@app.command()
|
|
25
|
+
@with_dry_run(summary="Create domain {name} with TTL {ttl}")
|
|
26
|
+
def create(name: str, ttl: int = 3600) -> None:
|
|
27
|
+
SIDE_EFFECT.append(f"created:{name}:{ttl}")
|
|
28
|
+
|
|
29
|
+
return app
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_explain_prints_plan_and_skips_execution() -> None:
|
|
33
|
+
runner = CliRunner()
|
|
34
|
+
app = _build_app()
|
|
35
|
+
result = runner.invoke(app, ["create", "example.com", "--ttl", "60", "--explain"])
|
|
36
|
+
assert result.exit_code == 0
|
|
37
|
+
assert "Create domain example.com with TTL 60" in result.output
|
|
38
|
+
assert SIDE_EFFECT == []
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_without_explain_runs_normally() -> None:
|
|
42
|
+
runner = CliRunner()
|
|
43
|
+
app = _build_app()
|
|
44
|
+
result = runner.invoke(app, ["create", "example.com", "--ttl", "60"])
|
|
45
|
+
assert result.exit_code == 0
|
|
46
|
+
assert SIDE_EFFECT == ["created:example.com:60"]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_explain_uses_default_value() -> None:
|
|
50
|
+
runner = CliRunner()
|
|
51
|
+
app = _build_app()
|
|
52
|
+
result = runner.invoke(app, ["create", "example.com", "--explain"])
|
|
53
|
+
assert result.exit_code == 0
|
|
54
|
+
assert "Create domain example.com with TTL 3600" in result.output
|
|
55
|
+
assert SIDE_EFFECT == []
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_summary_with_bad_placeholder_raises_clean_error() -> None:
|
|
59
|
+
runner = CliRunner()
|
|
60
|
+
|
|
61
|
+
app = typer.Typer()
|
|
62
|
+
|
|
63
|
+
@app.callback()
|
|
64
|
+
def _root() -> None:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
@app.command()
|
|
68
|
+
@with_dry_run(summary="Reference {nonexistent_param}")
|
|
69
|
+
def create(name: str) -> None:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
result = runner.invoke(app, ["create", "example.com", "--explain"])
|
|
73
|
+
assert result.exit_code != 0
|
|
74
|
+
# typer.BadParameter renders something like:
|
|
75
|
+
# "Invalid value: with_dry_run summary references unknown parameter 'nonexistent_param'"
|
|
76
|
+
assert "nonexistent_param" in result.output
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Tests for cli_entrypoint error wrapping."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
import typer
|
|
9
|
+
from typer.testing import CliRunner
|
|
10
|
+
|
|
11
|
+
from kctl_lib.error_handler import cli_entrypoint
|
|
12
|
+
from kctl_lib.exceptions import (
|
|
13
|
+
APIError,
|
|
14
|
+
AuthenticationError,
|
|
15
|
+
ConfigError,
|
|
16
|
+
)
|
|
17
|
+
from kctl_lib.exceptions import (
|
|
18
|
+
ConnectionError as KctlConnectionError,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _make_app(raise_exc: Exception) -> typer.Typer:
|
|
23
|
+
app = typer.Typer()
|
|
24
|
+
|
|
25
|
+
@app.command()
|
|
26
|
+
def go() -> None:
|
|
27
|
+
raise raise_exc
|
|
28
|
+
|
|
29
|
+
return cli_entrypoint(app)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# click >= 8.3 removed the `mix_stderr` kwarg and now always separates stderr by
|
|
33
|
+
# default, which is the behavior the tests require. We construct CliRunner via a
|
|
34
|
+
# helper that passes `mix_stderr=False` only on older click versions.
|
|
35
|
+
def _runner() -> CliRunner:
|
|
36
|
+
try:
|
|
37
|
+
return CliRunner(mix_stderr=False) # type: ignore[call-arg]
|
|
38
|
+
except TypeError:
|
|
39
|
+
return CliRunner()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TestConfigError:
|
|
43
|
+
def test_pretty_message(self) -> None:
|
|
44
|
+
runner = _runner()
|
|
45
|
+
app = _make_app(ConfigError("missing profile"))
|
|
46
|
+
result = runner.invoke(app, ["go"])
|
|
47
|
+
assert result.exit_code == 1
|
|
48
|
+
assert "missing profile" in result.stderr
|
|
49
|
+
assert "Traceback" not in result.stderr
|
|
50
|
+
|
|
51
|
+
def test_json_mode(self) -> None:
|
|
52
|
+
runner = _runner()
|
|
53
|
+
app = _make_app(ConfigError("missing profile"))
|
|
54
|
+
result = runner.invoke(app, ["--json", "go"])
|
|
55
|
+
assert result.exit_code == 1
|
|
56
|
+
payload = json.loads(result.stdout)
|
|
57
|
+
assert payload == {
|
|
58
|
+
"error": "ConfigError",
|
|
59
|
+
"message": "missing profile",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TestOtherExceptions:
|
|
64
|
+
@pytest.mark.parametrize(
|
|
65
|
+
"exc,fragment",
|
|
66
|
+
[
|
|
67
|
+
(AuthenticationError("bad token"), "AuthenticationError"),
|
|
68
|
+
(KctlConnectionError("api.example.com", ValueError("timeout")), "ConnectionError"),
|
|
69
|
+
(APIError("rate limited"), "APIError"),
|
|
70
|
+
],
|
|
71
|
+
)
|
|
72
|
+
def test_known_errors_become_clean_messages(self, exc, fragment) -> None:
|
|
73
|
+
runner = _runner()
|
|
74
|
+
app = _make_app(exc)
|
|
75
|
+
result = runner.invoke(app, ["go"])
|
|
76
|
+
assert result.exit_code == 1
|
|
77
|
+
assert fragment in result.stderr
|
|
78
|
+
assert "Traceback" not in result.stderr
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class TestUnknownException:
|
|
82
|
+
def test_unknown_exception_propagates(self) -> None:
|
|
83
|
+
runner = _runner()
|
|
84
|
+
app = _make_app(RuntimeError("surprise"))
|
|
85
|
+
result = runner.invoke(app, ["go"])
|
|
86
|
+
assert result.exit_code != 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class TestRootCallbackError:
|
|
90
|
+
def test_kctl_error_from_root_callback_is_caught(self) -> None:
|
|
91
|
+
from kctl_lib.error_handler import cli_entrypoint
|
|
92
|
+
from kctl_lib.exceptions import ConfigError
|
|
93
|
+
|
|
94
|
+
app = typer.Typer()
|
|
95
|
+
|
|
96
|
+
@app.callback()
|
|
97
|
+
def main() -> None:
|
|
98
|
+
raise ConfigError("bad root config")
|
|
99
|
+
|
|
100
|
+
@app.command()
|
|
101
|
+
def noop() -> None:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
wrapped = cli_entrypoint(app)
|
|
105
|
+
runner = _runner()
|
|
106
|
+
result = runner.invoke(wrapped, ["noop"])
|
|
107
|
+
assert result.exit_code == 1
|
|
108
|
+
assert "bad root config" in result.stderr
|
|
109
|
+
assert "Traceback" not in result.stderr
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class TestIdempotent:
|
|
113
|
+
def test_wrapping_twice_is_noop(self) -> None:
|
|
114
|
+
from kctl_lib.error_handler import cli_entrypoint
|
|
115
|
+
from kctl_lib.exceptions import ConfigError
|
|
116
|
+
|
|
117
|
+
app = typer.Typer()
|
|
118
|
+
|
|
119
|
+
@app.command()
|
|
120
|
+
def go() -> None:
|
|
121
|
+
raise ConfigError("x")
|
|
122
|
+
|
|
123
|
+
wrapped_once = cli_entrypoint(app)
|
|
124
|
+
wrapped_twice = cli_entrypoint(wrapped_once)
|
|
125
|
+
assert wrapped_once is wrapped_twice
|
|
126
|
+
|
|
127
|
+
# Callback should not be double-wrapped
|
|
128
|
+
cb = app.registered_commands[0].callback
|
|
129
|
+
assert getattr(cb, "_kctl_wrapped", False) is True
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Tests for introspection helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from typer.testing import CliRunner
|
|
9
|
+
|
|
10
|
+
from kctl_lib.introspection import (
|
|
11
|
+
_normalize_name,
|
|
12
|
+
dump_command_list,
|
|
13
|
+
dump_command_tree,
|
|
14
|
+
register_introspection_commands,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _sample_app() -> typer.Typer:
|
|
19
|
+
app = typer.Typer(help="Sample app")
|
|
20
|
+
domains = typer.Typer(help="Manage domains")
|
|
21
|
+
app.add_typer(domains, name="domains")
|
|
22
|
+
|
|
23
|
+
@domains.command()
|
|
24
|
+
def list_(active: bool = False) -> None:
|
|
25
|
+
"""List domains."""
|
|
26
|
+
|
|
27
|
+
@app.command()
|
|
28
|
+
def health() -> None:
|
|
29
|
+
"""Check health."""
|
|
30
|
+
|
|
31
|
+
return app
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TestDumpCommandTree:
|
|
35
|
+
def test_shape(self) -> None:
|
|
36
|
+
tree = dump_command_tree(_sample_app())
|
|
37
|
+
assert tree["name"] == "root"
|
|
38
|
+
groups = {g["name"]: g for g in tree["groups"]}
|
|
39
|
+
assert "domains" in groups
|
|
40
|
+
commands = {c["name"] for c in groups["domains"]["commands"]}
|
|
41
|
+
assert commands == {"list"}
|
|
42
|
+
|
|
43
|
+
def test_includes_top_level_commands(self) -> None:
|
|
44
|
+
tree = dump_command_tree(_sample_app())
|
|
45
|
+
top_commands = {c["name"] for c in tree["commands"]}
|
|
46
|
+
assert "health" in top_commands
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TestDumpCommandTreeSingleCommand:
|
|
50
|
+
def test_single_command_app_has_groups_and_commands_keys(self) -> None:
|
|
51
|
+
app = typer.Typer()
|
|
52
|
+
|
|
53
|
+
@app.command()
|
|
54
|
+
def only() -> None:
|
|
55
|
+
"""The only command."""
|
|
56
|
+
|
|
57
|
+
tree = dump_command_tree(app)
|
|
58
|
+
assert "groups" in tree
|
|
59
|
+
assert "commands" in tree
|
|
60
|
+
assert tree["groups"] == []
|
|
61
|
+
# Single-command apps may flatten the command into root params,
|
|
62
|
+
# so don't assert on commands contents — just that the key exists.
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TestNormalizeName:
|
|
66
|
+
def test_strips_trailing_dash(self) -> None:
|
|
67
|
+
assert _normalize_name("list-") == "list"
|
|
68
|
+
|
|
69
|
+
def test_preserves_clean_name(self) -> None:
|
|
70
|
+
assert _normalize_name("list") == "list"
|
|
71
|
+
|
|
72
|
+
def test_preserves_root(self) -> None:
|
|
73
|
+
assert _normalize_name("root") == "root"
|
|
74
|
+
|
|
75
|
+
def test_preserves_internal_dashes(self) -> None:
|
|
76
|
+
assert _normalize_name("sync-jobs") == "sync-jobs"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TestNestedGroups:
|
|
80
|
+
def test_three_level_nesting(self) -> None:
|
|
81
|
+
app = typer.Typer()
|
|
82
|
+
outer = typer.Typer()
|
|
83
|
+
inner = typer.Typer()
|
|
84
|
+
outer.add_typer(inner, name="sequence")
|
|
85
|
+
app.add_typer(outer, name="intercompany")
|
|
86
|
+
|
|
87
|
+
@inner.command()
|
|
88
|
+
def validate() -> None: ...
|
|
89
|
+
|
|
90
|
+
paths = dump_command_list(app)
|
|
91
|
+
assert "intercompany sequence validate" in paths
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class TestDumpCommandList:
|
|
95
|
+
def test_flat_paths(self) -> None:
|
|
96
|
+
paths = dump_command_list(_sample_app())
|
|
97
|
+
assert "domains list" in paths
|
|
98
|
+
assert "health" in paths
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class TestRegister:
|
|
102
|
+
def test_commands_tree_json(self) -> None:
|
|
103
|
+
app = _sample_app()
|
|
104
|
+
register_introspection_commands(app)
|
|
105
|
+
result = CliRunner().invoke(app, ["commands", "tree"])
|
|
106
|
+
assert result.exit_code == 0
|
|
107
|
+
payload = json.loads(result.stdout)
|
|
108
|
+
assert any(g["name"] == "domains" for g in payload["groups"])
|
|
109
|
+
|
|
110
|
+
def test_commands_list_json(self) -> None:
|
|
111
|
+
app = _sample_app()
|
|
112
|
+
register_introspection_commands(app)
|
|
113
|
+
result = CliRunner().invoke(app, ["commands", "list", "--json"])
|
|
114
|
+
assert result.exit_code == 0
|
|
115
|
+
payload = json.loads(result.stdout)
|
|
116
|
+
assert "domains list" in payload
|
|
117
|
+
assert "health" in payload
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
|
+
import pytest
|
|
7
8
|
import typer
|
|
8
9
|
|
|
9
10
|
from kctl_lib.skill_generator import check_stale, generate_skill
|
|
@@ -45,7 +46,8 @@ class TestGenerateSkill:
|
|
|
45
46
|
content = generate_skill(app, "kctl-test", "test-admin", "Test CLI admin")
|
|
46
47
|
assert content.startswith("---")
|
|
47
48
|
assert "name: test-admin" in content
|
|
48
|
-
|
|
49
|
+
# Triggers now live in the body ## Triggers section, not frontmatter
|
|
50
|
+
assert "## Triggers" in content
|
|
49
51
|
|
|
50
52
|
def test_writes_file(self, tmp_path: Path) -> None:
|
|
51
53
|
app = _make_test_app()
|
|
@@ -143,7 +145,10 @@ class TestDomainTriggers:
|
|
|
143
145
|
app = _make_test_app()
|
|
144
146
|
content = generate_skill(app, "kctl-test", "test-admin", "Test")
|
|
145
147
|
# "cmd1" is >3 chars and not in skip list, so should be a trigger
|
|
146
|
-
|
|
148
|
+
# Triggers now live in the body ## Triggers section
|
|
149
|
+
body = content.split("---", 2)[2]
|
|
150
|
+
triggers_section = body.split("## Triggers", 1)[1]
|
|
151
|
+
assert "cmd1" in triggers_section
|
|
147
152
|
|
|
148
153
|
def test_generic_subcommands_skipped(self) -> None:
|
|
149
154
|
app = typer.Typer(name="kctl-test", help="Test")
|
|
@@ -159,7 +164,104 @@ class TestDomainTriggers:
|
|
|
159
164
|
|
|
160
165
|
app.add_typer(sub, name="items")
|
|
161
166
|
content = generate_skill(app, "kctl-test", "test-admin", "Test")
|
|
162
|
-
# Generic names like "list" and "get" should NOT be triggers
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
167
|
+
# Generic names like "list" and "get" should NOT be triggers.
|
|
168
|
+
# Triggers now live in the body ## Triggers section.
|
|
169
|
+
body = content.split("---", 2)[2]
|
|
170
|
+
triggers_section = body.split("## Triggers", 1)[1].split("\n##", 1)[0]
|
|
171
|
+
assert '"list"' not in triggers_section
|
|
172
|
+
assert '"get"' not in triggers_section
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class TestSlimFrontmatter:
|
|
176
|
+
def test_description_capped_at_200_chars(self) -> None:
|
|
177
|
+
# Build a Typer app with many command groups -> many triggers
|
|
178
|
+
app = typer.Typer()
|
|
179
|
+
for name in ("alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta"):
|
|
180
|
+
sub = typer.Typer()
|
|
181
|
+
for verb in ("list", "get", "create", "update", "delete"):
|
|
182
|
+
|
|
183
|
+
@sub.command(verb)
|
|
184
|
+
def _impl() -> None: ...
|
|
185
|
+
|
|
186
|
+
app.add_typer(sub, name=name)
|
|
187
|
+
|
|
188
|
+
skill_md = generate_skill(app, "kctl-sample", "sample-admin", "Sample CLI admin")
|
|
189
|
+
# Extract description: line(s) from frontmatter. The frontmatter uses
|
|
190
|
+
# `description: >` folded scalar style, so collect the indented
|
|
191
|
+
# continuation lines until we reach a non-indented key or end of block.
|
|
192
|
+
first_block = skill_md.split("---", 2)[1]
|
|
193
|
+
desc_lines: list[str] = []
|
|
194
|
+
in_desc = False
|
|
195
|
+
for line in first_block.splitlines():
|
|
196
|
+
if line.startswith("description:"):
|
|
197
|
+
in_desc = True
|
|
198
|
+
# capture inline value after "description:" if any
|
|
199
|
+
inline = line.removeprefix("description:").strip()
|
|
200
|
+
if inline and inline != ">":
|
|
201
|
+
desc_lines.append(inline.strip("\"'"))
|
|
202
|
+
continue
|
|
203
|
+
if in_desc:
|
|
204
|
+
# folded scalar continuation lines are indented
|
|
205
|
+
if line.startswith((" ", "\t")):
|
|
206
|
+
desc_lines.append(line.strip())
|
|
207
|
+
else:
|
|
208
|
+
break
|
|
209
|
+
assert desc_lines, "description: not found in frontmatter"
|
|
210
|
+
# Folded scalar joins with spaces
|
|
211
|
+
desc = " ".join(desc_lines).strip()
|
|
212
|
+
assert len(desc) <= 200, f"description too long: {len(desc)} chars: {desc!r}"
|
|
213
|
+
|
|
214
|
+
def test_triggers_section_in_body(self) -> None:
|
|
215
|
+
app = typer.Typer()
|
|
216
|
+
sub = typer.Typer()
|
|
217
|
+
|
|
218
|
+
@sub.command("list")
|
|
219
|
+
def _list() -> None: ...
|
|
220
|
+
|
|
221
|
+
app.add_typer(sub, name="domains")
|
|
222
|
+
skill_md = generate_skill(app, "kctl-sample", "sample-admin", "Sample CLI admin")
|
|
223
|
+
assert "## Triggers" in skill_md
|
|
224
|
+
# Triggers section appears AFTER frontmatter close, in the body
|
|
225
|
+
body = skill_md.split("---", 2)[2]
|
|
226
|
+
assert "## Triggers" in body
|
|
227
|
+
triggers_section = body.split("## Triggers", 1)[1]
|
|
228
|
+
# Group name 'domains' should appear in the triggers list
|
|
229
|
+
assert "domains" in triggers_section
|
|
230
|
+
|
|
231
|
+
def test_frontmatter_does_not_contain_full_trigger_list(self) -> None:
|
|
232
|
+
# The frontmatter should NOT include the full trigger keyword list
|
|
233
|
+
# to keep the description short (<=200 chars).
|
|
234
|
+
app = typer.Typer()
|
|
235
|
+
for name in ("alpha", "beta", "gamma", "delta", "epsilon"):
|
|
236
|
+
sub = typer.Typer()
|
|
237
|
+
|
|
238
|
+
@sub.command("audit")
|
|
239
|
+
def _audit() -> None: ...
|
|
240
|
+
|
|
241
|
+
app.add_typer(sub, name=name)
|
|
242
|
+
|
|
243
|
+
skill_md = generate_skill(app, "kctl-sample", "sample-admin", "Sample CLI admin")
|
|
244
|
+
first_block = skill_md.split("---", 2)[1]
|
|
245
|
+
# The literal full trigger phrase should not appear in frontmatter
|
|
246
|
+
assert "Triggers on:" not in first_block
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class TestDescriptionCap:
|
|
250
|
+
def test_oversize_description_raises_value_error(self) -> None:
|
|
251
|
+
"""Cap must be runtime-enforced (not just an assert that PYTHONOPTIMIZE strips)."""
|
|
252
|
+
# Build an app whose generated description would exceed 200 chars.
|
|
253
|
+
# We force this by passing a long `description` parameter directly
|
|
254
|
+
# (the template substitutes it into the frontmatter line).
|
|
255
|
+
app = typer.Typer()
|
|
256
|
+
|
|
257
|
+
@app.command()
|
|
258
|
+
def noop() -> None: ...
|
|
259
|
+
|
|
260
|
+
long_desc = "X" * 500 # ensures total > 200 regardless of other content
|
|
261
|
+
with pytest.raises(ValueError, match="must be <=200 chars"):
|
|
262
|
+
generate_skill(
|
|
263
|
+
typer_app=app,
|
|
264
|
+
cli_name="kctl-overflow",
|
|
265
|
+
skill_name="overflow-admin",
|
|
266
|
+
description=long_desc,
|
|
267
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|