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/user_click.py ADDED
@@ -0,0 +1,1218 @@
1
+ """CLI commands for managing SystemLink users via the SystemLink User Service API.
2
+
3
+ Provides CLI commands for listing, creating, updating, deleting, and querying users.
4
+ All commands use Click for robust CLI interfaces and error handling.
5
+ """
6
+
7
+ import json
8
+ import re
9
+ import shutil
10
+ import sys
11
+ from typing import Any, Dict, List, Optional
12
+ from uuid import uuid4
13
+
14
+ import click
15
+ import questionary
16
+
17
+ from .cli_utils import paginate_list_output, validate_output_format
18
+ from .utils import (
19
+ ExitCodes,
20
+ format_success,
21
+ get_base_url,
22
+ handle_api_error,
23
+ make_api_request,
24
+ )
25
+ from .workspace_utils import get_workspace_display_name, resolve_workspace_id
26
+
27
+
28
+ def _get_policy_details(policy_id: str) -> Optional[dict]:
29
+ """Fetch policy details from the Auth service.
30
+
31
+ Args:
32
+ policy_id: The policy ID to fetch
33
+
34
+ Returns:
35
+ Policy details dictionary, or None if not found or no permission
36
+ """
37
+ try:
38
+ url = f"{get_base_url()}/niauth/v1/policies/{policy_id}"
39
+ resp = make_api_request("GET", url, payload=None, handle_errors=False)
40
+ return resp.json()
41
+ except Exception as exc:
42
+ # Check if this is a permission error
43
+ response = getattr(exc, "response", None)
44
+ if response is not None and response.status_code == 401:
45
+ try:
46
+ error_data = response.json()
47
+ if "error" in error_data and error_data["error"].get("name") == "Unauthorized":
48
+ # Return a special marker indicating permission denied
49
+ return {"_permission_error": True, "id": policy_id}
50
+ except (ValueError, KeyError):
51
+ pass
52
+ # If policy fetch fails for other reasons, return None
53
+ return None
54
+
55
+
56
+ def _get_policy_template_details(template_id: str) -> Optional[dict]:
57
+ """Fetch policy template details from the Auth service.
58
+
59
+ Args:
60
+ template_id: The policy template ID to fetch
61
+
62
+ Returns:
63
+ Policy template details dictionary, or None if not found or no permission
64
+ """
65
+ try:
66
+ url = f"{get_base_url()}/niauth/v1/policy-templates/{template_id}"
67
+ resp = make_api_request("GET", url, payload=None, handle_errors=False)
68
+ return resp.json()
69
+ except Exception as exc:
70
+ # Check if this is a permission error
71
+ response = getattr(exc, "response", None)
72
+ if response is not None and response.status_code == 401:
73
+ try:
74
+ error_data = response.json()
75
+ if "error" in error_data and error_data["error"].get("name") == "Unauthorized":
76
+ # Return a special marker indicating permission denied
77
+ return {"_permission_error": True, "id": template_id}
78
+ except (ValueError, KeyError):
79
+ pass
80
+ # If template fetch fails for other reasons, return None
81
+ return None
82
+
83
+
84
+ def _resolve_policy_template(template_id_or_name: str) -> str:
85
+ """Resolve a policy template by ID or name.
86
+
87
+ Args:
88
+ template_id_or_name: Either a template ID or a template name
89
+
90
+ Returns:
91
+ The policy template ID
92
+
93
+ Raises:
94
+ SystemExit: If template cannot be found or resolved
95
+ """
96
+ from urllib.parse import urlencode
97
+
98
+ # Try to look up by name first (more user-friendly), then fall back to ID lookup
99
+ base_url = f"{get_base_url()}/niauth/v1/policy-templates"
100
+ query_params = {"name": template_id_or_name}
101
+ url = f"{base_url}?{urlencode(query_params)}"
102
+
103
+ try:
104
+ resp = make_api_request("GET", url, payload=None, handle_errors=False)
105
+ resp.raise_for_status()
106
+ data = resp.json()
107
+
108
+ templates = data.get("policyTemplates", [])
109
+ if templates:
110
+ if len(templates) > 1:
111
+ click.echo(
112
+ f"✗ Multiple policy templates found with name '{template_id_or_name}'. "
113
+ "Please use the template ID instead.",
114
+ err=True,
115
+ )
116
+ sys.exit(ExitCodes.INVALID_INPUT)
117
+
118
+ # Found exactly one template by name - return its ID
119
+ template_id = templates[0].get("id")
120
+ if not template_id:
121
+ click.echo(
122
+ f"✗ Policy template '{template_id_or_name}' found but has no ID.",
123
+ err=True,
124
+ )
125
+ sys.exit(ExitCodes.INVALID_INPUT)
126
+ return template_id
127
+ # If no templates found by name, fall through to try as ID
128
+ except Exception:
129
+ # If name lookup fails (network error, etc.), we'll try as ID below
130
+ pass
131
+
132
+ # Try as template ID
133
+ try:
134
+ url = f"{get_base_url()}/niauth/v1/policy-templates/{template_id_or_name}"
135
+ resp = make_api_request("GET", url, payload=None, handle_errors=False)
136
+ resp.raise_for_status()
137
+ return template_id_or_name
138
+ except Exception as exc:
139
+ # Check if this is a 404 or other not found error
140
+ response = getattr(exc, "response", None)
141
+ if response is not None and response.status_code == 404:
142
+ click.echo(
143
+ f"✗ Policy template '{template_id_or_name}' not found by ID or name.",
144
+ err=True,
145
+ )
146
+ sys.exit(ExitCodes.NOT_FOUND)
147
+ # For other errors, propagate through standard error handling
148
+ handle_api_error(exc)
149
+ # This should never be reached, but make mypy happy
150
+ raise RuntimeError(f"Failed to resolve policy template: {template_id_or_name}")
151
+
152
+
153
+ def _create_workspace_policy_from_template(
154
+ template_id: str, workspace: str, name_hint: Optional[str] = None
155
+ ) -> str:
156
+ """Create a workspace-scoped policy from a template and return its ID."""
157
+ policy_name = name_hint or "workspace-policy"
158
+ # Ensure a reasonably unique name to avoid conflicts
159
+ generated_name = f"{policy_name}-{workspace}-{uuid4().hex[:8]}"
160
+
161
+ payload: Dict[str, Any] = {
162
+ "name": generated_name,
163
+ "type": "custom",
164
+ "templateId": template_id,
165
+ "workspace": workspace,
166
+ }
167
+
168
+ url = f"{get_base_url()}/niauth/v1/policies"
169
+ resp = make_api_request("POST", url, payload=payload)
170
+ data = resp.json()
171
+ policy_id = data.get("id")
172
+ if not policy_id:
173
+ raise ValueError("Policy creation did not return an ID")
174
+ return policy_id
175
+
176
+
177
+ def _process_workspace_policies(
178
+ workspace_policies: str, name_hint: Optional[str] = None
179
+ ) -> List[str]:
180
+ """Process workspace-policies string and create policies from templates.
181
+
182
+ Args:
183
+ workspace_policies: Comma-separated list of workspace:templateId pairs (or
184
+ workspace:templateName to lookup by name)
185
+ name_hint: Optional name hint for generated policy names
186
+
187
+ Returns:
188
+ List of created policy IDs
189
+
190
+ Raises:
191
+ SystemExit: If format is invalid, values are empty, or workspace cannot be resolved
192
+ """
193
+ policy_ids: List[str] = []
194
+ mappings = []
195
+
196
+ for item in workspace_policies.split(","):
197
+ pair = item.strip()
198
+ if not pair:
199
+ continue
200
+ if ":" not in pair:
201
+ click.echo(
202
+ "✗ Invalid workspace-policies format. Use workspace:templateId or "
203
+ "workspace:templateName (e.g., 'myWorkspace:template-123' or "
204
+ "'myWorkspace:MyPolicyTemplate')",
205
+ err=True,
206
+ )
207
+ sys.exit(ExitCodes.INVALID_INPUT)
208
+ ws, template_id_or_name = pair.split(":", 1)
209
+ ws = ws.strip()
210
+ template_id_or_name = template_id_or_name.strip()
211
+ if not ws or not template_id_or_name:
212
+ click.echo(
213
+ "✗ Invalid workspace-policies entry. Both workspace and template ID/name are required.",
214
+ err=True,
215
+ )
216
+ sys.exit(ExitCodes.INVALID_INPUT)
217
+
218
+ # Resolve workspace name to ID
219
+ ws_id = resolve_workspace_id(ws)
220
+ if not ws_id:
221
+ click.echo(
222
+ f"✗ Could not resolve workspace '{ws}'. Please verify the workspace exists.",
223
+ err=True,
224
+ )
225
+ sys.exit(ExitCodes.NOT_FOUND)
226
+
227
+ mappings.append((ws_id, template_id_or_name))
228
+
229
+ # Create policies for each mapping
230
+ for ws_id, template_id_or_name in mappings:
231
+ try:
232
+ # Resolve template name/ID to actual template ID
233
+ template_id = _resolve_policy_template(template_id_or_name)
234
+
235
+ created_policy_id = _create_workspace_policy_from_template(
236
+ template_id=template_id,
237
+ workspace=ws_id,
238
+ name_hint=name_hint or "user",
239
+ )
240
+ policy_ids.append(created_policy_id)
241
+ except ValueError as e:
242
+ click.echo(f"✗ Error: {str(e)}", err=True)
243
+ sys.exit(ExitCodes.INVALID_INPUT)
244
+ except SystemExit:
245
+ # Re-raise SystemExit (from _resolve_policy_template) to propagate errors
246
+ raise
247
+ except Exception as exc:
248
+ handle_api_error(exc)
249
+
250
+ return policy_ids
251
+
252
+
253
+ def _calculate_policy_column_widths() -> List[int]:
254
+ """Calculate dynamic column widths for policy statements based on terminal size."""
255
+ try:
256
+ terminal_width = shutil.get_terminal_size().columns
257
+ except Exception:
258
+ terminal_width = 120
259
+
260
+ actions_width = 48
261
+ resources_width = 24
262
+ workspace_width = 18
263
+ border_overhead = 14 # Table borders plus spacing for four columns
264
+
265
+ description_width = terminal_width - (
266
+ actions_width + resources_width + workspace_width + border_overhead
267
+ )
268
+ description_width = max(30, description_width)
269
+
270
+ return [actions_width, resources_width, workspace_width, description_width]
271
+
272
+
273
+ def _truncate_cell(value: str, width: int) -> str:
274
+ """Truncate cell content to fit within the specified width."""
275
+ if len(value) > width:
276
+ return value[: max(0, width - 3)] + "..."
277
+ return value
278
+
279
+
280
+ def _format_policy_table(policies: list) -> None:
281
+ """Format and display policies in a table format.
282
+
283
+ Args:
284
+ policies: List of policy IDs to expand and display
285
+ """
286
+ if not policies:
287
+ return
288
+
289
+ click.echo("\nPolicies:")
290
+ click.echo("=" * 80)
291
+
292
+ # Fetch policy details for each policy ID
293
+ policy_details = []
294
+ permission_errors = []
295
+
296
+ for policy_id in policies:
297
+ details = _get_policy_details(policy_id)
298
+ if details:
299
+ # Check if this is a permission error
300
+ if details.get("_permission_error"):
301
+ permission_errors.append(policy_id)
302
+ continue
303
+
304
+ # If policy has a templateId, fetch template details too
305
+ template_id = details.get("templateId")
306
+ if template_id:
307
+ template_details = _get_policy_template_details(template_id)
308
+ if template_details:
309
+ # Check if template access failed due to permissions
310
+ if template_details.get("_permission_error"):
311
+ details["template_permission_error"] = True
312
+ else:
313
+ # Merge template details into policy details
314
+ details["template"] = template_details
315
+ # If policy doesn't have statements but template does
316
+ if not details.get("statements") and template_details.get("statements"):
317
+ details["statements"] = template_details.get("statements", [])
318
+ policy_details.append(details)
319
+ else:
320
+ # If we can't fetch details, show just the ID
321
+ policy_details.append({"id": policy_id, "name": "Unknown", "statements": []})
322
+
323
+ if not policy_details and not permission_errors:
324
+ click.echo("No policy details available.")
325
+ return
326
+
327
+ # Show permission errors if any
328
+ if permission_errors:
329
+ click.echo("✗ Access denied to the following policies (insufficient permissions):")
330
+ for policy_id in permission_errors:
331
+ click.echo(f" - Policy ID: {policy_id}")
332
+ if policy_details:
333
+ click.echo() # Add spacing before showing accessible policies
334
+
335
+ # Display policy table
336
+ for i, policy in enumerate(policy_details):
337
+ if i > 0:
338
+ click.echo() # Add spacing between policies
339
+
340
+ policy_name = policy.get("name", "Unknown")
341
+ policy_id = policy.get("id", "Unknown")
342
+ policy_type = policy.get("type", "Unknown")
343
+
344
+ click.echo(f"Policy: {policy_name} (ID: {policy_id}, Type: {policy_type})")
345
+ click.echo("-" * 60)
346
+
347
+ statements = policy.get("statements", [])
348
+ if statements:
349
+ column_widths = _calculate_policy_column_widths()
350
+ headers = ["Actions", "Resources", "Workspace", "Description"]
351
+
352
+ def _border(left: str, junction: str, right: str) -> str:
353
+ parts = [left] + ["─" * (w + 2) for w in column_widths]
354
+ border_line = parts[0] + parts[1]
355
+ for segment in parts[2:]:
356
+ border_line += junction + segment
357
+ return border_line + right
358
+
359
+ click.echo(_border("┌", "┬", "┐"))
360
+ header_parts = ["│"]
361
+ for header, width in zip(headers, column_widths):
362
+ header_parts.append(f" {header:<{width}} │")
363
+ click.echo("".join(header_parts))
364
+ click.echo(_border("├", "┼", "┤"))
365
+
366
+ for statement in statements:
367
+ row_data = [
368
+ ", ".join(statement.get("actions", [])),
369
+ ", ".join(statement.get("resource", [])),
370
+ statement.get("workspace", ""),
371
+ statement.get("description", "") or "",
372
+ ]
373
+ row_parts = ["│"]
374
+ for value, width in zip(row_data, column_widths):
375
+ cell = _truncate_cell(str(value), width)
376
+ row_parts.append(f" {cell:<{width}} │")
377
+ click.echo("".join(row_parts))
378
+
379
+ click.echo(_border("└", "┴", "┘"))
380
+ else:
381
+ click.echo(" No statements defined for this policy.")
382
+
383
+ # Show additional policy info if available
384
+ template = policy.get("template")
385
+ if template:
386
+ template_name = template.get("name", "Unknown")
387
+ template_id = policy.get("templateId", "Unknown")
388
+ click.echo(f" Template: {template_name} (ID: {template_id})")
389
+ template_type = template.get("type")
390
+ if template_type:
391
+ click.echo(f" Template Type: {template_type}")
392
+ elif policy.get("template_permission_error"):
393
+ template_id = policy.get("templateId", "Unknown")
394
+ click.echo(f" Template ID: {template_id} (access denied - insufficient permissions)")
395
+ elif policy.get("templateId"):
396
+ click.echo(f" Template ID: {policy.get('templateId')} (details unavailable)")
397
+
398
+ if policy.get("builtIn"):
399
+ click.echo(" Built-in: Yes")
400
+
401
+ workspace = policy.get("workspace")
402
+ if workspace:
403
+ workspace_name = get_workspace_display_name(workspace)
404
+ if workspace_name and workspace_name != workspace:
405
+ click.echo(f" Workspace: {workspace_name} (ID: {workspace})")
406
+ else:
407
+ click.echo(f" Workspace: {workspace}")
408
+
409
+
410
+ def _query_all_users(
411
+ filter_str: Optional[str] = None,
412
+ sortby: str = "firstName",
413
+ order: str = "asc",
414
+ include_disabled: bool = False,
415
+ ) -> list:
416
+ """Query all users from the API with server-side pagination using continuation tokens.
417
+
418
+ Uses proper Dynamic LINQ filter syntax as specified in the User Service OpenAPI spec.
419
+ Pagination uses continuationToken (not skip/take) as per API specification.
420
+ Filter syntax follows SystemLink User Service API specification:
421
+ - Uses 'and'/'or' operators (not '&&'/'||')
422
+ - String values in double quotes
423
+ - Uses 'status = "active"' for filtering disabled users
424
+
425
+ TODO: Follow this pattern for other API clients that support continuation tokens
426
+ Reference: https://dev-api.lifecyclesolutions.ni.com/niuser/swagger/v1/niuser.yaml
427
+
428
+ Args:
429
+ filter_str: Filter expression for users
430
+ sortby: Field to sort by
431
+ order: Sort order ('asc' or 'desc')
432
+ include_disabled: Whether to include disabled users
433
+
434
+ Returns:
435
+ List of all users
436
+ """
437
+ url = f"{get_base_url()}/niuser/v1/users/query"
438
+ all_users = []
439
+ continuation_token = None
440
+ page_size = 100 # API maximum take limit is 100
441
+
442
+ # Build the base filter - combine user filter with active status filter if needed
443
+ combined_filter = filter_str
444
+ if not include_disabled:
445
+ # Add active status filter to the query using correct Dynamic LINQ syntax
446
+ # Note: User API uses 'status' field with values 'pending' or 'active'
447
+ active_filter = 'status = "active"'
448
+ if filter_str:
449
+ combined_filter = f"({filter_str}) and {active_filter}"
450
+ else:
451
+ combined_filter = active_filter
452
+
453
+ while True:
454
+ payload = {
455
+ "take": page_size,
456
+ "sortby": sortby,
457
+ "order": "ascending" if order == "asc" else "descending",
458
+ }
459
+
460
+ if combined_filter:
461
+ payload["filter"] = combined_filter
462
+
463
+ if continuation_token:
464
+ payload["continuationToken"] = continuation_token
465
+
466
+ resp = make_api_request("POST", url, payload=payload)
467
+ data = resp.json()
468
+ users = data.get("users", [])
469
+
470
+ if not users:
471
+ break
472
+
473
+ all_users.extend(users)
474
+
475
+ # Check for continuation token to get next page
476
+ continuation_token = data.get("continuationToken")
477
+ if not continuation_token:
478
+ break # No more pages available
479
+
480
+ return all_users
481
+
482
+
483
+ def register_user_commands(cli: click.Group) -> None:
484
+ """Register CLI commands for managing SystemLink users."""
485
+
486
+ @cli.group()
487
+ def user() -> None:
488
+ """Manage SystemLink users."""
489
+ pass
490
+
491
+ @user.command(name="list")
492
+ @click.option(
493
+ "--workspace",
494
+ "-w",
495
+ help="Filter by workspace name or ID",
496
+ )
497
+ @click.option(
498
+ "--take",
499
+ "-t",
500
+ type=int,
501
+ default=25,
502
+ show_default=True,
503
+ help="Maximum number of users to return",
504
+ )
505
+ @click.option(
506
+ "--format",
507
+ "-f",
508
+ type=click.Choice(["table", "json"]),
509
+ default="table",
510
+ show_default=True,
511
+ help="Output format",
512
+ )
513
+ @click.option(
514
+ "--include-disabled",
515
+ is_flag=True,
516
+ help="Include disabled users in the results",
517
+ )
518
+ @click.option(
519
+ "--sortby",
520
+ type=click.Choice(["firstName", "lastName", "email"]),
521
+ default="firstName",
522
+ show_default=True,
523
+ help="Sort users by field",
524
+ )
525
+ @click.option(
526
+ "--order",
527
+ type=click.Choice(["asc", "desc"]),
528
+ default="asc",
529
+ show_default=True,
530
+ help="Sort order",
531
+ )
532
+ @click.option(
533
+ "--filter",
534
+ help="Search text to filter users by first name, last name, or email",
535
+ )
536
+ @click.option(
537
+ "--type",
538
+ "user_type",
539
+ type=click.Choice(["all", "user", "service"]),
540
+ default="all",
541
+ show_default=True,
542
+ help="Filter by account type",
543
+ )
544
+ def list_users(
545
+ workspace: Optional[str] = None,
546
+ take: int = 25,
547
+ format: str = "table",
548
+ include_disabled: bool = False,
549
+ sortby: str = "firstName",
550
+ order: str = "asc",
551
+ filter: Optional[str] = None,
552
+ user_type: str = "all",
553
+ ) -> None:
554
+ """List users with optional filtering and sorting."""
555
+ format_output = validate_output_format(format)
556
+
557
+ try:
558
+ # Build search filter from user's filter text
559
+ # Convert simple search text to Dynamic LINQ query across name/email fields
560
+ search_filter = None
561
+ if filter:
562
+ # Escape quotes in the search text
563
+ escaped_filter = filter.replace('"', '\\"')
564
+ # Build a LINQ query that searches firstName, lastName, and email
565
+ search_filter = (
566
+ f'firstName.Contains("{escaped_filter}") or '
567
+ f'lastName.Contains("{escaped_filter}") or '
568
+ f'email.Contains("{escaped_filter}")'
569
+ )
570
+
571
+ # Build type filter if specified
572
+ type_filter = None
573
+ if user_type != "all":
574
+ type_filter = f'type = "{user_type}"'
575
+
576
+ # For JSON format, we can respect the take parameter and use server-side pagination
577
+ # For table format, we fetch all users and do client-side pagination for better UX
578
+ if format_output.lower() == "json":
579
+ # Use server-side pagination for JSON output
580
+ url = f"{get_base_url()}/niuser/v1/users/query"
581
+
582
+ # Build the filter - combine search filter with active status filter if needed
583
+ combined_filter = search_filter
584
+ if not include_disabled:
585
+ # Add active status filter to the query using correct Dynamic LINQ syntax
586
+ # Note: User API uses 'status' field with values 'pending' or 'active'
587
+ active_filter = 'status = "active"'
588
+ if combined_filter:
589
+ combined_filter = f"({combined_filter}) and {active_filter}"
590
+ else:
591
+ combined_filter = active_filter
592
+
593
+ # Add type filter
594
+ if type_filter:
595
+ if combined_filter:
596
+ combined_filter = f"({combined_filter}) and {type_filter}"
597
+ else:
598
+ combined_filter = type_filter
599
+
600
+ payload = {
601
+ "take": take,
602
+ "sortby": sortby,
603
+ "order": "ascending" if order == "asc" else "descending",
604
+ }
605
+
606
+ if combined_filter:
607
+ payload["filter"] = combined_filter
608
+
609
+ resp = make_api_request("POST", url, payload=payload)
610
+ data = resp.json()
611
+ users = data.get("users", [])
612
+
613
+ click.echo(json.dumps(users, indent=2))
614
+ return
615
+ else:
616
+ # For table format, fetch all users for proper client-side pagination
617
+ # Combine filters for table output
618
+ combined_filter_for_table = search_filter
619
+ if type_filter:
620
+ if combined_filter_for_table:
621
+ combined_filter_for_table = (
622
+ f"({combined_filter_for_table}) and {type_filter}"
623
+ )
624
+ else:
625
+ combined_filter_for_table = type_filter
626
+
627
+ all_users = _query_all_users(
628
+ filter_str=combined_filter_for_table,
629
+ sortby=sortby,
630
+ order=order,
631
+ include_disabled=include_disabled,
632
+ )
633
+
634
+ def user_formatter(user: dict) -> list:
635
+ status = "Active" if user.get("active", True) else "Inactive"
636
+ acct_type = user.get("type", "user")
637
+ type_display = "Service" if acct_type == "service" else "User"
638
+ return [
639
+ user.get("id", ""),
640
+ user.get("firstName", ""),
641
+ user.get("lastName", ""),
642
+ user.get("email", "") or "-",
643
+ type_display,
644
+ status,
645
+ ]
646
+
647
+ # Use client-side pagination with all fetched users
648
+ paginate_list_output(
649
+ items=all_users,
650
+ page_size=take,
651
+ format_output=format_output,
652
+ formatter_func=user_formatter,
653
+ headers=["ID", "First Name", "Last Name", "Email", "Type", "Status"],
654
+ column_widths=[36, 15, 15, 25, 10, 10],
655
+ empty_message="No users found.",
656
+ total_label="user(s)",
657
+ )
658
+
659
+ except Exception as exc:
660
+ handle_api_error(exc)
661
+
662
+ @user.command(name="get")
663
+ @click.option("--id", "-i", "user_id", help="User ID to retrieve")
664
+ @click.option("--email", "user_email", help="User email to retrieve")
665
+ @click.option(
666
+ "--format",
667
+ "-f",
668
+ type=click.Choice(["table", "json"], case_sensitive=False),
669
+ default="table",
670
+ show_default=True,
671
+ help="Output format: table or json",
672
+ )
673
+ def get_user(
674
+ user_id: Optional[str] = None, user_email: Optional[str] = None, format: str = "table"
675
+ ) -> None:
676
+ """Get details for a specific user by ID or email."""
677
+ if not user_id and not user_email:
678
+ click.echo("✗ Must provide either --id or --email.", err=True)
679
+ sys.exit(ExitCodes.INVALID_INPUT)
680
+
681
+ if user_id and user_email:
682
+ click.echo("✗ Cannot specify both --id and --email. Choose one.", err=True)
683
+ sys.exit(ExitCodes.INVALID_INPUT)
684
+
685
+ try:
686
+ user = None
687
+
688
+ if user_email:
689
+ # Search for user by email using query endpoint
690
+ query_url = f"{get_base_url()}/niuser/v1/users/query"
691
+ query_payload = {"filter": f'email = "{user_email}"', "take": 1}
692
+
693
+ query_resp = make_api_request(
694
+ "POST", query_url, payload=query_payload, handle_errors=False
695
+ )
696
+ query_data = query_resp.json()
697
+ users = query_data.get("users", [])
698
+
699
+ if not users:
700
+ click.echo(f"✗ User with email '{user_email}' not found.", err=True)
701
+ sys.exit(ExitCodes.NOT_FOUND)
702
+
703
+ if len(users) > 1:
704
+ click.echo(
705
+ f"✗ Multiple users found with email '{user_email}'. This should not happen.",
706
+ err=True,
707
+ )
708
+ sys.exit(ExitCodes.GENERAL_ERROR)
709
+
710
+ user = users[0]
711
+ else:
712
+ # Get user by ID using direct endpoint
713
+ url = f"{get_base_url()}/niuser/v1/users/{user_id}"
714
+ resp = make_api_request("GET", url, payload=None, handle_errors=False)
715
+ user = resp.json()
716
+
717
+ if format.lower() == "json":
718
+ # For JSON output, optionally expand policies
719
+ if user.get("policies"):
720
+ expanded_policies = []
721
+ policy_permission_errors = []
722
+
723
+ for policy_id in user.get("policies", []):
724
+ policy_details = _get_policy_details(policy_id)
725
+ if policy_details:
726
+ # Check if this is a permission error
727
+ if policy_details.get("_permission_error"):
728
+ policy_permission_errors.append(policy_id)
729
+ continue
730
+
731
+ # If policy has a templateId, fetch template details too
732
+ template_id = policy_details.get("templateId")
733
+ if template_id:
734
+ template_details = _get_policy_template_details(template_id)
735
+ if template_details:
736
+ # Check if template access failed due to permissions
737
+ if template_details.get("_permission_error"):
738
+ policy_details["template_permission_error"] = True
739
+ policy_details["templateId"] = template_id
740
+ else:
741
+ # Include template details in the expanded policy
742
+ policy_details["template"] = template_details
743
+ # If policy doesn't have statements but template does
744
+ if not policy_details.get(
745
+ "statements"
746
+ ) and template_details.get("statements"):
747
+ policy_details["statements"] = template_details.get(
748
+ "statements", []
749
+ )
750
+ expanded_policies.append(policy_details)
751
+ else:
752
+ expanded_policies.append({"id": policy_id, "name": "Unknown"})
753
+
754
+ user["expanded_policies"] = expanded_policies
755
+ if policy_permission_errors:
756
+ user["policy_permission_errors"] = policy_permission_errors
757
+
758
+ click.echo(json.dumps(user, indent=2))
759
+ return
760
+
761
+ # Table format
762
+ user_type = user.get("type", "user")
763
+ type_display = "Service Account" if user_type == "service" else "User"
764
+ click.echo(f"{type_display} Details:")
765
+ click.echo("=" * 50)
766
+ click.echo(f"ID: {user.get('id', 'N/A')}")
767
+ click.echo(f"Type: {type_display}")
768
+ click.echo(f"First Name: {user.get('firstName', 'N/A')}")
769
+ click.echo(f"Last Name: {user.get('lastName', 'N/A')}")
770
+ # Only show user-specific fields for non-service accounts
771
+ if user_type != "service":
772
+ click.echo(f"Email: {user.get('email', 'N/A')}")
773
+ click.echo(f"Phone: {user.get('phone', 'N/A')}")
774
+ click.echo(f"Login: {user.get('login', 'N/A')}")
775
+ click.echo(f"NIUA ID: {user.get('niuaId', 'N/A')}")
776
+ click.echo(f"Status: {user.get('status', 'N/A')}")
777
+ click.echo(f"Organization ID: {user.get('orgId', 'N/A')}")
778
+ click.echo(f"Created: {user.get('created', 'N/A')}")
779
+ click.echo(f"Updated: {user.get('updated', 'N/A')}")
780
+
781
+ policies = user.get("policies", [])
782
+ if policies:
783
+ _format_policy_table(policies)
784
+
785
+ keywords = user.get("keywords", [])
786
+ if keywords:
787
+ click.echo(f"\nKeywords: {', '.join(keywords)}")
788
+
789
+ properties = user.get("properties", {})
790
+ if properties:
791
+ click.echo("\nProperties:")
792
+ for key, value in properties.items():
793
+ click.echo(f" {key}: {value}")
794
+
795
+ except Exception as exc:
796
+ # Check if this is a permission error for user access
797
+ response = getattr(exc, "response", None)
798
+ if response is not None and response.status_code == 401:
799
+ try:
800
+ error_data = response.json()
801
+ if "error" in error_data and error_data["error"].get("name") == "Unauthorized":
802
+ click.echo(
803
+ "✗ Access denied to user information (insufficient permissions).",
804
+ err=True,
805
+ )
806
+ sys.exit(ExitCodes.PERMISSION_DENIED)
807
+ except (ValueError, KeyError):
808
+ pass
809
+
810
+ # Fall back to standard error handling
811
+ handle_api_error(exc)
812
+
813
+ @user.command(name="create")
814
+ @click.option(
815
+ "--type",
816
+ "user_type",
817
+ type=click.Choice(["user", "service"]),
818
+ help="Type of account: 'user' for human users, 'service' for API/automation accounts",
819
+ )
820
+ @click.option("--first-name", help="User's first name (or service account name)")
821
+ @click.option(
822
+ "--last-name",
823
+ help="User's last name (defaults to 'ServiceAccount' for service accounts)",
824
+ )
825
+ @click.option("--email", help="User's email address (not valid for service accounts)")
826
+ @click.option("--niua-id", help="User's NIUA ID (not valid for service accounts)")
827
+ @click.option("--login", help="User's login name (not valid for service accounts)")
828
+ @click.option("--phone", help="User's phone number (not valid for service accounts)")
829
+ @click.option("--accepted-tos", is_flag=True, help="Whether user has accepted terms of service")
830
+ @click.option(
831
+ "--policies",
832
+ help="Comma-separated list of policy IDs to assign to the user",
833
+ )
834
+ @click.option(
835
+ "--policy",
836
+ help="Single policy ID to assign to the user",
837
+ )
838
+ @click.option(
839
+ "--workspace-policies",
840
+ help=(
841
+ "Comma-separated list of workspace:template entries (workspace can be name or"
842
+ " ID; template can be template ID or template name); a policy will be created"
843
+ " per workspace from the template and assigned to the user"
844
+ ),
845
+ )
846
+ @click.option(
847
+ "--keywords",
848
+ help="Comma-separated list of keywords to associate with the user",
849
+ )
850
+ @click.option(
851
+ "--properties",
852
+ help="JSON string of key-value properties to associate with the user",
853
+ )
854
+ def create_user(
855
+ user_type: Optional[str] = None,
856
+ first_name: Optional[str] = None,
857
+ last_name: Optional[str] = None,
858
+ email: Optional[str] = None,
859
+ niua_id: Optional[str] = None,
860
+ login: Optional[str] = None,
861
+ phone: Optional[str] = None,
862
+ accepted_tos: bool = False,
863
+ policy: Optional[str] = None,
864
+ policies: Optional[str] = None,
865
+ workspace_policies: Optional[str] = None,
866
+ keywords: Optional[str] = None,
867
+ properties: Optional[str] = None,
868
+ ) -> None:
869
+ """Create a new user or service account.
870
+
871
+ For regular users (--type user):
872
+ If niuaId is not provided, it will default to the email address.
873
+ Required fields (first name, last name, email) will be prompted for.
874
+
875
+ For service accounts (--type service):
876
+ First name is required. Last name defaults to "ServiceAccount" if not provided.
877
+ Email, phone, niuaId, and login are not valid for service accounts.
878
+ """
879
+ from .utils import check_readonly_mode
880
+
881
+ check_readonly_mode("create a user")
882
+
883
+ # If user_type wasn't specified via CLI, prompt for it first
884
+ if user_type is None:
885
+ user_type = questionary.select(
886
+ "Account type?",
887
+ choices=["user", "service"],
888
+ default="user",
889
+ ).ask()
890
+ if user_type is None:
891
+ raise click.Abort()
892
+
893
+ is_service_account = user_type == "service"
894
+
895
+ # Validate that service accounts don't have invalid fields
896
+ if is_service_account:
897
+ invalid_fields = []
898
+ if email:
899
+ invalid_fields.append("--email")
900
+ if niua_id:
901
+ invalid_fields.append("--niua-id")
902
+ if login:
903
+ invalid_fields.append("--login")
904
+ if phone:
905
+ invalid_fields.append("--phone")
906
+
907
+ if invalid_fields:
908
+ click.echo(
909
+ f"✗ Service accounts cannot have: {', '.join(invalid_fields)}",
910
+ err=True,
911
+ )
912
+ sys.exit(ExitCodes.INVALID_INPUT)
913
+
914
+ # Prompt for required fields if not provided
915
+ if not first_name:
916
+ prompt_text = "Service account name" if is_service_account else "User's first name"
917
+ first_name = click.prompt(prompt_text, type=str)
918
+
919
+ # lastName is required for all account types
920
+ # For service accounts, default to "ServiceAccount" if not provided
921
+ if not last_name:
922
+ if is_service_account:
923
+ last_name = "ServiceAccount"
924
+ else:
925
+ last_name = click.prompt("User's last name", type=str)
926
+
927
+ if not is_service_account:
928
+ # Regular user also requires email
929
+ if not email:
930
+ email = click.prompt("User's email address", type=str)
931
+
932
+ # Validate email format (basic validation)
933
+ email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
934
+ if email and not re.match(email_pattern, email):
935
+ click.echo("✗ Invalid email format.", err=True)
936
+ sys.exit(ExitCodes.INVALID_INPUT)
937
+
938
+ # If niua_id is not provided, default it to the email
939
+ if not niua_id:
940
+ niua_id = email
941
+
942
+ url = f"{get_base_url()}/niuser/v1/users"
943
+
944
+ # Build user payload
945
+ payload: Dict[str, Any] = {
946
+ "type": user_type,
947
+ "firstName": first_name,
948
+ }
949
+
950
+ # lastName is required for all account types
951
+ payload["lastName"] = last_name
952
+
953
+ # Add additional fields for regular users
954
+ if not is_service_account:
955
+ payload["email"] = email
956
+ payload["niuaId"] = niua_id
957
+ payload["acceptedToS"] = accepted_tos
958
+ if login:
959
+ payload["login"] = login
960
+ if phone:
961
+ payload["phone"] = phone
962
+
963
+ policy_ids: list[str] = []
964
+ if policy:
965
+ policy_ids.append(policy.strip())
966
+ if policies:
967
+ policy_ids.extend([p.strip() for p in policies.split(",")])
968
+
969
+ if workspace_policies:
970
+ policy_ids.extend(_process_workspace_policies(workspace_policies, first_name))
971
+
972
+ if policy_ids:
973
+ # de-duplicate while preserving order
974
+ seen: set[str] = set()
975
+ deduped: list[str] = []
976
+ for pid in policy_ids:
977
+ if pid and pid not in seen:
978
+ seen.add(pid)
979
+ deduped.append(pid)
980
+ payload["policies"] = deduped
981
+
982
+ if keywords:
983
+ payload["keywords"] = [k.strip() for k in keywords.split(",")]
984
+
985
+ # Start properties with provided JSON, if any
986
+ props_obj: Dict[str, Any] = {}
987
+ if properties:
988
+ try:
989
+ props_obj = json.loads(properties)
990
+ except json.JSONDecodeError:
991
+ click.echo("✗ Invalid JSON format for properties.", err=True)
992
+ sys.exit(ExitCodes.INVALID_INPUT)
993
+
994
+ if props_obj:
995
+ payload["properties"] = props_obj
996
+
997
+ try:
998
+ resp = make_api_request("POST", url, payload=payload)
999
+ user = resp.json()
1000
+ user_id = user.get("id")
1001
+
1002
+ if is_service_account:
1003
+ format_success(
1004
+ "Service account created",
1005
+ {"ID": user_id, "Name": user.get("firstName")},
1006
+ )
1007
+ else:
1008
+ format_success("User created", {"ID": user_id, "Email": user.get("email")})
1009
+
1010
+ except Exception as exc:
1011
+ # Try to parse API error response for better error messages
1012
+ # Check if this is an HTTP error with JSON response
1013
+ response = getattr(exc, "response", None)
1014
+ if response is not None:
1015
+ try:
1016
+ error_data = response.json()
1017
+ if "error" in error_data:
1018
+ error_info = error_data["error"]
1019
+ api_message = error_info.get("message", "")
1020
+ error_name = error_info.get("name", "")
1021
+
1022
+ if api_message:
1023
+ click.echo(f"✗ {api_message}", err=True)
1024
+ if error_name == "Auth.ValidationError":
1025
+ sys.exit(ExitCodes.INVALID_INPUT)
1026
+ else:
1027
+ sys.exit(ExitCodes.GENERAL_ERROR)
1028
+ except (ValueError, KeyError, AttributeError):
1029
+ # Fall back to original error if we can't parse the JSON
1030
+ pass
1031
+
1032
+ # Fall back to standard error handling
1033
+ handle_api_error(exc)
1034
+
1035
+ @user.command(name="update")
1036
+ @click.option("--id", "-i", "user_id", required=True, help="User ID to update")
1037
+ @click.option("--first-name", help="User's first name (or service account name)")
1038
+ @click.option("--last-name", help="User's last name")
1039
+ @click.option("--email", help="User's email address (not valid for service accounts)")
1040
+ @click.option("--login", help="User's login name (not valid for service accounts)")
1041
+ @click.option("--phone", help="User's phone number (not valid for service accounts)")
1042
+ @click.option("--niua-id", help="User's NIUA ID (not valid for service accounts)")
1043
+ @click.option(
1044
+ "--accepted-tos",
1045
+ type=click.Choice(["true", "false"]),
1046
+ help="Whether user has accepted terms of service",
1047
+ )
1048
+ @click.option(
1049
+ "--policy",
1050
+ help="Single policy ID to assign to the user",
1051
+ )
1052
+ @click.option(
1053
+ "--policies",
1054
+ help="Comma-separated list of policy IDs to assign to the user",
1055
+ )
1056
+ @click.option(
1057
+ "--workspace-policies",
1058
+ help=(
1059
+ "Comma-separated list of workspace:template entries (workspace can be name or"
1060
+ " ID; template can be template ID or template name); a policy will be created"
1061
+ " per workspace from the template and assigned to the user"
1062
+ ),
1063
+ )
1064
+ @click.option(
1065
+ "--keywords",
1066
+ help="Comma-separated list of keywords to associate with the user",
1067
+ )
1068
+ @click.option(
1069
+ "--properties",
1070
+ help="JSON string of key-value properties to associate with the user",
1071
+ )
1072
+ def update_user(
1073
+ user_id: str,
1074
+ first_name: Optional[str] = None,
1075
+ last_name: Optional[str] = None,
1076
+ email: Optional[str] = None,
1077
+ login: Optional[str] = None,
1078
+ phone: Optional[str] = None,
1079
+ niua_id: Optional[str] = None,
1080
+ accepted_tos: Optional[str] = None,
1081
+ policy: Optional[str] = None,
1082
+ policies: Optional[str] = None,
1083
+ workspace_policies: Optional[str] = None,
1084
+ keywords: Optional[str] = None,
1085
+ properties: Optional[str] = None,
1086
+ ) -> None:
1087
+ """Update an existing user or service account."""
1088
+ from .utils import check_readonly_mode
1089
+
1090
+ check_readonly_mode("update a user")
1091
+
1092
+ # First, fetch the user to check if it's a service account
1093
+ get_url = f"{get_base_url()}/niuser/v1/users/{user_id}"
1094
+ try:
1095
+ get_resp = make_api_request("GET", get_url, payload=None, handle_errors=False)
1096
+ existing_user = get_resp.json()
1097
+ is_service_account = existing_user.get("type") == "service"
1098
+ except Exception:
1099
+ # If we can't fetch the user, proceed without validation
1100
+ # The API will reject invalid fields anyway
1101
+ is_service_account = False
1102
+
1103
+ # Validate that service accounts don't get invalid field updates
1104
+ if is_service_account:
1105
+ invalid_fields = []
1106
+ if email:
1107
+ invalid_fields.append("--email")
1108
+ if login:
1109
+ invalid_fields.append("--login")
1110
+ if phone:
1111
+ invalid_fields.append("--phone")
1112
+ if niua_id:
1113
+ invalid_fields.append("--niua-id")
1114
+ if accepted_tos:
1115
+ invalid_fields.append("--accepted-tos")
1116
+
1117
+ if invalid_fields:
1118
+ click.echo(
1119
+ f"✗ Service accounts cannot be updated with: {', '.join(invalid_fields)}",
1120
+ err=True,
1121
+ )
1122
+ sys.exit(ExitCodes.INVALID_INPUT)
1123
+
1124
+ url = f"{get_base_url()}/niuser/v1/users/{user_id}"
1125
+
1126
+ # Build update payload (only include provided fields)
1127
+ payload: Dict[str, Any] = {}
1128
+
1129
+ if first_name:
1130
+ payload["firstName"] = first_name
1131
+
1132
+ if last_name:
1133
+ payload["lastName"] = last_name
1134
+
1135
+ if email:
1136
+ payload["email"] = email
1137
+
1138
+ if login:
1139
+ payload["login"] = login
1140
+
1141
+ if phone:
1142
+ payload["phone"] = phone
1143
+
1144
+ if niua_id:
1145
+ payload["niuaId"] = niua_id
1146
+
1147
+ if accepted_tos:
1148
+ payload["acceptedToS"] = accepted_tos.lower() == "true"
1149
+
1150
+ policy_ids_upd: list[str] = []
1151
+ if policy:
1152
+ policy_ids_upd.append(policy.strip())
1153
+ if policies:
1154
+ policy_ids_upd.extend([p.strip() for p in policies.split(",")])
1155
+
1156
+ if workspace_policies:
1157
+ policy_ids_upd.extend(_process_workspace_policies(workspace_policies, first_name))
1158
+
1159
+ if policy_ids_upd:
1160
+ seen_upd: set[str] = set()
1161
+ deduped_upd: list[str] = []
1162
+ for pid in policy_ids_upd:
1163
+ if pid and pid not in seen_upd:
1164
+ seen_upd.add(pid)
1165
+ deduped_upd.append(pid)
1166
+ payload["policies"] = deduped_upd
1167
+
1168
+ if keywords:
1169
+ payload["keywords"] = [k.strip() for k in keywords.split(",")]
1170
+
1171
+ props_upd: Dict[str, Any] = {}
1172
+ if properties:
1173
+ try:
1174
+ props_upd = json.loads(properties)
1175
+ except json.JSONDecodeError:
1176
+ click.echo("✗ Invalid JSON format for properties.", err=True)
1177
+ sys.exit(ExitCodes.INVALID_INPUT)
1178
+
1179
+ if props_upd:
1180
+ payload["properties"] = props_upd
1181
+
1182
+ if not payload:
1183
+ click.echo("✗ No fields provided to update.", err=True)
1184
+ sys.exit(ExitCodes.INVALID_INPUT)
1185
+
1186
+ try:
1187
+ resp = make_api_request("PUT", url, payload=payload)
1188
+ user = resp.json()
1189
+ if is_service_account:
1190
+ format_success(
1191
+ "Service account updated",
1192
+ {"ID": user.get("id"), "Name": user.get("firstName")},
1193
+ )
1194
+ else:
1195
+ format_success("User updated", {"ID": user.get("id"), "Email": user.get("email")})
1196
+
1197
+ except Exception as exc:
1198
+ handle_api_error(exc)
1199
+
1200
+ @user.command(name="delete")
1201
+ @click.option("--id", "-i", "user_id", required=True, help="User ID to delete")
1202
+ @click.confirmation_option(
1203
+ prompt="Are you sure you want to delete this user? This action cannot be undone."
1204
+ )
1205
+ def delete_user(user_id: str) -> None:
1206
+ """Delete a user by ID."""
1207
+ from .utils import check_readonly_mode
1208
+
1209
+ check_readonly_mode("delete a user")
1210
+
1211
+ url = f"{get_base_url()}/niuser/v1/users/{user_id}"
1212
+
1213
+ try:
1214
+ make_api_request("DELETE", url, payload=None)
1215
+ format_success("User deleted", {"ID": user_id})
1216
+
1217
+ except Exception as exc:
1218
+ handle_api_error(exc)