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,988 @@
1
+ """CLI commands for managing SystemLink workflows."""
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ import tempfile
7
+ import webbrowser
8
+ from pathlib import Path
9
+ from typing import Any, Dict, List, Optional, Union
10
+
11
+ import click
12
+ import questionary
13
+ import requests
14
+
15
+ from . import workflow_preview
16
+ from .cli_utils import validate_output_format
17
+ from .platform import require_feature
18
+ from .universal_handlers import UniversalResponseHandler, FilteredResponse
19
+ from .utils import (
20
+ display_api_errors,
21
+ ExitCodes,
22
+ extract_error_type,
23
+ get_base_url,
24
+ get_workspace_id_with_fallback,
25
+ get_workspace_map,
26
+ handle_api_error,
27
+ load_json_file,
28
+ make_api_request,
29
+ sanitize_filename,
30
+ save_json_file,
31
+ )
32
+ from .workspace_utils import (
33
+ get_effective_workspace,
34
+ get_workspace_display_name,
35
+ resolve_workspace_filter,
36
+ )
37
+
38
+ """Workflow CLI commands.
39
+
40
+ Preview (Mermaid diagram) generation helpers live in
41
+ `slcli.workflow_preview`. Deprecated internal wrapper functions have been
42
+ removed; import the public helpers directly from that module.
43
+ """
44
+
45
+
46
+ def _query_all_workflows(
47
+ workspace_filter: Optional[str] = None, workspace_map: Optional[dict] = None
48
+ ) -> List[Dict[str, Any]]:
49
+ """Query all workflows using continuation token pagination.
50
+
51
+ Args:
52
+ workspace_filter: Optional workspace ID to filter by
53
+ workspace_map: Optional workspace mapping to avoid repeated lookups
54
+
55
+ Returns:
56
+ List of all workflows, optionally filtered by workspace
57
+ """
58
+ url = f"{get_base_url()}/niworkorder/v1/query-workflows?ff-userdefinedworkflowsfortestplaninstances=true"
59
+ all_workflows = []
60
+ continuation_token = None
61
+
62
+ while True:
63
+ # Build payload for the request
64
+ payload: Dict[str, Union[int, str]] = {
65
+ "take": 100, # Use smaller page size for efficient pagination
66
+ }
67
+
68
+ # Add workspace filter if specified
69
+ if workspace_filter:
70
+ payload["filter"] = f'WORKSPACE == "{workspace_filter}"'
71
+
72
+ # Add continuation token if we have one
73
+ if continuation_token:
74
+ payload["continuationToken"] = continuation_token
75
+
76
+ resp = make_api_request("POST", url, payload)
77
+ data = resp.json()
78
+
79
+ # Extract workflows from this page
80
+ workflows = data.get("workflows", [])
81
+ all_workflows.extend(workflows)
82
+
83
+ # Check if there are more pages
84
+ continuation_token = data.get("continuationToken")
85
+ if not continuation_token:
86
+ break
87
+
88
+ return all_workflows
89
+
90
+
91
+ def register_workflows_commands(cli: Any) -> None:
92
+ """Register the 'workflow' command group and its subcommands."""
93
+
94
+ @cli.group()
95
+ @click.pass_context
96
+ def workflow(ctx: click.Context) -> None:
97
+ """Manage workflows (init, import/export, update)."""
98
+ # Check for platform feature availability
99
+ # Only check if a subcommand is being invoked (not just --help)
100
+ if ctx.invoked_subcommand is not None:
101
+ require_feature("workflows")
102
+
103
+ @workflow.command(name="init")
104
+ @click.option(
105
+ "--name",
106
+ "-n",
107
+ help="Workflow name (will prompt if not provided)",
108
+ )
109
+ @click.option(
110
+ "--description",
111
+ "-d",
112
+ help="Workflow description (will prompt if not provided)",
113
+ )
114
+ @click.option(
115
+ "--workspace",
116
+ "-w",
117
+ default="Default",
118
+ help="Workspace name or ID (default: 'Default')",
119
+ )
120
+ @click.option(
121
+ "--output",
122
+ "-o",
123
+ help="Output file path (default: <name>-workflow.json)",
124
+ )
125
+ def init_workflow(
126
+ name: Optional[str], description: Optional[str], workspace: str, output: Optional[str]
127
+ ) -> None:
128
+ """Create a workflow JSON skeleton.
129
+
130
+ Creates a workflow JSON file with the required schema structure.
131
+ Name and description are recommended fields. Workspace is required
132
+ and defaults to 'Default' if not specified.
133
+ """
134
+ # Prompt for required fields if not provided
135
+ if not name:
136
+ name = click.prompt("Workflow name", type=str)
137
+ if not description:
138
+ description = click.prompt("Workflow description", type=str, default="")
139
+
140
+ # Generate output filename if not provided
141
+ if not output:
142
+ assert name is not None # Should be set by prompt above
143
+ safe_name = sanitize_filename(name, "workflow")
144
+ output = f"{safe_name}-workflow.json"
145
+
146
+ # Resolve workspace name to ID
147
+ try:
148
+ workspace = get_effective_workspace(workspace) or workspace
149
+ workspace_id = get_workspace_id_with_fallback(workspace)
150
+ except Exception as exc:
151
+ click.echo(f"✗ Error resolving workspace '{workspace}': {exc}", err=True)
152
+ sys.exit(ExitCodes.NOT_FOUND)
153
+
154
+ workflow_data = {
155
+ "name": name,
156
+ "description": description,
157
+ "workspace": workspace_id,
158
+ "actions": [
159
+ {
160
+ "name": "START",
161
+ "displayText": "Start",
162
+ "privilegeSpecificity": ["ExecuteTest"],
163
+ "executionAction": {"type": "MANUAL", "action": "START"},
164
+ },
165
+ {
166
+ "name": "COMPLETE",
167
+ "displayText": "Complete",
168
+ "privilegeSpecificity": ["Close"],
169
+ "executionAction": {"type": "MANUAL", "action": "COMPLETE"},
170
+ },
171
+ {
172
+ "name": "RUN_NOTEBOOK",
173
+ "displayText": "Run Notebook",
174
+ "iconClass": None,
175
+ "i18n": [],
176
+ "privilegeSpecificity": ["ExecuteTest"],
177
+ "executionAction": {
178
+ "action": "RUN_NOTEBOOK",
179
+ "type": "NOTEBOOK",
180
+ "notebookId": "00000000-0000-0000-0000-000000000000",
181
+ "parameters": {
182
+ "partNumber": "<partNumber>",
183
+ "dut": "<dutId>",
184
+ "operator": "<assignedTo>",
185
+ "testProgram": "<testProgram>",
186
+ "location": "<properties.region>-<properties.facility>-<properties.lab>",
187
+ },
188
+ },
189
+ },
190
+ {
191
+ "name": "PLAN_SCHEDULE",
192
+ "displayText": "Schedule Test Plan",
193
+ "iconClass": "SCHEDULE",
194
+ "i18n": [],
195
+ "privilegeSpecificity": [],
196
+ "executionAction": {"action": "PLAN_SCHEDULE", "type": "SCHEDULE"},
197
+ },
198
+ {
199
+ "name": "RUN_JOB",
200
+ "displayText": "Run Job",
201
+ "iconClass": "DEPLOY",
202
+ "i18n": [],
203
+ "privilegeSpecificity": [],
204
+ "executionAction": {
205
+ "action": "RUN_JOB",
206
+ "type": "JOB",
207
+ "jobs": [
208
+ {
209
+ "functions": ["state.apply"],
210
+ "arguments": [["<properties.startTestStateId>"]],
211
+ "metadata": {},
212
+ }
213
+ ],
214
+ },
215
+ },
216
+ ],
217
+ "states": [
218
+ {
219
+ "name": "NEW",
220
+ "dashboardAvailable": False,
221
+ "defaultSubstate": "NEW",
222
+ "substates": [
223
+ {
224
+ "name": "NEW",
225
+ "displayText": "New",
226
+ "availableActions": [
227
+ {
228
+ "action": "PLAN_SCHEDULE",
229
+ "nextState": "SCHEDULED",
230
+ "nextSubstate": "SCHEDULED",
231
+ "showInUI": True,
232
+ }
233
+ ],
234
+ }
235
+ ],
236
+ },
237
+ {
238
+ "name": "SCHEDULED",
239
+ "dashboardAvailable": True,
240
+ "defaultSubstate": "SCHEDULED",
241
+ "substates": [
242
+ {
243
+ "name": "SCHEDULED",
244
+ "displayText": "Scheduled",
245
+ "availableActions": [
246
+ {
247
+ "action": "START",
248
+ "nextState": "IN_PROGRESS",
249
+ "nextSubstate": "IN_PROGRESS",
250
+ "showInUI": True,
251
+ },
252
+ {
253
+ "action": "RUN_NOTEBOOK",
254
+ "nextState": "IN_PROGRESS",
255
+ "nextSubstate": "IN_PROGRESS",
256
+ "showInUI": True,
257
+ },
258
+ ],
259
+ }
260
+ ],
261
+ },
262
+ {
263
+ "name": "IN_PROGRESS",
264
+ "dashboardAvailable": True,
265
+ "defaultSubstate": "IN_PROGRESS",
266
+ "substates": [
267
+ {
268
+ "name": "IN_PROGRESS",
269
+ "displayText": "In progress",
270
+ "availableActions": [
271
+ {
272
+ "action": "COMPLETE",
273
+ "nextState": "PENDING_APPROVAL",
274
+ "nextSubstate": "PENDING_APPROVAL",
275
+ "showInUI": True,
276
+ }
277
+ ],
278
+ }
279
+ ],
280
+ },
281
+ {
282
+ "name": "PENDING_APPROVAL",
283
+ "dashboardAvailable": True,
284
+ "defaultSubstate": "PENDING_APPROVAL",
285
+ "substates": [
286
+ {
287
+ "name": "PENDING_APPROVAL",
288
+ "displayText": "Pending approval",
289
+ "availableActions": [
290
+ {
291
+ "action": "RUN_JOB",
292
+ "nextState": "CLOSED",
293
+ "nextSubstate": "CLOSED",
294
+ "showInUI": True,
295
+ }
296
+ ],
297
+ }
298
+ ],
299
+ },
300
+ {
301
+ "name": "CLOSED",
302
+ "dashboardAvailable": False,
303
+ "defaultSubstate": "CLOSED",
304
+ "substates": [
305
+ {"name": "CLOSED", "displayText": "Closed", "availableActions": []}
306
+ ],
307
+ },
308
+ {
309
+ "name": "CANCELED",
310
+ "dashboardAvailable": False,
311
+ "defaultSubstate": "CANCELED",
312
+ "substates": [
313
+ {"name": "CANCELED", "displayText": "Canceled", "availableActions": []}
314
+ ],
315
+ },
316
+ ],
317
+ }
318
+
319
+ try:
320
+ # Check if file already exists
321
+ if os.path.exists(output):
322
+ if not questionary.confirm(
323
+ f"File {output} already exists. Overwrite?",
324
+ default=False,
325
+ ).ask():
326
+ click.echo("Workflow initialization cancelled.")
327
+ return
328
+
329
+ # Save the workflow file
330
+ with open(output, "w", encoding="utf-8") as f:
331
+ json.dump(workflow_data, f, indent=2, ensure_ascii=False)
332
+
333
+ click.echo(f"✓ Workflow initialized: {output}")
334
+ click.echo("Edit the file to customize your workflow:")
335
+ click.echo(" - name and description are recommended")
336
+ click.echo(" - Define states, substates, and actions as needed")
337
+ click.echo(f" - Workspace is set to: {workspace} (ID: {workspace_id})")
338
+ click.echo(" - Use 'slcli workflows import' to upload the workflow when ready")
339
+
340
+ except Exception as exc:
341
+ click.echo(f"✗ Error creating workflow file: {exc}", err=True)
342
+ sys.exit(ExitCodes.GENERAL_ERROR)
343
+
344
+ @workflow.command(name="list")
345
+ @click.option(
346
+ "--workspace",
347
+ "-w",
348
+ help="Filter by workspace name or ID",
349
+ )
350
+ @click.option(
351
+ "--take",
352
+ "-t",
353
+ type=int,
354
+ default=25,
355
+ show_default=True,
356
+ help="Maximum number of workflows to return",
357
+ )
358
+ @click.option(
359
+ "--format",
360
+ "-f",
361
+ type=click.Choice(["table", "json"]),
362
+ default="table",
363
+ show_default=True,
364
+ help="Output format",
365
+ )
366
+ @click.option(
367
+ "--status",
368
+ "-s",
369
+ help="Filter by workflow status",
370
+ )
371
+ def list_workflows(
372
+ format: str = "table",
373
+ workspace: Optional[str] = None,
374
+ take: int = 25,
375
+ status: Optional[str] = None,
376
+ ) -> None:
377
+ """List available workflows."""
378
+ format_output = validate_output_format(format)
379
+
380
+ try:
381
+ workspace_map = get_workspace_map()
382
+
383
+ # Resolve workspace filter to ID if specified
384
+ workspace_id = None
385
+ workspace = get_effective_workspace(workspace)
386
+ if workspace:
387
+ workspace_id = resolve_workspace_filter(workspace, workspace_map)
388
+
389
+ # Use continuation token pagination to get all workflows
390
+ all_workflows = _query_all_workflows(workspace_id, workspace_map)
391
+
392
+ # Create a mock response with all data
393
+ resp = FilteredResponse({"workflows": all_workflows})
394
+
395
+ # Use universal response handler with workflow formatter
396
+ def workflow_formatter(workflow: dict) -> list:
397
+ ws_guid = workflow.get("workspace", "")
398
+ ws_name = get_workspace_display_name(ws_guid, workspace_map)
399
+ return [
400
+ workflow.get("name", "Unknown"),
401
+ ws_name,
402
+ workflow.get("id", ""),
403
+ workflow.get("description", "N/A")[:30], # Truncate description
404
+ ]
405
+
406
+ UniversalResponseHandler.handle_list_response(
407
+ resp=resp,
408
+ data_key="workflows",
409
+ item_name="workflow",
410
+ format_output=format_output,
411
+ formatter_func=workflow_formatter,
412
+ headers=["Name", "Workspace", "ID", "Description"],
413
+ column_widths=[40, 30, 36, 32],
414
+ empty_message="No workflows found.",
415
+ enable_pagination=True,
416
+ page_size=take,
417
+ )
418
+
419
+ except Exception as exc:
420
+ handle_api_error(exc)
421
+
422
+ @workflow.command(name="get")
423
+ @click.option("--id", "-i", "workflow_id", help="Workflow ID")
424
+ @click.option("--name", "-n", "workflow_name", help="Workflow name")
425
+ @click.option(
426
+ "--format",
427
+ "-f",
428
+ type=click.Choice(["table", "json"]),
429
+ default="table",
430
+ show_default=True,
431
+ help="Output format",
432
+ )
433
+ def get_workflow(
434
+ workflow_id: Optional[str] = None,
435
+ workflow_name: Optional[str] = None,
436
+ format: str = "table",
437
+ ) -> None:
438
+ """Show workflow details by ID or name."""
439
+ if not workflow_id and not workflow_name:
440
+ click.echo("✗ Must provide either --id or --name.", err=True)
441
+ sys.exit(ExitCodes.INVALID_INPUT)
442
+ if workflow_id and workflow_name:
443
+ click.echo("✗ Cannot specify both --id and --name.", err=True)
444
+ sys.exit(ExitCodes.INVALID_INPUT)
445
+
446
+ format_output = validate_output_format(format)
447
+
448
+ try:
449
+ # If name is provided, find the workflow by name first
450
+ if workflow_name:
451
+ query_url = f"{get_base_url()}/niworkorder/v1/query-workflows?ff-userdefinedworkflowsfortestplaninstances=true"
452
+ query_payload = {
453
+ "take": 1000,
454
+ "filter": f'NAME == "{workflow_name}"',
455
+ }
456
+ query_resp = make_api_request("POST", query_url, query_payload)
457
+ query_data = query_resp.json()
458
+ workflows = query_data.get("workflows", [])
459
+
460
+ # Find exact match
461
+ matching = [w for w in workflows if w.get("name") == workflow_name]
462
+ if not matching:
463
+ click.echo(f"✗ Workflow '{workflow_name}' not found.", err=True)
464
+ sys.exit(ExitCodes.NOT_FOUND)
465
+ workflow_id = matching[0].get("id", "")
466
+
467
+ # Fetch the workflow by ID
468
+ url = f"{get_base_url()}/niworkorder/v1/workflows/{workflow_id}?ff-userdefinedworkflowsfortestplaninstances=true"
469
+ resp = make_api_request("GET", url)
470
+ workflow = resp.json()
471
+
472
+ if not workflow:
473
+ identifier = workflow_id if not workflow_name else workflow_name
474
+ click.echo(f"✗ Workflow '{identifier}' not found.", err=True)
475
+ sys.exit(ExitCodes.NOT_FOUND)
476
+
477
+ if format_output == "json":
478
+ click.echo(json.dumps(workflow, indent=2))
479
+ return
480
+
481
+ # Table format
482
+ workspace_map = get_workspace_map()
483
+ ws_name = get_workspace_display_name(
484
+ workflow.get("workspace", ""),
485
+ workspace_map,
486
+ )
487
+
488
+ click.echo("Workflow Details:")
489
+ click.echo("=" * 50)
490
+ click.echo(f"Name: {workflow.get('name', 'N/A')}")
491
+ click.echo(f"ID: {workflow.get('id', 'N/A')}")
492
+ click.echo(f"Workspace: {ws_name}")
493
+ click.echo(f"Description: {workflow.get('description', 'N/A')}")
494
+ click.echo(f"State: {workflow.get('state', 'N/A')}")
495
+
496
+ except Exception as exc:
497
+ handle_api_error(exc)
498
+
499
+ @workflow.command(name="export")
500
+ @click.option("--id", "-i", "workflow_id", help="Workflow ID to export")
501
+ @click.option("--name", "-n", "workflow_name", help="Workflow name to export")
502
+ @click.option("--output", "-o", help="Output JSON file (default: <workflow-name>.json)")
503
+ def export_workflow(
504
+ workflow_id: Optional[str] = None,
505
+ workflow_name: Optional[str] = None,
506
+ output: Optional[str] = None,
507
+ ) -> None:
508
+ """Export a workflow to JSON by ID or name."""
509
+ if not workflow_id and not workflow_name:
510
+ click.echo("✗ Must provide either --id or --name.", err=True)
511
+ sys.exit(ExitCodes.INVALID_INPUT)
512
+ if workflow_id and workflow_name:
513
+ click.echo("✗ Cannot specify both --id and --name.", err=True)
514
+ sys.exit(ExitCodes.INVALID_INPUT)
515
+
516
+ # If name is provided, find the workflow by name first
517
+ if workflow_name:
518
+ query_url = f"{get_base_url()}/niworkorder/v1/query-workflows?ff-userdefinedworkflowsfortestplaninstances=true"
519
+ query_payload = {
520
+ "take": 1000,
521
+ "filter": f'NAME == "{workflow_name}"',
522
+ }
523
+ query_resp = make_api_request("POST", query_url, query_payload)
524
+ query_data = query_resp.json()
525
+ workflows = query_data.get("workflows", [])
526
+
527
+ # Find exact match (filter is case-insensitive substring)
528
+ matching = [w for w in workflows if w.get("name") == workflow_name]
529
+ if not matching:
530
+ click.echo(f"✗ Workflow '{workflow_name}' not found.", err=True)
531
+ sys.exit(ExitCodes.NOT_FOUND)
532
+ workflow_id = matching[0].get("id", "")
533
+
534
+ # Fetch the workflow by ID
535
+ url = f"{get_base_url()}/niworkorder/v1/workflows/{workflow_id}?ff-userdefinedworkflowsfortestplaninstances=true"
536
+ try:
537
+ resp = make_api_request("GET", url)
538
+ data = resp.json()
539
+
540
+ if not data:
541
+ identifier = workflow_id if not workflow_name else workflow_name
542
+ click.echo(f"✗ Workflow '{identifier}' not found.", err=True)
543
+ sys.exit(ExitCodes.NOT_FOUND)
544
+
545
+ # Generate output filename if not provided
546
+ if not output:
547
+ workflow_name = data.get("name", f"workflow-{workflow_id}")
548
+ safe_name = sanitize_filename(workflow_name, f"workflow-{workflow_id}")
549
+ output = f"{safe_name}.json"
550
+
551
+ save_json_file(data, output)
552
+ click.echo(f"✓ Workflow exported to {output}")
553
+ except Exception as exc:
554
+ if "not found" not in str(exc).lower():
555
+ handle_api_error(exc)
556
+ else:
557
+ click.echo(f"✗ Error: {exc}", err=True)
558
+ sys.exit(ExitCodes.NOT_FOUND)
559
+
560
+ @workflow.command(name="import")
561
+ @click.option(
562
+ "--file",
563
+ "input_file",
564
+ required=True,
565
+ help="Input JSON file",
566
+ )
567
+ @click.option(
568
+ "--workspace",
569
+ "-w",
570
+ help="Override workspace name or ID (uses value from file if not specified)",
571
+ )
572
+ def import_workflow(input_file: str, workspace: Optional[str]) -> None:
573
+ """Import a workflow from JSON.
574
+
575
+ Workspace can be specified via --workspace flag or included in the JSON file.
576
+ Command line workspace takes precedence over file contents.
577
+ """
578
+ from .utils import check_readonly_mode
579
+
580
+ check_readonly_mode("import a workflow")
581
+
582
+ url = f"{get_base_url()}/niworkorder/v1/workflows?ff-userdefinedworkflowsfortestplaninstances=true"
583
+ allowed_fields = {
584
+ "name",
585
+ "description",
586
+ "actions",
587
+ "states",
588
+ "workspace",
589
+ }
590
+ try:
591
+ data = load_json_file(input_file)
592
+
593
+ # Filter allowed fields
594
+ filtered_data = {k: v for k, v in data.items() if k in allowed_fields}
595
+
596
+ # Handle workspace resolution
597
+ if workspace:
598
+ # Override workspace from command line
599
+ try:
600
+ workspace_id = get_workspace_id_with_fallback(workspace)
601
+ filtered_data["workspace"] = workspace_id
602
+ except Exception as exc:
603
+ click.echo(f"✗ Error resolving workspace '{workspace}': {exc}", err=True)
604
+ sys.exit(ExitCodes.NOT_FOUND)
605
+ elif "workspace" not in filtered_data or not filtered_data["workspace"]:
606
+ # No workspace specified and none in file - try profile default
607
+ workspace = get_effective_workspace(None)
608
+ if workspace:
609
+ try:
610
+ workspace_id = get_workspace_id_with_fallback(workspace)
611
+ filtered_data["workspace"] = workspace_id
612
+ except Exception as exc:
613
+ click.echo(f"✗ Error resolving workspace '{workspace}': {exc}", err=True)
614
+ sys.exit(ExitCodes.NOT_FOUND)
615
+ else:
616
+ click.echo(
617
+ "✗ Workspace is required. Specify --workspace or include 'workspace' in the JSON file.",
618
+ err=True,
619
+ )
620
+ sys.exit(ExitCodes.INVALID_INPUT)
621
+ elif filtered_data["workspace"] and not filtered_data["workspace"].startswith("//"):
622
+ # Workspace in file - validate/resolve it if it looks like a name
623
+ try:
624
+ workspace_id = get_workspace_id_with_fallback(filtered_data["workspace"])
625
+ filtered_data["workspace"] = workspace_id
626
+ except Exception as exc:
627
+ click.echo(
628
+ f"✗ Error resolving workspace from file '{filtered_data['workspace']}': {exc}",
629
+ err=True,
630
+ )
631
+ sys.exit(ExitCodes.NOT_FOUND)
632
+
633
+ try:
634
+ resp = make_api_request("POST", url, filtered_data, handle_errors=False)
635
+ # Check for successful creation
636
+ if resp.status_code == 201:
637
+ response_data = resp.json() if resp.text.strip() else {}
638
+ workflow_id = response_data.get("id", "")
639
+ if workflow_id:
640
+ click.echo(f"✓ Workflow imported successfully with ID: {workflow_id}")
641
+ else:
642
+ click.echo("✓ Workflow imported successfully.")
643
+ else:
644
+ # Handle error responses - parse detailed error structure
645
+ response_data = resp.json() if resp.text.strip() else {}
646
+ _handle_workflow_error_response(response_data, "Workflow import failed")
647
+ except requests.exceptions.HTTPError as http_exc:
648
+ # Extract response data from the HTTP error for detailed parsing
649
+ if hasattr(http_exc, "response") and http_exc.response is not None:
650
+ try:
651
+ response_data = (
652
+ http_exc.response.json() if http_exc.response.text.strip() else {}
653
+ )
654
+ _handle_workflow_error_response(response_data, "Workflow import failed")
655
+ except Exception:
656
+ # Fallback to generic error handling if JSON parsing fails
657
+ handle_api_error(http_exc)
658
+ else:
659
+ handle_api_error(http_exc)
660
+
661
+ except Exception as exc:
662
+ handle_api_error(exc)
663
+
664
+ @workflow.command(name="delete")
665
+ @click.option(
666
+ "--id",
667
+ "-i",
668
+ "workflow_id",
669
+ required=True,
670
+ help="Workflow ID to delete",
671
+ )
672
+ @click.confirmation_option(prompt="Are you sure you want to delete this workflow?")
673
+ def delete_workflow(workflow_id: str) -> None:
674
+ """Delete a workflow."""
675
+ from .utils import check_readonly_mode
676
+
677
+ check_readonly_mode("delete a workflow")
678
+
679
+ url = f"{get_base_url()}/niworkorder/v1/delete-workflows?ff-userdefinedworkflowsfortestplaninstances=true"
680
+ payload = {"ids": [workflow_id]}
681
+ try:
682
+ try:
683
+ resp = make_api_request("POST", url, payload, handle_errors=False)
684
+ if resp.status_code in (200, 204):
685
+ # Parse the response to check for partial failures
686
+ response_data = resp.json() if resp.text.strip() else {}
687
+ _handle_workflow_delete_response(response_data, workflow_id)
688
+ else:
689
+ response_data = resp.json() if resp.text.strip() else {}
690
+ _handle_workflow_delete_response(response_data, workflow_id)
691
+ except requests.exceptions.HTTPError as http_exc:
692
+ # Extract response data from the HTTP error for detailed parsing
693
+ if hasattr(http_exc, "response") and http_exc.response is not None:
694
+ try:
695
+ response_data = (
696
+ http_exc.response.json() if http_exc.response.text.strip() else {}
697
+ )
698
+ _handle_workflow_delete_response(response_data, workflow_id)
699
+ except Exception:
700
+ # Fallback to generic error handling if JSON parsing fails
701
+ handle_api_error(http_exc)
702
+ else:
703
+ handle_api_error(http_exc)
704
+ except Exception as exc:
705
+ handle_api_error(exc)
706
+
707
+ @workflow.command(name="update")
708
+ @click.option(
709
+ "--id",
710
+ "-i",
711
+ "workflow_id",
712
+ required=True,
713
+ help="Workflow ID to update",
714
+ )
715
+ @click.option(
716
+ "--file",
717
+ "-f",
718
+ "input_file",
719
+ required=True,
720
+ help="Input JSON file with updated workflow data",
721
+ )
722
+ @click.option(
723
+ "--workspace",
724
+ "-w",
725
+ help="Override workspace name or ID (uses value from file if not specified)",
726
+ )
727
+ def update_workflow(workflow_id: str, input_file: str, workspace: Optional[str]) -> None:
728
+ """Update a workflow from JSON.
729
+
730
+ Workspace can be specified via --workspace flag or included in the JSON file.
731
+ Command line workspace takes precedence over file contents.
732
+ """
733
+ from .utils import check_readonly_mode
734
+
735
+ check_readonly_mode("update a workflow")
736
+
737
+ url = f"{get_base_url()}/niworkorder/v1/workflows/{workflow_id}?ff-userdefinedworkflowsfortestplaninstances=true"
738
+ allowed_fields = {
739
+ "name",
740
+ "description",
741
+ "actions",
742
+ "states",
743
+ "workspace",
744
+ }
745
+ try:
746
+ data = load_json_file(input_file)
747
+
748
+ # Filter allowed fields
749
+ filtered_data = {k: v for k, v in data.items() if k in allowed_fields}
750
+
751
+ # Handle workspace resolution
752
+ if workspace:
753
+ # Override workspace from command line
754
+ try:
755
+ workspace_id = get_workspace_id_with_fallback(workspace)
756
+ filtered_data["workspace"] = workspace_id
757
+ except Exception as exc:
758
+ click.echo(f"✗ Error resolving workspace '{workspace}': {exc}", err=True)
759
+ sys.exit(ExitCodes.NOT_FOUND)
760
+ elif "workspace" not in filtered_data or not filtered_data.get("workspace"):
761
+ # No workspace specified and none in file - try profile default
762
+ workspace = get_effective_workspace(None)
763
+ if workspace:
764
+ try:
765
+ workspace_id = get_workspace_id_with_fallback(workspace)
766
+ filtered_data["workspace"] = workspace_id
767
+ except Exception as exc:
768
+ click.echo(f"✗ Error resolving workspace '{workspace}': {exc}", err=True)
769
+ sys.exit(ExitCodes.NOT_FOUND)
770
+ elif (
771
+ "workspace" in filtered_data
772
+ and filtered_data["workspace"]
773
+ and not filtered_data["workspace"].startswith("//")
774
+ ):
775
+ # Workspace in file - validate/resolve it if it looks like a name
776
+ try:
777
+ workspace_id = get_workspace_id_with_fallback(filtered_data["workspace"])
778
+ filtered_data["workspace"] = workspace_id
779
+ except Exception as exc:
780
+ click.echo(
781
+ f"✗ Error resolving workspace from file '{filtered_data['workspace']}': {exc}",
782
+ err=True,
783
+ )
784
+ sys.exit(ExitCodes.NOT_FOUND)
785
+
786
+ try:
787
+ resp = make_api_request("PUT", url, filtered_data, handle_errors=False)
788
+ # Check for successful update
789
+ if resp.status_code == 200:
790
+ click.echo(f"✓ Workflow {workflow_id} updated successfully.")
791
+ else:
792
+ # Handle error responses
793
+ response_data = resp.json() if resp.text.strip() else {}
794
+ _handle_workflow_error_response(response_data, "Workflow update failed")
795
+ except requests.exceptions.HTTPError as http_exc:
796
+ # Extract response data from the HTTP error for detailed parsing
797
+ if hasattr(http_exc, "response") and http_exc.response is not None:
798
+ try:
799
+ response_data = (
800
+ http_exc.response.json() if http_exc.response.text.strip() else {}
801
+ )
802
+ _handle_workflow_error_response(response_data, "Workflow update failed")
803
+ except Exception:
804
+ # Fallback to generic error handling if JSON parsing fails
805
+ handle_api_error(http_exc)
806
+ else:
807
+ handle_api_error(http_exc)
808
+
809
+ except Exception as exc:
810
+ handle_api_error(exc)
811
+
812
+ @workflow.command(name="preview")
813
+ @click.option("--id", "-i", "workflow_id", help="Workflow ID to preview")
814
+ @click.option(
815
+ "--file",
816
+ "-f",
817
+ "input_file",
818
+ help="Local JSON file to preview (use '-' for stdin)",
819
+ )
820
+ @click.option("--output", "-o", help="Output file path (default: open in browser)")
821
+ @click.option(
822
+ "--format",
823
+ type=click.Choice(["html", "mmd"]),
824
+ default="html",
825
+ show_default=True,
826
+ help="Output format",
827
+ )
828
+ @click.option("--no-emoji", is_flag=True, default=False, help="Disable emoji in action labels")
829
+ @click.option(
830
+ "--no-legend",
831
+ is_flag=True,
832
+ default=False,
833
+ help="Disable legend block in HTML output",
834
+ )
835
+ @click.option(
836
+ "--no-open",
837
+ is_flag=True,
838
+ default=False,
839
+ help="Do not auto-open browser for HTML when no --output is provided",
840
+ )
841
+ def preview_workflow(
842
+ workflow_id: Optional[str],
843
+ input_file: Optional[str],
844
+ output: Optional[str],
845
+ format: str,
846
+ no_emoji: bool,
847
+ no_legend: bool,
848
+ no_open: bool,
849
+ ) -> None:
850
+ """Preview a workflow file without applying changes."""
851
+ from .utils import ExitCodes
852
+
853
+ if bool(workflow_id) == bool(input_file):
854
+ click.echo(
855
+ "✗ Must specify exactly one of --id or --file (use --file - for stdin)",
856
+ err=True,
857
+ )
858
+ sys.exit(ExitCodes.INVALID_INPUT)
859
+ try:
860
+ if workflow_id:
861
+ url = f"{get_base_url()}/niworkorder/v1/workflows/{workflow_id}?ff-userdefinedworkflowsfortestplaninstances=true"
862
+ resp = make_api_request("GET", url)
863
+ workflow_data: Dict[str, Any] = resp.json()
864
+ if not workflow_data:
865
+ click.echo(f"✗ Workflow with ID {workflow_id} not found.", err=True)
866
+ sys.exit(ExitCodes.NOT_FOUND)
867
+ else:
868
+ if input_file == "-":
869
+ try:
870
+ raw = sys.stdin.read()
871
+ workflow_data = json.loads(raw)
872
+ except json.JSONDecodeError as exc:
873
+ click.echo(f"✗ Invalid JSON from stdin: {exc}", err=True)
874
+ sys.exit(ExitCodes.INVALID_INPUT)
875
+ else:
876
+ assert input_file is not None
877
+ workflow_data = load_json_file(input_file)
878
+ mermaid_code = workflow_preview.generate_mermaid_diagram(
879
+ workflow_data, enable_emoji=not no_emoji
880
+ )
881
+ if format == "mmd":
882
+ if not output:
883
+ output = f"workflow-{workflow_data.get('name', 'preview')}.mmd"
884
+ with open(output, "w", encoding="utf-8") as f:
885
+ f.write(mermaid_code)
886
+ click.echo(f"✓ Mermaid diagram saved to {output}")
887
+ else:
888
+ html_content = workflow_preview.generate_html_with_mermaid(
889
+ workflow_data, mermaid_code, include_legend=not no_legend
890
+ )
891
+ if output:
892
+ with open(output, "w", encoding="utf-8") as f:
893
+ f.write(html_content)
894
+ click.echo(f"✓ HTML preview saved to {output}")
895
+ elif not no_open:
896
+ with tempfile.NamedTemporaryFile(
897
+ mode="w", suffix=".html", delete=False, encoding="utf-8"
898
+ ) as f:
899
+ f.write(html_content)
900
+ temp_file = f.name
901
+ webbrowser.open(f"file://{Path(temp_file).absolute()}")
902
+ click.echo("✓ Opening workflow preview in browser...")
903
+ else:
904
+ click.echo(html_content)
905
+ except Exception as exc: # noqa: BLE001
906
+ handle_api_error(exc)
907
+
908
+
909
+ def _handle_workflow_error_response(response_data: Dict[str, Any], operation_name: str) -> None:
910
+ """Parse and display detailed workflow error responses.
911
+
912
+ Args:
913
+ response_data: The JSON response data containing error information
914
+ operation_name: The name of the operation that failed (e.g., "Workflow import failed")
915
+ """
916
+ display_api_errors(operation_name, response_data, detailed=True)
917
+
918
+
919
+ def _handle_workflow_delete_response(response_data: Dict[str, Any], workflow_id: str) -> None:
920
+ """Parse and display workflow delete response, handling both success and failures.
921
+
922
+ Args:
923
+ response_data: The JSON response data from delete operation
924
+ workflow_id: The ID of the workflow that was requested to be deleted
925
+ """
926
+ # Handle successful deletion response: empty JSON {} with 204 status
927
+ if not response_data or response_data == {}:
928
+ click.echo(f"✓ Workflow {workflow_id} deleted successfully.")
929
+ return
930
+
931
+ # Handle successful deletion response format: {"ids": ["1023"]}
932
+ if "ids" in response_data:
933
+ deleted_ids = response_data.get("ids", [])
934
+ if workflow_id in deleted_ids:
935
+ click.echo(f"✓ Workflow {workflow_id} deleted successfully.")
936
+ return
937
+ else:
938
+ # Workflow ID not in the successful deletion list - unexpected
939
+ click.echo(f"✗ Unexpected response for workflow {workflow_id}:", err=True)
940
+ click.echo(f" Successfully deleted: {', '.join(deleted_ids)}", err=True)
941
+ sys.exit(1)
942
+
943
+ # Handle error response format with deletedWorkflowIds and failedWorkflowIds
944
+ deleted_ids = response_data.get("deletedWorkflowIds", [])
945
+ failed_ids = response_data.get("failedWorkflowIds", [])
946
+
947
+ # Check if our specific workflow was deleted successfully
948
+ if workflow_id in deleted_ids:
949
+ click.echo(f"✓ Workflow {workflow_id} deleted successfully.")
950
+ return
951
+
952
+ # Check if our specific workflow failed to delete
953
+ if workflow_id in failed_ids:
954
+ click.echo(f"✗ Failed to delete workflow {workflow_id}:", err=True)
955
+
956
+ # Parse error details for failed workflows
957
+ error = response_data.get("error", {})
958
+ if error:
959
+ main_message = error.get("message", "Unknown error")
960
+ click.echo(f" {main_message}", err=True)
961
+
962
+ # Parse inner errors for detailed failure reasons
963
+ inner_errors = error.get("innerErrors", [])
964
+ for inner_error in inner_errors:
965
+ resource_id = inner_error.get("resourceId", "")
966
+ if resource_id == workflow_id:
967
+ error_message = inner_error.get("message", "Unknown error")
968
+ error_name = inner_error.get("name", "")
969
+
970
+ # Extract more readable error type
971
+ if error_name:
972
+ error_type = extract_error_type(error_name)
973
+ click.echo(f" - {error_type}: {error_message}", err=True)
974
+ else:
975
+ click.echo(f" - {error_message}", err=True)
976
+
977
+ sys.exit(1)
978
+
979
+ # If workflow ID is not in either list, something unexpected happened
980
+ click.echo(f"✗ Unexpected response for workflow {workflow_id}:", err=True)
981
+ if deleted_ids:
982
+ click.echo(f" Successfully deleted: {', '.join(deleted_ids)}", err=True)
983
+ if failed_ids:
984
+ click.echo(f" Failed to delete: {', '.join(failed_ids)}", err=True)
985
+
986
+ # If there were any failures or unexpected responses, exit with error code
987
+ if failed_ids or not response_data:
988
+ sys.exit(1)