kctl-mm 0.2.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.
- kctl_mm/__init__.py +1 -0
- kctl_mm/__main__.py +4 -0
- kctl_mm/cli.py +119 -0
- kctl_mm/commands/__init__.py +0 -0
- kctl_mm/commands/audit.py +36 -0
- kctl_mm/commands/bots.py +48 -0
- kctl_mm/commands/channels.py +207 -0
- kctl_mm/commands/completions.py +32 -0
- kctl_mm/commands/config_cmd.py +500 -0
- kctl_mm/commands/dashboard.py +76 -0
- kctl_mm/commands/deploy.py +36 -0
- kctl_mm/commands/doctor.py +45 -0
- kctl_mm/commands/health.py +52 -0
- kctl_mm/commands/import_export.py +99 -0
- kctl_mm/commands/integrations.py +45 -0
- kctl_mm/commands/jobs.py +32 -0
- kctl_mm/commands/logs.py +21 -0
- kctl_mm/commands/maintenance.py +53 -0
- kctl_mm/commands/mm_config.py +61 -0
- kctl_mm/commands/permissions.py +56 -0
- kctl_mm/commands/plugins.py +57 -0
- kctl_mm/commands/posts.py +47 -0
- kctl_mm/commands/self_update.py +30 -0
- kctl_mm/commands/skill_cmd.py +76 -0
- kctl_mm/commands/status.py +14 -0
- kctl_mm/commands/teams.py +74 -0
- kctl_mm/commands/users.py +98 -0
- kctl_mm/commands/webhooks.py +58 -0
- kctl_mm/core/__init__.py +0 -0
- kctl_mm/core/callbacks.py +55 -0
- kctl_mm/core/client.py +245 -0
- kctl_mm/core/config.py +200 -0
- kctl_mm/core/exceptions.py +25 -0
- kctl_mm/core/mm_exec.py +57 -0
- kctl_mm-0.2.0.dist-info/METADATA +17 -0
- kctl_mm-0.2.0.dist-info/RECORD +38 -0
- kctl_mm-0.2.0.dist-info/WHEEL +4 -0
- kctl_mm-0.2.0.dist-info/entry_points.txt +2 -0
kctl_mm/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
kctl_mm/__main__.py
ADDED
kctl_mm/cli.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""kctl-mm main CLI entry point."""
|
|
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.exceptions import KctlError
|
|
10
|
+
|
|
11
|
+
from kctl_mm import __version__
|
|
12
|
+
from kctl_mm.core.callbacks import AppContext
|
|
13
|
+
from kctl_lib.self_update import notify_if_outdated
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(
|
|
16
|
+
name="kctl-mm",
|
|
17
|
+
help="Kodemeio Mattermost CLI - manage Mattermost Team Edition.",
|
|
18
|
+
no_args_is_help=True,
|
|
19
|
+
rich_markup_mode="rich",
|
|
20
|
+
pretty_exceptions_enable=False,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def version_callback(value: bool) -> None:
|
|
25
|
+
if value:
|
|
26
|
+
typer.echo(f"kctl-mm {__version__}")
|
|
27
|
+
raise typer.Exit()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.callback()
|
|
31
|
+
def main(
|
|
32
|
+
ctx: typer.Context,
|
|
33
|
+
json_output: Annotated[bool, typer.Option("--json")] = False,
|
|
34
|
+
quiet: Annotated[bool, typer.Option("--quiet", "-q")] = False,
|
|
35
|
+
output_format: Annotated[str, typer.Option("--format", "-f")] = "pretty",
|
|
36
|
+
no_header: Annotated[bool, typer.Option("--no-header")] = False,
|
|
37
|
+
profile: Annotated[str | None, typer.Option("--profile", "-p")] = None,
|
|
38
|
+
version: Annotated[bool | None, typer.Option("--version", "-V", callback=version_callback, is_eager=True)] = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
ctx.obj = AppContext(
|
|
41
|
+
json_mode=json_output,
|
|
42
|
+
quiet=quiet,
|
|
43
|
+
profile=profile,
|
|
44
|
+
format="json" if json_output else output_format,
|
|
45
|
+
no_header=no_header,
|
|
46
|
+
)
|
|
47
|
+
notify_if_outdated(ctx.obj.output, "kctl-mm", __version__)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _register() -> None:
|
|
51
|
+
from kctl_mm.commands import (
|
|
52
|
+
audit,
|
|
53
|
+
bots,
|
|
54
|
+
channels,
|
|
55
|
+
completions,
|
|
56
|
+
config_cmd,
|
|
57
|
+
dashboard,
|
|
58
|
+
deploy,
|
|
59
|
+
doctor,
|
|
60
|
+
health,
|
|
61
|
+
import_export,
|
|
62
|
+
integrations,
|
|
63
|
+
jobs,
|
|
64
|
+
logs,
|
|
65
|
+
maintenance,
|
|
66
|
+
mm_config,
|
|
67
|
+
permissions,
|
|
68
|
+
plugins,
|
|
69
|
+
posts,
|
|
70
|
+
self_update,
|
|
71
|
+
skill_cmd,
|
|
72
|
+
status,
|
|
73
|
+
teams,
|
|
74
|
+
users,
|
|
75
|
+
webhooks,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
mapping = [
|
|
79
|
+
("config", config_cmd),
|
|
80
|
+
("doctor", doctor),
|
|
81
|
+
("self-update", self_update),
|
|
82
|
+
("completions", completions),
|
|
83
|
+
("skill", skill_cmd),
|
|
84
|
+
("status", status),
|
|
85
|
+
("logs", logs),
|
|
86
|
+
("deploy", deploy),
|
|
87
|
+
("health", health),
|
|
88
|
+
("dashboard", dashboard),
|
|
89
|
+
("users", users),
|
|
90
|
+
("teams", teams),
|
|
91
|
+
("channels", channels),
|
|
92
|
+
("permissions", permissions),
|
|
93
|
+
("posts", posts),
|
|
94
|
+
("mm-config", mm_config),
|
|
95
|
+
("maintenance", maintenance),
|
|
96
|
+
("webhooks", webhooks),
|
|
97
|
+
("bots", bots),
|
|
98
|
+
("plugins", plugins),
|
|
99
|
+
("integrations", integrations),
|
|
100
|
+
("jobs", jobs),
|
|
101
|
+
("audit", audit),
|
|
102
|
+
("import-export", import_export),
|
|
103
|
+
]
|
|
104
|
+
for name, module in mapping:
|
|
105
|
+
app.add_typer(module.app, name=name)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
_register()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _run() -> None:
|
|
112
|
+
try:
|
|
113
|
+
app()
|
|
114
|
+
except KctlError as exc:
|
|
115
|
+
handle_cli_error(exc)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
if __name__ == "__main__":
|
|
119
|
+
_run()
|
|
File without changes
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from kctl_mm.core.callbacks import AppContext
|
|
6
|
+
|
|
7
|
+
app = typer.Typer(help="Audit & compliance reports.", no_args_is_help=True)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _c(ctx: typer.Context) -> AppContext:
|
|
11
|
+
return ctx.ensure_object(AppContext)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command("login")
|
|
15
|
+
def login_cmd(ctx: typer.Context, username: str) -> None:
|
|
16
|
+
c = _c(ctx)
|
|
17
|
+
user = c.client.get_user_by_username(username)
|
|
18
|
+
user_id = user["id"] if isinstance(user, dict) else user
|
|
19
|
+
c.output.raw_json(c.client.list_user_audits(user_id))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.command("security")
|
|
23
|
+
def security_cmd(ctx: typer.Context) -> None:
|
|
24
|
+
c = _c(ctx)
|
|
25
|
+
c.output.raw_json(c.client.list_compliance_reports())
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@app.command("compliance")
|
|
29
|
+
def compliance_cmd(
|
|
30
|
+
ctx: typer.Context,
|
|
31
|
+
report_id: str,
|
|
32
|
+
out: str = typer.Option(..., "--out", help="Local path to save report."),
|
|
33
|
+
) -> None:
|
|
34
|
+
c = _c(ctx)
|
|
35
|
+
path = c.client.download_compliance_report(report_id, out)
|
|
36
|
+
typer.echo(path)
|
kctl_mm/commands/bots.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from kctl_mm.core.callbacks import AppContext
|
|
6
|
+
|
|
7
|
+
app = typer.Typer(help="Bot management.", no_args_is_help=True)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _c(ctx: typer.Context) -> AppContext:
|
|
11
|
+
return ctx.ensure_object(AppContext)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command("list")
|
|
15
|
+
def list_cmd(ctx: typer.Context) -> None:
|
|
16
|
+
c = _c(ctx)
|
|
17
|
+
bots = c.client.list_bots()
|
|
18
|
+
rows = [[b.get("user_id", ""), b.get("username", ""), b.get("display_name", "")] for b in bots]
|
|
19
|
+
c.output.table(
|
|
20
|
+
"Bots",
|
|
21
|
+
[("ID", "dim"), ("Username", "cyan"), ("Display", "green")],
|
|
22
|
+
rows,
|
|
23
|
+
data_for_json=bots,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.command("create")
|
|
28
|
+
def create_cmd(ctx: typer.Context, username: str, display: str) -> None:
|
|
29
|
+
c = _c(ctx)
|
|
30
|
+
c.output.raw_json(c.client.create_bot(username, display))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.command("enable")
|
|
34
|
+
def enable_cmd(ctx: typer.Context, bot_id: str) -> None:
|
|
35
|
+
c = _c(ctx)
|
|
36
|
+
c.output.raw_json(c.client.enable_bot(bot_id))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@app.command("disable")
|
|
40
|
+
def disable_cmd(ctx: typer.Context, bot_id: str) -> None:
|
|
41
|
+
c = _c(ctx)
|
|
42
|
+
c.output.raw_json(c.client.disable_bot(bot_id))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@app.command("delete")
|
|
46
|
+
def delete_cmd(ctx: typer.Context, bot_id: str) -> None:
|
|
47
|
+
c = _c(ctx)
|
|
48
|
+
c.output.raw_json(c.client.delete_bot(bot_id))
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from kctl_mm.core.callbacks import AppContext
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(help="Channel management.", no_args_is_help=True)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _c(ctx: typer.Context) -> AppContext:
|
|
15
|
+
return ctx.ensure_object(AppContext)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@app.command("list")
|
|
19
|
+
def list_cmd(ctx: typer.Context, team_name: str) -> None:
|
|
20
|
+
c = _c(ctx)
|
|
21
|
+
team = c.client.get_team_by_name(team_name)
|
|
22
|
+
channels = c.client.list_channels_for_team(team["id"])
|
|
23
|
+
rows = [[ch.get("id", ""), ch.get("name", ""), ch.get("display_name", ""), ch.get("type", "")] for ch in channels]
|
|
24
|
+
c.output.table(
|
|
25
|
+
"Channels",
|
|
26
|
+
[("ID", "dim"), ("Name", "cyan"), ("Display", "green"), ("Type", "yellow")],
|
|
27
|
+
rows,
|
|
28
|
+
data_for_json=channels,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@app.command("get")
|
|
33
|
+
def get_cmd(ctx: typer.Context, team_name: str, channel_name: str) -> None:
|
|
34
|
+
c = _c(ctx)
|
|
35
|
+
team = c.client.get_team_by_name(team_name)
|
|
36
|
+
c.output.raw_json(c.client.get_channel_by_name(team["id"], channel_name))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@app.command("create")
|
|
40
|
+
def create_cmd(
|
|
41
|
+
ctx: typer.Context,
|
|
42
|
+
team_name: str,
|
|
43
|
+
channel_name: str,
|
|
44
|
+
display: str,
|
|
45
|
+
private: bool = typer.Option(False, "--private"),
|
|
46
|
+
) -> None:
|
|
47
|
+
c = _c(ctx)
|
|
48
|
+
team = c.client.get_team_by_name(team_name)
|
|
49
|
+
c.output.raw_json(c.client.create_channel(team["id"], channel_name, display, private=private))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.command("archive")
|
|
53
|
+
def archive_cmd(ctx: typer.Context, team_name: str, channel_name: str) -> None:
|
|
54
|
+
r = _c(ctx).mm_exec.mmctl(["channel", "archive", f"{team_name}:{channel_name}"])
|
|
55
|
+
typer.echo(r.stdout)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.command("members")
|
|
59
|
+
def members_cmd(ctx: typer.Context, team_name: str, channel_name: str) -> None:
|
|
60
|
+
c = _c(ctx)
|
|
61
|
+
team = c.client.get_team_by_name(team_name)
|
|
62
|
+
channel = c.client.get_channel_by_name(team["id"], channel_name)
|
|
63
|
+
c.output.raw_json(c.client.list_channel_members(channel["id"]))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@app.command("add")
|
|
67
|
+
def add_cmd(ctx: typer.Context, team_name: str, channel_name: str, user: str) -> None:
|
|
68
|
+
r = _c(ctx).mm_exec.mmctl(["channel", "users", "add", f"{team_name}:{channel_name}", user])
|
|
69
|
+
typer.echo(r.stdout)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@app.command("remove")
|
|
73
|
+
def remove_cmd(ctx: typer.Context, team_name: str, channel_name: str, user: str) -> None:
|
|
74
|
+
r = _c(ctx).mm_exec.mmctl(["channel", "users", "remove", f"{team_name}:{channel_name}", user])
|
|
75
|
+
typer.echo(r.stdout)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@app.command("rename")
|
|
79
|
+
def rename_cmd(ctx: typer.Context, team_name: str, channel_name: str, new_display: str) -> None:
|
|
80
|
+
c = _c(ctx)
|
|
81
|
+
team = c.client.get_team_by_name(team_name)
|
|
82
|
+
channel = c.client.get_channel_by_name(team["id"], channel_name)
|
|
83
|
+
c.output.raw_json(c.client.rename_channel(channel["id"], new_display))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@app.command("patch")
|
|
87
|
+
def patch_cmd(
|
|
88
|
+
ctx: typer.Context,
|
|
89
|
+
team_name: str,
|
|
90
|
+
channel_name: str,
|
|
91
|
+
display_name: Annotated[str | None, typer.Option("--display-name")] = None,
|
|
92
|
+
header: Annotated[str | None, typer.Option("--header")] = None,
|
|
93
|
+
purpose: Annotated[str | None, typer.Option("--purpose")] = None,
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Patch channel metadata (display name, header, purpose)."""
|
|
96
|
+
c = _c(ctx)
|
|
97
|
+
team = c.client.get_team_by_name(team_name)
|
|
98
|
+
channel = c.client.get_channel_by_name(team["id"], channel_name)
|
|
99
|
+
fields = {
|
|
100
|
+
k: v for k, v in {"display_name": display_name, "header": header, "purpose": purpose}.items() if v is not None
|
|
101
|
+
}
|
|
102
|
+
if not fields:
|
|
103
|
+
c.output.warn("No fields to patch.")
|
|
104
|
+
return
|
|
105
|
+
c.output.raw_json(c.client.patch_channel(channel["id"], **fields))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@app.command("sync-pinned")
|
|
109
|
+
def sync_pinned_cmd(
|
|
110
|
+
ctx: typer.Context,
|
|
111
|
+
team_name: Annotated[str, typer.Argument(help="Team slug (e.g. 'tpp').")],
|
|
112
|
+
channel_name: Annotated[str, typer.Argument(help="Channel name (e.g. 'onboarding').")],
|
|
113
|
+
source_dir: Annotated[Path, typer.Argument(help="Directory containing NN-*.md pinned-post sources.")],
|
|
114
|
+
display_name: Annotated[
|
|
115
|
+
str | None, typer.Option("--display-name", help="Create channel with this display name if missing.")
|
|
116
|
+
] = None,
|
|
117
|
+
header: Annotated[str | None, typer.Option("--header", help="Channel header (applied on every run).")] = None,
|
|
118
|
+
purpose: Annotated[str | None, typer.Option("--purpose", help="Channel purpose (applied on every run).")] = None,
|
|
119
|
+
private: Annotated[
|
|
120
|
+
bool, typer.Option("--private", help="Create the channel as private if it doesn't exist yet.")
|
|
121
|
+
] = False,
|
|
122
|
+
state_file: Annotated[
|
|
123
|
+
Path | None,
|
|
124
|
+
typer.Option("--state-file", help="Path to persist post-id mapping. Default: <source_dir>/.synced-ids.json."),
|
|
125
|
+
] = None,
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Idempotently sync a channel's pinned posts from a directory of markdown files.
|
|
128
|
+
|
|
129
|
+
Creates the channel if missing, patches header/purpose, then for each file
|
|
130
|
+
matching ``NN-*.md`` (sorted): creates a new pinned post or updates the
|
|
131
|
+
previously-synced one, based on a state file keyed by filename.
|
|
132
|
+
|
|
133
|
+
Re-runs are safe — existing posts are updated in place (Mattermost preserves
|
|
134
|
+
post id + pin status), new files produce new pinned posts, and deletions are
|
|
135
|
+
not handled automatically (unpin manually if you want to remove a topic).
|
|
136
|
+
"""
|
|
137
|
+
c = _c(ctx)
|
|
138
|
+
if not source_dir.is_dir():
|
|
139
|
+
c.output.error(f"source-dir {source_dir} does not exist or is not a directory")
|
|
140
|
+
raise typer.Exit(2)
|
|
141
|
+
state_path = state_file or (source_dir / ".synced-ids.json")
|
|
142
|
+
state: dict[str, str] = {}
|
|
143
|
+
if state_path.exists():
|
|
144
|
+
try:
|
|
145
|
+
state = json.loads(state_path.read_text())
|
|
146
|
+
except Exception:
|
|
147
|
+
c.output.warn(f"state file {state_path} unreadable — starting fresh")
|
|
148
|
+
|
|
149
|
+
team = c.client.get_team_by_name(team_name)
|
|
150
|
+
# Ensure channel exists
|
|
151
|
+
channel = None
|
|
152
|
+
try:
|
|
153
|
+
channel = c.client.get_channel_by_name(team["id"], channel_name)
|
|
154
|
+
if not isinstance(channel, dict) or "id" not in channel or len(channel.get("id", "")) != 26:
|
|
155
|
+
channel = None
|
|
156
|
+
except Exception:
|
|
157
|
+
channel = None
|
|
158
|
+
if channel is None:
|
|
159
|
+
c.output.info(f"channel #{channel_name} not found — creating")
|
|
160
|
+
channel = c.client.create_channel(
|
|
161
|
+
team["id"],
|
|
162
|
+
channel_name,
|
|
163
|
+
display_name or channel_name,
|
|
164
|
+
private=private,
|
|
165
|
+
)
|
|
166
|
+
channel_id = channel["id"]
|
|
167
|
+
|
|
168
|
+
if header is not None or purpose is not None or display_name is not None:
|
|
169
|
+
fields = {
|
|
170
|
+
k: v
|
|
171
|
+
for k, v in {"display_name": display_name, "header": header, "purpose": purpose}.items()
|
|
172
|
+
if v is not None
|
|
173
|
+
}
|
|
174
|
+
c.client.patch_channel(channel_id, **fields)
|
|
175
|
+
c.output.info(" patched channel metadata")
|
|
176
|
+
|
|
177
|
+
files = sorted(p for p in source_dir.glob("[0-9][0-9]-*.md"))
|
|
178
|
+
if not files:
|
|
179
|
+
c.output.warn("no NN-*.md files found in source-dir")
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
created = updated = failed = 0
|
|
183
|
+
for f in files:
|
|
184
|
+
slug = f.stem
|
|
185
|
+
body = f.read_text().strip()
|
|
186
|
+
existing = state.get(slug)
|
|
187
|
+
if existing:
|
|
188
|
+
try:
|
|
189
|
+
c.client.update_post(existing, body)
|
|
190
|
+
updated += 1
|
|
191
|
+
c.output.info(f" ↻ updated {slug}")
|
|
192
|
+
continue
|
|
193
|
+
except Exception as e:
|
|
194
|
+
c.output.warn(f" update of {slug} failed ({e}) — will recreate")
|
|
195
|
+
try:
|
|
196
|
+
post = c.client.create_post(channel_id, body)
|
|
197
|
+
pid = post["id"]
|
|
198
|
+
c.client.pin_post(pid)
|
|
199
|
+
state[slug] = pid
|
|
200
|
+
created += 1
|
|
201
|
+
c.output.success(f" + created + pinned {slug} ({pid})")
|
|
202
|
+
except Exception as e:
|
|
203
|
+
failed += 1
|
|
204
|
+
c.output.error(f" create of {slug} failed: {e}")
|
|
205
|
+
|
|
206
|
+
state_path.write_text(json.dumps(state, indent=2, sort_keys=True))
|
|
207
|
+
c.output.success(f"sync complete: created={created} updated={updated} failed={failed} · state={state_path}")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Shell completions command for kctl-mm."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(help="Generate or install shell completions.", no_args_is_help=False, invoke_without_command=True)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.callback(invoke_without_command=True)
|
|
13
|
+
def completions(
|
|
14
|
+
ctx: typer.Context,
|
|
15
|
+
shell: Annotated[str, typer.Argument(help="Shell type: zsh, bash, fish")] = "zsh",
|
|
16
|
+
install: Annotated[bool, typer.Option("--install", help="Install completions")] = False,
|
|
17
|
+
) -> None:
|
|
18
|
+
"""Generate or install shell completions."""
|
|
19
|
+
if ctx.invoked_subcommand is not None:
|
|
20
|
+
return
|
|
21
|
+
from kctl_lib.completions import get_completion_script, install_completions
|
|
22
|
+
|
|
23
|
+
if install:
|
|
24
|
+
path = install_completions("kctl-mm", shell)
|
|
25
|
+
if path:
|
|
26
|
+
typer.echo(f"Completions installed to {path}")
|
|
27
|
+
else:
|
|
28
|
+
typer.echo(f"Could not install completions for {shell}", err=True)
|
|
29
|
+
raise typer.Exit(code=1)
|
|
30
|
+
else:
|
|
31
|
+
script = get_completion_script("kctl-mm", shell)
|
|
32
|
+
typer.echo(script)
|