kctl-dbgate 0.3.0__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.
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0"
@@ -0,0 +1,3 @@
1
+ from kctl_dbgate.cli import _run
2
+
3
+ _run()
kctl_dbgate/cli.py ADDED
@@ -0,0 +1,94 @@
1
+ """Main CLI entry point for kctl-dbgate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+ from kctl_lib import handle_cli_error # noqa: F401
9
+ from kctl_lib.self_update import notify_if_outdated
10
+
11
+ from kctl_dbgate import __version__
12
+ from kctl_dbgate.commands.config_cmd import app as config_app
13
+ from kctl_dbgate.commands.connections import app as connections_app
14
+ from kctl_dbgate.commands.doctor_cmd import app as doctor_app
15
+ from kctl_dbgate.commands.health import app as health_app
16
+ from kctl_dbgate.commands.history import app as history_app
17
+ from kctl_dbgate.commands.plugins import app as plugins_app
18
+ from kctl_dbgate.commands.query import app as query_app
19
+ from kctl_dbgate.commands.servers import app as servers_app
20
+ from kctl_dbgate.commands.sessions import app as sessions_app
21
+ from kctl_dbgate.commands.skill_cmd import app as skill_app
22
+ from kctl_dbgate.commands.storage import app as storage_app
23
+ from kctl_dbgate.core.callbacks import AppContext
24
+ from kctl_dbgate.core.plugins import discover_and_load_plugins
25
+
26
+
27
+ def version_callback(value: bool) -> None:
28
+ if value:
29
+ typer.echo(f"kctl-dbgate {__version__}")
30
+ raise typer.Exit()
31
+
32
+
33
+ app = typer.Typer(
34
+ name="kctl-dbgate",
35
+ help="Kodemeio DBGate CLI - manage your DBGate deployment.",
36
+ no_args_is_help=True,
37
+ rich_markup_mode="rich",
38
+ pretty_exceptions_enable=False,
39
+ )
40
+
41
+
42
+ @app.callback()
43
+ def main(
44
+ ctx: typer.Context,
45
+ json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
46
+ quiet: Annotated[bool, typer.Option("--quiet", "-q", help="Suppress info messages")] = False,
47
+ profile: Annotated[str | None, typer.Option("--profile", "-p", help="Config profile name")] = None,
48
+ format: Annotated[str, typer.Option("--format", "-f", help="Output format: pretty, json, csv, yaml")] = "pretty",
49
+ no_header: Annotated[bool, typer.Option("--no-header", help="Omit header row in CSV output")] = False,
50
+ url: Annotated[str | None, typer.Option("--url", help="DBGate URL override")] = None,
51
+ login: Annotated[str | None, typer.Option("--login", help="UI login override")] = None,
52
+ password: Annotated[str | None, typer.Option("--password", help="UI password override")] = None,
53
+ version: Annotated[
54
+ bool, typer.Option("--version", "-V", callback=version_callback, is_eager=True, help="Show version")
55
+ ] = False,
56
+ ) -> None:
57
+ """Kodemeio DBGate CLI."""
58
+ ctx.ensure_object(dict)
59
+ ctx.obj = AppContext(
60
+ json_mode=json_output,
61
+ quiet=quiet,
62
+ profile=profile,
63
+ format=format,
64
+ no_header=no_header,
65
+ url_override=url,
66
+ login_override=login,
67
+ password_override=password,
68
+ )
69
+ notify_if_outdated(ctx.obj.output, "kctl-dbgate", __version__)
70
+
71
+
72
+ # Register command groups
73
+ app.add_typer(config_app, name="config")
74
+ app.add_typer(health_app, name="health")
75
+ app.add_typer(history_app, name="history")
76
+ app.add_typer(connections_app, name="connections")
77
+ app.add_typer(doctor_app, name="doctor")
78
+ app.add_typer(plugins_app, name="plugins")
79
+ app.add_typer(query_app, name="query")
80
+ app.add_typer(servers_app, name="servers")
81
+ app.add_typer(sessions_app, name="sessions")
82
+ app.add_typer(skill_app, name="skill")
83
+ app.add_typer(storage_app, name="storage")
84
+
85
+ # Load any third-party plugins
86
+ discover_and_load_plugins(app)
87
+
88
+
89
+ def _run() -> None:
90
+ app()
91
+
92
+
93
+ if __name__ == "__main__":
94
+ _run()
File without changes
@@ -0,0 +1,417 @@
1
+ """Configuration management commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated, Any
7
+
8
+ import typer
9
+ import yaml
10
+ from kctl_lib.exceptions import KctlError
11
+
12
+ from kctl_dbgate.core.callbacks import AppContext
13
+ from kctl_dbgate.core.client import DBGateClient
14
+ from kctl_dbgate.core.config import (
15
+ CONFIG_FILE,
16
+ SERVICE_KEY,
17
+ ServiceConfig,
18
+ get_all_services_in_profile,
19
+ get_default_profile,
20
+ get_profile_names,
21
+ get_service_config,
22
+ load_raw_config,
23
+ remove_profile,
24
+ resolve_active_profile_name,
25
+ save_raw_config,
26
+ set_default_profile,
27
+ set_service_config,
28
+ )
29
+
30
+ app = typer.Typer(help="Manage CLI configuration and profiles.")
31
+
32
+
33
+ def _mask(secret: str) -> str:
34
+ if not secret:
35
+ return "[dim]not set[/dim]"
36
+ if len(secret) <= 8:
37
+ return "****"
38
+ return f"{secret[:4]}{'*' * (len(secret) - 8)}{secret[-4:]}"
39
+
40
+
41
+ def _test_connection(url: str, login: str, password: str) -> tuple[bool, str]:
42
+ try:
43
+ client = DBGateClient(base_url=url, login=login, password=password)
44
+ client.login()
45
+ client.close()
46
+ return True, "authenticated"
47
+ except Exception as e:
48
+ return False, str(e)
49
+
50
+
51
+ @app.command()
52
+ def init(
53
+ ctx: typer.Context,
54
+ url: Annotated[str | None, typer.Option("--url", help="DBGate base URL")] = None,
55
+ login: Annotated[str | None, typer.Option("--login", help="UI username")] = None,
56
+ password: Annotated[str | None, typer.Option("--password", help="UI password")] = None,
57
+ name: Annotated[str | None, typer.Option("--name", "-n", help="Profile name")] = None,
58
+ ) -> None:
59
+ """Initialize CLI configuration (interactive if no flags given)."""
60
+ actx: AppContext = ctx.obj
61
+ out = actx.output
62
+
63
+ profile_name = name or typer.prompt("Profile name", default="kodemeio")
64
+ api_url = url or typer.prompt("DBGate URL (e.g. https://dbgate.kodeme.io)")
65
+ api_login = login or typer.prompt("Login", default="admin")
66
+ api_password = password or typer.prompt("Password", hide_input=True)
67
+
68
+ out.info(f"Testing connection to {api_url}...")
69
+ ok, info = _test_connection(api_url, api_login, api_password)
70
+ if ok:
71
+ out.success(f"Connected: {info}")
72
+ else:
73
+ out.error(f"Connection failed: {info}")
74
+ if not typer.confirm("Save configuration anyway?", default=False):
75
+ raise typer.Exit(code=1)
76
+
77
+ svc = ServiceConfig(url=api_url, login=api_login, password=api_password)
78
+ set_service_config(profile_name, svc)
79
+
80
+ if len(get_profile_names()) <= 1:
81
+ set_default_profile(profile_name)
82
+
83
+ out.success(f"Configuration saved to {CONFIG_FILE}")
84
+ out.kv("Profile", profile_name)
85
+ out.kv("Service", SERVICE_KEY)
86
+ out.kv("URL", api_url)
87
+ out.kv("Login", api_login)
88
+ out.kv("Password", _mask(api_password))
89
+
90
+
91
+ @app.command()
92
+ def show(ctx: typer.Context) -> None:
93
+ """Show full configuration (secrets masked)."""
94
+ actx: AppContext = ctx.obj
95
+ out = actx.output
96
+
97
+ default = get_default_profile()
98
+ profiles = get_profile_names()
99
+
100
+ if out.json_mode:
101
+ data = load_raw_config()
102
+ for _pname, pdata in data.get("profiles", {}).items():
103
+ for _svc, svc_data in pdata.items():
104
+ if isinstance(svc_data, dict) and "password" in svc_data:
105
+ svc_data["password"] = _mask(svc_data["password"])
106
+ out.raw_json(data)
107
+ return
108
+
109
+ sections: list[tuple[str, list[tuple[str, str]]]] = []
110
+ sections.append(
111
+ (
112
+ "General",
113
+ [
114
+ ("Config file", str(CONFIG_FILE)),
115
+ ("Default profile", default),
116
+ ("Total profiles", str(len(profiles))),
117
+ ("This CLI", f"kctl-dbgate -> service key: {SERVICE_KEY}"),
118
+ ],
119
+ )
120
+ )
121
+
122
+ for pname in profiles:
123
+ marker = " [green](default)[/green]" if pname == default else ""
124
+ services = get_all_services_in_profile(pname)
125
+
126
+ kvs: list[tuple[str, str]] = []
127
+ for svc_name, svc_data in services.items():
128
+ if not isinstance(svc_data, dict):
129
+ continue
130
+ svc_url = svc_data.get("url", "")
131
+ svc_login = svc_data.get("login", "")
132
+ svc_pwd = _mask(svc_data.get("password", ""))
133
+ indicator = "[green]●[/green]" if svc_name == SERVICE_KEY else "[dim]○[/dim]"
134
+ summary = f"{svc_url} user: {svc_login} password: {svc_pwd}" if svc_name == SERVICE_KEY else svc_url
135
+ kvs.append((f"{indicator} {svc_name}", summary))
136
+
137
+ if not kvs:
138
+ kvs.append(("(empty)", "no services configured"))
139
+
140
+ sections.append((f"Profile: {pname}{marker}", kvs))
141
+
142
+ out.detail("Configuration", sections)
143
+
144
+
145
+ @app.command("set")
146
+ def set_(
147
+ ctx: typer.Context,
148
+ key: Annotated[str, typer.Argument(help="Config key: url, login, password, or default_profile")],
149
+ value: Annotated[str, typer.Argument(help="Value to set")],
150
+ profile_arg: Annotated[str | None, typer.Option("--profile-name", help="Target profile")] = None,
151
+ ) -> None:
152
+ """Set a configuration value for the current service."""
153
+ actx: AppContext = ctx.obj
154
+ out = actx.output
155
+
156
+ if key == "default_profile":
157
+ set_default_profile(value)
158
+ out.success(f"Default profile set to: {value}")
159
+ return
160
+
161
+ valid_fields = {"url", "login", "password"}
162
+ if key not in valid_fields:
163
+ out.error(f"Unknown key: {key}")
164
+ out.info(f"Valid keys: {', '.join(sorted(valid_fields))}, default_profile")
165
+ raise typer.Exit(1)
166
+
167
+ pname = profile_arg or resolve_active_profile_name(actx.profile)
168
+ svc = get_service_config(pname)
169
+ setattr(svc, key, value)
170
+ set_service_config(pname, svc)
171
+
172
+ display = _mask(value) if key == "password" else value
173
+ out.success(f"[{pname}] {SERVICE_KEY}.{key} = {display}")
174
+
175
+
176
+ @app.command()
177
+ def use(
178
+ ctx: typer.Context,
179
+ name: Annotated[str, typer.Argument(help="Profile name to switch to")],
180
+ ) -> None:
181
+ """Switch the default profile."""
182
+ actx: AppContext = ctx.obj
183
+ out = actx.output
184
+
185
+ profiles = get_profile_names()
186
+ if name not in profiles:
187
+ out.error(f"Profile '{name}' not found")
188
+ out.info(f"Available: {', '.join(profiles)}")
189
+ raise typer.Exit(1)
190
+
191
+ old_default = get_default_profile()
192
+ set_default_profile(name)
193
+
194
+ svc = get_service_config(name)
195
+ if svc.url:
196
+ ok, info = _test_connection(svc.url, svc.login, svc.password)
197
+ if ok:
198
+ out.success(f"Switched to '{name}' ({svc.url}) — {info}")
199
+ else:
200
+ out.warn(f"Switched to '{name}' ({svc.url}) — {info}")
201
+ else:
202
+ out.warn(f"Switched to '{name}' — no {SERVICE_KEY} config in this profile")
203
+
204
+ out.info(f"Previous default: {old_default}")
205
+
206
+
207
+ @app.command()
208
+ def remove(
209
+ ctx: typer.Context,
210
+ name: Annotated[str, typer.Argument(help="Profile name to remove")],
211
+ force: Annotated[bool, typer.Option("--force", help="Skip confirmation")] = False,
212
+ service_only: Annotated[bool, typer.Option("--service-only", help=f"Only remove {SERVICE_KEY} config")] = False,
213
+ ) -> None:
214
+ """Remove a profile or just its DBGate config."""
215
+ actx: AppContext = ctx.obj
216
+ out = actx.output
217
+
218
+ profiles = get_profile_names()
219
+ if name not in profiles:
220
+ out.error(f"Profile '{name}' not found")
221
+ raise typer.Exit(1)
222
+
223
+ if service_only:
224
+ if not force:
225
+ svc = get_service_config(name)
226
+ if not typer.confirm(f"Remove {SERVICE_KEY} config from '{name}' ({svc.url})?"):
227
+ raise typer.Exit(0)
228
+ data = load_raw_config()
229
+ profile = data.get("profiles", {}).get(name, {})
230
+ profile.pop(SERVICE_KEY, None)
231
+ save_raw_config(data)
232
+ out.success(f"Removed {SERVICE_KEY} config from profile '{name}'")
233
+ else:
234
+ if not force:
235
+ services = get_all_services_in_profile(name)
236
+ svc_list = ", ".join(services.keys())
237
+ if not typer.confirm(f"Remove entire profile '{name}' (services: {svc_list})?"):
238
+ raise typer.Exit(0)
239
+ remove_profile(name)
240
+ out.success(f"Profile '{name}' removed")
241
+
242
+
243
+ @app.command()
244
+ def profiles(ctx: typer.Context) -> None:
245
+ """List all profiles with DBGate connection status."""
246
+ actx: AppContext = ctx.obj
247
+ out = actx.output
248
+
249
+ profile_names = get_profile_names()
250
+ if not profile_names:
251
+ out.warn("No profiles configured. Run: kctl-dbgate config init")
252
+ return
253
+
254
+ active = resolve_active_profile_name(actx.profile)
255
+ default = get_default_profile()
256
+
257
+ rows: list[list[str]] = []
258
+ for pname in profile_names:
259
+ svc = get_service_config(pname)
260
+ is_active = pname == active
261
+ status_marker = "[green]active[/green]" if is_active else ("default" if pname == default else "")
262
+ if svc.url:
263
+ ok, info = _test_connection(svc.url, svc.login, svc.password)
264
+ conn = f"[green]{info}[/green]" if ok else f"[red]{info[:40]}[/red]"
265
+ else:
266
+ conn = f"[dim]no {SERVICE_KEY} config[/dim]"
267
+ rows.append([pname, svc.url or "-", svc.login or "-", conn, status_marker])
268
+
269
+ out.table(
270
+ "Profiles",
271
+ [("Name", "cyan"), ("DBGate URL", ""), ("Login", ""), ("Status", ""), ("", "green")],
272
+ rows,
273
+ )
274
+
275
+
276
+ @app.command()
277
+ def current(ctx: typer.Context) -> None:
278
+ """Show the active profile and connection status."""
279
+ actx: AppContext = ctx.obj
280
+ out = actx.output
281
+
282
+ active = resolve_active_profile_name(actx.profile)
283
+ svc = get_service_config(active)
284
+
285
+ out.kv("Active profile", active)
286
+ out.kv("Default profile", get_default_profile())
287
+ out.kv("URL", svc.url or "[dim]not set[/dim]")
288
+ out.kv("Login", svc.login or "[dim]not set[/dim]")
289
+ out.kv("Password", _mask(svc.password))
290
+
291
+ if svc.url:
292
+ ok, info = _test_connection(svc.url, svc.login, svc.password)
293
+ if ok:
294
+ out.success(f"Connection OK — {info}")
295
+ else:
296
+ out.error(f"Connection failed: {info}")
297
+ else:
298
+ out.warn(f"No {SERVICE_KEY} config in active profile. Run: kctl-dbgate config init")
299
+
300
+
301
+ # ----------------------------------------------------------------------
302
+ # Server-side settings (POST /config/*)
303
+ # ----------------------------------------------------------------------
304
+
305
+
306
+ @app.command("server-show")
307
+ def server_show(ctx: typer.Context) -> None:
308
+ """Show DBGate's server-side configuration (POST /config/get)."""
309
+ actx: AppContext = ctx.obj
310
+ out = actx.output
311
+
312
+ try:
313
+ cfg = actx.client.call("/config/get")
314
+ except KctlError as e:
315
+ out.error(str(e))
316
+ raise typer.Exit(1) from e
317
+
318
+ if not isinstance(cfg, dict):
319
+ out.error(f"Unexpected response: {cfg!r}")
320
+ raise typer.Exit(1)
321
+
322
+ rows = [[str(k), str(v)] for k, v in sorted(cfg.items())]
323
+ out.table(
324
+ title="DBGate server configuration",
325
+ columns=[("Key", "cyan"), ("Value", "")],
326
+ rows=rows,
327
+ data_for_json=[cfg],
328
+ )
329
+
330
+
331
+ @app.command("server-set")
332
+ def server_set(
333
+ ctx: typer.Context,
334
+ key: Annotated[str, typer.Option("--key", help="Setting key")],
335
+ value: Annotated[str, typer.Option("--value", help="Setting value")],
336
+ ) -> None:
337
+ """Update a single server-side setting (POST /config/update-settings)."""
338
+ actx: AppContext = ctx.obj
339
+ out = actx.output
340
+ payload: dict[str, Any] = {key: value}
341
+ try:
342
+ actx.client.call("/config/update-settings", payload)
343
+ except KctlError as e:
344
+ out.error(str(e))
345
+ raise typer.Exit(1) from e
346
+ out.success(f"Setting {key} updated")
347
+
348
+
349
+ @app.command("server-changelog")
350
+ def server_changelog(ctx: typer.Context) -> None:
351
+ """Show DBGate server changelog (POST /config/changelog)."""
352
+ actx: AppContext = ctx.obj
353
+ out = actx.output
354
+ try:
355
+ result = actx.client.call("/config/changelog")
356
+ except KctlError as e:
357
+ out.error(str(e))
358
+ raise typer.Exit(1) from e
359
+ if isinstance(result, str):
360
+ out.text(result)
361
+ else:
362
+ out.text(str(result))
363
+
364
+
365
+ @app.command("server-export")
366
+ def server_export(
367
+ ctx: typer.Context,
368
+ outfile: Annotated[Path, typer.Argument(help="Path to write YAML dump to")],
369
+ ) -> None:
370
+ """Dump the server's /config/get response to a YAML file.
371
+
372
+ NOTE: DBGate exposes no true export endpoint, so this dumps the
373
+ read-only config snapshot. Use `server-import` with a file from
374
+ `import-connections-and-settings` producer if available.
375
+ """
376
+ actx: AppContext = ctx.obj
377
+ out = actx.output
378
+ try:
379
+ cfg = actx.client.call("/config/get")
380
+ except KctlError as e:
381
+ out.error(str(e))
382
+ raise typer.Exit(1) from e
383
+ outfile.write_text(yaml.safe_dump(cfg, sort_keys=True))
384
+ out.success(f"Server config exported to {outfile}")
385
+
386
+
387
+ @app.command("server-import")
388
+ def server_import(
389
+ ctx: typer.Context,
390
+ infile: Annotated[Path, typer.Argument(help="YAML file to import")],
391
+ dry_run: Annotated[bool, typer.Option("--dry-run", help="Don't send; just show what would be sent")] = False,
392
+ ) -> None:
393
+ """Import connections + settings from a YAML file.
394
+
395
+ Calls POST /config/import-connections-and-settings.
396
+ """
397
+ actx: AppContext = ctx.obj
398
+ out = actx.output
399
+
400
+ if not infile.exists():
401
+ out.error(f"File not found: {infile}")
402
+ raise typer.Exit(1)
403
+
404
+ payload = yaml.safe_load(infile.read_text()) or {}
405
+
406
+ if dry_run:
407
+ out.header("Dry-run payload")
408
+ out.text(yaml.safe_dump(payload, sort_keys=True))
409
+ out.info("No API call made (--dry-run)")
410
+ return
411
+
412
+ try:
413
+ actx.client.call("/config/import-connections-and-settings", payload)
414
+ except KctlError as e:
415
+ out.error(str(e))
416
+ raise typer.Exit(1) from e
417
+ out.success(f"Imported from {infile}")