kctl-redis 0.8.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
kctl_redis/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Kodemeio Redis CLI."""
2
+
3
+ __version__ = "0.8.4"
kctl_redis/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running as: python -m kctl_redis."""
2
+
3
+ from kctl_redis.cli import _run
4
+
5
+ _run()
kctl_redis/cli.py ADDED
@@ -0,0 +1,154 @@
1
+ """Main CLI entry point for kctl-redis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+ from kctl_lib import handle_cli_error
9
+ from kctl_lib import cli_entrypoint, register_introspection_commands
10
+
11
+ from kctl_redis import __version__
12
+ from kctl_redis.commands.backup import app as backup_app
13
+ from kctl_redis.commands.clients import app as clients_app
14
+ from kctl_redis.commands.config_cmd import app as config_app
15
+ from kctl_redis.commands.dashboard import app as dashboard_app
16
+ from kctl_redis.commands.db import app as db_app
17
+ from kctl_redis.commands.health import app as health_app
18
+ from kctl_redis.commands.keys import app as keys_app
19
+ from kctl_redis.commands.maintenance import app as maintenance_app
20
+ from kctl_redis.commands.memory import app as memory_app
21
+ from kctl_redis.commands.performance import app as performance_app
22
+ from kctl_redis.commands.persistence import app as persistence_app
23
+ from kctl_redis.commands.pubsub import app as pubsub_app
24
+ from kctl_redis.commands.query import app as query_app
25
+ from kctl_redis.commands.replication import app as replication_app
26
+ from kctl_redis.commands.server import app as server_app
27
+ from kctl_redis.commands.streams import app as streams_app
28
+ from kctl_redis.commands.skill_cmd import app as skill_app
29
+ from kctl_redis.commands.doctor_cmd import app as doctor_app
30
+ from kctl_redis.core.callbacks import AppContext
31
+ from kctl_redis.core.exceptions import KctlError
32
+ from kctl_lib.self_update import notify_if_outdated
33
+ from kctl_lib.tui import add_tui_command
34
+
35
+
36
+ def version_callback(value: bool) -> None:
37
+ if value:
38
+ typer.echo(f"kctl-redis {__version__}")
39
+ raise typer.Exit()
40
+
41
+
42
+ app = typer.Typer(
43
+ name="kctl-redis",
44
+ help="Kodemeio Redis CLI - manage Redis servers via SSH tunnel.",
45
+ no_args_is_help=True,
46
+ rich_markup_mode="rich",
47
+ pretty_exceptions_enable=False,
48
+ )
49
+
50
+
51
+ @app.callback()
52
+ def main(
53
+ ctx: typer.Context,
54
+ json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
55
+ quiet: Annotated[bool, typer.Option("--quiet", "-q", help="Suppress info messages")] = False,
56
+ profile: Annotated[str | None, typer.Option("--profile", "-p", help="Config profile name")] = None,
57
+ host: Annotated[str | None, typer.Option("--host", "-H", help="Redis host override")] = None,
58
+ port: Annotated[int | None, typer.Option("--port", help="Redis port override")] = None,
59
+ user: Annotated[str | None, typer.Option("--user", "-U", help="Redis username override")] = None,
60
+ password: Annotated[str | None, typer.Option("--password", help="Redis password override")] = None,
61
+ db: Annotated[int | None, typer.Option("--db", help="Redis database number override")] = None,
62
+ version: Annotated[
63
+ bool, typer.Option("--version", "-V", callback=version_callback, is_eager=True, help="Show version")
64
+ ] = False,
65
+ ) -> None:
66
+ """Kodemeio Redis CLI."""
67
+ ctx.ensure_object(dict)
68
+ ctx.obj = AppContext(
69
+ json_mode=json_output,
70
+ quiet=quiet,
71
+ profile=profile,
72
+ host_override=host,
73
+ port_override=port,
74
+ user_override=user,
75
+ password_override=password,
76
+ db_override=db,
77
+ )
78
+ notify_if_outdated(ctx.obj.output, "kctl-redis", __version__)
79
+
80
+
81
+ # Register all command groups
82
+ app.add_typer(config_app, name="config")
83
+ app.add_typer(health_app, name="health")
84
+ app.add_typer(dashboard_app, name="dashboard")
85
+ app.add_typer(query_app, name="query")
86
+ app.add_typer(keys_app, name="keys")
87
+ app.add_typer(db_app, name="db")
88
+ app.add_typer(memory_app, name="memory")
89
+ app.add_typer(clients_app, name="clients")
90
+ app.add_typer(server_app, name="server")
91
+ app.add_typer(persistence_app, name="persistence")
92
+ app.add_typer(replication_app, name="replication")
93
+ app.add_typer(pubsub_app, name="pubsub")
94
+ app.add_typer(streams_app, name="streams")
95
+ app.add_typer(performance_app, name="performance")
96
+ app.add_typer(backup_app, name="backup")
97
+ app.add_typer(maintenance_app, name="maintenance")
98
+ app.add_typer(skill_app, name="skill", hidden=True)
99
+ app.add_typer(doctor_app, name="doctor")
100
+ add_tui_command(app, service_key="redis", version=__version__)
101
+
102
+
103
+ @app.command("self-update")
104
+ def self_update_cmd(ctx: typer.Context) -> None:
105
+ """Check for updates and upgrade kctl-redis."""
106
+ actx = ctx.obj
107
+ out = actx.output
108
+
109
+ from kctl_lib.self_update import check_update
110
+ from kctl_lib.self_update import update as do_update
111
+
112
+ latest = check_update("kctl-redis", __version__)
113
+ if latest:
114
+ out.info(f"Updating to {latest}...")
115
+ do_update("kctl-redis")
116
+ out.success(f"Updated to {latest}")
117
+ else:
118
+ out.success("Already up to date")
119
+
120
+
121
+ @app.command()
122
+ def completions(
123
+ shell: Annotated[str, typer.Argument(help="Shell type: zsh, bash, fish")] = "zsh",
124
+ install: Annotated[bool, typer.Option("--install", help="Install completions")] = False,
125
+ ) -> None:
126
+ """Generate or install shell completions."""
127
+ from kctl_lib.completions import get_completion_script, install_completions
128
+
129
+ if install:
130
+ path = install_completions("kctl-redis", shell)
131
+ if path:
132
+ typer.echo(f"Completions installed to {path}")
133
+ else:
134
+ typer.echo(f"Could not install completions for {shell}", err=True)
135
+ raise typer.Exit(code=1)
136
+ else:
137
+ script = get_completion_script("kctl-redis", shell)
138
+ typer.echo(script)
139
+
140
+
141
+ # Wrap app so KctlError subclasses surface as clean user-facing messages.
142
+ app = cli_entrypoint(app)
143
+ register_introspection_commands(app)
144
+
145
+ def _run() -> None:
146
+ """Entry point with error handling."""
147
+ try:
148
+ app()
149
+ except KctlError as e:
150
+ handle_cli_error(e)
151
+
152
+
153
+ if __name__ == "__main__":
154
+ _run()
File without changes
@@ -0,0 +1,144 @@
1
+ """Backup commands for kctl-redis (via SSH/SCP)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Annotated
9
+
10
+ import typer
11
+
12
+ from kctl_lib.ssh import scp_download, scp_upload, ssh_run
13
+ from kctl_redis.core.callbacks import AppContext
14
+ from kctl_redis.core.config import ServiceConfig, resolve_connection
15
+
16
+ app = typer.Typer(help="Redis backup operations via SSH.", no_args_is_help=True)
17
+
18
+
19
+ def _resolve_ssh_config(app_ctx: AppContext) -> ServiceConfig:
20
+ """Resolve connection config with all CLI overrides applied."""
21
+ return resolve_connection(
22
+ profile_name=app_ctx.profile,
23
+ host_override=app_ctx.host_override,
24
+ port_override=app_ctx.port_override,
25
+ user_override=app_ctx.user_override,
26
+ password_override=app_ctx.password_override,
27
+ db_override=app_ctx.db_override,
28
+ )
29
+
30
+
31
+ @app.command()
32
+ def dump(
33
+ ctx: typer.Context,
34
+ output_path: Annotated[str, typer.Option("--output", "-o", help="Local output path")] = "",
35
+ ) -> None:
36
+ """Trigger BGSAVE and download the RDB file."""
37
+ app_ctx: AppContext = ctx.obj
38
+ out = app_ctx.output
39
+ c = app_ctx.client
40
+
41
+ out.info("Triggering BGSAVE...")
42
+ c.execute("BGSAVE")
43
+
44
+ deadline = time.monotonic() + 300
45
+ while True:
46
+ persist = c.info("persistence")
47
+ if not persist.get("rdb_bgsave_in_progress", 0):
48
+ break
49
+ if time.monotonic() >= deadline:
50
+ out.error("BGSAVE timed out after 300 seconds")
51
+ raise typer.Exit(1)
52
+ time.sleep(1)
53
+
54
+ status = c.info("persistence").get("rdb_last_bgsave_status", "?")
55
+ if status != "ok":
56
+ out.error(f"BGSAVE failed: {status}")
57
+ raise typer.Exit(1)
58
+
59
+ if not output_path:
60
+ timestamp = datetime.now(tz=timezone.utc).strftime("%Y%m%d-%H%M%S")
61
+ output_path = f"dump-{timestamp}.rdb"
62
+
63
+ cfg = _resolve_ssh_config(app_ctx)
64
+ remote_path = "/data/dump.rdb"
65
+
66
+ out.info(f"Downloading RDB from {cfg.ssh_host}...")
67
+ try:
68
+ scp_download(
69
+ cfg.ssh_host,
70
+ remote_path,
71
+ output_path,
72
+ user=cfg.ssh_user,
73
+ port=cfg.ssh_port,
74
+ ssh_key=cfg.ssh_key,
75
+ )
76
+ except Exception as e:
77
+ out.error(f"SCP failed: {e}")
78
+ raise typer.Exit(1) from e
79
+
80
+ out.success(f"Backup saved to {output_path}")
81
+ app_ctx.close()
82
+
83
+
84
+ @app.command()
85
+ def restore(
86
+ ctx: typer.Context,
87
+ file_path: Annotated[str, typer.Argument(help="Local RDB file to upload")],
88
+ ) -> None:
89
+ """Upload an RDB file to the server."""
90
+ app_ctx: AppContext = ctx.obj
91
+ out = app_ctx.output
92
+
93
+ local = Path(file_path)
94
+ if not local.exists():
95
+ out.error(f"File not found: {file_path}")
96
+ raise typer.Exit(1)
97
+
98
+ cfg = _resolve_ssh_config(app_ctx)
99
+ remote_path = "/data/dump.rdb"
100
+
101
+ typer.confirm("This will overwrite the remote dump.rdb. Redis must be restarted to load it. Continue?", abort=True)
102
+
103
+ out.info(f"Uploading {file_path} to {cfg.ssh_host}...")
104
+ try:
105
+ scp_upload(
106
+ cfg.ssh_host,
107
+ str(local),
108
+ remote_path,
109
+ user=cfg.ssh_user,
110
+ port=cfg.ssh_port,
111
+ ssh_key=cfg.ssh_key,
112
+ )
113
+ except Exception as e:
114
+ out.error(f"SCP failed: {e}")
115
+ raise typer.Exit(1) from e
116
+
117
+ out.success(f"Uploaded {file_path}. Restart Redis to load the restored data.")
118
+ app_ctx.close()
119
+
120
+
121
+ @app.command(name="list")
122
+ def list_backups(
123
+ ctx: typer.Context,
124
+ remote_dir: Annotated[str, typer.Option(help="Remote backup directory")] = "/backups",
125
+ ) -> None:
126
+ """List backup files on the remote server."""
127
+ app_ctx: AppContext = ctx.obj
128
+ out = app_ctx.output
129
+
130
+ cfg = _resolve_ssh_config(app_ctx)
131
+
132
+ result = ssh_run(
133
+ cfg.ssh_host,
134
+ f"ls -lhtr {remote_dir}/*.rdb 2>/dev/null || echo '(no backups found)'",
135
+ user=cfg.ssh_user,
136
+ port=cfg.ssh_port,
137
+ ssh_key=cfg.ssh_key,
138
+ )
139
+ if not result.ok:
140
+ out.error(f"SSH failed: {result.stderr}")
141
+ raise typer.Exit(1)
142
+
143
+ out.text(result.stdout)
144
+ app_ctx.close()
@@ -0,0 +1,71 @@
1
+ """Client connection management commands for kctl-redis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from kctl_redis.core.callbacks import AppContext
10
+
11
+ app = typer.Typer(help="Redis client management.", no_args_is_help=True)
12
+
13
+
14
+ @app.command(name="list")
15
+ def list_clients(ctx: typer.Context) -> None:
16
+ """List connected clients."""
17
+ app_ctx: AppContext = ctx.obj
18
+ out = app_ctx.output
19
+ r = app_ctx.client.r
20
+
21
+ clients = r.client_list()
22
+ if app_ctx.json_mode:
23
+ out.json({"clients": clients})
24
+ else:
25
+ rows = [
26
+ {
27
+ "id": c.get("id", "?"),
28
+ "addr": c.get("addr", "?"),
29
+ "name": c.get("name", ""),
30
+ "age": c.get("age", "?"),
31
+ "idle": c.get("idle", "?"),
32
+ "db": c.get("db", "?"),
33
+ "cmd": c.get("cmd", "?"),
34
+ }
35
+ for c in clients
36
+ ]
37
+ out.table(rows, columns=["id", "addr", "name", "age", "idle", "db", "cmd"], title="Connected Clients")
38
+
39
+ app_ctx.close()
40
+
41
+
42
+ @app.command()
43
+ def kill(
44
+ ctx: typer.Context,
45
+ client_id: Annotated[int, typer.Argument(help="Client ID to kill")],
46
+ ) -> None:
47
+ """Kill a client connection by ID."""
48
+ app_ctx: AppContext = ctx.obj
49
+ out = app_ctx.output
50
+ r = app_ctx.client.r
51
+
52
+ r.client_kill_filter(_id=client_id)
53
+ out.success(f"Killed client {client_id}")
54
+ app_ctx.close()
55
+
56
+
57
+ @app.command()
58
+ def info(ctx: typer.Context) -> None:
59
+ """Show client connection info."""
60
+ app_ctx: AppContext = ctx.obj
61
+ out = app_ctx.output
62
+ c = app_ctx.client
63
+
64
+ clients_info = c.info("clients")
65
+ if app_ctx.json_mode:
66
+ out.json(clients_info)
67
+ else:
68
+ rows = [{"metric": k, "value": str(v)} for k, v in clients_info.items()]
69
+ out.table(rows, columns=["metric", "value"], title="Client Info")
70
+
71
+ app_ctx.close()
@@ -0,0 +1,242 @@
1
+ """Config profile management commands for kctl-redis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from kctl_redis.core.callbacks import AppContext
10
+ from kctl_redis.core.config import (
11
+ ServiceConfig,
12
+ get_profile_names,
13
+ get_service_config,
14
+ remove_profile,
15
+ resolve_active_profile_name,
16
+ set_default_profile,
17
+ set_service_config,
18
+ )
19
+ from kctl_redis.core.exceptions import KctlError
20
+
21
+ app = typer.Typer(help="Manage kctl-redis configuration profiles.", no_args_is_help=True)
22
+
23
+
24
+ @app.command()
25
+ def init(
26
+ ctx: typer.Context,
27
+ profile: Annotated[str, typer.Option("--profile", "-p", help="Profile name")] = "production",
28
+ host: Annotated[str | None, typer.Option(help="Redis host (private IP)")] = None,
29
+ port: Annotated[int, typer.Option(help="Redis port")] = 6379,
30
+ username: Annotated[str, typer.Option(help="Redis username")] = "default",
31
+ password: Annotated[str | None, typer.Option(help="Redis password")] = None,
32
+ db: Annotated[int, typer.Option(help="Redis database number")] = 0,
33
+ ssh_host: Annotated[str | None, typer.Option(help="SSH jump host")] = None,
34
+ ssh_port: Annotated[int, typer.Option(help="SSH port")] = 22,
35
+ ssh_user: Annotated[str, typer.Option(help="SSH username")] = "root",
36
+ ssh_key: Annotated[str, typer.Option(help="SSH private key path")] = "~/.ssh/id_ed25519",
37
+ ) -> None:
38
+ """Initialize kctl-redis configuration."""
39
+ app_ctx: AppContext = ctx.obj
40
+ out = app_ctx.output
41
+
42
+ if not host:
43
+ host = typer.prompt("Redis host (private IP)")
44
+ if not password:
45
+ password = typer.prompt("Redis password", hide_input=True)
46
+ if not ssh_host:
47
+ ssh_host = typer.prompt("SSH jump host (public IP)")
48
+
49
+ svc = ServiceConfig(
50
+ host=host,
51
+ port=port,
52
+ username=username,
53
+ password=password,
54
+ db=db,
55
+ ssh_host=ssh_host,
56
+ ssh_port=ssh_port,
57
+ ssh_user=ssh_user,
58
+ ssh_key=ssh_key,
59
+ )
60
+ set_service_config(profile, svc)
61
+ set_default_profile(profile)
62
+ out.success(f"Profile '{profile}' created and set as default.")
63
+
64
+
65
+ @app.command()
66
+ def add(
67
+ ctx: typer.Context,
68
+ profile: Annotated[str, typer.Argument(help="Profile name")],
69
+ host: Annotated[str, typer.Option(help="Redis host")] = "",
70
+ port: Annotated[int, typer.Option(help="Redis port")] = 6379,
71
+ username: Annotated[str, typer.Option(help="Redis username")] = "default",
72
+ password: Annotated[str, typer.Option(help="Redis password")] = "",
73
+ db: Annotated[int, typer.Option(help="Redis database number")] = 0,
74
+ ssh_host: Annotated[str, typer.Option(help="SSH jump host")] = "",
75
+ ssh_port: Annotated[int, typer.Option(help="SSH port")] = 22,
76
+ ssh_user: Annotated[str, typer.Option(help="SSH username")] = "root",
77
+ ssh_key: Annotated[str, typer.Option(help="SSH key path")] = "~/.ssh/id_ed25519",
78
+ ) -> None:
79
+ """Add or update a profile."""
80
+ app_ctx: AppContext = ctx.obj
81
+ out = app_ctx.output
82
+ svc = ServiceConfig(
83
+ host=host,
84
+ port=port,
85
+ username=username,
86
+ password=password,
87
+ db=db,
88
+ ssh_host=ssh_host,
89
+ ssh_port=ssh_port,
90
+ ssh_user=ssh_user,
91
+ ssh_key=ssh_key,
92
+ )
93
+ set_service_config(profile, svc)
94
+ out.success(f"Profile '{profile}' saved.")
95
+
96
+
97
+ @app.command()
98
+ def use(
99
+ ctx: typer.Context,
100
+ profile: Annotated[str, typer.Argument(help="Profile to activate")],
101
+ ) -> None:
102
+ """Switch the default profile."""
103
+ app_ctx: AppContext = ctx.obj
104
+ out = app_ctx.output
105
+ names = get_profile_names()
106
+ if profile not in names:
107
+ out.error(f"Profile '{profile}' not found. Available: {', '.join(names)}")
108
+ raise typer.Exit(1)
109
+ set_default_profile(profile)
110
+ out.success(f"Default profile set to '{profile}'.")
111
+
112
+
113
+ @app.command(name="remove")
114
+ def remove_(
115
+ ctx: typer.Context,
116
+ profile: Annotated[str, typer.Argument(help="Profile to remove")],
117
+ force: Annotated[bool, typer.Option("--force", "-f", help="Skip confirmation")] = False,
118
+ ) -> None:
119
+ """Remove a profile."""
120
+ app_ctx: AppContext = ctx.obj
121
+ out = app_ctx.output
122
+ if not force:
123
+ typer.confirm(f"Remove profile '{profile}'?", abort=True)
124
+ remove_profile(profile)
125
+ out.success(f"Profile '{profile}' removed.")
126
+
127
+
128
+ @app.command()
129
+ def show(ctx: typer.Context) -> None:
130
+ """Show current configuration (passwords masked)."""
131
+ app_ctx: AppContext = ctx.obj
132
+ out = app_ctx.output
133
+ try:
134
+ pname = resolve_active_profile_name(app_ctx.profile)
135
+ except KctlError:
136
+ out.error("No profile configured. Run: kctl-redis config init")
137
+ raise typer.Exit(1)
138
+
139
+ svc = get_service_config(pname)
140
+ masked_pass = _mask(svc.password)
141
+ rows = [
142
+ {"key": "profile", "value": pname},
143
+ {"key": "host", "value": svc.host or "(not set)"},
144
+ {"key": "port", "value": str(svc.port)},
145
+ {"key": "username", "value": svc.username},
146
+ {"key": "password", "value": masked_pass},
147
+ {"key": "db", "value": str(svc.db)},
148
+ {"key": "ssh_host", "value": svc.ssh_host or "(not set)"},
149
+ {"key": "ssh_port", "value": str(svc.ssh_port)},
150
+ {"key": "ssh_user", "value": svc.ssh_user},
151
+ {"key": "ssh_key", "value": svc.ssh_key},
152
+ ]
153
+ out.table(rows, columns=["key", "value"], title="Redis Configuration")
154
+
155
+
156
+ @app.command(name="set")
157
+ def set_field(
158
+ ctx: typer.Context,
159
+ key: Annotated[str, typer.Argument(help="Config field name")],
160
+ value: Annotated[str, typer.Argument(help="Config field value")],
161
+ ) -> None:
162
+ """Set a configuration value."""
163
+ app_ctx: AppContext = ctx.obj
164
+ out = app_ctx.output
165
+ pname = resolve_active_profile_name(app_ctx.profile)
166
+ svc = get_service_config(pname)
167
+ valid_fields = set(ServiceConfig.model_fields.keys())
168
+ if key not in valid_fields:
169
+ out.error(f"Unknown field '{key}'. Valid: {', '.join(sorted(valid_fields))}")
170
+ raise typer.Exit(1)
171
+ field_info = ServiceConfig.model_fields[key]
172
+ if field_info.annotation is int:
173
+ setattr(svc, key, int(value))
174
+ else:
175
+ setattr(svc, key, value)
176
+ set_service_config(pname, svc)
177
+ out.success(f"Set {key} = {_mask(value) if 'pass' in key else value}")
178
+
179
+
180
+ @app.command()
181
+ def profiles(ctx: typer.Context) -> None:
182
+ """List all profiles."""
183
+ app_ctx: AppContext = ctx.obj
184
+ out = app_ctx.output
185
+ names = get_profile_names()
186
+ if not names:
187
+ out.info("No profiles configured. Run: kctl-redis config init")
188
+ return
189
+ try:
190
+ active = resolve_active_profile_name(app_ctx.profile)
191
+ except KctlError:
192
+ active = ""
193
+ rows = []
194
+ for name in names:
195
+ svc = get_service_config(name)
196
+ rows.append(
197
+ {
198
+ "name": name,
199
+ "active": "*" if name == active else "",
200
+ "host": svc.host or "(not set)",
201
+ "ssh_host": svc.ssh_host or "(not set)",
202
+ }
203
+ )
204
+ out.table(rows, columns=["name", "active", "host", "ssh_host"], title="Profiles")
205
+
206
+
207
+ @app.command()
208
+ def current(ctx: typer.Context) -> None:
209
+ """Show the active profile name."""
210
+ app_ctx: AppContext = ctx.obj
211
+ out = app_ctx.output
212
+ try:
213
+ pname = resolve_active_profile_name(app_ctx.profile)
214
+ out.text(pname)
215
+ except KctlError:
216
+ out.error("No profile configured.")
217
+ raise typer.Exit(1)
218
+
219
+
220
+ @app.command()
221
+ def test(ctx: typer.Context) -> None:
222
+ """Test Redis connection."""
223
+ app_ctx: AppContext = ctx.obj
224
+ out = app_ctx.output
225
+ try:
226
+ client = app_ctx.client
227
+ version = client.server_version
228
+ info = client.info("server")
229
+ out.success(f"Connected to Redis {version}")
230
+ out.kv("Uptime", f"{info.get('uptime_in_days', '?')} days")
231
+ out.kv("Role", info.get("role", "unknown"))
232
+ app_ctx.close()
233
+ except KctlError as e:
234
+ out.error(f"Connection failed: {e}")
235
+ raise typer.Exit(1)
236
+
237
+
238
+ def _mask(value: str) -> str:
239
+ """Mask a password: first4****last4."""
240
+ if not value or len(value) <= 8:
241
+ return "****"
242
+ return f"{value[:4]}****{value[-4:]}"