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.
Files changed (84) hide show
  1. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/PKG-INFO +1 -1
  2. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/pyproject.toml +1 -1
  3. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/__init__.py +9 -1
  4. kctl_lib-0.9.0/src/kctl_lib/dry_run.py +76 -0
  5. kctl_lib-0.9.0/src/kctl_lib/error_handler.py +140 -0
  6. kctl_lib-0.9.0/src/kctl_lib/introspection.py +108 -0
  7. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/skill_generator.py +23 -3
  8. kctl_lib-0.9.0/tests/test_dry_run.py +76 -0
  9. kctl_lib-0.9.0/tests/test_error_handler.py +129 -0
  10. kctl_lib-0.9.0/tests/test_introspection.py +117 -0
  11. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_skill_generator.py +108 -6
  12. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/.gitignore +0 -0
  13. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/README.md +0 -0
  14. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/_migration.py +0 -0
  15. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/api_client.py +0 -0
  16. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/async_api_client.py +0 -0
  17. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/callbacks.py +0 -0
  18. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/cli/__init__.py +0 -0
  19. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/cli/commands/__init__.py +0 -0
  20. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/cli/commands/current.py +0 -0
  21. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/cli/commands/list_cmd.py +0 -0
  22. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/cli/commands/migrate.py +0 -0
  23. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/cli/commands/show.py +0 -0
  24. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/cli/commands/validate.py +0 -0
  25. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/cli/main.py +0 -0
  26. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/completions.py +0 -0
  27. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/config.py +0 -0
  28. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/docker.py +0 -0
  29. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/doctor_base.py +0 -0
  30. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/exceptions.py +0 -0
  31. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/git_ops.py +0 -0
  32. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/history.py +0 -0
  33. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/logging_utils.py +0 -0
  34. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/monitor_base.py +0 -0
  35. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/output.py +0 -0
  36. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/plugins.py +0 -0
  37. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/progress.py +0 -0
  38. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/py.typed +0 -0
  39. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/runner.py +0 -0
  40. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/schemas.py +0 -0
  41. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/secrets.py +0 -0
  42. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/self_update.py +0 -0
  43. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/ssh.py +0 -0
  44. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/ssh_tunnel.py +0 -0
  45. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/testing.py +0 -0
  46. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/tui.py +0 -0
  47. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/tui.tcss +0 -0
  48. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/src/kctl_lib/validate.py +0 -0
  49. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/conftest.py +0 -0
  50. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/fixtures/clean_config.yaml +0 -0
  51. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/fixtures/dirty_config.yaml +0 -0
  52. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_api_client.py +0 -0
  53. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_async_api_client.py +0 -0
  54. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_callbacks.py +0 -0
  55. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_cli_current.py +0 -0
  56. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_cli_list.py +0 -0
  57. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_cli_migrate.py +0 -0
  58. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_cli_show.py +0 -0
  59. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_cli_smoke.py +0 -0
  60. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_cli_validate.py +0 -0
  61. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_completions.py +0 -0
  62. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_config.py +0 -0
  63. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_docker.py +0 -0
  64. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_doctor_base.py +0 -0
  65. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_exceptions.py +0 -0
  66. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_git_ops.py +0 -0
  67. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_history.py +0 -0
  68. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_inheritance.py +0 -0
  69. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_logging_utils.py +0 -0
  70. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_mask_secret_fields.py +0 -0
  71. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_migration.py +0 -0
  72. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_monitor_base.py +0 -0
  73. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_output.py +0 -0
  74. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_plugins.py +0 -0
  75. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_profile_banner.py +0 -0
  76. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_progress.py +0 -0
  77. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_runner.py +0 -0
  78. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_schemas.py +0 -0
  79. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_secrets.py +0 -0
  80. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_self_update.py +0 -0
  81. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_ssh.py +0 -0
  82. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_ssh_tunnel.py +0 -0
  83. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_tui.py +0 -0
  84. {kctl_lib-0.8.2 → kctl_lib-0.9.0}/tests/test_validate.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kctl-lib
3
- Version: 0.8.2
3
+ Version: 0.9.0
4
4
  Summary: Shared core library for kctl-* CLI tools
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: httpx>=0.28.0
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "kctl-lib"
7
- version = "0.8.2"
7
+ version = "0.9.0"
8
8
  description = "Shared core library for kctl-* CLI tools"
9
9
  requires-python = ">=3.12"
10
10
  dependencies = [
@@ -24,13 +24,15 @@ Public API:
24
24
  - tui: Textual TUI (add_tui_command)
25
25
  """
26
26
 
27
- __version__ = "0.8.2"
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" {description} ({len(groups)} groups, ~{total_commands} commands).")
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
- assert "Triggers on:" in content
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
- assert '"cmd1"' in content
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
- triggers_line = [line for line in content.splitlines() if "Triggers on:" in line][0]
164
- assert '"list"' not in triggers_line
165
- assert '"get"' not in triggers_line
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