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 ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
kctl_mm/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from kctl_mm.cli import _run
2
+
3
+ if __name__ == "__main__":
4
+ _run()
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)
@@ -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)