kx-cli 0.0.6__tar.gz → 0.0.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. {kx_cli-0.0.6/src/kx_cli.egg-info → kx_cli-0.0.8}/PKG-INFO +43 -7
  2. {kx_cli-0.0.6 → kx_cli-0.0.8}/README.md +39 -5
  3. {kx_cli-0.0.6 → kx_cli-0.0.8}/pyproject.toml +5 -2
  4. {kx_cli-0.0.6 → kx_cli-0.0.8}/src/kx/commands/delete.py +9 -2
  5. {kx_cli-0.0.6 → kx_cli-0.0.8}/src/kx/commands/describe.py +4 -1
  6. {kx_cli-0.0.6 → kx_cli-0.0.8}/src/kx/commands/edit.py +6 -1
  7. {kx_cli-0.0.6 → kx_cli-0.0.8}/src/kx/commands/events.py +12 -1
  8. {kx_cli-0.0.6 → kx_cli-0.0.8}/src/kx/commands/exec.py +3 -0
  9. {kx_cli-0.0.6 → kx_cli-0.0.8}/src/kx/commands/get.py +13 -4
  10. {kx_cli-0.0.6 → kx_cli-0.0.8}/src/kx/commands/logs.py +17 -3
  11. {kx_cli-0.0.6 → kx_cli-0.0.8}/src/kx/commands/port_forward.py +4 -1
  12. {kx_cli-0.0.6 → kx_cli-0.0.8}/src/kx/console.py +64 -10
  13. kx_cli-0.0.8/src/kx/errors.py +80 -0
  14. {kx_cli-0.0.6 → kx_cli-0.0.8}/src/kx/kinds.py +4 -0
  15. {kx_cli-0.0.6 → kx_cli-0.0.8}/src/kx/main.py +115 -25
  16. kx_cli-0.0.8/src/kx/refresh.py +63 -0
  17. {kx_cli-0.0.6 → kx_cli-0.0.8}/src/kx/state.py +23 -2
  18. {kx_cli-0.0.6 → kx_cli-0.0.8}/src/kx/types.py +2 -0
  19. {kx_cli-0.0.6 → kx_cli-0.0.8/src/kx_cli.egg-info}/PKG-INFO +43 -7
  20. {kx_cli-0.0.6 → kx_cli-0.0.8}/src/kx_cli.egg-info/SOURCES.txt +3 -0
  21. {kx_cli-0.0.6 → kx_cli-0.0.8}/src/kx_cli.egg-info/requires.txt +4 -1
  22. {kx_cli-0.0.6 → kx_cli-0.0.8}/tests/test_cli_get.py +14 -0
  23. {kx_cli-0.0.6 → kx_cli-0.0.8}/tests/test_cli_help.py +12 -0
  24. kx_cli-0.0.8/tests/test_cli_kind_alias.py +155 -0
  25. {kx_cli-0.0.6 → kx_cli-0.0.8}/tests/test_console.py +23 -0
  26. {kx_cli-0.0.6 → kx_cli-0.0.8}/tests/test_delete.py +27 -0
  27. {kx_cli-0.0.6 → kx_cli-0.0.8}/tests/test_describe.py +26 -0
  28. {kx_cli-0.0.6 → kx_cli-0.0.8}/tests/test_edit.py +26 -0
  29. kx_cli-0.0.8/tests/test_error_handling.py +193 -0
  30. {kx_cli-0.0.6 → kx_cli-0.0.8}/tests/test_events.py +45 -2
  31. {kx_cli-0.0.6 → kx_cli-0.0.8}/tests/test_exec.py +36 -2
  32. {kx_cli-0.0.6 → kx_cli-0.0.8}/tests/test_get.py +25 -0
  33. {kx_cli-0.0.6 → kx_cli-0.0.8}/tests/test_logs.py +49 -0
  34. kx_cli-0.0.8/tests/test_namespace.py +105 -0
  35. {kx_cli-0.0.6 → kx_cli-0.0.8}/tests/test_port_forward.py +26 -0
  36. kx_cli-0.0.8/tests/test_refresh.py +82 -0
  37. {kx_cli-0.0.6 → kx_cli-0.0.8}/tests/test_state.py +50 -1
  38. kx_cli-0.0.6/src/kx/errors.py +0 -27
  39. kx_cli-0.0.6/tests/test_error_handling.py +0 -94
  40. kx_cli-0.0.6/tests/test_namespace.py +0 -51
  41. {kx_cli-0.0.6 → kx_cli-0.0.8}/LICENSE +0 -0
  42. {kx_cli-0.0.6 → kx_cli-0.0.8}/setup.cfg +0 -0
  43. {kx_cli-0.0.6 → kx_cli-0.0.8}/src/kx/__init__.py +0 -0
  44. {kx_cli-0.0.6 → kx_cli-0.0.8}/src/kx/commands/__init__.py +0 -0
  45. {kx_cli-0.0.6 → kx_cli-0.0.8}/src/kx/commands/back.py +0 -0
  46. {kx_cli-0.0.6 → kx_cli-0.0.8}/src/kx/commands/diagnostic.py +0 -0
  47. {kx_cli-0.0.6 → kx_cli-0.0.8}/src/kx/commands/drop.py +0 -0
  48. {kx_cli-0.0.6 → kx_cli-0.0.8}/src/kx/commands/forward.py +0 -0
  49. {kx_cli-0.0.6 → kx_cli-0.0.8}/src/kx/commands/labels.py +0 -0
  50. {kx_cli-0.0.6 → kx_cli-0.0.8}/src/kx/commands/namespace.py +0 -0
  51. {kx_cli-0.0.6 → kx_cli-0.0.8}/src/kx/commands/rollout.py +0 -0
  52. {kx_cli-0.0.6 → kx_cli-0.0.8}/src/kx/commands/scale.py +0 -0
  53. {kx_cli-0.0.6 → kx_cli-0.0.8}/src/kx/commands/state.py +0 -0
  54. {kx_cli-0.0.6 → kx_cli-0.0.8}/src/kx/commands/theme.py +0 -0
  55. {kx_cli-0.0.6 → kx_cli-0.0.8}/src/kx/commands/tree.py +0 -0
  56. {kx_cli-0.0.6 → kx_cli-0.0.8}/src/kx/commands/yaml.py +0 -0
  57. {kx_cli-0.0.6 → kx_cli-0.0.8}/src/kx/config.py +0 -0
  58. {kx_cli-0.0.6 → kx_cli-0.0.8}/src/kx/diagnostics.py +0 -0
  59. {kx_cli-0.0.6 → kx_cli-0.0.8}/src/kx/events.py +0 -0
  60. {kx_cli-0.0.6 → kx_cli-0.0.8}/src/kx/graph.py +0 -0
  61. {kx_cli-0.0.6 → kx_cli-0.0.8}/src/kx/index.py +0 -0
  62. {kx_cli-0.0.6 → kx_cli-0.0.8}/src/kx/k8s.py +0 -0
  63. {kx_cli-0.0.6 → kx_cli-0.0.8}/src/kx/kubectl.py +0 -0
  64. {kx_cli-0.0.6 → kx_cli-0.0.8}/src/kx/themes.py +0 -0
  65. {kx_cli-0.0.6 → kx_cli-0.0.8}/src/kx_cli.egg-info/dependency_links.txt +0 -0
  66. {kx_cli-0.0.6 → kx_cli-0.0.8}/src/kx_cli.egg-info/entry_points.txt +0 -0
  67. {kx_cli-0.0.6 → kx_cli-0.0.8}/src/kx_cli.egg-info/top_level.txt +0 -0
  68. {kx_cli-0.0.6 → kx_cli-0.0.8}/tests/test_back.py +0 -0
  69. {kx_cli-0.0.6 → kx_cli-0.0.8}/tests/test_config.py +0 -0
  70. {kx_cli-0.0.6 → kx_cli-0.0.8}/tests/test_diagnostic.py +0 -0
  71. {kx_cli-0.0.6 → kx_cli-0.0.8}/tests/test_diagnostics.py +0 -0
  72. {kx_cli-0.0.6 → kx_cli-0.0.8}/tests/test_drop.py +0 -0
  73. {kx_cli-0.0.6 → kx_cli-0.0.8}/tests/test_forward.py +0 -0
  74. {kx_cli-0.0.6 → kx_cli-0.0.8}/tests/test_graph.py +0 -0
  75. {kx_cli-0.0.6 → kx_cli-0.0.8}/tests/test_index.py +0 -0
  76. {kx_cli-0.0.6 → kx_cli-0.0.8}/tests/test_kinds.py +0 -0
  77. {kx_cli-0.0.6 → kx_cli-0.0.8}/tests/test_labels.py +0 -0
  78. {kx_cli-0.0.6 → kx_cli-0.0.8}/tests/test_rollout.py +0 -0
  79. {kx_cli-0.0.6 → kx_cli-0.0.8}/tests/test_scale.py +0 -0
  80. {kx_cli-0.0.6 → kx_cli-0.0.8}/tests/test_theme.py +0 -0
  81. {kx_cli-0.0.6 → kx_cli-0.0.8}/tests/test_themes.py +0 -0
  82. {kx_cli-0.0.6 → kx_cli-0.0.8}/tests/test_tree.py +0 -0
  83. {kx_cli-0.0.6 → kx_cli-0.0.8}/tests/test_yaml.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kx-cli
3
- Version: 0.0.6
3
+ Version: 0.0.8
4
4
  Summary: kubectl wrapper with index-based resource selection
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: Programming Language :: Python :: 3.11
@@ -9,7 +9,7 @@ Classifier: Programming Language :: Python :: 3.13
9
9
  Requires-Python: >=3.11
10
10
  Description-Content-Type: text/markdown
11
11
  License-File: LICENSE
12
- Requires-Dist: typer==0.26.8
12
+ Requires-Dist: typer==0.27.0
13
13
  Requires-Dist: kubernetes
14
14
  Requires-Dist: rich
15
15
  Provides-Extra: dev
@@ -17,6 +17,8 @@ Requires-Dist: ruff; extra == "dev"
17
17
  Requires-Dist: pre-commit; extra == "dev"
18
18
  Requires-Dist: pytest; extra == "dev"
19
19
  Requires-Dist: pytest-cov; extra == "dev"
20
+ Provides-Extra: build
21
+ Requires-Dist: pyinstaller; extra == "build"
20
22
  Dynamic: license-file
21
23
 
22
24
  <div align="center">
@@ -48,10 +50,42 @@ Dynamic: license-file
48
50
 
49
51
  ## Install
50
52
 
53
+ Requires Python 3.11+ and `kubectl` on your PATH.
54
+
55
+ With [uv](https://docs.astral.sh/uv/) (recommended):
56
+
57
+ ```bash
58
+ uv tool install kx-cli
59
+ ```
60
+
61
+ With [pipx](https://pipx.pypa.io/):
62
+
63
+ ```bash
64
+ pipx install kx-cli
65
+ ```
66
+
67
+ With pip:
68
+
51
69
  ```bash
52
70
  pip install kx-cli
53
71
  ```
54
72
 
73
+ As a kubectl plugin via [krew](https://krew.sigs.k8s.io/), where kx is published as `idx` (no Python required; pending krew-index acceptance):
74
+
75
+ ```bash
76
+ kubectl krew install idx
77
+ alias kx="kubectl idx"
78
+ ```
79
+
80
+ Standalone binaries (linux/macOS, amd64/arm64, no Python required) are attached to each [GitHub Release](https://github.com/jzills/kx/releases).
81
+
82
+ Or try it without installing (the package is `kx-cli`, the command is `kx`):
83
+
84
+ ```bash
85
+ uvx --from kx-cli kx get pods
86
+ pipx run --spec kx-cli kx get pods
87
+ ```
88
+
55
89
  ## Usage
56
90
 
57
91
  ### List resources
@@ -60,7 +94,9 @@ pip install kx-cli
60
94
  kx get <resource> [--match|-m <substring>] [kubectl flags...]
61
95
  ```
62
96
 
63
- Fetches resources and assigns index numbers. Any extra flags (e.g. `-n <namespace>`, `-A`) are passed through to kubectl. Use `--match`/`-m` to filter results by name (substring, case-insensitive).
97
+ Fetches resources and assigns index numbers. Any extra flags (e.g. `-n <namespace>`, `-A`) are passed through to kubectl. Use `--match`/`-m` to filter results by name (substring, case-insensitive). All-namespace listings (`-A`) are display-only — rows aren't indexed, since names aren't unique across namespaces; scope to a namespace to select.
98
+
99
+ Known kinds can drop the `get`: `kx pods`, `kx deploy -n kube-system`, `kx svc --match api`. This covers the built-in kinds and their kubectl shorthands (`po`, `deploy`, `svc`, `sts`, ...); existing commands take precedence, so `kx ns 3` still switches namespaces (bare `kx ns` lists them). CRDs and other resource types still use `kx get <resource>`. An integer after a kind is an index into the current state: `kx po 3` (or `kx get po 3`) relists just that pod, erroring if index 3 isn't a pod. Multiple indexes work too: `kx po 1 3`.
64
100
 
65
101
  ```
66
102
  $ kx get pods
@@ -73,19 +109,19 @@ Pods · default · 3 items
73
109
 
74
110
  All subsequent commands reference resources by their `X` index from the last `kx get`.
75
111
 
76
- Global flags: `--no-color` disables styled output, `--version` prints the installed version, and `--help` on any command shows usage, examples, and aliases.
112
+ Global flags: `--no-color` disables styled output, `-v`/`--version` prints the installed version, and `--help` on any command shows usage, examples, and aliases.
77
113
 
78
114
  ### Commands
79
115
 
80
116
  <!-- commands-table-start -->
81
117
  | Command | Description |
82
118
  |---|---|
83
- | `kx get <resource> [--match/-m text] [kubectl flags...]` | List resources and assign index numbers for use with other commands. |
119
+ | `kx get <resource> [--match/-m str] [kubectl flags...]` | List resources and assign index numbers for use with other commands; shorthand: kx <kind> (e.g. kx pods, kx po 3). |
84
120
  | `kx describe <indexes>... [kubectl flags...]` | Show full kubectl describe output for one or more indexed resources. |
85
121
  | `kx events <indexes>...` | Show Kubernetes events for one or more indexed resources. |
86
122
  | `kx logs <index> [kubectl flags...]` | Stream logs for an indexed resource; aggregates across pods for Deployments, StatefulSets, DaemonSets, and Services. |
87
123
  | `kx labels <indexes>... [--selector/-s]` | Show labels for one or more indexed resources; --selector formats output as a label selector. |
88
- | `kx yaml <indexes>... [--show text]` | Print the raw YAML manifest for one or more indexed resources; --show filters to specific top-level fields. |
124
+ | `kx yaml <indexes>... [--show str]` | Print the raw YAML manifest for one or more indexed resources; --show filters to specific top-level fields. |
89
125
  | `kx delete <indexes>... [--yes/-y]` | Delete one or more indexed resources (prompts for confirmation unless --yes). |
90
126
  | `kx edit <index> [kubectl flags...]` | Open an indexed resource in your editor via kubectl edit. |
91
127
  | `kx exec <index> [<cmd>...] [kubectl flags...]` | Open an interactive shell in an indexed pod (bash, falling back to sh). |
@@ -94,7 +130,7 @@ Global flags: `--no-color` disables styled output, `--version` prints the instal
94
130
  | `kx scale <index> <replicas>` | Scale an indexed Deployment, StatefulSet, or ReplicaSet to a given replica count. |
95
131
  | `kx port-forward <index> <port> [kubectl flags...]` | Forward a local port to an indexed resource (Pod, Deployment, ReplicaSet, StatefulSet, DaemonSet, Service). |
96
132
  | `kx diagnostic <index>` | Run read-only health diagnostics on an indexed Deployment, StatefulSet, DaemonSet, or Pod; alias: kx diag. |
97
- | `kx namespace <index>` | Switch to an indexed namespace; alias: kx ns (run kx get namespaces first). |
133
+ | `kx namespace [<index>]` | List namespaces, or switch to an indexed one; alias: kx ns. |
98
134
  | `kx theme [<name>]` | List available color themes or persist a choice by name or index. |
99
135
  | `kx state [<position>] [--all/-a]` | Show current state, jump to a history position, or list all entries with --all. |
100
136
  | `kx drop <position>` | Remove a history entry by position (shown in kx state --all). |
@@ -27,10 +27,42 @@
27
27
 
28
28
  ## Install
29
29
 
30
+ Requires Python 3.11+ and `kubectl` on your PATH.
31
+
32
+ With [uv](https://docs.astral.sh/uv/) (recommended):
33
+
34
+ ```bash
35
+ uv tool install kx-cli
36
+ ```
37
+
38
+ With [pipx](https://pipx.pypa.io/):
39
+
40
+ ```bash
41
+ pipx install kx-cli
42
+ ```
43
+
44
+ With pip:
45
+
30
46
  ```bash
31
47
  pip install kx-cli
32
48
  ```
33
49
 
50
+ As a kubectl plugin via [krew](https://krew.sigs.k8s.io/), where kx is published as `idx` (no Python required; pending krew-index acceptance):
51
+
52
+ ```bash
53
+ kubectl krew install idx
54
+ alias kx="kubectl idx"
55
+ ```
56
+
57
+ Standalone binaries (linux/macOS, amd64/arm64, no Python required) are attached to each [GitHub Release](https://github.com/jzills/kx/releases).
58
+
59
+ Or try it without installing (the package is `kx-cli`, the command is `kx`):
60
+
61
+ ```bash
62
+ uvx --from kx-cli kx get pods
63
+ pipx run --spec kx-cli kx get pods
64
+ ```
65
+
34
66
  ## Usage
35
67
 
36
68
  ### List resources
@@ -39,7 +71,9 @@ pip install kx-cli
39
71
  kx get <resource> [--match|-m <substring>] [kubectl flags...]
40
72
  ```
41
73
 
42
- Fetches resources and assigns index numbers. Any extra flags (e.g. `-n <namespace>`, `-A`) are passed through to kubectl. Use `--match`/`-m` to filter results by name (substring, case-insensitive).
74
+ Fetches resources and assigns index numbers. Any extra flags (e.g. `-n <namespace>`, `-A`) are passed through to kubectl. Use `--match`/`-m` to filter results by name (substring, case-insensitive). All-namespace listings (`-A`) are display-only — rows aren't indexed, since names aren't unique across namespaces; scope to a namespace to select.
75
+
76
+ Known kinds can drop the `get`: `kx pods`, `kx deploy -n kube-system`, `kx svc --match api`. This covers the built-in kinds and their kubectl shorthands (`po`, `deploy`, `svc`, `sts`, ...); existing commands take precedence, so `kx ns 3` still switches namespaces (bare `kx ns` lists them). CRDs and other resource types still use `kx get <resource>`. An integer after a kind is an index into the current state: `kx po 3` (or `kx get po 3`) relists just that pod, erroring if index 3 isn't a pod. Multiple indexes work too: `kx po 1 3`.
43
77
 
44
78
  ```
45
79
  $ kx get pods
@@ -52,19 +86,19 @@ Pods · default · 3 items
52
86
 
53
87
  All subsequent commands reference resources by their `X` index from the last `kx get`.
54
88
 
55
- Global flags: `--no-color` disables styled output, `--version` prints the installed version, and `--help` on any command shows usage, examples, and aliases.
89
+ Global flags: `--no-color` disables styled output, `-v`/`--version` prints the installed version, and `--help` on any command shows usage, examples, and aliases.
56
90
 
57
91
  ### Commands
58
92
 
59
93
  <!-- commands-table-start -->
60
94
  | Command | Description |
61
95
  |---|---|
62
- | `kx get <resource> [--match/-m text] [kubectl flags...]` | List resources and assign index numbers for use with other commands. |
96
+ | `kx get <resource> [--match/-m str] [kubectl flags...]` | List resources and assign index numbers for use with other commands; shorthand: kx <kind> (e.g. kx pods, kx po 3). |
63
97
  | `kx describe <indexes>... [kubectl flags...]` | Show full kubectl describe output for one or more indexed resources. |
64
98
  | `kx events <indexes>...` | Show Kubernetes events for one or more indexed resources. |
65
99
  | `kx logs <index> [kubectl flags...]` | Stream logs for an indexed resource; aggregates across pods for Deployments, StatefulSets, DaemonSets, and Services. |
66
100
  | `kx labels <indexes>... [--selector/-s]` | Show labels for one or more indexed resources; --selector formats output as a label selector. |
67
- | `kx yaml <indexes>... [--show text]` | Print the raw YAML manifest for one or more indexed resources; --show filters to specific top-level fields. |
101
+ | `kx yaml <indexes>... [--show str]` | Print the raw YAML manifest for one or more indexed resources; --show filters to specific top-level fields. |
68
102
  | `kx delete <indexes>... [--yes/-y]` | Delete one or more indexed resources (prompts for confirmation unless --yes). |
69
103
  | `kx edit <index> [kubectl flags...]` | Open an indexed resource in your editor via kubectl edit. |
70
104
  | `kx exec <index> [<cmd>...] [kubectl flags...]` | Open an interactive shell in an indexed pod (bash, falling back to sh). |
@@ -73,7 +107,7 @@ Global flags: `--no-color` disables styled output, `--version` prints the instal
73
107
  | `kx scale <index> <replicas>` | Scale an indexed Deployment, StatefulSet, or ReplicaSet to a given replica count. |
74
108
  | `kx port-forward <index> <port> [kubectl flags...]` | Forward a local port to an indexed resource (Pod, Deployment, ReplicaSet, StatefulSet, DaemonSet, Service). |
75
109
  | `kx diagnostic <index>` | Run read-only health diagnostics on an indexed Deployment, StatefulSet, DaemonSet, or Pod; alias: kx diag. |
76
- | `kx namespace <index>` | Switch to an indexed namespace; alias: kx ns (run kx get namespaces first). |
110
+ | `kx namespace [<index>]` | List namespaces, or switch to an indexed one; alias: kx ns. |
77
111
  | `kx theme [<name>]` | List available color themes or persist a choice by name or index. |
78
112
  | `kx state [<position>] [--all/-a]` | Show current state, jump to a history position, or list all entries with --all. |
79
113
  | `kx drop <position>` | Remove a history entry by position (shown in kx state --all). |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kx-cli"
3
- version = "0.0.6"
3
+ version = "0.0.8"
4
4
  description = "kubectl wrapper with index-based resource selection"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -11,7 +11,7 @@ classifiers = [
11
11
  "Programming Language :: Python :: 3.13",
12
12
  ]
13
13
  dependencies = [
14
- "typer==0.26.8",
14
+ "typer==0.27.0",
15
15
  "kubernetes",
16
16
  "rich",
17
17
  ]
@@ -23,6 +23,9 @@ dev = [
23
23
  "pytest",
24
24
  "pytest-cov",
25
25
  ]
26
+ build = [
27
+ "pyinstaller",
28
+ ]
26
29
 
27
30
  [project.scripts]
28
31
  kx = "kx.main:app"
@@ -1,6 +1,8 @@
1
+ from contextlib import nullcontext
2
+
1
3
  from kx.kubectl import KubectlServiceProtocol
2
4
  from kx.state import StateServiceProtocol
3
- from kx.types import Confirm
5
+ from kx.types import Confirm, Status
4
6
 
5
7
 
6
8
  class DeleteCommand:
@@ -9,14 +11,19 @@ class DeleteCommand:
9
11
  state: StateServiceProtocol,
10
12
  kubectl: KubectlServiceProtocol,
11
13
  confirm: Confirm,
14
+ status: Status | None = None,
12
15
  ):
13
16
  self.state = state
14
17
  self.kubectl = kubectl
15
18
  self.confirm = confirm
19
+ self.status = status or (lambda _msg: nullcontext())
16
20
 
17
21
  def execute(self, index: int, yes: bool) -> str:
18
22
  name, namespace, kind = self.state.fields(index)
23
+ # The confirm prompt must stay outside the spinner: a prompt inside a
24
+ # Live region breaks input.
19
25
  if not yes:
20
26
  self.confirm(f"Delete {kind}/{name} in {namespace}?")
21
- self.kubectl.run(["delete", kind, name, "-n", namespace])
27
+ with self.status("deleting"):
28
+ self.kubectl.run(["delete", kind, name, "-n", namespace])
22
29
  return f"Deleted {kind}/{name}"
@@ -1,4 +1,5 @@
1
1
  from kx.kubectl import KubectlServiceProtocol
2
+ from kx.refresh import ensure_exists
2
3
  from kx.state import StateServiceProtocol
3
4
 
4
5
 
@@ -14,6 +15,8 @@ class DescribeCommand:
14
15
  def execute(self, index: int, extra_args: list[str] | None = None) -> None:
15
16
  extra_args = extra_args or []
16
17
  name, namespace, kind = self.state.fields(index)
17
- self.kubectl.run_interactive(
18
+ rc = self.kubectl.run_interactive(
18
19
  ["describe", kind, name, "-n", namespace, *extra_args]
19
20
  )
21
+ if rc != 0:
22
+ ensure_exists(self.kubectl, kind, name, namespace)
@@ -1,4 +1,5 @@
1
1
  from kx.kubectl import KubectlServiceProtocol
2
+ from kx.refresh import ensure_exists
2
3
  from kx.state import StateServiceProtocol
3
4
 
4
5
 
@@ -10,4 +11,8 @@ class EditCommand:
10
11
  def execute(self, index: int, extra_args: list[str] | None = None) -> None:
11
12
  extra_args = extra_args or []
12
13
  name, namespace, kind = self.state.fields(index)
13
- self.kubectl.run_interactive(["edit", kind, name, "-n", namespace, *extra_args])
14
+ rc = self.kubectl.run_interactive(
15
+ ["edit", kind, name, "-n", namespace, *extra_args]
16
+ )
17
+ if rc != 0:
18
+ ensure_exists(self.kubectl, kind, name, namespace)
@@ -1,11 +1,19 @@
1
1
  from kx.events import EventsServiceProtocol
2
+ from kx.kubectl import KubectlServiceProtocol
3
+ from kx.refresh import ensure_exists
2
4
  from kx.state import StateServiceProtocol
3
5
 
4
6
 
5
7
  class EventsCommand:
6
- def __init__(self, state: StateServiceProtocol, events: EventsServiceProtocol):
8
+ def __init__(
9
+ self,
10
+ state: StateServiceProtocol,
11
+ events: EventsServiceProtocol,
12
+ kubectl: KubectlServiceProtocol,
13
+ ):
7
14
  self.state = state
8
15
  self.events = events
16
+ self.kubectl = kubectl
9
17
 
10
18
  def execute(self, index: int) -> str:
11
19
  name, namespace, kind = self.state.fields(index)
@@ -13,6 +21,9 @@ class EventsCommand:
13
21
  filtered = self.events.filter(all_events, name, kind)
14
22
 
15
23
  if not filtered:
24
+ # Deleted resources keep their events ~1h; only an empty result
25
+ # warrants checking whether the target itself is gone.
26
+ ensure_exists(self.kubectl, kind, name, namespace)
16
27
  return "No events found"
17
28
 
18
29
  output = []
@@ -2,6 +2,7 @@ import subprocess
2
2
 
3
3
  from kx.kinds import Kind
4
4
  from kx.kubectl import KubectlServiceProtocol
5
+ from kx.refresh import ensure_exists
5
6
  from kx.state import StateServiceProtocol
6
7
 
7
8
 
@@ -29,6 +30,7 @@ class ExecCommand:
29
30
  stderr=subprocess.DEVNULL,
30
31
  )
31
32
  if rc != 0:
33
+ ensure_exists(self.kubectl, str(Kind.Pod), name, namespace)
32
34
  raise ValueError(f"Command failed in container (exit {rc}).")
33
35
  else:
34
36
  for shell in self.shells:
@@ -50,6 +52,7 @@ class ExecCommand:
50
52
  ["exec", "-it", name, "-n", namespace, *extra_args, "--", shell]
51
53
  )
52
54
  return
55
+ ensure_exists(self.kubectl, str(Kind.Pod), name, namespace)
53
56
  raise ValueError(
54
57
  "No shell found in container. Provide an explicit command: kx exec <index> -- /path/to/binary"
55
58
  )
@@ -1,7 +1,7 @@
1
1
  from kx.index import IndexServiceProtocol
2
2
  from kx.kinds import normalize_kind
3
3
  from kx.kubectl import KubectlServiceProtocol
4
- from kx.state import State, StateServiceProtocol
4
+ from kx.state import Query, State, StateServiceProtocol
5
5
 
6
6
 
7
7
  def _extract_namespace(extra_args: list[str]) -> str | None:
@@ -34,14 +34,23 @@ class GetCommand:
34
34
  output = self.kubectl.run(["get", resource, *extra_args])
35
35
  if filter_term:
36
36
  output = self.index.filter(output, filter_term)
37
- indexed_output, names = self.index.add(output)
38
37
  all_namespaces = any(arg in ("-A", "--all-namespaces") for arg in extra_args)
39
- if names and not all_namespaces:
38
+ if all_namespaces:
39
+ # Names aren't unique across namespaces, so `-A` results are never
40
+ # indexed — returning unindexed output keeps dead X numbers off
41
+ # the screen.
42
+ return output
43
+ indexed_output, names = self.index.add(output)
44
+ if names:
40
45
  namespace = (
41
46
  _extract_namespace(extra_args) or self.kubectl.current_namespace()
42
47
  )
43
48
  kind = normalize_kind(resource)
44
49
  self.state.save(
45
- State(resources={name: kind for name in names}, namespace=namespace)
50
+ State(
51
+ resources={name: kind for name in names},
52
+ namespace=namespace,
53
+ query=Query(resource=resource, args=extra_args, match=filter_term),
54
+ )
46
55
  )
47
56
  return indexed_output
@@ -1,22 +1,35 @@
1
1
  import json
2
+ from contextlib import nullcontext
2
3
 
3
4
  from kx.kinds import Kind
4
5
  from kx.kubectl import KubectlServiceProtocol
6
+ from kx.refresh import ensure_exists
5
7
  from kx.state import StateServiceProtocol
8
+ from kx.types import Status
6
9
 
7
10
  _AGGREGATE_KINDS = {Kind.Deployment, Kind.StatefulSet, Kind.DaemonSet, Kind.Service}
8
11
 
9
12
 
10
13
  class LogsCommand:
11
- def __init__(self, state: StateServiceProtocol, kubectl: KubectlServiceProtocol):
14
+ def __init__(
15
+ self,
16
+ state: StateServiceProtocol,
17
+ kubectl: KubectlServiceProtocol,
18
+ status: Status | None = None,
19
+ ):
12
20
  self.state = state
13
21
  self.kubectl = kubectl
22
+ self.status = status or (lambda _msg: nullcontext())
14
23
 
15
24
  def execute(self, index: int, extra_args: list[str] | None = None) -> None:
16
25
  extra_args = extra_args or []
17
26
  name, namespace, kind = self.state.fields(index)
18
27
  if kind == Kind.Pod:
19
- self.kubectl.run_interactive(["logs", name, "-n", namespace, *extra_args])
28
+ rc = self.kubectl.run_interactive(
29
+ ["logs", name, "-n", namespace, *extra_args]
30
+ )
31
+ if rc != 0:
32
+ ensure_exists(self.kubectl, kind, name, namespace)
20
33
  elif kind in _AGGREGATE_KINDS:
21
34
  selector = self._selector(name, namespace, kind)
22
35
  self.kubectl.run_interactive(
@@ -26,7 +39,8 @@ class LogsCommand:
26
39
  raise ValueError(f"Logs are not supported for '{kind}'.")
27
40
 
28
41
  def _selector(self, name: str, namespace: str, kind: str) -> str:
29
- raw = self.kubectl.run(["get", kind, name, "-n", namespace, "-o", "json"])
42
+ with self.status("resolving pod selector"):
43
+ raw = self.kubectl.run(["get", kind, name, "-n", namespace, "-o", "json"])
30
44
  obj = json.loads(raw)
31
45
  labels = self._extract_labels(obj, kind)
32
46
  if not labels:
@@ -1,5 +1,6 @@
1
1
  from kx.kinds import Kind
2
2
  from kx.kubectl import KubectlServiceProtocol
3
+ from kx.refresh import ensure_exists
3
4
  from kx.state import StateServiceProtocol
4
5
 
5
6
  _SUPPORTED_KINDS = {
@@ -24,6 +25,8 @@ class PortForwardCommand:
24
25
  name, namespace, kind = self.state.fields(index)
25
26
  if kind not in _SUPPORTED_KINDS:
26
27
  raise ValueError(f"port-forward is not supported for '{kind}'.")
27
- self.kubectl.run_interactive(
28
+ rc = self.kubectl.run_interactive(
28
29
  ["port-forward", f"{kind}/{name}", port, "-n", namespace, *extra_args]
29
30
  )
31
+ if rc != 0:
32
+ ensure_exists(self.kubectl, kind, name, namespace)
@@ -1,6 +1,7 @@
1
1
  import re
2
2
  import json
3
- from contextlib import nullcontext
3
+ import threading
4
+ from contextlib import contextmanager
4
5
  from datetime import datetime, timezone
5
6
 
6
7
  import typer
@@ -88,7 +89,7 @@ def print_success(msg: str) -> None:
88
89
 
89
90
  def print_error(msg: str) -> None:
90
91
  styled = re.sub(r"'([^']+)'", "[accent]'\\1'[/accent]", msg)
91
- _console.print(f"[header]✗[/header] [body]{styled}[/body]")
92
+ _console.print(f"[error]✗[/error] [body]{styled}[/body]")
92
93
 
93
94
 
94
95
  def print_banner(kind: str, name: str, namespace: str = "", extra: str = "") -> None:
@@ -100,12 +101,56 @@ def print_banner(kind: str, name: str, namespace: str = "", extra: str = "") ->
100
101
  _console.print(f"[muted]{' · '.join(parts)}[/muted]")
101
102
 
102
103
 
104
+ # How long a command runs silently before the spinner appears. Quick commands
105
+ # intentionally show nothing — a spinner that flashes for a fraction of a
106
+ # second is noise, not feedback.
107
+ _SPINNER_DELAY = 0.2
108
+
109
+
110
+ @contextmanager
103
111
  def status(message: str):
104
- """Spinner shown while waiting on the cluster; a no-op off-terminal so
105
- piped output and test captures never receive spinner frames."""
112
+ """Spinner shown while waiting on the cluster, only once the command has
113
+ run for _SPINNER_DELAY. A no-op off-terminal so piped output and test
114
+ captures never receive spinner frames."""
106
115
  if not _console.is_terminal:
107
- return nullcontext()
108
- return _console.status(f"[muted]{message}…[/muted]", spinner_style="muted")
116
+ yield
117
+ return
118
+ # Low refresh with a matching spinner speed: roughly one frame per redraw,
119
+ # so the line isn't rewritten faster than the animation advances (the
120
+ # default 12.5 fps redraw flickers on some terminals).
121
+ live_status = _console.status(
122
+ f"[muted]{message}…[/muted]",
123
+ spinner_style="muted",
124
+ refresh_per_second=4,
125
+ speed=0.4,
126
+ )
127
+ lock = threading.Lock()
128
+ started = False
129
+ finished = False
130
+
131
+ def show() -> None:
132
+ nonlocal started
133
+ with lock:
134
+ if finished:
135
+ return
136
+ started = True
137
+ live_status.start()
138
+ # Status.start() schedules the first paint a full refresh interval
139
+ # out (250ms at 4 fps); paint now — the delay has already passed.
140
+ live_status._live.refresh()
141
+
142
+ timer = threading.Timer(_SPINNER_DELAY, show)
143
+ timer.start()
144
+ try:
145
+ yield
146
+ finally:
147
+ timer.cancel()
148
+ # The lock closes the race where the timer has fired but show() hasn't
149
+ # started the spinner yet, which would otherwise leave it running.
150
+ with lock:
151
+ finished = True
152
+ if started:
153
+ live_status.stop()
109
154
 
110
155
 
111
156
  def confirm(message: str) -> None:
@@ -147,7 +192,9 @@ def _print_get_caption(resource_type: str, namespace: str, count: int) -> None:
147
192
  )
148
193
 
149
194
 
150
- def render_indexed_table(text: str, resource_type: str, namespace: str) -> None:
195
+ def render_indexed_table(
196
+ text: str, resource_type: str, namespace: str, note: str | None = None
197
+ ) -> None:
151
198
  lines = [line for line in text.splitlines() if line.strip()]
152
199
  if not lines:
153
200
  # kubectl exits 0 with empty stdout when nothing matches ("No
@@ -157,7 +204,7 @@ def render_indexed_table(text: str, resource_type: str, namespace: str) -> None:
157
204
 
158
205
  header_line = lines[0]
159
206
  first_col = header_line.split()[0] if header_line.split() else ""
160
- if first_col != "X":
207
+ if first_col != "X" and note is None:
161
208
  _console.print(text, markup=False, highlight=False)
162
209
  return
163
210
 
@@ -222,6 +269,8 @@ def render_indexed_table(text: str, resource_type: str, namespace: str) -> None:
222
269
 
223
270
  _print_get_caption(resource_type, namespace, len(rows))
224
271
  _console.print(table)
272
+ if note:
273
+ _console.print(f"[muted]{note}[/muted]")
225
274
 
226
275
 
227
276
  def render_events_table(text: str) -> None:
@@ -505,7 +554,9 @@ def print_version(version: str) -> None:
505
554
  _console.print(f"kx {version}", markup=False, highlight=False)
506
555
 
507
556
 
508
- def print_help(sections: list[tuple[str, list[tuple[str, str]]]]) -> None:
557
+ def print_help(
558
+ sections: list[tuple[str, list[tuple[str, str]]]], version: str | None = None
559
+ ) -> None:
509
560
  _console.print()
510
561
  for line in _KX_ART:
511
562
  _console.print(line, style="header", markup=False, highlight=False)
@@ -526,11 +577,14 @@ def print_help(sections: list[tuple[str, list[tuple[str, str]]]]) -> None:
526
577
  f" [body]{'--no-color':<14}[/body] [muted]Disable styled output.[/muted]"
527
578
  )
528
579
  _console.print(
529
- f" [body]{'--version':<14}[/body] [muted]Show the kx version and exit.[/muted]"
580
+ f" [body]{'-v, --version':<14}[/body] [muted]Show the kx version and exit.[/muted]"
530
581
  )
531
582
  _console.print(
532
583
  f" [body]{'--help':<14}[/body] [muted]Show this message and exit.[/muted]"
533
584
  )
585
+ if version:
586
+ _console.print()
587
+ _console.print(f"[muted]v{version}[/muted]")
534
588
 
535
589
 
536
590
  _SWATCH_PARTS = (
@@ -0,0 +1,80 @@
1
+ import functools
2
+ from typing import Callable
3
+
4
+ import typer
5
+ from kubernetes.client.exceptions import ApiException
6
+
7
+ from kx import console
8
+ from kx.refresh import RefreshService, StaleResourceError
9
+
10
+ _refresh_provider: Callable[[], RefreshService] | None = None
11
+
12
+
13
+ def set_refresh(provider: Callable[[], RefreshService]) -> None:
14
+ """Register a factory for the RefreshService used on stale-state failures.
15
+
16
+ A factory (not an instance) so the service is built per failure from the
17
+ caller's current service objects — tests patch those with mocks.
18
+ """
19
+ global _refresh_provider
20
+ _refresh_provider = provider
21
+
22
+
23
+ def _message(error: BaseException) -> str:
24
+ if isinstance(error, ApiException):
25
+ return f"Kubernetes API error: {error.status} {error.reason}"
26
+ return str(error)
27
+
28
+
29
+ def _try_refresh(error: BaseException) -> None:
30
+ """If the failure means the indexed resource is gone, re-run the entry's
31
+ saved query and render the fresh list so the user can pick a new index."""
32
+ if _refresh_provider is None:
33
+ return
34
+ refresh = _refresh_provider()
35
+ if not refresh.matches(error):
36
+ return
37
+ try:
38
+ recovered = refresh.recover()
39
+ except (RuntimeError, ValueError, ApiException):
40
+ return
41
+ if recovered is None:
42
+ console.print_raw("Run 'kx get <resource>' to refresh the list.")
43
+ return
44
+ table, resource, namespace = recovered
45
+ console.print_raw("State was stale — refreshed, pick a new index:")
46
+ console.render_indexed_table(table, resource, namespace)
47
+
48
+
49
+ def handle_errors(func=None, *, refresh: bool = True):
50
+ """Render expected command failures as a styled error and exit 1.
51
+
52
+ Wraps a Typer command so that `RuntimeError` (e.g. a non-zero kubectl exit or
53
+ missing state), `ValueError` (unsupported kind, bad argument), and the
54
+ Kubernetes SDK's `ApiException` are shown via `console.print_error` instead of
55
+ surfacing as a traceback. When the error signals a stale state entry (the
56
+ indexed resource no longer exists), the entry's saved query is re-run and the
57
+ refreshed list rendered. Commands that don't always consume an index (`get`)
58
+ pass `refresh=False` — message-sniffed NotFounds are then ignored, but an
59
+ explicit `StaleResourceError` still triggers the refresh. `typer.Exit`
60
+ and `typer.Abort` are not caught, so control-flow exits and confirmation
61
+ aborts pass through untouched. `functools.wraps` preserves `__wrapped__` so
62
+ Typer still reads the original signature.
63
+ """
64
+
65
+ def decorate(inner):
66
+ @functools.wraps(inner)
67
+ def wrapper(*args, **kwargs):
68
+ try:
69
+ return inner(*args, **kwargs)
70
+ except (RuntimeError, ValueError, ApiException) as e:
71
+ console.print_error(_message(e))
72
+ if refresh or isinstance(e, StaleResourceError):
73
+ _try_refresh(e)
74
+ raise typer.Exit(1)
75
+
76
+ return wrapper
77
+
78
+ if func is not None:
79
+ return decorate(func)
80
+ return decorate
@@ -81,6 +81,10 @@ _PLURAL_DISPLAY: dict[Kind, str] = {
81
81
  }
82
82
 
83
83
 
84
+ def is_kind_spelling(token: str) -> bool:
85
+ return token.lower() in _KIND_MAP
86
+
87
+
84
88
  def normalize_kind(resource_type: str) -> Kind | str:
85
89
  return _KIND_MAP.get(resource_type.lower(), resource_type)
86
90