kctl-cf 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.
Files changed (47) hide show
  1. kctl_cf/__init__.py +3 -0
  2. kctl_cf/__main__.py +5 -0
  3. kctl_cf/cli.py +189 -0
  4. kctl_cf/commands/__init__.py +0 -0
  5. kctl_cf/commands/access.py +447 -0
  6. kctl_cf/commands/aliases.py +107 -0
  7. kctl_cf/commands/analytics.py +194 -0
  8. kctl_cf/commands/argo.py +97 -0
  9. kctl_cf/commands/cache.py +77 -0
  10. kctl_cf/commands/config_cmd.py +379 -0
  11. kctl_cf/commands/custom_hostnames.py +148 -0
  12. kctl_cf/commands/doctor_cmd.py +82 -0
  13. kctl_cf/commands/email_routing.py +233 -0
  14. kctl_cf/commands/export.py +400 -0
  15. kctl_cf/commands/health.py +508 -0
  16. kctl_cf/commands/load_balancers.py +298 -0
  17. kctl_cf/commands/page_rules.py +169 -0
  18. kctl_cf/commands/pages.py +301 -0
  19. kctl_cf/commands/r2.py +171 -0
  20. kctl_cf/commands/records.py +256 -0
  21. kctl_cf/commands/redirects.py +183 -0
  22. kctl_cf/commands/selftest.py +59 -0
  23. kctl_cf/commands/skill_cmd.py +76 -0
  24. kctl_cf/commands/spectrum.py +178 -0
  25. kctl_cf/commands/speed.py +198 -0
  26. kctl_cf/commands/ssl.py +205 -0
  27. kctl_cf/commands/status.py +162 -0
  28. kctl_cf/commands/terraform.py +107 -0
  29. kctl_cf/commands/tokens.py +287 -0
  30. kctl_cf/commands/tunnels.py +211 -0
  31. kctl_cf/commands/waf.py +329 -0
  32. kctl_cf/commands/waiting_rooms.py +178 -0
  33. kctl_cf/commands/workers.py +381 -0
  34. kctl_cf/commands/zones.py +214 -0
  35. kctl_cf/core/__init__.py +0 -0
  36. kctl_cf/core/callbacks.py +28 -0
  37. kctl_cf/core/client.py +50 -0
  38. kctl_cf/core/config.py +144 -0
  39. kctl_cf/core/exceptions.py +25 -0
  40. kctl_cf/core/notify.py +113 -0
  41. kctl_cf/core/output.py +5 -0
  42. kctl_cf/core/plugins.py +57 -0
  43. kctl_cf/core/utils.py +75 -0
  44. kctl_cf-0.3.0.dist-info/METADATA +18 -0
  45. kctl_cf-0.3.0.dist-info/RECORD +47 -0
  46. kctl_cf-0.3.0.dist-info/WHEEL +4 -0
  47. kctl_cf-0.3.0.dist-info/entry_points.txt +2 -0
kctl_cf/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """kctl-cf: Kodemeio Cloudflare CLI."""
2
+
3
+ __version__ = "0.3.0"
kctl_cf/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running as: python -m kctl_cf."""
2
+
3
+ from kctl_cf.cli import _run
4
+
5
+ _run()
kctl_cf/cli.py ADDED
@@ -0,0 +1,189 @@
1
+ """Main CLI entry point for kctl-cf."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from kctl_cf import __version__
11
+ from kctl_cf.commands.access import app as access_app
12
+ from kctl_cf.commands.analytics import app as analytics_app
13
+ from kctl_cf.commands.argo import app as argo_app
14
+ from kctl_cf.commands.cache import app as cache_app
15
+ from kctl_cf.commands.config_cmd import app as config_app
16
+ from kctl_cf.commands.custom_hostnames import app as custom_hostnames_app
17
+ from kctl_cf.commands.email_routing import app as email_routing_app
18
+ from kctl_cf.commands.export import app as export_app
19
+ from kctl_cf.commands.health import app as health_app
20
+ from kctl_cf.commands.load_balancers import app as load_balancers_app
21
+ from kctl_cf.commands.page_rules import app as page_rules_app
22
+ from kctl_cf.commands.pages import app as pages_app
23
+ from kctl_cf.commands.r2 import app as r2_app
24
+ from kctl_cf.commands.records import app as records_app
25
+ from kctl_cf.commands.redirects import app as redirects_app
26
+ from kctl_cf.commands.selftest import app as selftest_app
27
+ from kctl_cf.commands.spectrum import app as spectrum_app
28
+ from kctl_cf.commands.speed import app as speed_app
29
+ from kctl_cf.commands.ssl import app as ssl_app
30
+ from kctl_cf.commands.status import app as status_app
31
+ from kctl_cf.commands.terraform import app as terraform_app
32
+ from kctl_cf.commands.tokens import app as tokens_app
33
+ from kctl_cf.commands.tunnels import app as tunnels_app
34
+ from kctl_cf.commands.waf import app as waf_app
35
+ from kctl_cf.commands.waiting_rooms import app as waiting_rooms_app
36
+ from kctl_cf.commands.workers import app as workers_app
37
+ from kctl_cf.commands.zones import app as zones_app
38
+ from kctl_cf.core.callbacks import AppContext
39
+ from kctl_cf.core.exceptions import APIError, AuthenticationError, ConfigError, KctlError
40
+ from kctl_cf.core.exceptions import ConnectionError as KctlConnectionError
41
+ from kctl_cf.core.plugins import discover_and_load_plugins
42
+ from kctl_lib.self_update import notify_if_outdated
43
+
44
+
45
+ def version_callback(value: bool) -> None:
46
+ if value:
47
+ typer.echo(f"kctl-cf {__version__}")
48
+ raise typer.Exit()
49
+
50
+
51
+ app = typer.Typer(
52
+ name="kctl-cf",
53
+ help="Kodemeio Cloudflare CLI - manage DNS, tunnels, WAF, cache, Workers, R2, email routing, access.",
54
+ no_args_is_help=True,
55
+ rich_markup_mode="rich",
56
+ pretty_exceptions_enable=False,
57
+ )
58
+
59
+
60
+ @app.callback()
61
+ def main(
62
+ ctx: typer.Context,
63
+ json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
64
+ quiet: Annotated[bool, typer.Option("--quiet", "-q", help="Suppress info messages")] = False,
65
+ format: Annotated[str, typer.Option("--format", "-f", help="Output format: pretty/json/csv/yaml")] = "pretty",
66
+ no_header: Annotated[bool, typer.Option("--no-header", help="Omit header row in CSV output")] = False,
67
+ profile: Annotated[str | None, typer.Option("--profile", "-p", help="Config profile name")] = None,
68
+ api_token: Annotated[str | None, typer.Option("--api-token", help="API token override")] = None,
69
+ account_id: Annotated[str | None, typer.Option("--account-id", help="Account ID override")] = None,
70
+ version: Annotated[
71
+ bool, typer.Option("--version", "-V", callback=version_callback, is_eager=True, help="Show version")
72
+ ] = False,
73
+ ) -> None:
74
+ """Kodemeio Cloudflare CLI."""
75
+ ctx.ensure_object(dict)
76
+ ctx.obj = AppContext(
77
+ json_mode=json_output,
78
+ quiet=quiet,
79
+ format=format,
80
+ no_header=no_header,
81
+ profile=profile,
82
+ api_token_override=api_token,
83
+ account_id_override=account_id,
84
+ )
85
+ notify_if_outdated(ctx.obj.output, "kctl-cf", __version__)
86
+
87
+
88
+ app.add_typer(config_app, name="config")
89
+ app.add_typer(zones_app, name="zones")
90
+ app.add_typer(records_app, name="records")
91
+ app.add_typer(tokens_app, name="tokens")
92
+ app.add_typer(tunnels_app, name="tunnels")
93
+ app.add_typer(health_app, name="health")
94
+ app.add_typer(waf_app, name="waf")
95
+ app.add_typer(cache_app, name="cache")
96
+ app.add_typer(ssl_app, name="ssl")
97
+ app.add_typer(workers_app, name="workers")
98
+ app.add_typer(r2_app, name="r2")
99
+ app.add_typer(export_app, name="export")
100
+ app.add_typer(terraform_app, name="terraform")
101
+ app.add_typer(email_routing_app, name="email-routing")
102
+ app.add_typer(page_rules_app, name="page-rules")
103
+ app.add_typer(pages_app, name="pages")
104
+ app.add_typer(redirects_app, name="redirects")
105
+ app.add_typer(access_app, name="access")
106
+ app.add_typer(speed_app, name="speed")
107
+ app.add_typer(status_app, name="status")
108
+ app.add_typer(analytics_app, name="analytics")
109
+ app.add_typer(selftest_app, name="selftest")
110
+ app.add_typer(argo_app, name="argo")
111
+ app.add_typer(custom_hostnames_app, name="custom-hostnames")
112
+ app.add_typer(load_balancers_app, name="load-balancers")
113
+ app.add_typer(spectrum_app, name="spectrum")
114
+ app.add_typer(waiting_rooms_app, name="waiting-rooms")
115
+ # Load plugins from entry points
116
+ discover_and_load_plugins(app)
117
+
118
+ # Register short aliases and skill generation
119
+ from kctl_cf.commands.aliases import register_aliases # noqa: E402
120
+ from kctl_cf.commands.skill_cmd import app as skill_app # noqa: E402
121
+
122
+ register_aliases(app)
123
+ from kctl_cf.commands.doctor_cmd import app as doctor_app # noqa: E402
124
+
125
+ app.add_typer(doctor_app, name="doctor")
126
+ app.add_typer(skill_app, name="skill", hidden=True)
127
+
128
+
129
+ @app.command("self-update")
130
+ def self_update_cmd(ctx: typer.Context) -> None:
131
+ """Check for updates and upgrade kctl-cf."""
132
+ actx = ctx.obj
133
+ out = actx.output
134
+
135
+ from kctl_lib.self_update import check_update
136
+ from kctl_lib.self_update import update as do_update
137
+
138
+ latest = check_update("kctl-cf", __version__)
139
+ if latest:
140
+ out.info(f"Updating to {latest}...")
141
+ do_update("kctl-cf")
142
+ out.success(f"Updated to {latest}")
143
+ else:
144
+ out.success("Already up to date")
145
+
146
+
147
+ @app.command()
148
+ def completions(
149
+ shell: Annotated[str, typer.Argument(help="Shell type: zsh, bash, fish")] = "zsh",
150
+ install: Annotated[bool, typer.Option("--install", help="Install completions")] = False,
151
+ ) -> None:
152
+ """Generate or install shell completions."""
153
+ from kctl_lib.completions import get_completion_script, install_completions
154
+
155
+ if install:
156
+ path = install_completions("kctl-cf", shell)
157
+ if path:
158
+ typer.echo(f"Completions installed to {path}")
159
+ else:
160
+ typer.echo(f"Could not install completions for {shell}", err=True)
161
+ raise typer.Exit(code=1)
162
+ else:
163
+ script = get_completion_script("kctl-cf", shell)
164
+ typer.echo(script)
165
+
166
+
167
+ def _run() -> None:
168
+ try:
169
+ app()
170
+ except KctlConnectionError as e:
171
+ typer.echo(f"Connection error: {e}", err=True)
172
+ raise typer.Exit(1) from e
173
+ except AuthenticationError as e:
174
+ typer.echo(f"Auth error: {e}", err=True)
175
+ raise typer.Exit(1) from e
176
+ except APIError as e:
177
+ typer.echo(f"API error: {e}", err=True)
178
+ raise typer.Exit(1) from e
179
+ except ConfigError as e:
180
+ typer.echo(f"Config error: {e}", err=True)
181
+ raise typer.Exit(1) from e
182
+ except KctlError as e:
183
+ typer.echo(f"Error: {e}", err=True)
184
+ raise typer.Exit(1) from e
185
+
186
+
187
+ if __name__ == "__main__":
188
+ _run()
189
+ # test
File without changes
@@ -0,0 +1,447 @@
1
+ """Zero Trust Access management commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from kctl_cf.core.callbacks import AppContext
10
+ from kctl_cf.core.utils import require_account
11
+
12
+ app = typer.Typer(help="Manage Cloudflare Zero Trust Access.")
13
+
14
+
15
+ def _parse_access_rules(c: AppContext, rules: list[str] | None) -> list[dict]:
16
+ """Parse access rules from CLI format (email:user@x.com, email_domain:x.com, everyone:)."""
17
+ if not rules:
18
+ return []
19
+ parsed = []
20
+ for rule in rules:
21
+ if ":" not in rule:
22
+ c.output.error(
23
+ f"Invalid rule format: {rule!r} (expected type:value, e.g. 'email:user@x.com' or 'everyone:')"
24
+ )
25
+ raise typer.Exit(1)
26
+ rule_type, rule_value = rule.split(":", 1)
27
+ if rule_type == "email":
28
+ parsed.append({"email": {"email": rule_value}})
29
+ elif rule_type == "email_domain":
30
+ parsed.append({"email_domain": {"domain": rule_value}})
31
+ elif rule_type == "everyone":
32
+ parsed.append({"everyone": {}})
33
+ else:
34
+ parsed.append({rule_type: {"value": rule_value}})
35
+ return parsed
36
+
37
+
38
+ @app.command()
39
+ def apps(ctx: typer.Context) -> None:
40
+ """List Access applications."""
41
+ c: AppContext = ctx.obj
42
+ acct = require_account(c)
43
+ result = c.client.get(f"/accounts/{acct}/access/apps")
44
+ if not isinstance(result, list):
45
+ result = []
46
+ rows = []
47
+ for a in result:
48
+ rows.append(
49
+ [
50
+ a.get("id", "")[:12],
51
+ a.get("name", ""),
52
+ a.get("domain", ""),
53
+ a.get("type", ""),
54
+ a.get("session_duration", ""),
55
+ a.get("created_at", "")[:19],
56
+ ]
57
+ )
58
+ c.output.table(
59
+ "Access Applications",
60
+ [("ID", "dim"), ("Name", "cyan"), ("Domain", "green"), ("Type", ""), ("Session", "dim"), ("Created", "dim")],
61
+ rows,
62
+ data_for_json=result,
63
+ )
64
+
65
+
66
+ @app.command("get-app")
67
+ def get_app(
68
+ ctx: typer.Context,
69
+ app_id: Annotated[str, typer.Argument(help="Application ID")],
70
+ ) -> None:
71
+ """Get details for an Access application."""
72
+ c: AppContext = ctx.obj
73
+ acct = require_account(c)
74
+ result = c.client.get(f"/accounts/{acct}/access/apps/{app_id}")
75
+ if not isinstance(result, dict):
76
+ result = {}
77
+ policies_count = len(result.get("policies", [])) if isinstance(result.get("policies"), list) else 0
78
+ sections = [
79
+ (
80
+ "Application",
81
+ [
82
+ ("ID", result.get("id", "")),
83
+ ("Name", result.get("name", "")),
84
+ ("Domain", result.get("domain", "")),
85
+ ("Type", result.get("type", "")),
86
+ ("AUD", result.get("aud", "")[:40]),
87
+ ("Session Duration", result.get("session_duration", "")),
88
+ ("Auto-Redirect to IdP", str(result.get("auto_redirect_to_identity", False))),
89
+ ],
90
+ ),
91
+ (
92
+ "Settings",
93
+ [
94
+ ("Policies", str(policies_count)),
95
+ ("App Launcher Visible", str(result.get("app_launcher_visible", True))),
96
+ ("Skip Interstitial", str(result.get("skip_interstitial", False))),
97
+ ("Created", result.get("created_at", "")[:19]),
98
+ ("Updated", result.get("updated_at", "")[:19]),
99
+ ],
100
+ ),
101
+ ]
102
+ c.output.detail(f"Access App — {result.get('name', app_id)}", sections, data_for_json=result)
103
+
104
+
105
+ @app.command()
106
+ def policies(
107
+ ctx: typer.Context,
108
+ app_id: Annotated[str, typer.Argument(help="Application ID")],
109
+ ) -> None:
110
+ """List policies for an Access application."""
111
+ c: AppContext = ctx.obj
112
+ acct = require_account(c)
113
+ result = c.client.get(f"/accounts/{acct}/access/apps/{app_id}/policies")
114
+ if not isinstance(result, list):
115
+ result = []
116
+ rows = []
117
+ for p in result:
118
+ include = p.get("include", [])
119
+ include_str = ", ".join(str(i.get("email", {}).get("email", "") or i) for i in include[:3]) if include else ""
120
+ rows.append(
121
+ [
122
+ p.get("id", "")[:12],
123
+ p.get("name", ""),
124
+ p.get("decision", ""),
125
+ str(p.get("precedence", "")),
126
+ include_str[:40],
127
+ ]
128
+ )
129
+ c.output.table(
130
+ f"Access Policies — {app_id[:12]}",
131
+ [("ID", "dim"), ("Name", "cyan"), ("Decision", "green"), ("Precedence", "dim"), ("Include", "")],
132
+ rows,
133
+ data_for_json=result,
134
+ )
135
+
136
+
137
+ @app.command()
138
+ def groups(ctx: typer.Context) -> None:
139
+ """List Access groups."""
140
+ c: AppContext = ctx.obj
141
+ acct = require_account(c)
142
+ result = c.client.get(f"/accounts/{acct}/access/groups")
143
+ if not isinstance(result, list):
144
+ result = []
145
+ rows = []
146
+ for g in result:
147
+ include = g.get("include", [])
148
+ include_count = len(include) if isinstance(include, list) else 0
149
+ rows.append(
150
+ [
151
+ g.get("id", "")[:12],
152
+ g.get("name", ""),
153
+ str(include_count),
154
+ g.get("created_at", "")[:19],
155
+ g.get("updated_at", "")[:19],
156
+ ]
157
+ )
158
+ c.output.table(
159
+ "Access Groups",
160
+ [("ID", "dim"), ("Name", "cyan"), ("Include Rules", "green"), ("Created", "dim"), ("Updated", "dim")],
161
+ rows,
162
+ data_for_json=result,
163
+ )
164
+
165
+
166
+ @app.command("create-group")
167
+ def create_group(
168
+ ctx: typer.Context,
169
+ name: Annotated[str, typer.Option("--name", help="Group name")],
170
+ include: Annotated[
171
+ list[str] | None,
172
+ typer.Option("--include", help="Include rules (email:user@example.com or email_domain:example.com)"),
173
+ ] = None,
174
+ require: Annotated[
175
+ list[str] | None, typer.Option("--require", help="Require rules (same format as include)")
176
+ ] = None,
177
+ ) -> None:
178
+ """Create an Access group."""
179
+ c: AppContext = ctx.obj
180
+ acct = require_account(c)
181
+ payload: dict = {
182
+ "name": name,
183
+ "include": _parse_access_rules(c, include),
184
+ }
185
+ if require:
186
+ payload["require"] = _parse_access_rules(c, require)
187
+
188
+ result = c.client.post(f"/accounts/{acct}/access/groups", json=payload)
189
+ group_id = result.get("id", "") if isinstance(result, dict) else ""
190
+ c.output.success(f"Access group created: {group_id}")
191
+
192
+
193
+ @app.command("delete-group")
194
+ def delete_group(
195
+ ctx: typer.Context,
196
+ group_id: Annotated[str, typer.Argument(help="Access group ID to delete")],
197
+ force: Annotated[bool, typer.Option("--force", help="Required to confirm deletion")] = False,
198
+ ) -> None:
199
+ """Delete an Access group. Requires --force to confirm."""
200
+ c: AppContext = ctx.obj
201
+ if not force:
202
+ c.output.error("Delete blocked: pass --force to confirm deletion")
203
+ raise typer.Exit(1)
204
+ acct = require_account(c)
205
+ c.client.delete(f"/accounts/{acct}/access/groups/{group_id}")
206
+ c.output.success(f"Access group deleted: {group_id}")
207
+
208
+
209
+ @app.command("create-app")
210
+ def create_app(
211
+ ctx: typer.Context,
212
+ name: Annotated[str, typer.Option("--name", help="Application name")],
213
+ domain: Annotated[str, typer.Option("--domain", help="Application domain")],
214
+ app_type: Annotated[
215
+ str, typer.Option("--type", help="Application type (self_hosted, ssh, vnc, bookmark)")
216
+ ] = "self_hosted",
217
+ session_duration: Annotated[str, typer.Option("--session-duration", help="Session duration (e.g. 24h)")] = "24h",
218
+ ) -> None:
219
+ """Create an Access application."""
220
+ c: AppContext = ctx.obj
221
+ acct = require_account(c)
222
+ payload = {
223
+ "name": name,
224
+ "domain": domain,
225
+ "type": app_type,
226
+ "session_duration": session_duration,
227
+ }
228
+ result = c.client.post(f"/accounts/{acct}/access/apps", json=payload)
229
+ app_id = result.get("id", "") if isinstance(result, dict) else ""
230
+ c.output.success(f"Access application created: {app_id}")
231
+
232
+
233
+ @app.command("update-app")
234
+ def update_app(
235
+ ctx: typer.Context,
236
+ app_id: Annotated[str, typer.Argument(help="Application ID")],
237
+ name: Annotated[str | None, typer.Option("--name", help="New name")] = None,
238
+ domain: Annotated[str | None, typer.Option("--domain", help="New domain")] = None,
239
+ session_duration: Annotated[str | None, typer.Option("--session-duration", help="Session duration")] = None,
240
+ ) -> None:
241
+ """Update an Access application (merges with existing settings)."""
242
+ c: AppContext = ctx.obj
243
+ acct = require_account(c)
244
+ if not any([name, domain, session_duration]):
245
+ c.output.warn("No fields to update")
246
+ return
247
+ existing = c.client.get(f"/accounts/{acct}/access/apps/{app_id}")
248
+ if not isinstance(existing, dict):
249
+ existing = {}
250
+ payload: dict = {
251
+ "name": name or existing.get("name", ""),
252
+ "domain": domain or existing.get("domain", ""),
253
+ "type": existing.get("type", "self_hosted"),
254
+ "session_duration": session_duration or existing.get("session_duration", "24h"),
255
+ }
256
+ c.client.put(f"/accounts/{acct}/access/apps/{app_id}", json=payload)
257
+ c.output.success(f"Access application updated: {app_id}")
258
+
259
+
260
+ @app.command("delete-app")
261
+ def delete_app(
262
+ ctx: typer.Context,
263
+ app_id: Annotated[str, typer.Argument(help="Application ID to delete")],
264
+ force: Annotated[bool, typer.Option("--force", help="Required to confirm deletion")] = False,
265
+ ) -> None:
266
+ """Delete an Access application. Requires --force to confirm."""
267
+ c: AppContext = ctx.obj
268
+ if not force:
269
+ c.output.error("Delete blocked: pass --force to confirm deletion")
270
+ raise typer.Exit(1)
271
+ acct = require_account(c)
272
+ c.client.delete(f"/accounts/{acct}/access/apps/{app_id}")
273
+ c.output.success(f"Access application deleted: {app_id}")
274
+
275
+
276
+ @app.command("create-policy")
277
+ def create_policy(
278
+ ctx: typer.Context,
279
+ app_id: Annotated[str, typer.Argument(help="Application ID")],
280
+ name: Annotated[str, typer.Option("--name", help="Policy name")],
281
+ decision: Annotated[str, typer.Option("--decision", help="Decision (allow, deny, non_identity, bypass)")] = "allow",
282
+ include: Annotated[
283
+ list[str] | None,
284
+ typer.Option("--include", help="Include rules (email:user@x.com or email_domain:x.com)"),
285
+ ] = None,
286
+ ) -> None:
287
+ """Create a policy for an Access application."""
288
+ c: AppContext = ctx.obj
289
+ acct = require_account(c)
290
+ include_rules = _parse_access_rules(c, include)
291
+ payload = {"name": name, "decision": decision, "include": include_rules}
292
+ result = c.client.post(f"/accounts/{acct}/access/apps/{app_id}/policies", json=payload)
293
+ policy_id = result.get("id", "") if isinstance(result, dict) else ""
294
+ c.output.success(f"Access policy created: {policy_id}")
295
+
296
+
297
+ @app.command("update-policy")
298
+ def update_policy(
299
+ ctx: typer.Context,
300
+ app_id: Annotated[str, typer.Argument(help="Application ID")],
301
+ policy_id: Annotated[str, typer.Argument(help="Policy ID")],
302
+ name: Annotated[str | None, typer.Option("--name", help="New policy name")] = None,
303
+ decision: Annotated[str | None, typer.Option("--decision", help="New decision")] = None,
304
+ ) -> None:
305
+ """Update a policy for an Access application (merges with existing)."""
306
+ c: AppContext = ctx.obj
307
+ acct = require_account(c)
308
+ if not any([name, decision]):
309
+ c.output.warn("No fields to update")
310
+ return
311
+ existing = c.client.get(f"/accounts/{acct}/access/apps/{app_id}/policies/{policy_id}")
312
+ if not isinstance(existing, dict):
313
+ existing = {}
314
+ payload: dict = {
315
+ "name": name or existing.get("name", ""),
316
+ "decision": decision or existing.get("decision", "allow"),
317
+ "include": existing.get("include", []),
318
+ }
319
+ c.client.put(f"/accounts/{acct}/access/apps/{app_id}/policies/{policy_id}", json=payload)
320
+ c.output.success(f"Access policy updated: {policy_id}")
321
+
322
+
323
+ @app.command("delete-policy")
324
+ def delete_policy(
325
+ ctx: typer.Context,
326
+ app_id: Annotated[str, typer.Argument(help="Application ID")],
327
+ policy_id: Annotated[str, typer.Argument(help="Policy ID to delete")],
328
+ force: Annotated[bool, typer.Option("--force", help="Required to confirm deletion")] = False,
329
+ ) -> None:
330
+ """Delete a policy for an Access application. Requires --force to confirm."""
331
+ c: AppContext = ctx.obj
332
+ if not force:
333
+ c.output.error("Delete blocked: pass --force to confirm deletion")
334
+ raise typer.Exit(1)
335
+ acct = require_account(c)
336
+ c.client.delete(f"/accounts/{acct}/access/apps/{app_id}/policies/{policy_id}")
337
+ c.output.success(f"Access policy deleted: {policy_id}")
338
+
339
+
340
+ @app.command()
341
+ def idps(ctx: typer.Context) -> None:
342
+ """List Access identity providers."""
343
+ c: AppContext = ctx.obj
344
+ acct = require_account(c)
345
+ result = c.client.get(f"/accounts/{acct}/access/identity_providers")
346
+ if not isinstance(result, list):
347
+ result = []
348
+ rows = []
349
+ for idp in result:
350
+ rows.append(
351
+ [
352
+ idp.get("id", "")[:12],
353
+ idp.get("name", ""),
354
+ idp.get("type", ""),
355
+ idp.get("created_at", "")[:19],
356
+ ]
357
+ )
358
+ c.output.table(
359
+ "Identity Providers",
360
+ [("ID", "dim"), ("Name", "cyan"), ("Type", "green"), ("Created", "dim")],
361
+ rows,
362
+ data_for_json=result,
363
+ )
364
+
365
+
366
+ @app.command("service-tokens")
367
+ def service_tokens(ctx: typer.Context) -> None:
368
+ """List Access service tokens."""
369
+ c: AppContext = ctx.obj
370
+ acct = require_account(c)
371
+ result = c.client.get(f"/accounts/{acct}/access/service_tokens")
372
+ if not isinstance(result, list):
373
+ result = []
374
+ rows = []
375
+ for t in result:
376
+ rows.append(
377
+ [
378
+ t.get("id", "")[:12],
379
+ t.get("name", ""),
380
+ t.get("client_id", "")[:20],
381
+ t.get("expires_at", "")[:19] if t.get("expires_at") else "never",
382
+ t.get("created_at", "")[:19],
383
+ ]
384
+ )
385
+ c.output.table(
386
+ "Service Tokens",
387
+ [("ID", "dim"), ("Name", "cyan"), ("Client ID", ""), ("Expires", "green"), ("Created", "dim")],
388
+ rows,
389
+ data_for_json=result,
390
+ )
391
+
392
+
393
+ @app.command("create-service-token")
394
+ def create_service_token(
395
+ ctx: typer.Context,
396
+ name: Annotated[str, typer.Option("--name", help="Service token name")],
397
+ duration: Annotated[str, typer.Option("--duration", help="Token duration (e.g. 8760h for 1 year)")] = "8760h",
398
+ ) -> None:
399
+ """Create an Access service token."""
400
+ c: AppContext = ctx.obj
401
+ acct = require_account(c)
402
+ payload = {"name": name, "duration": duration}
403
+ result = c.client.post(f"/accounts/{acct}/access/service_tokens", json=payload)
404
+ if not isinstance(result, dict):
405
+ result = {}
406
+ if c.output.json_mode:
407
+ c.output.raw_json(result)
408
+ else:
409
+ c.output.success(f"Service token created: {result.get('id', '')}")
410
+ c.output.kv("Client ID", result.get("client_id", ""))
411
+ c.output.warn("Client Secret (save this — shown only once):")
412
+ c.output.text(result.get("client_secret", ""))
413
+
414
+
415
+ @app.command("rotate-service-token")
416
+ def rotate_service_token(
417
+ ctx: typer.Context,
418
+ token_id: Annotated[str, typer.Argument(help="Service token ID")],
419
+ ) -> None:
420
+ """Rotate an Access service token (generates new secret)."""
421
+ c: AppContext = ctx.obj
422
+ acct = require_account(c)
423
+ result = c.client.post(f"/accounts/{acct}/access/service_tokens/{token_id}/rotate")
424
+ if not isinstance(result, dict):
425
+ result = {}
426
+ if c.output.json_mode:
427
+ c.output.raw_json(result)
428
+ else:
429
+ c.output.success(f"Service token rotated: {token_id}")
430
+ c.output.warn("New Client Secret (save this — shown only once):")
431
+ c.output.text(result.get("client_secret", ""))
432
+
433
+
434
+ @app.command("delete-service-token")
435
+ def delete_service_token(
436
+ ctx: typer.Context,
437
+ token_id: Annotated[str, typer.Argument(help="Service token ID to delete")],
438
+ force: Annotated[bool, typer.Option("--force", help="Required to confirm deletion")] = False,
439
+ ) -> None:
440
+ """Delete an Access service token. Requires --force to confirm."""
441
+ c: AppContext = ctx.obj
442
+ if not force:
443
+ c.output.error("Delete blocked: pass --force to confirm deletion")
444
+ raise typer.Exit(1)
445
+ acct = require_account(c)
446
+ c.client.delete(f"/accounts/{acct}/access/service_tokens/{token_id}")
447
+ c.output.success(f"Service token deleted: {token_id}")