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
@@ -0,0 +1,599 @@
1
+ """CLI commands for managing SystemLink test plan templates."""
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ import click
9
+ import questionary
10
+
11
+ from .cli_utils import validate_output_format
12
+ from .platform import require_feature
13
+ from .universal_handlers import UniversalResponseHandler, FilteredResponse
14
+ from .utils import (
15
+ ExitCodes,
16
+ extract_error_type,
17
+ get_base_url,
18
+ get_workspace_map,
19
+ handle_api_error,
20
+ load_json_file,
21
+ make_api_request,
22
+ sanitize_filename,
23
+ )
24
+ from .workspace_utils import (
25
+ get_effective_workspace,
26
+ get_workspace_display_name,
27
+ resolve_workspace_filter,
28
+ )
29
+
30
+
31
+ def _escape_filter_value(value: str) -> str:
32
+ """Escape quotes and backslashes for filter literals."""
33
+ return value.replace("\\", "\\\\").replace('"', '\\"')
34
+
35
+
36
+ def _build_template_search_filter(search: str) -> str:
37
+ """Build a case-insensitive substring filter across key template fields.
38
+
39
+ Avoid ToLower() and use Contains() with several case variants.
40
+ Uses field names: name, templateGroup, description.
41
+ """
42
+ original = _escape_filter_value(search)
43
+ lower = _escape_filter_value(search.lower())
44
+ upper = _escape_filter_value(search.upper())
45
+ title = _escape_filter_value(search.title())
46
+
47
+ def variants(field: str) -> str:
48
+ return " or ".join(
49
+ [
50
+ f'{field}.Contains("{original}")',
51
+ f'{field}.Contains("{lower}")',
52
+ f'{field}.Contains("{upper}")',
53
+ f'{field}.Contains("{title}")',
54
+ ]
55
+ )
56
+
57
+ fields = ["name", "templateGroup", "description"]
58
+ clauses = [f"({variants(f)})" for f in fields]
59
+ return f"(({' or '.join(clauses)}))"
60
+
61
+
62
+ def _query_all_templates(
63
+ workspace_filter: Optional[str] = None,
64
+ workspace_map: Optional[dict] = None,
65
+ search_text: Optional[str] = None,
66
+ ) -> List[Dict[str, Any]]:
67
+ """Query all test plan templates using continuation token pagination.
68
+
69
+ Args:
70
+ workspace_filter: Optional workspace ID to filter by
71
+ workspace_map: Optional workspace mapping to avoid repeated lookups
72
+
73
+ Returns:
74
+ List of all templates, optionally filtered by workspace
75
+ """
76
+ url = f"{get_base_url()}/niworkorder/v1/query-testplan-templates"
77
+ all_templates = []
78
+ continuation_token = None
79
+
80
+ while True:
81
+ # Build payload for the request
82
+ payload = {
83
+ "take": 100, # Use smaller page size for efficient pagination
84
+ "orderBy": "TEMPLATE_GROUP",
85
+ "descending": False,
86
+ "projection": ["ID", "NAME", "WORKSPACE", "TEMPLATE_GROUP"],
87
+ }
88
+
89
+ # Add workspace filter and user filter if specified
90
+ filter_parts: List[str] = []
91
+ if workspace_filter:
92
+ filter_parts.append(f'WORKSPACE == "{workspace_filter}"')
93
+ if search_text:
94
+ filter_parts.append(_build_template_search_filter(search_text))
95
+ if filter_parts:
96
+ payload["filter"] = " and ".join(filter_parts)
97
+
98
+ # Add continuation token if we have one
99
+ if continuation_token:
100
+ payload["continuationToken"] = continuation_token
101
+
102
+ resp = make_api_request("POST", url, payload)
103
+ data = resp.json()
104
+
105
+ # Extract templates from this page
106
+ templates = data.get("testPlanTemplates", [])
107
+ all_templates.extend(templates)
108
+
109
+ # Check if there are more pages
110
+ continuation_token = data.get("continuationToken")
111
+ if not continuation_token:
112
+ break
113
+
114
+ return all_templates
115
+
116
+
117
+ def register_templates_commands(cli: Any) -> None:
118
+ """Register the 'template' command group and its subcommands."""
119
+
120
+ @cli.group()
121
+ @click.pass_context
122
+ def template(ctx: click.Context) -> None:
123
+ """Manage test plan templates."""
124
+ # Check for platform feature availability
125
+ # Only check if a subcommand is being invoked (not just --help)
126
+ if ctx.invoked_subcommand is not None:
127
+ require_feature("templates")
128
+
129
+ @template.command(name="init")
130
+ @click.option(
131
+ "--name",
132
+ "-n",
133
+ help="Template name (will prompt if not provided)",
134
+ )
135
+ @click.option(
136
+ "--template-group",
137
+ "-g",
138
+ help="Template group (will prompt if not provided)",
139
+ )
140
+ @click.option(
141
+ "--output",
142
+ "-o",
143
+ help="Output file path (default: <name>-template.json)",
144
+ )
145
+ def init_template(
146
+ name: Optional[str], template_group: Optional[str], output: Optional[str]
147
+ ) -> None:
148
+ """Create a template JSON scaffold.
149
+
150
+ Creates a template JSON file with the required schema structure.
151
+ Name and Template Group are mandatory, all other fields are optional.
152
+ """
153
+ # Prompt for required fields if not provided
154
+ if not name:
155
+ name = click.prompt("Template name", type=str)
156
+ if not template_group:
157
+ template_group = click.prompt("Template group", type=str)
158
+
159
+ # At this point, name and template_group are guaranteed to be strings
160
+ assert name is not None
161
+ assert template_group is not None
162
+
163
+ # Generate output filename if not provided
164
+ if not output:
165
+ safe_name = sanitize_filename(name, "template")
166
+ output = f"{safe_name}-template.json"
167
+
168
+ # Create template structure based on the schema
169
+ template_data = {
170
+ "testPlanTemplates": [
171
+ {
172
+ # Required fields
173
+ "name": name,
174
+ "templateGroup": template_group,
175
+ # Optional fields - customize as needed
176
+ "productFamilies": ["// Add product families like: cRIO, BTS, PXI, etc."],
177
+ "partNumbers": ["// Add specific part numbers like: 156502A-11L, ADC-1688"],
178
+ "summary": "// Brief summary of what this template does",
179
+ "description": "// Detailed description of the test template and its purpose",
180
+ "testProgram": "// Name of the test program to execute",
181
+ "estimatedDurationInSeconds": 3600,
182
+ "systemFilter": '// Filter expression for system selection, e.g., properties.data[\\"Lab\\"] = \\"Battery Pack Lab\\"',
183
+ "executionActions": [
184
+ {
185
+ "type": "JOB",
186
+ "action": "START",
187
+ "jobs": [
188
+ {
189
+ "functions": ["state.apply"],
190
+ "arguments": [["<properties.startTestStateId>"]],
191
+ }
192
+ ],
193
+ },
194
+ {
195
+ "type": "NOTEBOOK",
196
+ "action": "PAUSE",
197
+ "notebookId": "// UUID of notebook to pause",
198
+ "parameters": {"operation": "pause"},
199
+ },
200
+ {
201
+ "type": "NOTEBOOK",
202
+ "action": "RESUME",
203
+ "notebookId": "// UUID of notebook to resume",
204
+ "parameters": {"operation": "resume"},
205
+ },
206
+ {"type": "MANUAL", "action": "ABORT"},
207
+ {
208
+ "type": "NOTEBOOK",
209
+ "action": "END",
210
+ "notebookId": "// UUID of final notebook to execute",
211
+ "parameters": {
212
+ "partNumber": "<partNumber>",
213
+ "dut": "<dutId>",
214
+ "operator": "<assignedTo>",
215
+ "testProgram": "<testProgram>",
216
+ "location": "<properties.region>-<properties.facility>-<properties.lab>",
217
+ },
218
+ },
219
+ ],
220
+ "fileIds": ["// Array of file UUIDs associated with this template"],
221
+ "workspace": "// UUID of the workspace where this template belongs",
222
+ "properties": {
223
+ "region": "// Example: Austin",
224
+ "facility": "// Example: Building A",
225
+ "lab": "// Example: Battery Pack Lab",
226
+ "startTestStateId": "// UUID for initial test state",
227
+ },
228
+ "workflowId": "// Optional: UUID of associated workflow",
229
+ }
230
+ ]
231
+ }
232
+
233
+ try:
234
+ # Check if file already exists
235
+ if os.path.exists(output):
236
+ if not questionary.confirm(
237
+ f"File {output} already exists. Overwrite?",
238
+ default=False,
239
+ ).ask():
240
+ click.echo("Template initialization cancelled.")
241
+ return
242
+
243
+ # Save the template file
244
+ with open(output, "w", encoding="utf-8") as f:
245
+ json.dump(template_data, f, indent=2, ensure_ascii=False)
246
+
247
+ click.echo(f"✓ Template initialized: {output}")
248
+ click.echo("Edit the file to customize your template:")
249
+ click.echo(" - name and templateGroup are required")
250
+ click.echo(
251
+ " - All other fields are optional (remove unused fields or set appropriate values)"
252
+ )
253
+ click.echo(" - Use 'slcli templates import' to upload the template when ready")
254
+ click.echo(
255
+ " - See TestPlanTemplate.json for a complete example with execution actions"
256
+ )
257
+
258
+ except Exception as exc:
259
+ click.echo(f"✗ Error creating template file: {exc}", err=True)
260
+ sys.exit(ExitCodes.GENERAL_ERROR)
261
+
262
+ @template.command(name="list")
263
+ @click.option(
264
+ "--workspace",
265
+ "-w",
266
+ help="Filter by workspace name or ID",
267
+ )
268
+ @click.option(
269
+ "--take",
270
+ "-t",
271
+ type=int,
272
+ default=25,
273
+ show_default=True,
274
+ help="Maximum number of templates to return",
275
+ )
276
+ @click.option(
277
+ "--format",
278
+ "-f",
279
+ type=click.Choice(["table", "json"]),
280
+ default="table",
281
+ show_default=True,
282
+ help="Output format",
283
+ )
284
+ @click.option(
285
+ "--filter",
286
+ "filter_text",
287
+ help="Case-insensitive substring to match name/group/description",
288
+ )
289
+ def list_templates(
290
+ workspace: Optional[str] = None,
291
+ take: int = 25,
292
+ format: str = "table",
293
+ filter_text: Optional[str] = None,
294
+ ) -> None:
295
+ """List test plan templates."""
296
+ format_output = validate_output_format(format)
297
+
298
+ try:
299
+ workspace_map = get_workspace_map()
300
+
301
+ # Resolve workspace filter to ID if specified
302
+ workspace_id = None
303
+ workspace = get_effective_workspace(workspace)
304
+ if workspace:
305
+ workspace_id = resolve_workspace_filter(workspace, workspace_map)
306
+
307
+ # Use continuation token pagination to get all templates
308
+ all_templates = _query_all_templates(workspace_id, workspace_map, filter_text)
309
+
310
+ # Create a mock response with all data
311
+ resp: Any = FilteredResponse({"testPlanTemplates": all_templates})
312
+
313
+ # Use universal response handler with template formatter
314
+ def template_formatter(template: dict) -> list:
315
+ ws_guid = template.get("workspace", "")
316
+ ws_name = get_workspace_display_name(ws_guid, workspace_map)
317
+ return [
318
+ template.get("name", "Unknown"),
319
+ ws_name,
320
+ template.get("id", ""),
321
+ template.get("templateGroup", "N/A"),
322
+ ]
323
+
324
+ UniversalResponseHandler.handle_list_response(
325
+ resp=resp,
326
+ data_key="testPlanTemplates",
327
+ item_name="template",
328
+ format_output=format_output,
329
+ formatter_func=template_formatter,
330
+ headers=["Name", "Workspace", "Template ID", "Group"],
331
+ column_widths=[40, 30, 36, 25],
332
+ empty_message="No test plan templates found.",
333
+ enable_pagination=True,
334
+ page_size=take,
335
+ )
336
+
337
+ except Exception as exc:
338
+ handle_api_error(exc)
339
+
340
+ @template.command(name="get")
341
+ @click.option("--id", "-i", "template_id", help="Test plan template ID")
342
+ @click.option("--name", "-n", "template_name", help="Test plan template name")
343
+ @click.option(
344
+ "--format",
345
+ "-f",
346
+ type=click.Choice(["table", "json"]),
347
+ default="table",
348
+ show_default=True,
349
+ help="Output format",
350
+ )
351
+ def get_template(
352
+ template_id: Optional[str] = None,
353
+ template_name: Optional[str] = None,
354
+ format: str = "table",
355
+ ) -> None:
356
+ """Show template details by ID or name."""
357
+ if not template_id and not template_name:
358
+ click.echo("✗ Must provide either --id or --name.", err=True)
359
+ sys.exit(ExitCodes.INVALID_INPUT)
360
+ if template_id and template_name:
361
+ click.echo("✗ Cannot specify both --id and --name.", err=True)
362
+ sys.exit(ExitCodes.INVALID_INPUT)
363
+
364
+ format_output = validate_output_format(format)
365
+ url = f"{get_base_url()}/niworkorder/v1/query-testplan-templates"
366
+
367
+ # Build filter based on what was provided
368
+ if template_id:
369
+ filter_str = f'ID == "{template_id}"'
370
+ else:
371
+ filter_str = f'NAME == "{template_name}"'
372
+
373
+ payload = {"take": 1000, "filter": filter_str}
374
+
375
+ try:
376
+ resp = make_api_request("POST", url, payload)
377
+ data = resp.json()
378
+ items = data.get("testPlanTemplates", []) if isinstance(data, dict) else []
379
+
380
+ # If searching by name, need to find exact match
381
+ if template_name:
382
+ items = [t for t in items if t.get("name") == template_name]
383
+
384
+ if not items:
385
+ identifier = template_id if template_id else template_name
386
+ click.echo(f"✗ Template '{identifier}' not found.", err=True)
387
+ sys.exit(ExitCodes.NOT_FOUND)
388
+
389
+ template = items[0]
390
+
391
+ if format_output == "json":
392
+ click.echo(json.dumps(template, indent=2))
393
+ return
394
+
395
+ # Table format
396
+ workspace_map = get_workspace_map()
397
+ ws_name = get_workspace_display_name(
398
+ template.get("workspace", ""),
399
+ workspace_map,
400
+ )
401
+
402
+ click.echo("Template Details:")
403
+ click.echo("=" * 50)
404
+ click.echo(f"Name: {template.get('name', 'N/A')}")
405
+ click.echo(f"ID: {template.get('id', 'N/A')}")
406
+ click.echo(f"Workspace: {ws_name}")
407
+ click.echo(f"Group: {template.get('templateGroup', 'N/A')}")
408
+ click.echo(f"Description: {template.get('description', 'N/A')}")
409
+
410
+ except Exception as exc:
411
+ handle_api_error(exc)
412
+
413
+ @template.command(name="export")
414
+ @click.option("--id", "-i", "template_id", help="Test plan template ID to export")
415
+ @click.option("--name", "-n", "template_name", help="Test plan template name to export")
416
+ @click.option("--output", "-o", help="Output JSON file (default: <template-name>.json)")
417
+ def export_template(
418
+ template_id: Optional[str] = None,
419
+ template_name: Optional[str] = None,
420
+ output: Optional[str] = None,
421
+ ) -> None:
422
+ """Export a template to JSON by ID or name."""
423
+ if not template_id and not template_name:
424
+ click.echo("✗ Must provide either --id or --name.", err=True)
425
+ sys.exit(ExitCodes.INVALID_INPUT)
426
+ if template_id and template_name:
427
+ click.echo("✗ Cannot specify both --id and --name.", err=True)
428
+ sys.exit(ExitCodes.INVALID_INPUT)
429
+
430
+ url = f"{get_base_url()}/niworkorder/v1/query-testplan-templates"
431
+
432
+ # Build filter based on what was provided
433
+ if template_id:
434
+ filter_str = f'ID == "{template_id}"'
435
+ else:
436
+ filter_str = f'NAME == "{template_name}"'
437
+
438
+ payload = {"take": 1000, "filter": filter_str}
439
+
440
+ try:
441
+ resp = make_api_request("POST", url, payload)
442
+ data = resp.json()
443
+ items = data.get("testPlanTemplates", []) if isinstance(data, dict) else []
444
+ # If searching by name, need to find exact match (filter is case-insensitive substring)
445
+ if template_name:
446
+ items = [t for t in items if t.get("name") == template_name]
447
+
448
+ if not items:
449
+ identifier = template_id if template_id else template_name
450
+ click.echo(f"✗ Test plan template '{identifier}' not found.", err=True)
451
+ sys.exit(ExitCodes.NOT_FOUND)
452
+
453
+ template_data = items[0]
454
+
455
+ # Generate output filename if not provided
456
+ if not output:
457
+ template_name = template_data.get("name", f"template-{template_id}")
458
+ safe_name = sanitize_filename(template_name, f"template-{template_id}")
459
+ output = f"{safe_name}.json"
460
+
461
+ # Use universal export handler
462
+ UniversalResponseHandler.handle_export_response(
463
+ resp=resp,
464
+ item_name="template",
465
+ output_file=output,
466
+ success_message_template="✓ Template exported to {output_file}",
467
+ )
468
+
469
+ except Exception as exc:
470
+ handle_api_error(exc)
471
+
472
+ @template.command(name="import")
473
+ @click.option(
474
+ "--file",
475
+ "input_file",
476
+ required=True,
477
+ help="Input JSON file",
478
+ )
479
+ def import_template(input_file: str) -> None:
480
+ """Import a template from JSON."""
481
+ from .utils import check_readonly_mode
482
+
483
+ check_readonly_mode("import a template")
484
+
485
+ url = f"{get_base_url()}/niworkorder/v1/testplan-templates"
486
+ allowed_fields = {
487
+ "name",
488
+ "templateGroup",
489
+ "productFamilies",
490
+ "partNumbers",
491
+ "summary",
492
+ "description",
493
+ "testProgram",
494
+ "estimatedDurationInSeconds",
495
+ "systemFilter",
496
+ "executionActions",
497
+ "fileIds",
498
+ "workspace",
499
+ "properties",
500
+ "dashboard",
501
+ "workflowId",
502
+ }
503
+ try:
504
+ data: Any = load_json_file(input_file)
505
+ if isinstance(data, dict) and "testPlanTemplates" in data:
506
+ data = data["testPlanTemplates"]
507
+ elif isinstance(data, dict):
508
+ data = [data]
509
+ # At this point, data should be a list of dicts
510
+ templates_data: List[Dict[str, Any]] = data if isinstance(data, list) else []
511
+ filtered = []
512
+ for entry in templates_data:
513
+ if isinstance(entry, dict):
514
+ filtered.append({k: v for k, v in entry.items() if k in allowed_fields})
515
+ payload = {"testPlanTemplates": filtered}
516
+
517
+ resp = make_api_request("POST", url, payload)
518
+
519
+ # Check response body for partial failures, even if HTTP status is 200
520
+ response_data = resp.json() if resp.text.strip() else {}
521
+ failed_templates = response_data.get("failedTestPlanTemplates", [])
522
+
523
+ if failed_templates:
524
+ # Handle partial or complete failures
525
+ click.echo("✗ Template import failed:", err=True)
526
+
527
+ # Extract detailed error information from the response
528
+ main_error = response_data.get("error", {})
529
+ inner_errors = main_error.get("innerErrors", [])
530
+
531
+ # Create a mapping of resource IDs to error details
532
+ error_details = {}
533
+ for inner_error in inner_errors:
534
+ resource_id = inner_error.get("resourceId", "Unknown")
535
+ error_name = inner_error.get("name", "")
536
+ error_message = inner_error.get("message", "Unknown error")
537
+ resource_type = inner_error.get("resourceType", "")
538
+
539
+ error_details[resource_id] = {
540
+ "name": error_name,
541
+ "message": error_message,
542
+ "type": resource_type,
543
+ }
544
+
545
+ # Report errors for each failed template
546
+ for failed_template in failed_templates:
547
+ template_name = failed_template.get("name", "Unknown")
548
+
549
+ # Try to find matching error details
550
+ error_info = error_details.get(template_name, {})
551
+ error_name = error_info.get("name", "")
552
+ error_message = error_info.get("message", "Unknown error")
553
+
554
+ # Format the error output
555
+ if error_name:
556
+ error_type = extract_error_type(error_name)
557
+ click.echo(f" - {template_name}: {error_type} - {error_message}", err=True)
558
+ else:
559
+ click.echo(f" - {template_name}: {error_message}", err=True)
560
+
561
+ # Show general error information if available
562
+ if main_error.get("message") and len(failed_templates) > 1:
563
+ click.echo(f"\nGeneral error: {main_error.get('message')}", err=True)
564
+
565
+ sys.exit(ExitCodes.GENERAL_ERROR)
566
+ else:
567
+ click.echo("✓ Test plan template imported successfully.")
568
+
569
+ except Exception as exc:
570
+ handle_api_error(exc)
571
+
572
+ @template.command(name="delete")
573
+ @click.option(
574
+ "--id",
575
+ "-i",
576
+ "template_id",
577
+ required=True,
578
+ help="Test plan template ID to delete",
579
+ )
580
+ @click.confirmation_option(prompt="Are you sure you want to delete this template?")
581
+ def delete_template(template_id: str) -> None:
582
+ """Delete a template."""
583
+ from .utils import check_readonly_mode
584
+
585
+ check_readonly_mode("delete a template")
586
+
587
+ url = f"{get_base_url()}/niworkorder/v1/delete-testplan-templates"
588
+ payload = {"ids": [template_id]}
589
+ try:
590
+ resp = make_api_request("POST", url, payload)
591
+ if resp.status_code in (200, 204):
592
+ click.echo(f"✓ Test plan template {template_id} deleted successfully.")
593
+ else:
594
+ click.echo(
595
+ f"✗ Failed to delete test plan template {template_id}: {resp.text}", err=True
596
+ )
597
+ sys.exit(ExitCodes.GENERAL_ERROR)
598
+ except Exception as exc:
599
+ handle_api_error(exc)