kx-cli 0.0.9__tar.gz → 0.0.11__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 (107) hide show
  1. {kx_cli-0.0.9/src/kx_cli.egg-info → kx_cli-0.0.11}/PKG-INFO +21 -5
  2. {kx_cli-0.0.9 → kx_cli-0.0.11}/README.md +20 -4
  3. {kx_cli-0.0.9 → kx_cli-0.0.11}/pyproject.toml +1 -1
  4. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/commands/events.py +16 -9
  5. kx_cli-0.0.11/src/kx/commands/secret.py +58 -0
  6. kx_cli-0.0.11/src/kx/commands/tree.py +65 -0
  7. kx_cli-0.0.11/src/kx/commands/yaml.py +43 -0
  8. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/config.py +7 -0
  9. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/console.py +47 -47
  10. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/events.py +16 -1
  11. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/graph.py +120 -0
  12. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/index.py +13 -0
  13. kx_cli-0.0.11/src/kx/kubectl.py +64 -0
  14. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/main.py +223 -22
  15. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/scanner.py +12 -2
  16. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/state.py +30 -7
  17. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/types.py +2 -0
  18. {kx_cli-0.0.9 → kx_cli-0.0.11/src/kx_cli.egg-info}/PKG-INFO +21 -5
  19. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx_cli.egg-info/SOURCES.txt +4 -0
  20. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_cli_help.py +22 -0
  21. kx_cli-0.0.11/tests/test_cli_secret.py +352 -0
  22. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_config.py +34 -0
  23. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_console.py +82 -15
  24. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_events.py +37 -11
  25. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_graph.py +120 -1
  26. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_index.py +20 -0
  27. kx_cli-0.0.11/tests/test_kubectl.py +87 -0
  28. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_scanner.py +14 -1
  29. kx_cli-0.0.11/tests/test_secret.py +100 -0
  30. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_state.py +59 -0
  31. kx_cli-0.0.11/tests/test_tree.py +130 -0
  32. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_yaml.py +22 -0
  33. kx_cli-0.0.9/src/kx/commands/tree.py +0 -34
  34. kx_cli-0.0.9/src/kx/commands/yaml.py +0 -32
  35. kx_cli-0.0.9/src/kx/kubectl.py +0 -49
  36. kx_cli-0.0.9/tests/test_tree.py +0 -59
  37. {kx_cli-0.0.9 → kx_cli-0.0.11}/LICENSE +0 -0
  38. {kx_cli-0.0.9 → kx_cli-0.0.11}/setup.cfg +0 -0
  39. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/__init__.py +0 -0
  40. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/commands/__init__.py +0 -0
  41. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/commands/_metadata.py +0 -0
  42. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/commands/annotations.py +0 -0
  43. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/commands/back.py +0 -0
  44. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/commands/context.py +0 -0
  45. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/commands/contexts.py +0 -0
  46. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/commands/delete.py +0 -0
  47. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/commands/describe.py +0 -0
  48. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/commands/diagnostic.py +0 -0
  49. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/commands/drop.py +0 -0
  50. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/commands/edit.py +0 -0
  51. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/commands/exec.py +0 -0
  52. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/commands/forward.py +0 -0
  53. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/commands/get.py +0 -0
  54. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/commands/labels.py +0 -0
  55. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/commands/logs.py +0 -0
  56. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/commands/metadata_write.py +0 -0
  57. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/commands/namespace.py +0 -0
  58. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/commands/port_forward.py +0 -0
  59. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/commands/rollout.py +0 -0
  60. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/commands/scale.py +0 -0
  61. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/commands/scan.py +0 -0
  62. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/commands/state.py +0 -0
  63. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/commands/theme.py +0 -0
  64. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/commands/top.py +0 -0
  65. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/diagnostics.py +0 -0
  66. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/errors.py +0 -0
  67. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/k8s.py +0 -0
  68. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/kinds.py +0 -0
  69. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/refresh.py +0 -0
  70. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx/themes.py +0 -0
  71. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx_cli.egg-info/dependency_links.txt +0 -0
  72. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx_cli.egg-info/entry_points.txt +0 -0
  73. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx_cli.egg-info/requires.txt +0 -0
  74. {kx_cli-0.0.9 → kx_cli-0.0.11}/src/kx_cli.egg-info/top_level.txt +0 -0
  75. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_annotations.py +0 -0
  76. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_back.py +0 -0
  77. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_cli_context.py +0 -0
  78. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_cli_get.py +0 -0
  79. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_cli_kind_alias.py +0 -0
  80. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_cli_label_annotate.py +0 -0
  81. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_cli_top.py +0 -0
  82. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_context.py +0 -0
  83. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_contexts.py +0 -0
  84. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_delete.py +0 -0
  85. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_describe.py +0 -0
  86. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_diagnostic.py +0 -0
  87. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_diagnostics.py +0 -0
  88. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_drop.py +0 -0
  89. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_edit.py +0 -0
  90. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_error_handling.py +0 -0
  91. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_exec.py +0 -0
  92. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_forward.py +0 -0
  93. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_get.py +0 -0
  94. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_kinds.py +0 -0
  95. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_labels.py +0 -0
  96. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_logs.py +0 -0
  97. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_metadata_helper.py +0 -0
  98. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_metadata_write.py +0 -0
  99. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_namespace.py +0 -0
  100. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_port_forward.py +0 -0
  101. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_refresh.py +0 -0
  102. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_rollout.py +0 -0
  103. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_scale.py +0 -0
  104. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_scan.py +0 -0
  105. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_theme.py +0 -0
  106. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_themes.py +0 -0
  107. {kx_cli-0.0.9 → kx_cli-0.0.11}/tests/test_top.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kx-cli
3
- Version: 0.0.9
3
+ Version: 0.0.11
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
@@ -70,7 +70,7 @@ With pip:
70
70
  pip install kx-cli
71
71
  ```
72
72
 
73
- As a kubectl plugin via [krew](https://krew.sigs.k8s.io/), where kx is published as `idx` (no Python required; krew-index submission in progress):
73
+ As a kubectl plugin via [krew](https://krew.sigs.k8s.io/), where kx is published as `idx` (no Python required):
74
74
 
75
75
  ```bash
76
76
  kubectl krew install idx
@@ -92,14 +92,15 @@ pipx run --spec kx-cli kx get pods
92
92
 
93
93
  Known kinds can drop the `get`: `kx pods`, `kx deploy -n kube-system`, `kx svc --match api` — kubectl shorthands (`po`, `deploy`, `svc`, `sts`, ...) included. An integer after a kind relists just that index: `kx po 3`. CRDs and other resource types still use `kx get <resource>`.
94
94
 
95
- Global flags: `--no-color` disables styled output, `-v`/`--version` prints the installed version, and `--help` on any command shows usage, examples, and aliases.
95
+ Global flags: `--no-color` disables styled output, `-v`/`--version` prints the installed version, and `-h`/`--help` on any command shows usage, examples, and aliases.
96
96
 
97
97
  ### Commands
98
98
 
99
99
  <!-- commands-table-start -->
100
100
  | Command | Description |
101
101
  |---|---|
102
- | `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). |
102
+ | `kx get <resource> [--match/-m str] [--decode] [--key/-k str] [--yes/-y] [kubectl flags...]` | List resources and assign index numbers for use with other commands; shorthand: kx <kind> (e.g. kx pods, kx po 3). |
103
+ | `kx secret [--match/-m str] [--decode] [--key/-k str] [--yes/-y] [kubectl flags...]` | List Secrets like kx get, or show an indexed Secret's data with --decode; alias: kx secrets. |
103
104
  | `kx top [--match/-m str] [--no-limits] [kubectl flags...]` | List CPU/memory usage for pods in the current namespace and assign index numbers, like kx get; shows usage as a percent of each pod's resource limits unless --no-limits. |
104
105
  | `kx describe <indexes>... [kubectl flags...]` | Show full kubectl describe output for one or more indexed resources. |
105
106
  | `kx events <indexes>...` | Show Kubernetes events for one or more indexed resources. |
@@ -113,7 +114,7 @@ Global flags: `--no-color` disables styled output, `-v`/`--version` prints the i
113
114
  | `kx delete <indexes>... [--yes/-y]` | Delete one or more indexed resources (prompts for confirmation unless --yes). |
114
115
  | `kx edit <index> [kubectl flags...]` | Open an indexed resource in your editor via kubectl edit. |
115
116
  | `kx exec <index> [<cmd>...] [kubectl flags...]` | Open an interactive shell in an indexed pod (bash, falling back to sh). |
116
- | `kx tree <index> [--index/-i]` | Show the ownership graph for an indexed resource; --index assigns indexes to tree nodes. |
117
+ | `kx tree [<index>] [--index/-i]` | Show the ownership graph for an indexed resource, or the whole current namespace when no index is given; --index assigns indexes to tree nodes. A Namespace index graphs that namespace. |
117
118
  | `kx rollout <action> <index>` | Run a rollout action (status, restart, pause, resume, history, undo) on a Deployment, StatefulSet, or DaemonSet. |
118
119
  | `kx scale <index> <replicas>` | Scale an indexed Deployment, StatefulSet, or ReplicaSet to a given replica count. |
119
120
  | `kx port-forward <index> <port> [kubectl flags...]` | Forward a local port to an indexed resource (Pod, Deployment, ReplicaSet, StatefulSet, DaemonSet, Service). |
@@ -147,6 +148,21 @@ CronJob runs, usage near limits), a per-pod status table, recent log tails
147
148
  from broken containers, and warning events — one screen instead of four
148
149
  kubectl commands.
149
150
 
151
+ ### Read a Secret in plaintext
152
+
153
+ `kx secret <index> --decode` prints an indexed Secret's keys and values
154
+ decoded, instead of the base64 kubectl returns. Values that aren't text show a
155
+ `<binary, N bytes>` placeholder rather than garbling the table. `--key`/`-k`
156
+ prints a single value raw — no banner, no wrapping — so it drops straight into
157
+ a shell: `export PGPASSWORD=$(kx secret 1 --decode -k password)`, or redirect a
158
+ binary value to a file. Bare `kx secret --decode` decodes every Secret in the
159
+ namespace in one call, `-n` included — it confirms first unless you pass
160
+ `--yes`/`-y`, since that prints every credential in the namespace.
161
+
162
+ <div align="center">
163
+ <img src="https://raw.githubusercontent.com/jzills/kx/main/demo/secret.gif" alt="kx secret --decode demo" width="800"/>
164
+ </div>
165
+
150
166
  ### Scan images for vulnerabilities
151
167
 
152
168
  `kx scan <index>` scans the unique container images of an indexed workload
@@ -47,7 +47,7 @@ With pip:
47
47
  pip install kx-cli
48
48
  ```
49
49
 
50
- As a kubectl plugin via [krew](https://krew.sigs.k8s.io/), where kx is published as `idx` (no Python required; krew-index submission in progress):
50
+ As a kubectl plugin via [krew](https://krew.sigs.k8s.io/), where kx is published as `idx` (no Python required):
51
51
 
52
52
  ```bash
53
53
  kubectl krew install idx
@@ -69,14 +69,15 @@ pipx run --spec kx-cli kx get pods
69
69
 
70
70
  Known kinds can drop the `get`: `kx pods`, `kx deploy -n kube-system`, `kx svc --match api` — kubectl shorthands (`po`, `deploy`, `svc`, `sts`, ...) included. An integer after a kind relists just that index: `kx po 3`. CRDs and other resource types still use `kx get <resource>`.
71
71
 
72
- Global flags: `--no-color` disables styled output, `-v`/`--version` prints the installed version, and `--help` on any command shows usage, examples, and aliases.
72
+ Global flags: `--no-color` disables styled output, `-v`/`--version` prints the installed version, and `-h`/`--help` on any command shows usage, examples, and aliases.
73
73
 
74
74
  ### Commands
75
75
 
76
76
  <!-- commands-table-start -->
77
77
  | Command | Description |
78
78
  |---|---|
79
- | `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). |
79
+ | `kx get <resource> [--match/-m str] [--decode] [--key/-k str] [--yes/-y] [kubectl flags...]` | List resources and assign index numbers for use with other commands; shorthand: kx <kind> (e.g. kx pods, kx po 3). |
80
+ | `kx secret [--match/-m str] [--decode] [--key/-k str] [--yes/-y] [kubectl flags...]` | List Secrets like kx get, or show an indexed Secret's data with --decode; alias: kx secrets. |
80
81
  | `kx top [--match/-m str] [--no-limits] [kubectl flags...]` | List CPU/memory usage for pods in the current namespace and assign index numbers, like kx get; shows usage as a percent of each pod's resource limits unless --no-limits. |
81
82
  | `kx describe <indexes>... [kubectl flags...]` | Show full kubectl describe output for one or more indexed resources. |
82
83
  | `kx events <indexes>...` | Show Kubernetes events for one or more indexed resources. |
@@ -90,7 +91,7 @@ Global flags: `--no-color` disables styled output, `-v`/`--version` prints the i
90
91
  | `kx delete <indexes>... [--yes/-y]` | Delete one or more indexed resources (prompts for confirmation unless --yes). |
91
92
  | `kx edit <index> [kubectl flags...]` | Open an indexed resource in your editor via kubectl edit. |
92
93
  | `kx exec <index> [<cmd>...] [kubectl flags...]` | Open an interactive shell in an indexed pod (bash, falling back to sh). |
93
- | `kx tree <index> [--index/-i]` | Show the ownership graph for an indexed resource; --index assigns indexes to tree nodes. |
94
+ | `kx tree [<index>] [--index/-i]` | Show the ownership graph for an indexed resource, or the whole current namespace when no index is given; --index assigns indexes to tree nodes. A Namespace index graphs that namespace. |
94
95
  | `kx rollout <action> <index>` | Run a rollout action (status, restart, pause, resume, history, undo) on a Deployment, StatefulSet, or DaemonSet. |
95
96
  | `kx scale <index> <replicas>` | Scale an indexed Deployment, StatefulSet, or ReplicaSet to a given replica count. |
96
97
  | `kx port-forward <index> <port> [kubectl flags...]` | Forward a local port to an indexed resource (Pod, Deployment, ReplicaSet, StatefulSet, DaemonSet, Service). |
@@ -124,6 +125,21 @@ CronJob runs, usage near limits), a per-pod status table, recent log tails
124
125
  from broken containers, and warning events — one screen instead of four
125
126
  kubectl commands.
126
127
 
128
+ ### Read a Secret in plaintext
129
+
130
+ `kx secret <index> --decode` prints an indexed Secret's keys and values
131
+ decoded, instead of the base64 kubectl returns. Values that aren't text show a
132
+ `<binary, N bytes>` placeholder rather than garbling the table. `--key`/`-k`
133
+ prints a single value raw — no banner, no wrapping — so it drops straight into
134
+ a shell: `export PGPASSWORD=$(kx secret 1 --decode -k password)`, or redirect a
135
+ binary value to a file. Bare `kx secret --decode` decodes every Secret in the
136
+ namespace in one call, `-n` included — it confirms first unless you pass
137
+ `--yes`/`-y`, since that prints every credential in the namespace.
138
+
139
+ <div align="center">
140
+ <img src="https://raw.githubusercontent.com/jzills/kx/main/demo/secret.gif" alt="kx secret --decode demo" width="800"/>
141
+ </div>
142
+
127
143
  ### Scan images for vulnerabilities
128
144
 
129
145
  `kx scan <index>` scans the unique container images of an indexed workload
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kx-cli"
3
- version = "0.0.9"
3
+ version = "0.0.11"
4
4
  description = "kubectl wrapper with index-based resource selection"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,4 +1,4 @@
1
- from kx.events import EventsServiceProtocol
1
+ from kx.events import EventRow, EventsServiceProtocol
2
2
  from kx.kubectl import KubectlServiceProtocol
3
3
  from kx.refresh import ensure_exists
4
4
  from kx.state import StateServiceProtocol
@@ -15,7 +15,7 @@ class EventsCommand:
15
15
  self.events = events
16
16
  self.kubectl = kubectl
17
17
 
18
- def execute(self, index: int) -> str:
18
+ def execute(self, index: int) -> list[EventRow]:
19
19
  name, namespace, kind = self.state.fields(index)
20
20
  all_events = self.events.get(namespace)
21
21
  filtered = self.events.filter(all_events, name, kind)
@@ -24,14 +24,21 @@ class EventsCommand:
24
24
  # Deleted resources keep their events ~1h; only an empty result
25
25
  # warrants checking whether the target itself is gone.
26
26
  ensure_exists(self.kubectl, kind, name, namespace)
27
- return "No events found"
27
+ return []
28
28
 
29
- output = []
29
+ rows = []
30
30
  for event in filtered:
31
31
  obj = event.involved_object
32
- output.append(
33
- f"{event.type:8} {event.reason:30} "
34
- f"{obj.kind:10} {event.metadata.creation_timestamp} "
35
- f"{event.message}"
32
+ timestamp = getattr(event, "last_timestamp", None) or getattr(
33
+ event.metadata, "creation_timestamp", None
36
34
  )
37
- return "\n".join(output)
35
+ rows.append(
36
+ EventRow(
37
+ type=event.type or "",
38
+ reason=event.reason or "",
39
+ kind=obj.kind,
40
+ message=event.message or "",
41
+ timestamp=timestamp,
42
+ )
43
+ )
44
+ return rows
@@ -0,0 +1,58 @@
1
+ import base64
2
+ import json
3
+
4
+ from kx.kubectl import KubectlServiceProtocol
5
+ from kx.state import StateServiceProtocol
6
+
7
+
8
+ def to_display(value: bytes) -> str:
9
+ """Decoded text, or a placeholder for values that aren't valid UTF-8.
10
+
11
+ Keeps keystores and other binary payloads from garbling the table; `--key`
12
+ still emits their raw bytes so they can be redirected to a file."""
13
+ try:
14
+ return value.decode("utf-8")
15
+ except UnicodeDecodeError:
16
+ return f"<binary, {len(value)} bytes>"
17
+
18
+
19
+ def _decode_data(obj: dict) -> dict[str, bytes]:
20
+ """Decoded `data` for one Secret object. Values stay `bytes` so the render
21
+ layer owns the text/binary decision. `stringData` is write-only and never
22
+ returned by the API, so `data` holds everything."""
23
+ return {
24
+ key: base64.b64decode(value) for key, value in (obj.get("data") or {}).items()
25
+ }
26
+
27
+
28
+ class SecretCommand:
29
+ def __init__(self, state: StateServiceProtocol, kubectl: KubectlServiceProtocol):
30
+ self.state = state
31
+ self.kubectl = kubectl
32
+
33
+ def execute(self, index: int) -> dict[str, bytes]:
34
+ """Decoded data for the indexed Secret."""
35
+ name, namespace, kind = self.state.fields(index)
36
+ raw = self.kubectl.run(
37
+ ["get", kind, name, "-n", namespace, "-o", "json"],
38
+ )
39
+ return _decode_data(json.loads(raw))
40
+
41
+ def execute_all(
42
+ self, extra_args: list[str] | None = None
43
+ ) -> list[tuple[str, str, dict[str, bytes]]]:
44
+ """Every Secret in the target namespace as (name, namespace, data).
45
+
46
+ One kubectl call returns the whole list with data attached, so a
47
+ namespace sweep costs the same as a single fetch. `extra_args` carries
48
+ the user's kubectl flags so `-n` selects the namespace."""
49
+ raw = self.kubectl.run(["get", "secret", "-o", "json", *(extra_args or [])])
50
+ obj = json.loads(raw)
51
+ return [
52
+ (
53
+ item.get("metadata", {}).get("name", ""),
54
+ item.get("metadata", {}).get("namespace", ""),
55
+ _decode_data(item),
56
+ )
57
+ for item in obj.get("items") or []
58
+ ]
@@ -0,0 +1,65 @@
1
+ from rich.tree import Tree
2
+
3
+ from kx.kinds import Kind
4
+ from kx.kubectl import KubectlServiceProtocol
5
+ from kx.state import State, StateServiceProtocol
6
+ from kx.types import (
7
+ BuildIndexedTree,
8
+ BuildNamespaceIndexedTree,
9
+ BuildNamespaceTree,
10
+ BuildTree,
11
+ )
12
+
13
+
14
+ class TreeCommand:
15
+ def __init__(
16
+ self,
17
+ state: StateServiceProtocol,
18
+ kubectl: KubectlServiceProtocol,
19
+ build_tree: BuildTree,
20
+ build_indexed_tree: BuildIndexedTree,
21
+ build_namespace_tree: BuildNamespaceTree,
22
+ build_namespace_indexed_tree: BuildNamespaceIndexedTree,
23
+ ):
24
+ self.state = state
25
+ self.kubectl = kubectl
26
+ self.build_tree = build_tree
27
+ self.build_indexed_tree = build_indexed_tree
28
+ self.build_namespace_tree = build_namespace_tree
29
+ self.build_namespace_indexed_tree = build_namespace_indexed_tree
30
+
31
+ def execute(self, index: int, indexed: bool = False) -> Tree:
32
+ name, namespace, kind = self.state.fields(index)
33
+ # A Namespace row walks that namespace itself — its own name, not the
34
+ # namespace the `kx get ns` was run in.
35
+ if kind == Kind.Namespace:
36
+ return self._namespace(name, indexed)
37
+ if indexed:
38
+ tree, resources = self.build_indexed_tree(kind, name, namespace)
39
+ if resources:
40
+ self.state.save(
41
+ State(
42
+ resources={name: kind for name, kind in resources},
43
+ namespace=namespace,
44
+ )
45
+ )
46
+ else:
47
+ tree = self.build_tree(kind, name, namespace)
48
+ return tree
49
+
50
+ def execute_namespace(self, namespace: str, indexed: bool = False) -> Tree:
51
+ return self._namespace(namespace, indexed)
52
+
53
+ def _namespace(self, namespace: str, indexed: bool) -> Tree:
54
+ if indexed:
55
+ tree, resources = self.build_namespace_indexed_tree(namespace)
56
+ if resources:
57
+ self.state.save(
58
+ State(
59
+ resources={name: kind for name, kind in resources},
60
+ namespace=namespace,
61
+ )
62
+ )
63
+ else:
64
+ tree = self.build_namespace_tree(namespace)
65
+ return tree
@@ -0,0 +1,43 @@
1
+ import yaml
2
+
3
+ from kx.kubectl import KubectlServiceProtocol
4
+ from kx.state import StateServiceProtocol
5
+
6
+
7
+ def _find_keys(data: dict | list, keys: set[str]) -> dict:
8
+ """Collect the value of each requested key from anywhere in the manifest,
9
+ preferring the shallowest occurrence when a key appears at multiple depths.
10
+
11
+ A breadth-first walk guarantees shallowest-wins: a workload's own top-level
12
+ `metadata` is returned rather than its pod template's nested `metadata`,
13
+ while genuinely nested-only keys (e.g. `containerStatuses` under `status`)
14
+ are still found. A matched key's own subtree is not descended into."""
15
+ result: dict = {}
16
+ frontier: list = [data]
17
+ while frontier:
18
+ deeper: list = []
19
+ for node in frontier:
20
+ if isinstance(node, dict):
21
+ for k, v in node.items():
22
+ if k in keys:
23
+ result.setdefault(k, v)
24
+ else:
25
+ deeper.append(v)
26
+ elif isinstance(node, list):
27
+ deeper.extend(node)
28
+ frontier = deeper
29
+ return result
30
+
31
+
32
+ class YamlCommand:
33
+ def __init__(self, state: StateServiceProtocol, kubectl: KubectlServiceProtocol):
34
+ self.state = state
35
+ self.kubectl = kubectl
36
+
37
+ def execute(self, index: int, show: list[str] | None = None) -> str:
38
+ name, namespace, kind = self.state.fields(index)
39
+ raw = self.kubectl.run(["get", kind, name, "-n", namespace, "-o", "yaml"])
40
+ if not show:
41
+ return raw
42
+ data = yaml.safe_load(raw)
43
+ return yaml.dump(_find_keys(data, set(show)), default_flow_style=False)
@@ -52,6 +52,13 @@ def load_config() -> Config:
52
52
  if "KX_THEME" in os.environ:
53
53
  kwargs["theme"] = os.environ["KX_THEME"]
54
54
 
55
+ if "max_history" in kwargs:
56
+ mh = kwargs["max_history"]
57
+ # bool is an int subclass; reject it and any non-int/non-positive value
58
+ # (a string from TOML, 0, or a negative) with a clear message.
59
+ if isinstance(mh, bool) or not isinstance(mh, int) or mh < 1:
60
+ raise SystemExit("kx: max_history must be a positive integer")
61
+
55
62
  if "theme" in kwargs and kwargs["theme"] not in THEMES:
56
63
  raise SystemExit(
57
64
  f"kx: unknown theme '{kwargs['theme']}' (run kx theme to list themes)"
@@ -1,5 +1,6 @@
1
1
  import re
2
2
  import json
3
+ import sys
3
4
  import threading
4
5
  from contextlib import contextmanager
5
6
  from datetime import datetime, timezone
@@ -14,6 +15,8 @@ from rich.table import Table
14
15
  from rich.text import Text
15
16
 
16
17
  from kx.diagnostics import SEVERITY_PATTERN, Severity
18
+ from kx.events import EventRow
19
+ from kx.index import _parse_output
17
20
  from kx.kinds import plural_display
18
21
  from kx.scanner import SEVERITIES, ImageScan
19
22
  from kx.state import StateHistory
@@ -192,6 +195,25 @@ def print_raw(text: str) -> None:
192
195
  _console.print(text, markup=False, highlight=False)
193
196
 
194
197
 
198
+ def write_value(value: bytes) -> None:
199
+ """Write a secret value to stdout unstyled, bypassing Rich entirely.
200
+
201
+ Rich wraps at the console width (_PIPE_WIDTH off-terminal), which would
202
+ inject newlines into a long value — a cert or token — and corrupt
203
+ `$(kx secret 1 --decode -k tls.crt)`. Writing to the byte buffer also keeps
204
+ `> store.p12` byte-exact. The trailing newline is added only for text, so
205
+ binary redirects reproduce the file exactly while text stays shell-friendly.
206
+ """
207
+ try:
208
+ value.decode("utf-8")
209
+ except UnicodeDecodeError:
210
+ payload = value
211
+ else:
212
+ payload = value + b"\n"
213
+ sys.stdout.buffer.write(payload)
214
+ sys.stdout.buffer.flush()
215
+
216
+
195
217
  def print_rich(renderable) -> None:
196
218
  _console.print(renderable)
197
219
 
@@ -245,28 +267,20 @@ def _print_get_caption(resource_type: str, namespace: str, count: int) -> None:
245
267
  def render_indexed_table(
246
268
  text: str, resource_type: str, namespace: str, note: str | None = None
247
269
  ) -> None:
248
- lines = [line for line in text.splitlines() if line.strip()]
249
- if not lines:
250
- # kubectl exits 0 with empty stdout when nothing matches ("No
251
- # resources found" goes to stderr) — show the caption, not silence.
252
- _print_get_caption(resource_type, namespace, 0)
253
- return
254
-
255
- header_line = lines[0]
256
- first_col = header_line.split()[0] if header_line.split() else ""
257
- if first_col != "X" and note is None:
258
- _console.print(text, markup=False, highlight=False)
270
+ # Share index._parse_output (the single table parser) rather than a private
271
+ # copy: it extends the last column to end-of-line, so a value wider than its
272
+ # header (kubectl doesn't pad the last column) is never sliced off.
273
+ headers, rows, _ = _parse_output(text)
274
+ if not headers:
275
+ # Non-tabular output (JSON/YAML, or a table with no NAME column) prints
276
+ # as-is; genuinely empty stdout (kubectl sends "No resources found" to
277
+ # stderr) shows the zero-count caption instead of silence.
278
+ if text.strip():
279
+ _console.print(text, markup=False, highlight=False)
280
+ else:
281
+ _print_get_caption(resource_type, namespace, 0)
259
282
  return
260
283
 
261
- spans = [(m.start(), m.end()) for m in re.finditer(r"\S+\s*", header_line)]
262
- headers = [header_line[start:end].strip() for start, end in spans]
263
-
264
- rows = []
265
- for line in lines[1:]:
266
- cols = [line[start:end].strip() for start, end in spans]
267
- if cols:
268
- rows.append(cols)
269
-
270
284
  if not rows:
271
285
  _print_get_caption(resource_type, namespace, 0)
272
286
  return
@@ -329,8 +343,8 @@ def render_indexed_table(
329
343
  _console.print(f"[muted]{note}[/muted]")
330
344
 
331
345
 
332
- def render_events_table(text: str) -> None:
333
- if text.strip() == "No events found":
346
+ def render_events_table(rows: list[EventRow]) -> None:
347
+ if not rows:
334
348
  _console.print("[muted]No events found[/muted]")
335
349
  return
336
350
 
@@ -343,31 +357,17 @@ def render_events_table(text: str) -> None:
343
357
  for col in ("TYPE", "REASON", "KIND", "AGE", "MESSAGE"):
344
358
  table.add_column(col)
345
359
 
346
- for line in text.splitlines():
347
- if not line.strip():
348
- continue
349
- event_type = line[0:8].strip()
350
- reason = line[9:39].strip()
351
- kind = line[40:50].strip()
352
- rest = line[51:]
353
- parts = rest.split(" ", 2)
354
- timestamp = (
355
- f"{parts[0]} {parts[1]}" if len(parts) >= 2 else (parts[0] if parts else "")
356
- )
357
- message = parts[2] if len(parts) >= 3 else ""
358
-
359
- try:
360
- age = _format_age(datetime.fromisoformat(timestamp))
361
- except ValueError:
362
- age = timestamp
363
-
364
- type_color = "muted" if event_type == "Normal" else "warn"
360
+ for row in rows:
361
+ age = _format_age(row.timestamp)
362
+ type_color = "muted" if row.type == "Normal" else "warn"
365
363
  table.add_row(
366
- f"[{type_color}]{event_type}[/]",
367
- reason,
368
- kind,
364
+ f"[{type_color}]{row.type}[/]",
365
+ row.reason,
366
+ row.kind,
369
367
  f"[muted]{age}[/]",
370
- message,
368
+ # Text (not markup) so bracketed characters in a message are shown
369
+ # literally, matching the WARNING EVENTS section.
370
+ Text(row.message),
371
371
  )
372
372
 
373
373
  _console.print(table)
@@ -687,7 +687,7 @@ def print_command_help(ctx) -> None:
687
687
  names = " ".join(opt.opts)
688
688
  _console.print(f" [body]{names:<20}[/body] [muted]{opt.help or ''}[/muted]")
689
689
  _console.print(
690
- f" [body]{'--help':<20}[/body] [muted]Show this message and exit.[/muted]"
690
+ f" [body]{'-h, --help':<20}[/body] [muted]Show this message and exit.[/muted]"
691
691
  )
692
692
 
693
693
  aliases = getattr(ctx.command.callback, "_aliases", [])
@@ -745,7 +745,7 @@ def print_help(
745
745
  f" [body]{'-v, --version':<14}[/body] [muted]Show the kx version and exit.[/muted]"
746
746
  )
747
747
  _console.print(
748
- f" [body]{'--help':<14}[/body] [muted]Show this message and exit.[/muted]"
748
+ f" [body]{'-h, --help':<14}[/body] [muted]Show this message and exit.[/muted]"
749
749
  )
750
750
  if version:
751
751
  _console.print()
@@ -1,9 +1,24 @@
1
- from kubernetes import client
1
+ from dataclasses import dataclass
2
+ from datetime import datetime
2
3
  from typing import Protocol
3
4
 
5
+ from kubernetes import client
6
+
4
7
  from kx.k8s import load_config
5
8
 
6
9
 
10
+ @dataclass
11
+ class EventRow:
12
+ """One event, flattened for rendering. `timestamp` is the event's last-seen
13
+ time (falling back to creation) as a datetime, or None if unavailable."""
14
+
15
+ type: str
16
+ reason: str
17
+ kind: str
18
+ message: str
19
+ timestamp: datetime | None = None
20
+
21
+
7
22
  class EventsServiceProtocol(Protocol):
8
23
  def get(self, namespace: str) -> list: ...
9
24
  def filter(self, events: list, name: str, kind: str) -> list: ...
@@ -16,6 +16,17 @@ def build_indexed_tree(
16
16
  return _build(kind, name, namespace, indexed=True)
17
17
 
18
18
 
19
+ def build_namespace_tree(namespace: str) -> Tree:
20
+ root, _ = _build_namespace(namespace, indexed=False)
21
+ return root
22
+
23
+
24
+ def build_namespace_indexed_tree(
25
+ namespace: str,
26
+ ) -> tuple[Tree, list[tuple[str, str]]]:
27
+ return _build_namespace(namespace, indexed=True)
28
+
29
+
19
30
  def _build(
20
31
  kind: str, name: str, namespace: str, indexed: bool
21
32
  ) -> tuple[Tree, list[tuple[str, str]]]:
@@ -54,6 +65,115 @@ def _build(
54
65
  return root, resources
55
66
 
56
67
 
68
+ # Prefix + semantic color for each node kind in a namespace forest. Controllers
69
+ # render as roots with their full kind name; owned resources use the short
70
+ # prefixes the single-resource tree already uses (rs/job/pod).
71
+ _NODE_STYLE: dict[Kind, tuple[str, str]] = {
72
+ Kind.Deployment: ("Deployment", "header"),
73
+ Kind.StatefulSet: ("StatefulSet", "header"),
74
+ Kind.DaemonSet: ("DaemonSet", "header"),
75
+ Kind.CronJob: ("CronJob", "header"),
76
+ Kind.Job: ("job", "accent"),
77
+ Kind.ReplicaSet: ("rs", "accent"),
78
+ Kind.Pod: ("pod", "body"),
79
+ }
80
+
81
+ # Stable order roots appear under the Namespace node.
82
+ _ROOT_ORDER: list[Kind] = [
83
+ Kind.Deployment,
84
+ Kind.StatefulSet,
85
+ Kind.DaemonSet,
86
+ Kind.CronJob,
87
+ Kind.Job,
88
+ Kind.ReplicaSet,
89
+ Kind.Pod,
90
+ ]
91
+
92
+
93
+ def _build_namespace(
94
+ namespace: str, indexed: bool
95
+ ) -> tuple[Tree, list[tuple[str, str]]]:
96
+ """Full ownership forest for a namespace: every workload controller as a
97
+ root with its owned resources beneath, plus orphan Jobs/ReplicaSets and bare
98
+ Pods so nothing is hidden. Each pod appears exactly once. Unlike `_build`,
99
+ the Namespace root node is not indexed — children number from 1."""
100
+ load_config()
101
+ apps = client.AppsV1Api()
102
+ core = client.CoreV1Api()
103
+ batch = client.BatchV1Api()
104
+
105
+ deployments = apps.list_namespaced_deployment(namespace).items
106
+ replica_sets = apps.list_namespaced_replica_set(namespace).items
107
+ stateful_sets = apps.list_namespaced_stateful_set(namespace).items
108
+ daemon_sets = apps.list_namespaced_daemon_set(namespace).items
109
+ cron_jobs = batch.list_namespaced_cron_job(namespace).items
110
+ jobs = batch.list_namespaced_job(namespace).items
111
+ pods = core.list_namespaced_pod(namespace).items
112
+
113
+ # Resources that can be owned by something else in the namespace, indexed by
114
+ # each owner uid so a parent finds its children in one pass.
115
+ child_kinds = (
116
+ [(rs, Kind.ReplicaSet) for rs in replica_sets]
117
+ + [(job, Kind.Job) for job in jobs]
118
+ + [(pod, Kind.Pod) for pod in pods]
119
+ )
120
+ children_by_owner: dict[str, list[tuple[object, Kind]]] = {}
121
+ for obj, kind in child_kinds:
122
+ for ref in obj.metadata.owner_references or []:
123
+ children_by_owner.setdefault(ref.uid, []).append((obj, kind))
124
+
125
+ present_uids = {
126
+ obj.metadata.uid
127
+ for obj in (
128
+ *deployments,
129
+ *replica_sets,
130
+ *stateful_sets,
131
+ *daemon_sets,
132
+ *cron_jobs,
133
+ *jobs,
134
+ *pods,
135
+ )
136
+ }
137
+
138
+ # Controllers are always roots; an owned child (rs/job/pod) is a root only
139
+ # when orphaned — its owner isn't among the namespace's collected objects.
140
+ roots: list[tuple[object, Kind]] = [
141
+ *[(d, Kind.Deployment) for d in deployments],
142
+ *[(s, Kind.StatefulSet) for s in stateful_sets],
143
+ *[(d, Kind.DaemonSet) for d in daemon_sets],
144
+ *[(c, Kind.CronJob) for c in cron_jobs],
145
+ ]
146
+ for obj, kind in child_kinds:
147
+ refs = obj.metadata.owner_references or []
148
+ if not any(ref.uid in present_uids for ref in refs):
149
+ roots.append((obj, kind))
150
+
151
+ order = {kind: position for position, kind in enumerate(_ROOT_ORDER)}
152
+ roots.sort(key=lambda pair: (order[pair[1]], pair[0].metadata.name))
153
+
154
+ resources: list[tuple[str, str]] = []
155
+ root = Tree(f"[header]Namespace/{namespace}[/header]")
156
+ if not roots:
157
+ root.add("[muted](no workloads)[/muted]")
158
+ return root, resources
159
+
160
+ for obj, kind in roots:
161
+ _render(obj, kind, root, children_by_owner, resources, indexed)
162
+ return root, resources
163
+
164
+
165
+ def _render(obj, kind, parent, children_by_owner, resources, indexed):
166
+ """Add `obj` under `parent`, then recurse into its owned children; a Pod is
167
+ a leaf whose containers are listed directly."""
168
+ prefix, color = _NODE_STYLE[kind]
169
+ node = _add_node(parent, resources, indexed, color, prefix, obj.metadata.name, kind)
170
+ if kind == Kind.Pod:
171
+ _add_containers(obj, node)
172
+ return
173
+ for child, child_kind in children_by_owner.get(obj.metadata.uid, []):
174
+ _render(child, child_kind, node, children_by_owner, resources, indexed)
175
+
176
+
57
177
  def _add_node(parent, resources, indexed, color, prefix, name, kind):
58
178
  """Add a labelled child node; when indexed, prepend its 1-based index and
59
179
  record `(name, kind)` in `resources`."""