systemlink-cli 1.3.1__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 (74) hide show
  1. slcli/__init__.py +1 -0
  2. slcli/__main__.py +23 -0
  3. slcli/_version.py +4 -0
  4. slcli/asset_click.py +1289 -0
  5. slcli/cli_formatters.py +218 -0
  6. slcli/cli_utils.py +504 -0
  7. slcli/comment_click.py +602 -0
  8. slcli/completion_click.py +418 -0
  9. slcli/config.py +81 -0
  10. slcli/config_click.py +498 -0
  11. slcli/dff_click.py +979 -0
  12. slcli/dff_decorators.py +24 -0
  13. slcli/example_click.py +404 -0
  14. slcli/example_loader.py +274 -0
  15. slcli/example_provisioner.py +2777 -0
  16. slcli/examples/README.md +134 -0
  17. slcli/examples/_schema/schema-v1.0.json +169 -0
  18. slcli/examples/demo-complete-workflow/README.md +323 -0
  19. slcli/examples/demo-complete-workflow/config.yaml +638 -0
  20. slcli/examples/demo-test-plans/README.md +132 -0
  21. slcli/examples/demo-test-plans/config.yaml +154 -0
  22. slcli/examples/exercise-5-1-parametric-insights/README.md +101 -0
  23. slcli/examples/exercise-5-1-parametric-insights/config.yaml +1589 -0
  24. slcli/examples/exercise-7-1-test-plans/README.md +93 -0
  25. slcli/examples/exercise-7-1-test-plans/config.yaml +323 -0
  26. slcli/examples/spec-compliance-notebooks/README.md +140 -0
  27. slcli/examples/spec-compliance-notebooks/config.yaml +112 -0
  28. slcli/examples/spec-compliance-notebooks/notebooks/SpecAnalysis_ComplianceCalculation.ipynb +1553 -0
  29. slcli/examples/spec-compliance-notebooks/notebooks/SpecComplianceCalculation.ipynb +1577 -0
  30. slcli/examples/spec-compliance-notebooks/notebooks/SpecfileExtractionAndIngestion.ipynb +912 -0
  31. slcli/examples/spec-compliance-notebooks/spec_template.xlsx +0 -0
  32. slcli/feed_click.py +892 -0
  33. slcli/file_click.py +932 -0
  34. slcli/function_click.py +1400 -0
  35. slcli/function_templates.py +85 -0
  36. slcli/main.py +406 -0
  37. slcli/mcp_click.py +269 -0
  38. slcli/mcp_server.py +748 -0
  39. slcli/notebook_click.py +1770 -0
  40. slcli/platform.py +345 -0
  41. slcli/policy_click.py +679 -0
  42. slcli/policy_utils.py +411 -0
  43. slcli/profiles.py +411 -0
  44. slcli/response_handlers.py +359 -0
  45. slcli/routine_click.py +763 -0
  46. slcli/skill_click.py +253 -0
  47. slcli/skills/slcli/SKILL.md +713 -0
  48. slcli/skills/slcli/references/analysis-recipes.md +474 -0
  49. slcli/skills/slcli/references/filtering.md +236 -0
  50. slcli/skills/systemlink-webapp/SKILL.md +744 -0
  51. slcli/skills/systemlink-webapp/references/deployment.md +123 -0
  52. slcli/skills/systemlink-webapp/references/nimble-angular.md +380 -0
  53. slcli/skills/systemlink-webapp/references/systemlink-services.md +192 -0
  54. slcli/ssl_trust.py +93 -0
  55. slcli/system_click.py +2216 -0
  56. slcli/table_utils.py +124 -0
  57. slcli/tag_click.py +794 -0
  58. slcli/templates_click.py +599 -0
  59. slcli/testmonitor_click.py +1667 -0
  60. slcli/universal_handlers.py +305 -0
  61. slcli/user_click.py +1218 -0
  62. slcli/utils.py +832 -0
  63. slcli/web_editor.py +295 -0
  64. slcli/webapp_click.py +981 -0
  65. slcli/workflow_preview.py +287 -0
  66. slcli/workflows_click.py +988 -0
  67. slcli/workitem_click.py +2258 -0
  68. slcli/workspace_click.py +576 -0
  69. slcli/workspace_utils.py +206 -0
  70. systemlink_cli-1.3.1.dist-info/METADATA +20 -0
  71. systemlink_cli-1.3.1.dist-info/RECORD +74 -0
  72. systemlink_cli-1.3.1.dist-info/WHEEL +4 -0
  73. systemlink_cli-1.3.1.dist-info/entry_points.txt +7 -0
  74. systemlink_cli-1.3.1.dist-info/licenses/LICENSE +21 -0
slcli/policy_click.py ADDED
@@ -0,0 +1,679 @@
1
+ """CLI commands for managing SystemLink auth policies and policy templates."""
2
+
3
+ import sys
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ import click
7
+ import questionary
8
+
9
+ from .cli_utils import validate_output_format
10
+ from .policy_utils import (
11
+ _build_policy_payload,
12
+ _display_policy_details,
13
+ _display_template_details,
14
+ _fetch_policy_details,
15
+ _fetch_template_details,
16
+ _format_policy_list_row,
17
+ _format_template_list_row,
18
+ _parse_properties_from_cli,
19
+ )
20
+ from .universal_handlers import FilteredResponse, UniversalResponseHandler
21
+ from .utils import (
22
+ ExitCodes,
23
+ format_success,
24
+ get_base_url,
25
+ handle_api_error,
26
+ make_api_request,
27
+ )
28
+
29
+
30
+ def register_policy_commands(cli: Any) -> None:
31
+ """Register the 'policy' command group and its subcommands."""
32
+
33
+ @cli.group(name="auth")
34
+ def auth() -> None:
35
+ """Manage SystemLink auth policies and policy templates."""
36
+ pass
37
+
38
+ @auth.group(name="policy")
39
+ def policy_group() -> None:
40
+ """Manage authorization policies."""
41
+ pass
42
+
43
+ @auth.group(name="template")
44
+ def template_group() -> None:
45
+ """Manage policy templates."""
46
+ pass
47
+
48
+ @policy_group.command(name="list")
49
+ @click.option(
50
+ "--type",
51
+ "policy_type",
52
+ type=click.Choice(["default", "internal", "custom", "role"], case_sensitive=False),
53
+ default=None,
54
+ help="Filter by policy type",
55
+ )
56
+ @click.option("--builtin", is_flag=True, help="Show built-in policies only")
57
+ @click.option("--name", type=str, default=None, help="Filter by name (contains search)")
58
+ @click.option(
59
+ "--sortby",
60
+ type=click.Choice(["name", "created", "updated"]),
61
+ default="name",
62
+ show_default=True,
63
+ help="Sort field",
64
+ )
65
+ @click.option(
66
+ "--order",
67
+ type=click.Choice(["asc", "desc"]),
68
+ default="asc",
69
+ show_default=True,
70
+ help="Sort order",
71
+ )
72
+ @click.option(
73
+ "--take",
74
+ "-t",
75
+ type=int,
76
+ default=None,
77
+ help="Limit results (default: 25 for table, all for JSON)",
78
+ )
79
+ @click.option(
80
+ "--format",
81
+ "-f",
82
+ type=click.Choice(["table", "json"]),
83
+ default="table",
84
+ show_default=True,
85
+ help="Output format",
86
+ )
87
+ @click.option(
88
+ "--skip",
89
+ "-s",
90
+ type=int,
91
+ default=0,
92
+ show_default=True,
93
+ help="Number of items to skip before returning results",
94
+ )
95
+ def list_policies(
96
+ policy_type: Optional[str],
97
+ builtin: bool,
98
+ name: Optional[str],
99
+ sortby: str,
100
+ order: str,
101
+ take: Optional[int],
102
+ format: str,
103
+ skip: int,
104
+ ) -> None:
105
+ """List policies with optional filtering."""
106
+ validate_output_format(format)
107
+
108
+ try:
109
+ from urllib.parse import urlencode
110
+
111
+ if take is None:
112
+ take = 25 if format == "table" else 100
113
+
114
+ base_url = f"{get_base_url()}/niauth/v1/policies"
115
+
116
+ def build_url(page_take: int, page_skip: int) -> str:
117
+ query_params: Dict[str, Any] = {
118
+ "take": page_take,
119
+ "skip": page_skip,
120
+ "sortby": sortby,
121
+ "order": "ascending" if order == "asc" else "descending",
122
+ }
123
+ if policy_type:
124
+ query_params["type"] = policy_type.lower()
125
+ if builtin:
126
+ query_params["builtIn"] = "true"
127
+ if name:
128
+ query_params["name"] = f"*{name}*"
129
+ return f"{base_url}?{urlencode(query_params)}"
130
+
131
+ if format == "json":
132
+ import json
133
+
134
+ all_policies: List[Dict[str, Any]] = []
135
+ current_skip = skip
136
+ remaining = take
137
+
138
+ while True:
139
+ page_take = min(remaining, 100) if remaining else 100
140
+ url = build_url(page_take, current_skip)
141
+ resp = make_api_request("GET", url, payload=None)
142
+ data = resp.json()
143
+ policies_page = data.get("policies", [])
144
+
145
+ if not policies_page:
146
+ break
147
+
148
+ all_policies.extend(policies_page)
149
+
150
+ if remaining:
151
+ if len(all_policies) >= remaining:
152
+ all_policies = all_policies[:remaining]
153
+ break
154
+ remaining -= len(policies_page)
155
+
156
+ if len(policies_page) < page_take:
157
+ break
158
+
159
+ current_skip += page_take
160
+
161
+ click.echo(json.dumps(all_policies, indent=2) if all_policies else "[]")
162
+ return
163
+
164
+ current_skip = skip
165
+
166
+ while True:
167
+ url = build_url(take, current_skip)
168
+ resp = make_api_request("GET", url, payload=None)
169
+ data = resp.json()
170
+ policies = data.get("policies", [])
171
+ total_count = data.get("totalCount")
172
+
173
+ if not policies and current_skip == skip:
174
+ click.echo("No policies found.")
175
+ return
176
+ if not policies:
177
+ break
178
+
179
+ combined_resp = FilteredResponse({"policies": policies})
180
+ UniversalResponseHandler.handle_list_response(
181
+ resp=combined_resp,
182
+ data_key="policies",
183
+ item_name="policy",
184
+ format_output=format,
185
+ formatter_func=_format_policy_list_row,
186
+ headers=["ID", "Name", "Type", "Built-in", "Statements"],
187
+ column_widths=[36, 30, 12, 10, 15],
188
+ enable_pagination=False,
189
+ page_size=take,
190
+ )
191
+
192
+ if total_count is not None:
193
+ shown = current_skip + len(policies)
194
+ remaining = max(total_count - shown, 0)
195
+ message = f"Showing {len(policies)} of {total_count} policy(ies)."
196
+ if remaining:
197
+ message += f" {remaining} more available."
198
+ click.echo(message)
199
+
200
+ current_skip += take
201
+
202
+ if len(policies) < take:
203
+ break
204
+
205
+ try:
206
+ is_tty = sys.stdout.isatty() and sys.stdin.isatty()
207
+ except Exception:
208
+ is_tty = False
209
+
210
+ if not is_tty:
211
+ break
212
+ if not questionary.confirm(f"Show next {take} policies?", default=True).ask():
213
+ break
214
+
215
+ except Exception as exc:
216
+ handle_api_error(exc)
217
+
218
+ @policy_group.command(name="get")
219
+ @click.argument("policy_id")
220
+ @click.option(
221
+ "--format",
222
+ "-f",
223
+ type=click.Choice(["table", "json"]),
224
+ default="table",
225
+ show_default=True,
226
+ help="Output format",
227
+ )
228
+ def get_policy(policy_id: str, format: str) -> None:
229
+ """Get policy details."""
230
+ validate_output_format(format)
231
+
232
+ try:
233
+ url = f"{get_base_url()}/niauth/v1/policies/{policy_id}"
234
+ resp = make_api_request("GET", url, payload=None)
235
+ _display_policy_details(resp.json(), format)
236
+ except Exception as exc:
237
+ handle_api_error(exc)
238
+
239
+ @policy_group.command(name="create")
240
+ @click.argument("template_id")
241
+ @click.option("--name", required=True, help="Name for the new policy")
242
+ @click.option("--workspace", required=True, help="Target workspace ID")
243
+ @click.option(
244
+ "--properties",
245
+ "-p",
246
+ type=str,
247
+ multiple=True,
248
+ help="Custom properties as key=value (repeatable)",
249
+ )
250
+ def create_policy(template_id: str, name: str, workspace: str, properties: tuple) -> None:
251
+ """Create a new policy from a template scoped to a workspace."""
252
+ from .utils import check_readonly_mode
253
+
254
+ check_readonly_mode("create a policy")
255
+
256
+ try:
257
+ properties_dict = _parse_properties_from_cli(properties) if properties else None
258
+
259
+ payload = _build_policy_payload(
260
+ name=name,
261
+ policy_type="custom",
262
+ statements=None,
263
+ template_id=template_id,
264
+ workspace=workspace,
265
+ properties=properties_dict,
266
+ )
267
+
268
+ url = f"{get_base_url()}/niauth/v1/policies"
269
+ resp = make_api_request("POST", url, payload=payload)
270
+ created_policy = resp.json()
271
+
272
+ format_success(
273
+ "Policy created from template",
274
+ {
275
+ "name": created_policy.get("name"),
276
+ "id": created_policy.get("id"),
277
+ "type": created_policy.get("type"),
278
+ },
279
+ )
280
+ except ValueError as e:
281
+ click.echo(f"✗ Error: {str(e)}", err=True)
282
+ sys.exit(ExitCodes.INVALID_INPUT)
283
+ except Exception as exc:
284
+ handle_api_error(exc)
285
+
286
+ @policy_group.command(name="update")
287
+ @click.argument("policy_id")
288
+ @click.option("--name", type=str, default=None, help="New policy name")
289
+ @click.option(
290
+ "--template",
291
+ "template_id",
292
+ type=str,
293
+ default=None,
294
+ help="Policy template ID to apply or reapply",
295
+ )
296
+ @click.option(
297
+ "--workspace",
298
+ type=str,
299
+ default=None,
300
+ help="Workspace ID (required when applying a template)",
301
+ )
302
+ @click.option(
303
+ "--properties",
304
+ "-p",
305
+ type=str,
306
+ multiple=True,
307
+ help="Updated custom properties as key=value (repeatable)",
308
+ )
309
+ def update_policy(
310
+ policy_id: str,
311
+ name: Optional[str],
312
+ template_id: Optional[str],
313
+ workspace: Optional[str],
314
+ properties: tuple,
315
+ ) -> None:
316
+ """Update an existing policy, optionally applying a new template."""
317
+ from .utils import check_readonly_mode
318
+
319
+ check_readonly_mode("update a policy")
320
+
321
+ try:
322
+ url = f"{get_base_url()}/niauth/v1/policies/{policy_id}"
323
+ current_resp = make_api_request("GET", url, payload=None)
324
+ current_policy = current_resp.json()
325
+
326
+ properties_dict = (
327
+ _parse_properties_from_cli(properties)
328
+ if properties
329
+ else current_policy.get("properties")
330
+ )
331
+
332
+ # If template is provided, use template; otherwise keep current statements
333
+ statements_list = None
334
+ if not template_id:
335
+ statements_list = current_policy.get("statements")
336
+
337
+ # Use provided workspace or current workspace
338
+ effective_workspace = workspace or current_policy.get("workspace")
339
+
340
+ payload = _build_policy_payload(
341
+ name=name or current_policy.get("name"),
342
+ policy_type=current_policy.get("type"),
343
+ statements=statements_list,
344
+ template_id=template_id or current_policy.get("templateId"),
345
+ workspace=effective_workspace,
346
+ properties=properties_dict,
347
+ )
348
+
349
+ resp = make_api_request("PUT", url, payload=payload)
350
+ updated_policy = resp.json()
351
+
352
+ format_success(
353
+ "Policy updated",
354
+ {
355
+ "name": updated_policy.get("name"),
356
+ "id": updated_policy.get("id"),
357
+ "type": updated_policy.get("type"),
358
+ },
359
+ )
360
+ except ValueError as e:
361
+ click.echo(f"✗ Error: {str(e)}", err=True)
362
+ sys.exit(ExitCodes.INVALID_INPUT)
363
+ except Exception as exc:
364
+ handle_api_error(exc)
365
+
366
+ @policy_group.command(name="delete")
367
+ @click.argument("policy_id")
368
+ @click.option("--force", is_flag=True, help="Skip confirmation prompt")
369
+ def delete_policy(policy_id: str, force: bool) -> None:
370
+ """Delete a policy."""
371
+ from .utils import check_readonly_mode
372
+
373
+ check_readonly_mode("delete a policy")
374
+
375
+ try:
376
+ if not force:
377
+ details = _fetch_policy_details(policy_id, handle_errors=False)
378
+ policy_name = details.get("name") if details else policy_id
379
+ if not questionary.confirm(
380
+ f"Delete policy '{policy_name}'?",
381
+ default=False,
382
+ ).ask():
383
+ click.echo("Deletion cancelled.")
384
+ return
385
+
386
+ url = f"{get_base_url()}/niauth/v1/policies/{policy_id}"
387
+ resp = make_api_request("DELETE", url, payload=None)
388
+ if resp.status_code not in (200, 204):
389
+ resp.raise_for_status()
390
+
391
+ format_success("Policy deleted", {"id": policy_id})
392
+ except Exception as exc:
393
+ handle_api_error(exc)
394
+
395
+ @policy_group.command(name="diff")
396
+ @click.argument("policy_id_1")
397
+ @click.argument("policy_id_2")
398
+ def diff_policies(policy_id_1: str, policy_id_2: str) -> None:
399
+ """Show a basic diff between two policies."""
400
+ try:
401
+ url1 = f"{get_base_url()}/niauth/v1/policies/{policy_id_1}"
402
+ url2 = f"{get_base_url()}/niauth/v1/policies/{policy_id_2}"
403
+ resp1 = make_api_request("GET", url1, payload=None)
404
+ resp2 = make_api_request("GET", url2, payload=None)
405
+ p1 = resp1.json()
406
+ p2 = resp2.json()
407
+
408
+ def set_from_list(lst: Any) -> set:
409
+ return set(lst or [])
410
+
411
+ click.echo("\nPolicy Diff")
412
+ click.echo("-" * 80)
413
+ click.echo(f"Name: {p1.get('name','')} vs {p2.get('name','')}")
414
+ click.echo(f"Type: {p1.get('type','')} vs {p2.get('type','')}")
415
+ click.echo(f"Built-in: {p1.get('builtIn',False)} vs {p2.get('builtIn',False)}")
416
+ click.echo(f"Workspace: {p1.get('workspace','N/A')} vs {p2.get('workspace','N/A')}")
417
+
418
+ s1 = p1.get("statements", [])
419
+ s2 = p2.get("statements", [])
420
+ click.echo(f"\nStatements: {len(s1)} vs {len(s2)}")
421
+
422
+ # Aggregate actions/resources/workspaces for quick comparison
423
+ def aggregate(parts: List[Dict[str, Any]]) -> Dict[str, set]:
424
+ act: set = set()
425
+ res: set = set()
426
+ ws: set = set()
427
+ for st in parts:
428
+ act |= set_from_list(st.get("actions"))
429
+ res |= set_from_list(st.get("resource"))
430
+ w = st.get("workspace")
431
+ if w:
432
+ ws.add(w)
433
+ return {"actions": act, "resources": res, "workspaces": ws}
434
+
435
+ agg1 = aggregate(s1)
436
+ agg2 = aggregate(s2)
437
+
438
+ def show_set_diff(label: str, a: set, b: set) -> None:
439
+ only_a = sorted(a - b)
440
+ only_b = sorted(b - a)
441
+ click.echo(f"\n{label} differences:")
442
+ if not only_a and not only_b:
443
+ click.echo(" No differences")
444
+ return
445
+ if only_a:
446
+ click.echo(" Only in policy 1:")
447
+ for item in only_a:
448
+ click.echo(f" • {item}")
449
+ if only_b:
450
+ click.echo(" Only in policy 2:")
451
+ for item in only_b:
452
+ click.echo(f" • {item}")
453
+
454
+ show_set_diff("Actions", agg1["actions"], agg2["actions"])
455
+ show_set_diff("Resources", agg1["resources"], agg2["resources"])
456
+ show_set_diff("Workspaces", agg1["workspaces"], agg2["workspaces"])
457
+
458
+ except Exception as exc:
459
+ handle_api_error(exc)
460
+
461
+ @template_group.command(name="list")
462
+ @click.option(
463
+ "--type",
464
+ "template_type",
465
+ type=click.Choice(["user", "service"], case_sensitive=False),
466
+ default=None,
467
+ help="Filter by template type",
468
+ )
469
+ @click.option("--builtin", is_flag=True, help="Show built-in templates only")
470
+ @click.option("--name", type=str, default=None, help="Filter by name (contains search)")
471
+ @click.option(
472
+ "--sortby",
473
+ type=click.Choice(["name", "created", "updated"]),
474
+ default="name",
475
+ show_default=True,
476
+ help="Sort field",
477
+ )
478
+ @click.option(
479
+ "--order",
480
+ type=click.Choice(["asc", "desc"]),
481
+ default="asc",
482
+ show_default=True,
483
+ help="Sort order",
484
+ )
485
+ @click.option(
486
+ "--take",
487
+ "-t",
488
+ type=int,
489
+ default=None,
490
+ help="Limit results (default: 25 for table, all for JSON)",
491
+ )
492
+ @click.option(
493
+ "--format",
494
+ "-f",
495
+ type=click.Choice(["table", "json"]),
496
+ default="table",
497
+ show_default=True,
498
+ help="Output format",
499
+ )
500
+ @click.option(
501
+ "--skip",
502
+ "-s",
503
+ type=int,
504
+ default=0,
505
+ show_default=True,
506
+ help="Number of items to skip before returning results",
507
+ )
508
+ def list_templates(
509
+ template_type: Optional[str],
510
+ builtin: bool,
511
+ name: Optional[str],
512
+ sortby: str,
513
+ order: str,
514
+ take: Optional[int],
515
+ format: str,
516
+ skip: int,
517
+ ) -> None:
518
+ """List policy templates with optional filtering."""
519
+ validate_output_format(format)
520
+
521
+ try:
522
+ from urllib.parse import urlencode
523
+
524
+ if take is None:
525
+ take = 25 if format == "table" else 100
526
+
527
+ base_url = f"{get_base_url()}/niauth/v1/policy-templates"
528
+
529
+ def build_url(page_take: int, page_skip: int) -> str:
530
+ query_params: Dict[str, Any] = {
531
+ "take": page_take,
532
+ "skip": page_skip,
533
+ "sortby": sortby,
534
+ "order": "ascending" if order == "asc" else "descending",
535
+ }
536
+ if template_type:
537
+ query_params["type"] = template_type.lower()
538
+ if builtin:
539
+ query_params["builtIn"] = "true"
540
+ if name:
541
+ query_params["name"] = f"*{name}*"
542
+ return f"{base_url}?{urlencode(query_params)}"
543
+
544
+ if format == "json":
545
+ import json
546
+
547
+ all_templates: List[Dict[str, Any]] = []
548
+ current_skip = skip
549
+ remaining = take
550
+
551
+ while True:
552
+ page_take = min(remaining, 100) if remaining else 100
553
+ url = build_url(page_take, current_skip)
554
+ resp = make_api_request("GET", url, payload=None)
555
+ data = resp.json()
556
+ templates_page = data.get("policyTemplates", [])
557
+
558
+ if not templates_page:
559
+ break
560
+
561
+ all_templates.extend(templates_page)
562
+
563
+ if remaining:
564
+ if len(all_templates) >= remaining:
565
+ all_templates = all_templates[:remaining]
566
+ break
567
+ remaining -= len(templates_page)
568
+
569
+ if len(templates_page) < page_take:
570
+ break
571
+
572
+ current_skip += page_take
573
+
574
+ click.echo(json.dumps(all_templates, indent=2) if all_templates else "[]")
575
+ return
576
+
577
+ current_skip = skip
578
+
579
+ while True:
580
+ url = build_url(take, current_skip)
581
+ resp = make_api_request("GET", url, payload=None)
582
+ data = resp.json()
583
+ templates = data.get("policyTemplates", [])
584
+ total_count = data.get("totalCount")
585
+
586
+ if not templates and current_skip == skip:
587
+ click.echo("No policy templates found.")
588
+ return
589
+ if not templates:
590
+ break
591
+
592
+ combined_resp = FilteredResponse({"policyTemplates": templates})
593
+ UniversalResponseHandler.handle_list_response(
594
+ resp=combined_resp,
595
+ data_key="policyTemplates",
596
+ item_name="template",
597
+ format_output=format,
598
+ formatter_func=_format_template_list_row,
599
+ headers=["ID", "Name", "Type", "Built-in", "Statements"],
600
+ column_widths=[36, 30, 12, 10, 15],
601
+ enable_pagination=False,
602
+ page_size=take,
603
+ )
604
+
605
+ if total_count is not None:
606
+ shown = current_skip + len(templates)
607
+ remaining = max(total_count - shown, 0)
608
+ message = f"Showing {len(templates)} of {total_count} policy template(s)."
609
+ if remaining:
610
+ message += f" {remaining} more available."
611
+ click.echo(message)
612
+
613
+ current_skip += take
614
+
615
+ if len(templates) < take:
616
+ break
617
+
618
+ try:
619
+ is_tty = sys.stdout.isatty() and sys.stdin.isatty()
620
+ except Exception:
621
+ is_tty = False
622
+
623
+ if not is_tty:
624
+ break
625
+ if not questionary.confirm(f"Show next {take} templates?", default=True).ask():
626
+ break
627
+
628
+ except Exception as exc:
629
+ handle_api_error(exc)
630
+
631
+ @template_group.command(name="get")
632
+ @click.argument("template_id")
633
+ @click.option(
634
+ "--format",
635
+ "-f",
636
+ type=click.Choice(["table", "json"]),
637
+ default="table",
638
+ show_default=True,
639
+ help="Output format",
640
+ )
641
+ def get_template(template_id: str, format: str) -> None:
642
+ """Get policy template details."""
643
+ validate_output_format(format)
644
+
645
+ try:
646
+ url = f"{get_base_url()}/niauth/v1/policy-templates/{template_id}"
647
+ resp = make_api_request("GET", url, payload=None)
648
+ _display_template_details(resp.json(), format)
649
+ except Exception as exc:
650
+ handle_api_error(exc)
651
+
652
+ @template_group.command(name="delete")
653
+ @click.argument("template_id")
654
+ @click.option("--force", is_flag=True, help="Skip confirmation prompt")
655
+ def delete_template(template_id: str, force: bool) -> None:
656
+ """Delete a policy template."""
657
+ from .utils import check_readonly_mode
658
+
659
+ check_readonly_mode("delete a policy template")
660
+
661
+ try:
662
+ if not force:
663
+ details = _fetch_template_details(template_id, handle_errors=False)
664
+ template_name = details.get("name") if details else template_id
665
+ if not questionary.confirm(
666
+ f"Delete policy template '{template_name}'?",
667
+ default=False,
668
+ ).ask():
669
+ click.echo("Deletion cancelled.")
670
+ return
671
+
672
+ url = f"{get_base_url()}/niauth/v1/policy-templates/{template_id}"
673
+ resp = make_api_request("DELETE", url, payload=None)
674
+ if resp.status_code not in (200, 204):
675
+ resp.raise_for_status()
676
+
677
+ format_success("Policy template deleted", {"id": template_id})
678
+ except Exception as exc:
679
+ handle_api_error(exc)