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,2258 @@
1
+ """CLI commands for managing SystemLink work items, templates, and 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, Tuple, Union
10
+
11
+ import click
12
+ import requests
13
+
14
+ from . import workflow_preview
15
+ from .cli_utils import validate_output_format
16
+ from .platform import require_feature
17
+ from .universal_handlers import FilteredResponse, UniversalResponseHandler
18
+ from .utils import (
19
+ ExitCodes,
20
+ display_api_errors,
21
+ extract_error_type,
22
+ format_success,
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
+
39
+ # ---------------------------------------------------------------------------
40
+ # URL helpers
41
+ # ---------------------------------------------------------------------------
42
+
43
+
44
+ def _wi_url(path: str) -> str:
45
+ """Return a fully-qualified niworkitem v1 URL."""
46
+ return f"{get_base_url()}/niworkitem/v1{path}"
47
+
48
+
49
+ def _wf_url(path: str) -> str:
50
+ """Return a fully-qualified niworkorder v1 URL with feature flag."""
51
+ return f"{get_base_url()}/niworkorder/v1{path}?ff-userdefinedworkflowsfortestplaninstances=true"
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Pagination helpers
56
+ # ---------------------------------------------------------------------------
57
+
58
+
59
+ def _query_all_workitems(
60
+ filter_expr: Optional[str] = None,
61
+ substitutions: Optional[List[Any]] = None,
62
+ workspace_filter: Optional[str] = None,
63
+ max_items: Optional[int] = None,
64
+ ) -> List[Dict[str, Any]]:
65
+ """Fetch work items via continuation-token pagination.
66
+
67
+ Args:
68
+ filter_expr: Optional LINQ filter expression.
69
+ substitutions: Optional substitution list for the filter expression.
70
+ workspace_filter: Optional workspace ID to restrict results.
71
+ max_items: Maximum number of items to return. ``None`` means fetch
72
+ all. Used to guard against buggy continuation tokens that are
73
+ returned even when the requested take has been satisfied.
74
+
75
+ Returns:
76
+ List of up to *max_items* matching work items.
77
+ """
78
+ url = _wi_url("/query-workitems")
79
+ all_items: List[Dict[str, Any]] = []
80
+ continuation_token: Optional[str] = None
81
+
82
+ while True:
83
+ payload: Dict[str, Any] = {"take": 100}
84
+ combined_filter_parts: List[str] = []
85
+ combined_subs: List[Any] = []
86
+
87
+ if filter_expr:
88
+ combined_filter_parts.append(f"({filter_expr})")
89
+ combined_subs.extend(substitutions or [])
90
+
91
+ if workspace_filter:
92
+ idx = len(combined_subs)
93
+ combined_filter_parts.append(f"workspace == @{idx}")
94
+ combined_subs.append(workspace_filter)
95
+
96
+ if combined_filter_parts:
97
+ payload["filter"] = " && ".join(combined_filter_parts)
98
+ if combined_subs:
99
+ payload["substitutions"] = combined_subs
100
+
101
+ if continuation_token:
102
+ payload["continuationToken"] = continuation_token
103
+
104
+ resp = make_api_request("POST", url, payload)
105
+ data = resp.json()
106
+
107
+ page = data.get("workItems", [])
108
+ if not page:
109
+ # Empty page — no more real results regardless of token
110
+ break
111
+ all_items.extend(page)
112
+
113
+ # Honour the caller's requested limit; guards against a server-side
114
+ # bug where a continuation token is returned even after all matching
115
+ # items have been delivered.
116
+ if max_items is not None and len(all_items) >= max_items:
117
+ break
118
+
119
+ continuation_token = data.get("continuationToken")
120
+ if not continuation_token:
121
+ break
122
+
123
+ if max_items is not None:
124
+ return all_items[:max_items]
125
+ return all_items
126
+
127
+
128
+ def _query_all_templates(
129
+ filter_expr: Optional[str] = None,
130
+ substitutions: Optional[List[Any]] = None,
131
+ workspace_filter: Optional[str] = None,
132
+ max_items: Optional[int] = None,
133
+ ) -> List[Dict[str, Any]]:
134
+ """Fetch work item templates via continuation-token pagination.
135
+
136
+ Args:
137
+ filter_expr: Optional LINQ filter expression.
138
+ substitutions: Optional substitution list for the filter expression.
139
+ workspace_filter: Optional workspace ID to restrict results.
140
+ max_items: Maximum number of items to return. ``None`` means fetch
141
+ all. Used to guard against buggy continuation tokens that are
142
+ returned even when the requested take has been satisfied.
143
+
144
+ Returns:
145
+ List of up to *max_items* matching templates.
146
+ """
147
+ url = _wi_url("/query-workitem-templates")
148
+ all_items: List[Dict[str, Any]] = []
149
+ continuation_token: Optional[str] = None
150
+
151
+ while True:
152
+ payload: Dict[str, Any] = {"take": 100}
153
+ combined_filter_parts: List[str] = []
154
+ combined_subs: List[Any] = []
155
+
156
+ if filter_expr:
157
+ combined_filter_parts.append(f"({filter_expr})")
158
+ combined_subs.extend(substitutions or [])
159
+
160
+ if workspace_filter:
161
+ idx = len(combined_subs)
162
+ combined_filter_parts.append(f"workspace == @{idx}")
163
+ combined_subs.append(workspace_filter)
164
+
165
+ if combined_filter_parts:
166
+ payload["filter"] = " && ".join(combined_filter_parts)
167
+ if combined_subs:
168
+ payload["substitutions"] = combined_subs
169
+
170
+ if continuation_token:
171
+ payload["continuationToken"] = continuation_token
172
+
173
+ resp = make_api_request("POST", url, payload)
174
+ data = resp.json()
175
+
176
+ page = data.get("workItemTemplates", [])
177
+ if not page:
178
+ break
179
+ all_items.extend(page)
180
+
181
+ if max_items is not None and len(all_items) >= max_items:
182
+ break
183
+
184
+ continuation_token = data.get("continuationToken")
185
+ if not continuation_token:
186
+ break
187
+
188
+ if max_items is not None:
189
+ return all_items[:max_items]
190
+ return all_items
191
+
192
+
193
+ def _query_all_workflows(
194
+ workspace_filter: Optional[str] = None,
195
+ max_items: Optional[int] = None,
196
+ ) -> List[Dict[str, Any]]:
197
+ """Fetch workflows via continuation-token pagination (niworkorder API).
198
+
199
+ Args:
200
+ workspace_filter: Optional workspace ID to filter by.
201
+ max_items: Maximum number of workflows to return. ``None`` means
202
+ fetch all. Used to guard against buggy continuation tokens that
203
+ are returned even when the requested take has been satisfied.
204
+
205
+ Returns:
206
+ List of up to *max_items* workflows.
207
+ """
208
+ url = _wf_url("/query-workflows")
209
+ all_workflows: List[Dict[str, Any]] = []
210
+ continuation_token: Optional[str] = None
211
+
212
+ while True:
213
+ payload: Dict[str, Union[int, str]] = {"take": 100}
214
+ if workspace_filter:
215
+ payload["filter"] = f'WORKSPACE == "{workspace_filter}"'
216
+ if continuation_token:
217
+ payload["continuationToken"] = continuation_token
218
+
219
+ resp = make_api_request("POST", url, payload)
220
+ data = resp.json()
221
+
222
+ page = data.get("workflows", [])
223
+ if not page:
224
+ break
225
+ all_workflows.extend(page)
226
+
227
+ if max_items is not None and len(all_workflows) >= max_items:
228
+ break
229
+
230
+ continuation_token = data.get("continuationToken")
231
+ if not continuation_token:
232
+ break
233
+
234
+ if max_items is not None:
235
+ return all_workflows[:max_items]
236
+ return all_workflows
237
+
238
+
239
+ # ---------------------------------------------------------------------------
240
+ # Error-handling helpers
241
+ # ---------------------------------------------------------------------------
242
+
243
+
244
+ def _fetch_workitems_page(
245
+ filter_expr: Optional[str],
246
+ substitutions: Optional[List[Any]],
247
+ workspace_filter: Optional[str],
248
+ take: int,
249
+ continuation_token: Optional[str],
250
+ ) -> Tuple[List[Dict[str, Any]], Optional[str]]:
251
+ """Fetch a single page of work items from the server.
252
+
253
+ Guards against a known service bug where a continuation token is returned
254
+ even after all matching items have been delivered: if the page is smaller
255
+ than *take* the returned token (if any) is discarded.
256
+
257
+ Returns:
258
+ Tuple of (items, next_continuation_token).
259
+ """
260
+ url = _wi_url("/query-workitems")
261
+ payload: Dict[str, Any] = {"take": take}
262
+ combined_filter_parts: List[str] = []
263
+ combined_subs: List[Any] = []
264
+
265
+ if filter_expr:
266
+ combined_filter_parts.append(f"({filter_expr})")
267
+ combined_subs.extend(substitutions or [])
268
+
269
+ if workspace_filter:
270
+ idx = len(combined_subs)
271
+ combined_filter_parts.append(f"workspace == @{idx}")
272
+ combined_subs.append(workspace_filter)
273
+
274
+ if combined_filter_parts:
275
+ payload["filter"] = " && ".join(combined_filter_parts)
276
+ if combined_subs:
277
+ payload["substitutions"] = combined_subs
278
+
279
+ if continuation_token:
280
+ payload["continuationToken"] = continuation_token
281
+
282
+ resp = make_api_request("POST", url, payload)
283
+ data = resp.json()
284
+ items = data.get("workItems", [])
285
+
286
+ # Only trust the continuation token when a full page was returned.
287
+ # Fewer items than requested means the server has nothing left, even if
288
+ # it (incorrectly) still emits a token.
289
+ next_token: Optional[str] = data.get("continuationToken") if len(items) >= take else None
290
+ return items, next_token
291
+
292
+
293
+ def _fetch_templates_page(
294
+ filter_expr: Optional[str],
295
+ substitutions: Optional[List[Any]],
296
+ workspace_filter: Optional[str],
297
+ take: int,
298
+ continuation_token: Optional[str],
299
+ ) -> Tuple[List[Dict[str, Any]], Optional[str]]:
300
+ """Fetch a single page of work item templates from the server.
301
+
302
+ See :func:`_fetch_workitems_page` for the stale-token guard rationale.
303
+
304
+ Returns:
305
+ Tuple of (items, next_continuation_token).
306
+ """
307
+ url = _wi_url("/query-workitem-templates")
308
+ payload: Dict[str, Any] = {"take": take}
309
+ combined_filter_parts: List[str] = []
310
+ combined_subs: List[Any] = []
311
+
312
+ if filter_expr:
313
+ combined_filter_parts.append(f"({filter_expr})")
314
+ combined_subs.extend(substitutions or [])
315
+
316
+ if workspace_filter:
317
+ idx = len(combined_subs)
318
+ combined_filter_parts.append(f"workspace == @{idx}")
319
+ combined_subs.append(workspace_filter)
320
+
321
+ if combined_filter_parts:
322
+ payload["filter"] = " && ".join(combined_filter_parts)
323
+ if combined_subs:
324
+ payload["substitutions"] = combined_subs
325
+
326
+ if continuation_token:
327
+ payload["continuationToken"] = continuation_token
328
+
329
+ resp = make_api_request("POST", url, payload)
330
+ data = resp.json()
331
+ items = data.get("workItemTemplates", [])
332
+
333
+ next_token: Optional[str] = data.get("continuationToken") if len(items) >= take else None
334
+ return items, next_token
335
+
336
+
337
+ def _fetch_workflows_page(
338
+ workspace_filter: Optional[str],
339
+ take: int,
340
+ continuation_token: Optional[str],
341
+ ) -> Tuple[List[Dict[str, Any]], Optional[str]]:
342
+ """Fetch a single page of workflows from the server (niworkorder API).
343
+
344
+ See :func:`_fetch_workitems_page` for the stale-token guard rationale.
345
+
346
+ Returns:
347
+ Tuple of (items, next_continuation_token).
348
+ """
349
+ url = _wf_url("/query-workflows")
350
+ payload: Dict[str, Union[int, str]] = {"take": take}
351
+ if workspace_filter:
352
+ payload["filter"] = f'WORKSPACE == "{workspace_filter}"'
353
+ if continuation_token:
354
+ payload["continuationToken"] = continuation_token
355
+
356
+ resp = make_api_request("POST", url, payload)
357
+ data = resp.json()
358
+ items = data.get("workflows", [])
359
+
360
+ next_token: Optional[str] = data.get("continuationToken") if len(items) >= take else None
361
+ return items, next_token
362
+
363
+
364
+ def _handle_workflow_error_response(response_data: Dict[str, Any], operation_name: str) -> None:
365
+ """Display detailed workflow error responses.
366
+
367
+ Args:
368
+ response_data: JSON response data containing error information.
369
+ operation_name: Description of the failed operation.
370
+ """
371
+ display_api_errors(operation_name, response_data, detailed=True)
372
+
373
+
374
+ def _handle_workflow_delete_response(response_data: Dict[str, Any], workflow_id: str) -> None:
375
+ """Handle workflow delete response, supporting partial success.
376
+
377
+ Args:
378
+ response_data: JSON response data from the delete operation.
379
+ workflow_id: ID of the workflow that was requested to be deleted.
380
+ """
381
+ if not response_data or response_data == {}:
382
+ click.echo(f"✓ Workflow {workflow_id} deleted successfully.")
383
+ return
384
+
385
+ if "ids" in response_data:
386
+ deleted_ids = response_data.get("ids", [])
387
+ if workflow_id in deleted_ids:
388
+ click.echo(f"✓ Workflow {workflow_id} deleted successfully.")
389
+ return
390
+ click.echo(f"✗ Unexpected response for workflow {workflow_id}:", err=True)
391
+ click.echo(f" Successfully deleted: {', '.join(deleted_ids)}", err=True)
392
+ sys.exit(1)
393
+
394
+ deleted_ids = response_data.get("deletedWorkflowIds", [])
395
+ failed_ids = response_data.get("failedWorkflowIds", [])
396
+
397
+ if workflow_id in deleted_ids:
398
+ click.echo(f"✓ Workflow {workflow_id} deleted successfully.")
399
+ return
400
+
401
+ if workflow_id in failed_ids:
402
+ click.echo(f"✗ Failed to delete workflow {workflow_id}:", err=True)
403
+ error = response_data.get("error", {})
404
+ if error:
405
+ click.echo(f" {error.get('message', 'Unknown error')}", err=True)
406
+ for inner_error in error.get("innerErrors", []):
407
+ if inner_error.get("resourceId", "") == workflow_id:
408
+ msg = inner_error.get("message", "Unknown error")
409
+ name = inner_error.get("name", "")
410
+ if name:
411
+ click.echo(f" - {extract_error_type(name)}: {msg}", err=True)
412
+ else:
413
+ click.echo(f" - {msg}", err=True)
414
+ sys.exit(1)
415
+
416
+ click.echo(f"✗ Unexpected response for workflow {workflow_id}:", err=True)
417
+ if deleted_ids:
418
+ click.echo(f" Successfully deleted: {', '.join(deleted_ids)}", err=True)
419
+ if failed_ids:
420
+ click.echo(f" Failed to delete: {', '.join(failed_ids)}", err=True)
421
+ if failed_ids or not response_data:
422
+ sys.exit(1)
423
+
424
+
425
+ # ---------------------------------------------------------------------------
426
+ # Main registration function
427
+ # ---------------------------------------------------------------------------
428
+
429
+
430
+ def register_workitem_commands(cli: Any) -> None:
431
+ """Register the 'workitem' command group and all subcommands."""
432
+
433
+ @cli.group()
434
+ def workitem() -> None:
435
+ """Manage work items, templates, and workflows."""
436
+
437
+ # -----------------------------------------------------------------------
438
+ # workitem list
439
+ # -----------------------------------------------------------------------
440
+ @workitem.command(name="list")
441
+ @click.option(
442
+ "--format",
443
+ "-f",
444
+ type=click.Choice(["table", "json"]),
445
+ default="table",
446
+ show_default=True,
447
+ help="Output format",
448
+ )
449
+ @click.option(
450
+ "--take",
451
+ "-t",
452
+ type=int,
453
+ default=25,
454
+ show_default=True,
455
+ help="Maximum number of work items to return (table) or fetch (json)",
456
+ )
457
+ @click.option(
458
+ "--filter",
459
+ "filter_expr",
460
+ default=None,
461
+ help="Dynamic LINQ filter expression (e.g. 'state == \"NEW\"')",
462
+ )
463
+ @click.option(
464
+ "--state",
465
+ "-s",
466
+ default=None,
467
+ help=(
468
+ "Filter by state: NEW, DEFINED, REVIEWED, SCHEDULED, "
469
+ "IN_PROGRESS, PENDING_APPROVAL, CLOSED, CANCELED"
470
+ ),
471
+ )
472
+ @click.option(
473
+ "--workspace",
474
+ "-w",
475
+ default=None,
476
+ help="Filter by workspace name or ID",
477
+ )
478
+ def list_workitems(
479
+ format: str,
480
+ take: int,
481
+ filter_expr: Optional[str],
482
+ state: Optional[str],
483
+ workspace: Optional[str],
484
+ ) -> None:
485
+ """List work items."""
486
+ format_output = validate_output_format(format)
487
+
488
+ try:
489
+ workspace_map = get_workspace_map()
490
+ workspace_id = None
491
+ workspace = get_effective_workspace(workspace)
492
+ if workspace:
493
+ workspace_id = resolve_workspace_filter(workspace, workspace_map)
494
+
495
+ # Build final filter, combining --filter and --state
496
+ final_filter: Optional[str] = None
497
+ subs: List[Any] = []
498
+ parts: List[str] = []
499
+
500
+ if state:
501
+ parts.append(f"state == @{len(subs)}")
502
+ subs.append(state.upper())
503
+
504
+ if filter_expr:
505
+ # Offset substitution indices from user-provided filter
506
+ import re
507
+
508
+ def _offset(m: Any) -> str:
509
+ return f"@{int(m.group(1)) + len(subs)}"
510
+
511
+ user_filter = re.sub(r"@(\d+)", _offset, filter_expr)
512
+ parts.append(f"({user_filter})")
513
+
514
+ if parts:
515
+ final_filter = " && ".join(parts)
516
+
517
+ if format_output == "json":
518
+ items = _query_all_workitems(
519
+ final_filter, subs or None, workspace_id, max_items=take
520
+ )
521
+ click.echo(json.dumps(items, indent=2))
522
+ return
523
+
524
+ def _fmt(item: Dict[str, Any]) -> list:
525
+ ws_name = get_workspace_display_name(item.get("workspace", ""), workspace_map)
526
+ assigned = item.get("assignedTo", "") or ""
527
+ # Shorten UUID to 8 chars
528
+ if len(assigned) > 8:
529
+ assigned = assigned[:8] + "…"
530
+ return [
531
+ item.get("id", ""),
532
+ (item.get("name", "") or "")[:35],
533
+ item.get("type", "") or "",
534
+ item.get("state", "") or "",
535
+ assigned,
536
+ ws_name[:20],
537
+ ]
538
+
539
+ # Table: server-side pagination — fetch exactly `take` items per request
540
+ cont: Optional[str] = None
541
+ displayed = 0
542
+ while True:
543
+ page, cont = _fetch_workitems_page(
544
+ final_filter, subs or None, workspace_id, take, cont
545
+ )
546
+ if not page:
547
+ if displayed == 0:
548
+ click.echo("No work items found.")
549
+ break
550
+ displayed += len(page)
551
+ resp: Any = FilteredResponse({"workItems": page})
552
+ UniversalResponseHandler.handle_list_response(
553
+ resp=resp,
554
+ data_key="workItems",
555
+ item_name="work item",
556
+ format_output=format_output,
557
+ formatter_func=_fmt,
558
+ headers=["ID", "Name", "Type", "State", "Assigned To", "Workspace"],
559
+ column_widths=[12, 36, 16, 18, 10, 21],
560
+ empty_message="No work items found.",
561
+ enable_pagination=False,
562
+ )
563
+ if not cont:
564
+ break
565
+ click.echo(f"\nShowing {displayed} work item(s). More may be available.")
566
+ if not click.confirm(f"Show next {take} results?", default=True):
567
+ break
568
+
569
+ except Exception as exc:
570
+ handle_api_error(exc)
571
+
572
+ # -----------------------------------------------------------------------
573
+ # workitem get
574
+ # -----------------------------------------------------------------------
575
+ @workitem.command(name="get")
576
+ @click.argument("work_item_id")
577
+ @click.option(
578
+ "--format",
579
+ "-f",
580
+ type=click.Choice(["table", "json"]),
581
+ default="table",
582
+ show_default=True,
583
+ help="Output format",
584
+ )
585
+ def get_workitem(work_item_id: str, format: str) -> None:
586
+ """Get details for a work item by ID."""
587
+ format_output = validate_output_format(format)
588
+
589
+ try:
590
+ url = _wi_url(f"/workitems/{work_item_id}")
591
+ resp = make_api_request("GET", url)
592
+ item: Dict[str, Any] = resp.json()
593
+
594
+ if not item:
595
+ click.echo(f"✗ Work item '{work_item_id}' not found.", err=True)
596
+ sys.exit(ExitCodes.NOT_FOUND)
597
+
598
+ if format_output == "json":
599
+ click.echo(json.dumps(item, indent=2))
600
+ return
601
+
602
+ workspace_map = get_workspace_map()
603
+ ws_name = get_workspace_display_name(item.get("workspace", ""), workspace_map)
604
+
605
+ click.echo("Work Item Details:")
606
+ click.echo("=" * 50)
607
+ click.echo(f"ID: {item.get('id', 'N/A')}")
608
+ click.echo(f"Name: {item.get('name', 'N/A')}")
609
+ click.echo(f"Type: {item.get('type', 'N/A')}")
610
+ click.echo(f"State: {item.get('state', 'N/A')}")
611
+ click.echo(f"Substate: {item.get('substate', 'N/A')}")
612
+ click.echo(f"Description: {item.get('description', 'N/A')}")
613
+ click.echo(f"Assigned To: {item.get('assignedTo', 'N/A')}")
614
+ click.echo(f"Requested By: {item.get('requestedBy', 'N/A')}")
615
+ click.echo(f"Part Number: {item.get('partNumber', 'N/A')}")
616
+ click.echo(f"Test Program: {item.get('testProgram', 'N/A')}")
617
+ click.echo(f"Workspace: {ws_name}")
618
+ click.echo(f"Workflow ID: {item.get('workflowId', 'N/A')}")
619
+ click.echo(f"Created At: {item.get('createdAt', 'N/A')}")
620
+ click.echo(f"Updated At: {item.get('updatedAt', 'N/A')}")
621
+
622
+ if item.get("properties"):
623
+ click.echo("Properties:")
624
+ for k, v in item["properties"].items():
625
+ click.echo(f" {k}: {v}")
626
+
627
+ except Exception as exc:
628
+ handle_api_error(exc)
629
+
630
+ # -----------------------------------------------------------------------
631
+ # workitem create
632
+ # -----------------------------------------------------------------------
633
+ @workitem.command(name="create")
634
+ @click.option("--name", "-n", default=None, help="Work item name")
635
+ @click.option("--type", "wi_type", default=None, help="Work item type (e.g. testplan)")
636
+ @click.option(
637
+ "--state",
638
+ "-s",
639
+ default=None,
640
+ help="Initial state (e.g. NEW, DEFINED)",
641
+ )
642
+ @click.option("--description", "-d", default=None, help="Work item description")
643
+ @click.option("--assigned-to", default=None, help="User ID to assign the work item to")
644
+ @click.option(
645
+ "--workflow-id",
646
+ default=None,
647
+ help="ID of the workflow to associate with this work item",
648
+ )
649
+ @click.option("--workspace", "-w", default=None, help="Workspace name or ID")
650
+ @click.option(
651
+ "--part-number",
652
+ default=None,
653
+ help="Part number to associate with this work item",
654
+ )
655
+ @click.option(
656
+ "--file",
657
+ "-F",
658
+ "input_file",
659
+ default=None,
660
+ help=(
661
+ "JSON file with full CreateWorkItemRequest body "
662
+ "(field options override values in file)"
663
+ ),
664
+ )
665
+ @click.option(
666
+ "--format",
667
+ "-f",
668
+ type=click.Choice(["table", "json"]),
669
+ default="table",
670
+ show_default=True,
671
+ help="Output format",
672
+ )
673
+ def create_workitem(
674
+ name: Optional[str],
675
+ wi_type: Optional[str],
676
+ state: Optional[str],
677
+ description: Optional[str],
678
+ assigned_to: Optional[str],
679
+ workflow_id: Optional[str],
680
+ workspace: Optional[str],
681
+ part_number: Optional[str],
682
+ input_file: Optional[str],
683
+ format: str,
684
+ ) -> None:
685
+ """Create a new work item."""
686
+ from .utils import check_readonly_mode
687
+
688
+ check_readonly_mode("create a work item")
689
+
690
+ try:
691
+ # Load from file if provided, then overlay CLI options
692
+ wi_data: Dict[str, Any] = {}
693
+ if input_file:
694
+ wi_data = load_json_file(input_file)
695
+
696
+ if name is not None:
697
+ wi_data["name"] = name
698
+ if wi_type is not None:
699
+ wi_data["type"] = wi_type
700
+ if state is not None:
701
+ wi_data["state"] = state
702
+ if description is not None:
703
+ wi_data["description"] = description
704
+ if assigned_to is not None:
705
+ wi_data["assignedTo"] = assigned_to
706
+ if workflow_id is not None:
707
+ wi_data["workflowId"] = workflow_id
708
+ if workspace is None:
709
+ workspace = get_effective_workspace(workspace)
710
+ if workspace is not None:
711
+ ws_id = get_workspace_id_with_fallback(workspace)
712
+ wi_data["workspace"] = ws_id
713
+ if part_number is not None:
714
+ wi_data["partNumber"] = part_number
715
+
716
+ url = _wi_url("/workitems")
717
+ payload = {"workItems": [wi_data]}
718
+ resp = make_api_request("POST", url, payload, handle_errors=False)
719
+ data = resp.json()
720
+
721
+ if resp.status_code in (200, 201):
722
+ created = data.get("createdWorkItems", [])
723
+ if created:
724
+ item = created[0]
725
+ if format == "json":
726
+ click.echo(json.dumps(item, indent=2))
727
+ else:
728
+ format_success(
729
+ "Work item created",
730
+ {"id": item.get("id", ""), "name": item.get("name", "")},
731
+ )
732
+ else:
733
+ # Partial success - check failedWorkItems
734
+ failed = data.get("failedWorkItems", [])
735
+ if failed:
736
+ click.echo("✗ Failed to create work item.", err=True)
737
+ display_api_errors("Work item creation failed", data, detailed=True)
738
+ sys.exit(ExitCodes.GENERAL_ERROR)
739
+ click.echo("✓ Work item created.")
740
+ else:
741
+ display_api_errors("Work item creation failed", data, detailed=True)
742
+ sys.exit(ExitCodes.GENERAL_ERROR)
743
+
744
+ except SystemExit:
745
+ raise
746
+ except Exception as exc:
747
+ handle_api_error(exc)
748
+
749
+ # -----------------------------------------------------------------------
750
+ # workitem create-from-template
751
+ # -----------------------------------------------------------------------
752
+ @workitem.command(name="create-from-template")
753
+ @click.argument("template_id")
754
+ @click.option("--name", "-n", default=None, help="Work item name (overrides template)")
755
+ @click.option(
756
+ "--state",
757
+ "-s",
758
+ default=None,
759
+ help="Initial state (e.g. NEW, DEFINED)",
760
+ )
761
+ @click.option(
762
+ "--description",
763
+ "-d",
764
+ default=None,
765
+ help="Work item description (overrides template)",
766
+ )
767
+ @click.option("--assigned-to", default=None, help="User ID to assign the work item to")
768
+ @click.option(
769
+ "--workflow-id",
770
+ default=None,
771
+ help="ID of the workflow to associate with this work item",
772
+ )
773
+ @click.option(
774
+ "--workspace",
775
+ "-w",
776
+ default=None,
777
+ help="Workspace name or ID (overrides template workspace)",
778
+ )
779
+ @click.option(
780
+ "--part-number",
781
+ default=None,
782
+ help="Part number (overrides template's first part number)",
783
+ )
784
+ @click.option(
785
+ "--format",
786
+ "-f",
787
+ type=click.Choice(["table", "json"]),
788
+ default="table",
789
+ show_default=True,
790
+ help="Output format",
791
+ )
792
+ def create_workitem_from_template(
793
+ template_id: str,
794
+ name: Optional[str],
795
+ state: Optional[str],
796
+ description: Optional[str],
797
+ assigned_to: Optional[str],
798
+ workflow_id: Optional[str],
799
+ workspace: Optional[str],
800
+ part_number: Optional[str],
801
+ format: str,
802
+ ) -> None:
803
+ """Create a new work item pre-filled from a work item template."""
804
+ from .utils import check_readonly_mode
805
+
806
+ check_readonly_mode("create a work item from template")
807
+
808
+ try:
809
+ # Fetch template by ID
810
+ tmpl_url = _wi_url("/query-workitem-templates")
811
+ tmpl_payload = {
812
+ "filter": "id == @0",
813
+ "substitutions": [template_id],
814
+ "take": 1,
815
+ }
816
+ tmpl_resp = make_api_request("POST", tmpl_url, tmpl_payload)
817
+ tmpl_data = tmpl_resp.json()
818
+ templates = tmpl_data.get("workItemTemplates", [])
819
+
820
+ if not templates:
821
+ click.echo(f"✗ Template '{template_id}' not found.", err=True)
822
+ sys.exit(ExitCodes.NOT_FOUND)
823
+
824
+ tmpl = templates[0]
825
+
826
+ # Seed work item from template fields
827
+ wi_data: Dict[str, Any] = {}
828
+ if tmpl.get("name"):
829
+ wi_data["name"] = tmpl["name"]
830
+ if tmpl.get("type"):
831
+ wi_data["type"] = tmpl["type"]
832
+ if tmpl.get("description"):
833
+ wi_data["description"] = tmpl["description"]
834
+ if tmpl.get("workspace"):
835
+ wi_data["workspace"] = tmpl["workspace"]
836
+ template_part_numbers: List[str] = tmpl.get("partNumbers") or []
837
+ if template_part_numbers:
838
+ wi_data["partNumber"] = template_part_numbers[0]
839
+
840
+ # Apply CLI overrides
841
+ if name is not None:
842
+ wi_data["name"] = name
843
+ if state is not None:
844
+ wi_data["state"] = state
845
+ if description is not None:
846
+ wi_data["description"] = description
847
+ if assigned_to is not None:
848
+ wi_data["assignedTo"] = assigned_to
849
+ if workflow_id is not None:
850
+ wi_data["workflowId"] = workflow_id
851
+ if workspace is None:
852
+ workspace = get_effective_workspace(workspace)
853
+ if workspace is not None:
854
+ ws_id = get_workspace_id_with_fallback(workspace)
855
+ wi_data["workspace"] = ws_id
856
+ if part_number is not None:
857
+ wi_data["partNumber"] = part_number
858
+
859
+ # Create the work item
860
+ create_url = _wi_url("/workitems")
861
+ create_payload = {"workItems": [wi_data]}
862
+ resp = make_api_request("POST", create_url, create_payload, handle_errors=False)
863
+ data = resp.json()
864
+
865
+ if resp.status_code in (200, 201):
866
+ created = data.get("createdWorkItems", [])
867
+ if created:
868
+ item = created[0]
869
+ if format == "json":
870
+ click.echo(json.dumps(item, indent=2))
871
+ else:
872
+ format_success(
873
+ "Work item created from template",
874
+ {"id": item.get("id", ""), "name": item.get("name", "")},
875
+ )
876
+ else:
877
+ failed = data.get("failedWorkItems", [])
878
+ if failed:
879
+ click.echo("✗ Failed to create work item from template.", err=True)
880
+ display_api_errors("Work item creation failed", data, detailed=True)
881
+ sys.exit(ExitCodes.GENERAL_ERROR)
882
+ click.echo("✓ Work item created from template.")
883
+ else:
884
+ display_api_errors("Work item creation failed", data, detailed=True)
885
+ sys.exit(ExitCodes.GENERAL_ERROR)
886
+
887
+ except SystemExit:
888
+ raise
889
+ except Exception as exc:
890
+ handle_api_error(exc)
891
+
892
+ # -----------------------------------------------------------------------
893
+ # workitem update
894
+ # -----------------------------------------------------------------------
895
+ @workitem.command(name="update")
896
+ @click.argument("work_item_id")
897
+ @click.option("--name", "-n", default=None, help="New name")
898
+ @click.option("--state", "-s", default=None, help="New state")
899
+ @click.option("--description", "-d", default=None, help="New description")
900
+ @click.option("--assigned-to", default=None, help="User ID to reassign to")
901
+ @click.option(
902
+ "--file",
903
+ "-F",
904
+ "input_file",
905
+ default=None,
906
+ help=(
907
+ "JSON file with UpdateWorkItemRequest fields " "(field options override values in file)"
908
+ ),
909
+ )
910
+ def update_workitem(
911
+ work_item_id: str,
912
+ name: Optional[str],
913
+ state: Optional[str],
914
+ description: Optional[str],
915
+ assigned_to: Optional[str],
916
+ input_file: Optional[str],
917
+ ) -> None:
918
+ """Update a work item by ID."""
919
+ from .utils import check_readonly_mode
920
+
921
+ check_readonly_mode("update a work item")
922
+
923
+ try:
924
+ wi_data: Dict[str, Any] = {"id": work_item_id}
925
+ if input_file:
926
+ file_data = load_json_file(input_file)
927
+ wi_data.update(file_data)
928
+ wi_data["id"] = work_item_id # ID always from arg
929
+
930
+ if name is not None:
931
+ wi_data["name"] = name
932
+ if state is not None:
933
+ wi_data["state"] = state
934
+ if description is not None:
935
+ wi_data["description"] = description
936
+ if assigned_to is not None:
937
+ wi_data["assignedTo"] = assigned_to
938
+
939
+ url = _wi_url("/update-workitems")
940
+ payload = {"workItems": [wi_data]}
941
+ resp = make_api_request("POST", url, payload, handle_errors=False)
942
+ data = resp.json()
943
+
944
+ if resp.status_code == 200:
945
+ updated = data.get("updatedWorkItems", [])
946
+ if updated:
947
+ click.echo(f"✓ Work item {work_item_id} updated successfully.")
948
+ else:
949
+ failed = data.get("failedWorkItems", [])
950
+ if failed:
951
+ click.echo(f"✗ Failed to update work item {work_item_id}.", err=True)
952
+ display_api_errors("Work item update failed", data, detailed=True)
953
+ sys.exit(ExitCodes.GENERAL_ERROR)
954
+ else:
955
+ display_api_errors("Work item update failed", data, detailed=True)
956
+ sys.exit(ExitCodes.GENERAL_ERROR)
957
+
958
+ except SystemExit:
959
+ raise
960
+ except Exception as exc:
961
+ handle_api_error(exc)
962
+
963
+ # -----------------------------------------------------------------------
964
+ # workitem delete
965
+ # -----------------------------------------------------------------------
966
+ @workitem.command(name="delete")
967
+ @click.argument("work_item_ids", nargs=-1, required=True)
968
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
969
+ def delete_workitems(work_item_ids: tuple, yes: bool) -> None:
970
+ """Delete one or more work items by ID."""
971
+ from .utils import check_readonly_mode
972
+
973
+ check_readonly_mode("delete work items")
974
+
975
+ ids_list = list(work_item_ids)
976
+ if not yes:
977
+ id_str = ", ".join(ids_list)
978
+ if not click.confirm(f"Delete work item(s) {id_str}?"):
979
+ click.echo("Aborted.")
980
+ return
981
+
982
+ try:
983
+ url = _wi_url("/delete-workitems")
984
+ payload = {"ids": ids_list}
985
+ resp = make_api_request("POST", url, payload, handle_errors=False)
986
+
987
+ if resp.status_code == 204:
988
+ click.echo(f"✓ Work item(s) deleted successfully.")
989
+ return
990
+
991
+ data = resp.json() if resp.text.strip() else {}
992
+
993
+ if resp.status_code == 200:
994
+ deleted = data.get("deletedWorkItemIds", [])
995
+ failed = data.get("failedWorkItemIds", [])
996
+ if deleted:
997
+ click.echo(f"✓ Deleted: {', '.join(deleted)}")
998
+ if failed:
999
+ click.echo(f"✗ Failed to delete: {', '.join(failed)}", err=True)
1000
+ display_api_errors("Work item deletion failed", data, detailed=True)
1001
+ sys.exit(ExitCodes.GENERAL_ERROR)
1002
+ else:
1003
+ display_api_errors("Work item deletion failed", data, detailed=True)
1004
+ sys.exit(ExitCodes.GENERAL_ERROR)
1005
+
1006
+ except SystemExit:
1007
+ raise
1008
+ except Exception as exc:
1009
+ handle_api_error(exc)
1010
+
1011
+ # -----------------------------------------------------------------------
1012
+ # workitem execute
1013
+ # -----------------------------------------------------------------------
1014
+ @workitem.command(name="execute")
1015
+ @click.argument("work_item_id")
1016
+ @click.option(
1017
+ "--action",
1018
+ "-a",
1019
+ required=True,
1020
+ help="Action to execute (e.g. START, END, COMPLETE)",
1021
+ )
1022
+ def execute_workitem(work_item_id: str, action: str) -> None:
1023
+ """Execute an action on a work item (e.g. START, END)."""
1024
+ from .utils import check_readonly_mode
1025
+
1026
+ check_readonly_mode("execute an action on a work item")
1027
+
1028
+ try:
1029
+ url = _wi_url(f"/workitems/{work_item_id}/execute")
1030
+ payload = {"action": action.upper()}
1031
+ resp = make_api_request("POST", url, payload, handle_errors=False)
1032
+
1033
+ if resp.status_code in (200, 202):
1034
+ data = resp.json() if resp.text.strip() else {}
1035
+ result = data.get("result", {})
1036
+ if result:
1037
+ click.echo(f"✓ Action '{action.upper()}' executed on work item {work_item_id}.")
1038
+ if isinstance(result, dict) and result.get("type"):
1039
+ click.echo(f" Execution type: {result['type']}")
1040
+ else:
1041
+ click.echo(f"✓ Action '{action.upper()}' executed on work item {work_item_id}.")
1042
+ else:
1043
+ data = resp.json() if resp.text.strip() else {}
1044
+ display_api_errors("Work item execution failed", data, detailed=True)
1045
+ sys.exit(ExitCodes.GENERAL_ERROR)
1046
+
1047
+ except SystemExit:
1048
+ raise
1049
+ except Exception as exc:
1050
+ handle_api_error(exc)
1051
+
1052
+ # -----------------------------------------------------------------------
1053
+ # workitem schedule
1054
+ # -----------------------------------------------------------------------
1055
+ @workitem.command(name="schedule")
1056
+ @click.argument("work_item_id")
1057
+ @click.option(
1058
+ "--start",
1059
+ default=None,
1060
+ help="Planned start date/time (ISO-8601, e.g. 2026-03-01T09:00:00Z)",
1061
+ )
1062
+ @click.option(
1063
+ "--end",
1064
+ default=None,
1065
+ help="Planned end date/time (ISO-8601)",
1066
+ )
1067
+ @click.option(
1068
+ "--duration",
1069
+ type=int,
1070
+ default=None,
1071
+ help="Planned duration in seconds",
1072
+ )
1073
+ @click.option("--assigned-to", default=None, help="User ID to assign")
1074
+ @click.option(
1075
+ "--system",
1076
+ "system_ids",
1077
+ multiple=True,
1078
+ metavar="SYSTEM_ID",
1079
+ help="Assign a system resource by system ID (repeatable).",
1080
+ )
1081
+ @click.option(
1082
+ "--fixture",
1083
+ "fixture_ids",
1084
+ multiple=True,
1085
+ metavar="FIXTURE_ID",
1086
+ help=(
1087
+ "Assign a fixture/slot resource by asset ID (repeatable). "
1088
+ "Use `slcli asset list --asset-type FIXTURE` to find IDs."
1089
+ ),
1090
+ )
1091
+ @click.option(
1092
+ "--dut",
1093
+ "dut_ids",
1094
+ multiple=True,
1095
+ metavar="DUT_ID",
1096
+ help=(
1097
+ "Assign a DUT resource by asset ID (repeatable). "
1098
+ "Use `slcli asset list --asset-type DEVICE_UNDER_TEST` to find IDs."
1099
+ ),
1100
+ )
1101
+ def schedule_workitem(
1102
+ work_item_id: str,
1103
+ start: Optional[str],
1104
+ end: Optional[str],
1105
+ duration: Optional[int],
1106
+ assigned_to: Optional[str],
1107
+ system_ids: Tuple[str, ...],
1108
+ fixture_ids: Tuple[str, ...],
1109
+ dut_ids: Tuple[str, ...],
1110
+ ) -> None:
1111
+ """Schedule a work item (set planned start/end time and/or assign resources).
1112
+
1113
+ Resources:
1114
+ --system assigns a system (by system/minion ID).
1115
+ --fixture assigns a fixture/slot (by asset ID; asset type FIXTURE).
1116
+ --dut assigns a DUT (by asset ID; asset type DEVICE_UNDER_TEST).
1117
+
1118
+ Multiple --system/--fixture/--dut flags can be provided for multi-resource scheduling.
1119
+ """
1120
+ from .utils import check_readonly_mode
1121
+
1122
+ check_readonly_mode("schedule a work item")
1123
+
1124
+ if not any([start, end, duration, assigned_to, system_ids, fixture_ids, dut_ids]):
1125
+ click.echo(
1126
+ "✗ Provide at least one of --start, --end, --duration, --assigned-to, "
1127
+ "--system, --fixture, or --dut.",
1128
+ err=True,
1129
+ )
1130
+ sys.exit(ExitCodes.INVALID_INPUT)
1131
+
1132
+ try:
1133
+ schedule: Dict[str, Any] = {}
1134
+ if start:
1135
+ schedule["plannedStartDateTime"] = start
1136
+ if end:
1137
+ schedule["plannedEndDateTime"] = end
1138
+ if duration is not None:
1139
+ schedule["plannedDurationInSeconds"] = duration
1140
+
1141
+ wi_req: Dict[str, Any] = {"id": work_item_id}
1142
+ if schedule:
1143
+ wi_req["schedule"] = schedule
1144
+ if assigned_to:
1145
+ wi_req["assignedTo"] = assigned_to
1146
+
1147
+ resources: Dict[str, Any] = {}
1148
+ if system_ids:
1149
+ resources["systems"] = {"selections": [{"id": sid} for sid in system_ids]}
1150
+ if fixture_ids:
1151
+ resources["fixtures"] = {"selections": [{"id": fid} for fid in fixture_ids]}
1152
+ if dut_ids:
1153
+ resources["duts"] = {"selections": [{"id": did} for did in dut_ids]}
1154
+ if resources:
1155
+ wi_req["resources"] = resources
1156
+
1157
+ url = _wi_url("/schedule-workitems")
1158
+ payload = {"workItems": [wi_req]}
1159
+ resp = make_api_request("POST", url, payload, handle_errors=False)
1160
+ data = resp.json() if resp.text.strip() else {}
1161
+
1162
+ if resp.status_code == 200:
1163
+ scheduled = data.get("scheduledWorkItems", [])
1164
+ if scheduled:
1165
+ click.echo(f"✓ Work item {work_item_id} scheduled successfully.")
1166
+ else:
1167
+ failed = data.get("failedWorkItems", [])
1168
+ if failed:
1169
+ click.echo(f"✗ Failed to schedule work item {work_item_id}.", err=True)
1170
+ display_api_errors("Work item scheduling failed", data, detailed=True)
1171
+ sys.exit(ExitCodes.GENERAL_ERROR)
1172
+ else:
1173
+ display_api_errors("Work item scheduling failed", data, detailed=True)
1174
+ sys.exit(ExitCodes.GENERAL_ERROR)
1175
+
1176
+ except SystemExit:
1177
+ raise
1178
+ except Exception as exc:
1179
+ handle_api_error(exc)
1180
+
1181
+ # =======================================================================
1182
+ # workitem template subgroup
1183
+ # =======================================================================
1184
+
1185
+ @workitem.group(name="template")
1186
+ def template_group() -> None:
1187
+ """Manage work item templates."""
1188
+
1189
+ @template_group.command(name="list")
1190
+ @click.option(
1191
+ "--format",
1192
+ "-f",
1193
+ type=click.Choice(["table", "json"]),
1194
+ default="table",
1195
+ show_default=True,
1196
+ help="Output format",
1197
+ )
1198
+ @click.option(
1199
+ "--take",
1200
+ "-t",
1201
+ type=int,
1202
+ default=25,
1203
+ show_default=True,
1204
+ help="Maximum number of templates to display per page (table) or fetch (json)",
1205
+ )
1206
+ @click.option(
1207
+ "--filter",
1208
+ "filter_expr",
1209
+ default=None,
1210
+ help="Dynamic LINQ filter expression",
1211
+ )
1212
+ @click.option("--workspace", "-w", default=None, help="Filter by workspace name or ID")
1213
+ def list_templates(
1214
+ format: str,
1215
+ take: int,
1216
+ filter_expr: Optional[str],
1217
+ workspace: Optional[str],
1218
+ ) -> None:
1219
+ """List work item templates."""
1220
+ format_output = validate_output_format(format)
1221
+
1222
+ try:
1223
+ workspace_map = get_workspace_map()
1224
+ workspace_id = None
1225
+ workspace = get_effective_workspace(workspace)
1226
+ if workspace:
1227
+ workspace_id = resolve_workspace_filter(workspace, workspace_map)
1228
+
1229
+ if format_output == "json":
1230
+ items = _query_all_templates(filter_expr, None, workspace_id, max_items=take)
1231
+ click.echo(json.dumps(items, indent=2))
1232
+ return
1233
+
1234
+ # Table: server-side pagination
1235
+ def _fmt(item: Dict[str, Any]) -> list:
1236
+ ws_name = get_workspace_display_name(item.get("workspace", ""), workspace_map)
1237
+ return [
1238
+ item.get("id", ""),
1239
+ (item.get("name", "") or "")[:40],
1240
+ item.get("type", "") or "",
1241
+ item.get("templateGroup", "") or "",
1242
+ ws_name[:20],
1243
+ ]
1244
+
1245
+ cont: Optional[str] = None
1246
+ displayed = 0
1247
+ while True:
1248
+ page, cont = _fetch_templates_page(filter_expr, None, workspace_id, take, cont)
1249
+ if not page:
1250
+ if displayed == 0:
1251
+ click.echo("No templates found.")
1252
+ break
1253
+ displayed += len(page)
1254
+ resp: Any = FilteredResponse({"workItemTemplates": page})
1255
+ UniversalResponseHandler.handle_list_response(
1256
+ resp=resp,
1257
+ data_key="workItemTemplates",
1258
+ item_name="template",
1259
+ format_output=format_output,
1260
+ formatter_func=_fmt,
1261
+ headers=["ID", "Name", "Type", "Template Group", "Workspace"],
1262
+ column_widths=[12, 41, 16, 20, 21],
1263
+ empty_message="No templates found.",
1264
+ enable_pagination=False,
1265
+ )
1266
+ if not cont:
1267
+ break
1268
+ click.echo(f"\nShowing {displayed} template(s). More may be available.")
1269
+ if not click.confirm(f"Show next {take} results?", default=True):
1270
+ break
1271
+
1272
+ except Exception as exc:
1273
+ handle_api_error(exc)
1274
+
1275
+ @template_group.command(name="get")
1276
+ @click.argument("template_id")
1277
+ @click.option(
1278
+ "--format",
1279
+ "-f",
1280
+ type=click.Choice(["table", "json"]),
1281
+ default="table",
1282
+ show_default=True,
1283
+ help="Output format",
1284
+ )
1285
+ def get_template(template_id: str, format: str) -> None:
1286
+ """Get details for a work item template by ID."""
1287
+ format_output = validate_output_format(format)
1288
+
1289
+ try:
1290
+ url = _wi_url("/query-workitem-templates")
1291
+ payload = {
1292
+ "filter": f"id == @0",
1293
+ "substitutions": [template_id],
1294
+ "take": 1,
1295
+ }
1296
+ resp = make_api_request("POST", url, payload)
1297
+ data = resp.json()
1298
+ items = data.get("workItemTemplates", [])
1299
+
1300
+ if not items:
1301
+ click.echo(f"✗ Template '{template_id}' not found.", err=True)
1302
+ sys.exit(ExitCodes.NOT_FOUND)
1303
+
1304
+ item = items[0]
1305
+
1306
+ if format_output == "json":
1307
+ click.echo(json.dumps(item, indent=2))
1308
+ return
1309
+
1310
+ workspace_map = get_workspace_map()
1311
+ ws_name = get_workspace_display_name(item.get("workspace", ""), workspace_map)
1312
+
1313
+ click.echo("Work Item Template Details:")
1314
+ click.echo("=" * 50)
1315
+ click.echo(f"ID: {item.get('id', 'N/A')}")
1316
+ click.echo(f"Name: {item.get('name', 'N/A')}")
1317
+ click.echo(f"Type: {item.get('type', 'N/A')}")
1318
+ click.echo(f"Template Group: {item.get('templateGroup', 'N/A')}")
1319
+ click.echo(f"Summary: {item.get('summary', 'N/A')}")
1320
+ click.echo(f"Description: {item.get('description', 'N/A')}")
1321
+ click.echo(f"Test Program: {item.get('testProgram', 'N/A')}")
1322
+ click.echo(f"Workspace: {ws_name}")
1323
+ click.echo(f"Created At: {item.get('createdAt', 'N/A')}")
1324
+ click.echo(f"Updated At: {item.get('updatedAt', 'N/A')}")
1325
+
1326
+ if item.get("partNumbers"):
1327
+ click.echo(f"Part Numbers: {', '.join(item['partNumbers'])}")
1328
+ if item.get("productFamilies"):
1329
+ click.echo(f"Product Families: {', '.join(item['productFamilies'])}")
1330
+ if item.get("properties"):
1331
+ click.echo("Properties:")
1332
+ for k, v in item["properties"].items():
1333
+ click.echo(f" {k}: {v}")
1334
+
1335
+ except Exception as exc:
1336
+ handle_api_error(exc)
1337
+
1338
+ @template_group.command(name="create")
1339
+ @click.option("--name", "-n", required=False, default=None, help="Template name")
1340
+ @click.option("--type", "wi_type", default=None, help="Work item type (e.g. testplan)")
1341
+ @click.option("--template-group", default=None, help="Template group label")
1342
+ @click.option("--description", "-d", default=None, help="Template description")
1343
+ @click.option("--summary", default=None, help="Template summary")
1344
+ @click.option("--workspace", "-w", default=None, help="Workspace name or ID")
1345
+ @click.option(
1346
+ "--file",
1347
+ "-F",
1348
+ "input_file",
1349
+ default=None,
1350
+ help="JSON file with full CreateWorkItemTemplateRequest body",
1351
+ )
1352
+ @click.option(
1353
+ "--format",
1354
+ "-f",
1355
+ type=click.Choice(["table", "json"]),
1356
+ default="table",
1357
+ show_default=True,
1358
+ help="Output format",
1359
+ )
1360
+ def create_template(
1361
+ name: Optional[str],
1362
+ wi_type: Optional[str],
1363
+ template_group: Optional[str],
1364
+ description: Optional[str],
1365
+ summary: Optional[str],
1366
+ workspace: Optional[str],
1367
+ input_file: Optional[str],
1368
+ format: str,
1369
+ ) -> None:
1370
+ """Create a new work item template."""
1371
+ from .utils import check_readonly_mode
1372
+
1373
+ check_readonly_mode("create a work item template")
1374
+
1375
+ if not input_file and not (name and wi_type and template_group):
1376
+ click.echo(
1377
+ "✗ Provide --file or all of --name, --type, and --template-group.",
1378
+ err=True,
1379
+ )
1380
+ sys.exit(ExitCodes.INVALID_INPUT)
1381
+
1382
+ try:
1383
+ tmpl_data: Dict[str, Any] = {}
1384
+ if input_file:
1385
+ tmpl_data = load_json_file(input_file)
1386
+
1387
+ if name is not None:
1388
+ tmpl_data["name"] = name
1389
+ if wi_type is not None:
1390
+ tmpl_data["type"] = wi_type
1391
+ if template_group is not None:
1392
+ tmpl_data["templateGroup"] = template_group
1393
+ if description is not None:
1394
+ tmpl_data["description"] = description
1395
+ if summary is not None:
1396
+ tmpl_data["summary"] = summary
1397
+ if workspace is None:
1398
+ workspace = get_effective_workspace(workspace)
1399
+ if workspace is not None:
1400
+ ws_id = get_workspace_id_with_fallback(workspace)
1401
+ tmpl_data["workspace"] = ws_id
1402
+
1403
+ url = _wi_url("/workitem-templates")
1404
+ payload = {"workItemTemplates": [tmpl_data]}
1405
+ resp = make_api_request("POST", url, payload, handle_errors=False)
1406
+ data = resp.json()
1407
+
1408
+ if resp.status_code in (200, 201):
1409
+ created = data.get("createdWorkItemTemplates", [])
1410
+ if created:
1411
+ item = created[0]
1412
+ if format == "json":
1413
+ click.echo(json.dumps(item, indent=2))
1414
+ else:
1415
+ format_success(
1416
+ "Template created",
1417
+ {"id": item.get("id", ""), "name": item.get("name", "")},
1418
+ )
1419
+ else:
1420
+ failed = data.get("failedWorkItemTemplates", [])
1421
+ if failed:
1422
+ click.echo("✗ Failed to create template.", err=True)
1423
+ display_api_errors("Template creation failed", data, detailed=True)
1424
+ sys.exit(ExitCodes.GENERAL_ERROR)
1425
+ click.echo("✓ Template created.")
1426
+ else:
1427
+ display_api_errors("Template creation failed", data, detailed=True)
1428
+ sys.exit(ExitCodes.GENERAL_ERROR)
1429
+
1430
+ except SystemExit:
1431
+ raise
1432
+ except Exception as exc:
1433
+ handle_api_error(exc)
1434
+
1435
+ @template_group.command(name="update")
1436
+ @click.argument("template_id")
1437
+ @click.option("--name", "-n", default=None, help="New name")
1438
+ @click.option("--description", "-d", default=None, help="New description")
1439
+ @click.option("--summary", default=None, help="New summary")
1440
+ @click.option("--template-group", default=None, help="New template group")
1441
+ @click.option(
1442
+ "--file",
1443
+ "-F",
1444
+ "input_file",
1445
+ default=None,
1446
+ help="JSON file with UpdateWorkItemTemplateRequest fields",
1447
+ )
1448
+ def update_template(
1449
+ template_id: str,
1450
+ name: Optional[str],
1451
+ description: Optional[str],
1452
+ summary: Optional[str],
1453
+ template_group: Optional[str],
1454
+ input_file: Optional[str],
1455
+ ) -> None:
1456
+ """Update a work item template by ID."""
1457
+ from .utils import check_readonly_mode
1458
+
1459
+ check_readonly_mode("update a work item template")
1460
+
1461
+ try:
1462
+ tmpl_data: Dict[str, Any] = {"id": template_id}
1463
+ if input_file:
1464
+ file_data = load_json_file(input_file)
1465
+ tmpl_data.update(file_data)
1466
+ tmpl_data["id"] = template_id
1467
+
1468
+ if name is not None:
1469
+ tmpl_data["name"] = name
1470
+ if description is not None:
1471
+ tmpl_data["description"] = description
1472
+ if summary is not None:
1473
+ tmpl_data["summary"] = summary
1474
+ if template_group is not None:
1475
+ tmpl_data["templateGroup"] = template_group
1476
+
1477
+ url = _wi_url("/update-workitem-templates")
1478
+ payload = {"workItemTemplates": [tmpl_data]}
1479
+ resp = make_api_request("POST", url, payload, handle_errors=False)
1480
+ data = resp.json()
1481
+
1482
+ if resp.status_code == 200:
1483
+ updated = data.get("updatedWorkItemTemplates", [])
1484
+ if updated:
1485
+ click.echo(f"✓ Template {template_id} updated successfully.")
1486
+ else:
1487
+ failed = data.get("failedWorkItemTemplates", [])
1488
+ if failed:
1489
+ click.echo(f"✗ Failed to update template {template_id}.", err=True)
1490
+ display_api_errors("Template update failed", data, detailed=True)
1491
+ sys.exit(ExitCodes.GENERAL_ERROR)
1492
+ else:
1493
+ display_api_errors("Template update failed", data, detailed=True)
1494
+ sys.exit(ExitCodes.GENERAL_ERROR)
1495
+
1496
+ except SystemExit:
1497
+ raise
1498
+ except Exception as exc:
1499
+ handle_api_error(exc)
1500
+
1501
+ @template_group.command(name="delete")
1502
+ @click.argument("template_ids", nargs=-1, required=True)
1503
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
1504
+ def delete_templates(template_ids: tuple, yes: bool) -> None:
1505
+ """Delete one or more work item templates by ID."""
1506
+ from .utils import check_readonly_mode
1507
+
1508
+ check_readonly_mode("delete work item templates")
1509
+
1510
+ ids_list = list(template_ids)
1511
+ if not yes:
1512
+ if not click.confirm(f"Delete template(s) {', '.join(ids_list)}?"):
1513
+ click.echo("Aborted.")
1514
+ return
1515
+
1516
+ try:
1517
+ url = _wi_url("/delete-workitem-templates")
1518
+ payload = {"ids": ids_list}
1519
+ resp = make_api_request("POST", url, payload, handle_errors=False)
1520
+
1521
+ if resp.status_code == 204:
1522
+ click.echo("✓ Template(s) deleted successfully.")
1523
+ return
1524
+
1525
+ data = resp.json() if resp.text.strip() else {}
1526
+
1527
+ if resp.status_code == 200:
1528
+ deleted = data.get("deletedWorkItemTemplateIds", [])
1529
+ failed = data.get("failedWorkItemTemplateIds", [])
1530
+ if deleted:
1531
+ click.echo(f"✓ Deleted: {', '.join(deleted)}")
1532
+ if failed:
1533
+ click.echo(f"✗ Failed to delete: {', '.join(failed)}", err=True)
1534
+ display_api_errors("Template deletion failed", data, detailed=True)
1535
+ sys.exit(ExitCodes.GENERAL_ERROR)
1536
+ else:
1537
+ display_api_errors("Template deletion failed", data, detailed=True)
1538
+ sys.exit(ExitCodes.GENERAL_ERROR)
1539
+
1540
+ except SystemExit:
1541
+ raise
1542
+ except Exception as exc:
1543
+ handle_api_error(exc)
1544
+
1545
+ # =======================================================================
1546
+ # workitem workflow subgroup (refactored from workflows_click.py)
1547
+ # =======================================================================
1548
+
1549
+ @workitem.group(name="workflow")
1550
+ @click.pass_context
1551
+ def workflow_group(ctx: click.Context) -> None:
1552
+ """Manage workflows (create, list, import/export, update, delete, preview)."""
1553
+ # Check for platform feature availability
1554
+ # Only check if a subcommand is being invoked (not just --help)
1555
+ if ctx.invoked_subcommand is not None:
1556
+ require_feature("workflows")
1557
+
1558
+ @workflow_group.command(name="init")
1559
+ @click.option("--name", "-n", help="Workflow name (will prompt if not provided)")
1560
+ @click.option("--description", "-d", help="Workflow description (will prompt if omitted)")
1561
+ @click.option(
1562
+ "--workspace",
1563
+ "-w",
1564
+ default="Default",
1565
+ help="Workspace name or ID (default: 'Default')",
1566
+ )
1567
+ @click.option("--output", "-o", help="Output file path (default: <name>-workflow.json)")
1568
+ def init_workflow(
1569
+ name: Optional[str],
1570
+ description: Optional[str],
1571
+ workspace: str,
1572
+ output: Optional[str],
1573
+ ) -> None:
1574
+ """Create a workflow JSON skeleton."""
1575
+ if not name:
1576
+ name = click.prompt("Workflow name", type=str)
1577
+ if not description:
1578
+ description = click.prompt("Workflow description", type=str, default="")
1579
+
1580
+ if not output:
1581
+ assert name is not None
1582
+ safe_name = sanitize_filename(name, "workflow")
1583
+ output = f"{safe_name}-workflow.json"
1584
+
1585
+ try:
1586
+ workspace = get_effective_workspace(workspace) or workspace
1587
+ workspace_id = get_workspace_id_with_fallback(workspace)
1588
+ except Exception as exc:
1589
+ click.echo(f"✗ Error resolving workspace '{workspace}': {exc}", err=True)
1590
+ sys.exit(ExitCodes.NOT_FOUND)
1591
+
1592
+ workflow_data = {
1593
+ "name": name,
1594
+ "description": description,
1595
+ "workspace": workspace_id,
1596
+ "actions": [
1597
+ {
1598
+ "name": "START",
1599
+ "displayText": "Start",
1600
+ "privilegeSpecificity": ["ExecuteTest"],
1601
+ "executionAction": {"type": "MANUAL", "action": "START"},
1602
+ },
1603
+ {
1604
+ "name": "COMPLETE",
1605
+ "displayText": "Complete",
1606
+ "privilegeSpecificity": ["Close"],
1607
+ "executionAction": {"type": "MANUAL", "action": "COMPLETE"},
1608
+ },
1609
+ {
1610
+ "name": "RUN_NOTEBOOK",
1611
+ "displayText": "Run Notebook",
1612
+ "iconClass": None,
1613
+ "i18n": [],
1614
+ "privilegeSpecificity": ["ExecuteTest"],
1615
+ "executionAction": {
1616
+ "action": "RUN_NOTEBOOK",
1617
+ "type": "NOTEBOOK",
1618
+ "notebookId": "00000000-0000-0000-0000-000000000000",
1619
+ "parameters": {
1620
+ "partNumber": "<partNumber>",
1621
+ "dut": "<dutId>",
1622
+ "operator": "<assignedTo>",
1623
+ "testProgram": "<testProgram>",
1624
+ "location": "<properties.region>-<properties.facility>-<properties.lab>",
1625
+ },
1626
+ },
1627
+ },
1628
+ {
1629
+ "name": "PLAN_SCHEDULE",
1630
+ "displayText": "Schedule Test Plan",
1631
+ "iconClass": "SCHEDULE",
1632
+ "i18n": [],
1633
+ "privilegeSpecificity": [],
1634
+ "executionAction": {"action": "PLAN_SCHEDULE", "type": "SCHEDULE"},
1635
+ },
1636
+ {
1637
+ "name": "RUN_JOB",
1638
+ "displayText": "Run Job",
1639
+ "iconClass": "DEPLOY",
1640
+ "i18n": [],
1641
+ "privilegeSpecificity": [],
1642
+ "executionAction": {
1643
+ "action": "RUN_JOB",
1644
+ "type": "JOB",
1645
+ "jobs": [
1646
+ {
1647
+ "functions": ["state.apply"],
1648
+ "arguments": [["<properties.startTestStateId>"]],
1649
+ "metadata": {},
1650
+ }
1651
+ ],
1652
+ },
1653
+ },
1654
+ ],
1655
+ "states": [
1656
+ {
1657
+ "name": "NEW",
1658
+ "dashboardAvailable": False,
1659
+ "defaultSubstate": "NEW",
1660
+ "substates": [
1661
+ {
1662
+ "name": "NEW",
1663
+ "displayText": "New",
1664
+ "availableActions": [
1665
+ {
1666
+ "action": "PLAN_SCHEDULE",
1667
+ "nextState": "SCHEDULED",
1668
+ "nextSubstate": "SCHEDULED",
1669
+ "showInUI": True,
1670
+ }
1671
+ ],
1672
+ }
1673
+ ],
1674
+ },
1675
+ {
1676
+ "name": "SCHEDULED",
1677
+ "dashboardAvailable": True,
1678
+ "defaultSubstate": "SCHEDULED",
1679
+ "substates": [
1680
+ {
1681
+ "name": "SCHEDULED",
1682
+ "displayText": "Scheduled",
1683
+ "availableActions": [
1684
+ {
1685
+ "action": "START",
1686
+ "nextState": "IN_PROGRESS",
1687
+ "nextSubstate": "IN_PROGRESS",
1688
+ "showInUI": True,
1689
+ },
1690
+ {
1691
+ "action": "RUN_NOTEBOOK",
1692
+ "nextState": "IN_PROGRESS",
1693
+ "nextSubstate": "IN_PROGRESS",
1694
+ "showInUI": True,
1695
+ },
1696
+ ],
1697
+ }
1698
+ ],
1699
+ },
1700
+ {
1701
+ "name": "IN_PROGRESS",
1702
+ "dashboardAvailable": True,
1703
+ "defaultSubstate": "IN_PROGRESS",
1704
+ "substates": [
1705
+ {
1706
+ "name": "IN_PROGRESS",
1707
+ "displayText": "In progress",
1708
+ "availableActions": [
1709
+ {
1710
+ "action": "COMPLETE",
1711
+ "nextState": "PENDING_APPROVAL",
1712
+ "nextSubstate": "PENDING_APPROVAL",
1713
+ "showInUI": True,
1714
+ }
1715
+ ],
1716
+ }
1717
+ ],
1718
+ },
1719
+ {
1720
+ "name": "PENDING_APPROVAL",
1721
+ "dashboardAvailable": True,
1722
+ "defaultSubstate": "PENDING_APPROVAL",
1723
+ "substates": [
1724
+ {
1725
+ "name": "PENDING_APPROVAL",
1726
+ "displayText": "Pending approval",
1727
+ "availableActions": [
1728
+ {
1729
+ "action": "RUN_JOB",
1730
+ "nextState": "CLOSED",
1731
+ "nextSubstate": "CLOSED",
1732
+ "showInUI": True,
1733
+ }
1734
+ ],
1735
+ }
1736
+ ],
1737
+ },
1738
+ {
1739
+ "name": "CLOSED",
1740
+ "dashboardAvailable": False,
1741
+ "defaultSubstate": "CLOSED",
1742
+ "substates": [
1743
+ {"name": "CLOSED", "displayText": "Closed", "availableActions": []}
1744
+ ],
1745
+ },
1746
+ {
1747
+ "name": "CANCELED",
1748
+ "dashboardAvailable": False,
1749
+ "defaultSubstate": "CANCELED",
1750
+ "substates": [
1751
+ {
1752
+ "name": "CANCELED",
1753
+ "displayText": "Canceled",
1754
+ "availableActions": [],
1755
+ }
1756
+ ],
1757
+ },
1758
+ ],
1759
+ }
1760
+
1761
+ try:
1762
+ if os.path.exists(output):
1763
+ if not click.confirm(f"File {output} already exists. Overwrite?"):
1764
+ click.echo("Workflow initialization cancelled.")
1765
+ return
1766
+
1767
+ with open(output, "w", encoding="utf-8") as fh:
1768
+ json.dump(workflow_data, fh, indent=2, ensure_ascii=False)
1769
+
1770
+ click.echo(f"✓ Workflow initialized: {output}")
1771
+ click.echo("Edit the file to customize your workflow:")
1772
+ click.echo(" - name and description are recommended")
1773
+ click.echo(" - Define states, substates, and actions as needed")
1774
+ click.echo(f" - Workspace is set to: {workspace} (ID: {workspace_id})")
1775
+ click.echo(" - Use 'slcli workitem workflow import' to upload when ready")
1776
+
1777
+ except Exception as exc:
1778
+ click.echo(f"✗ Error creating workflow file: {exc}", err=True)
1779
+ sys.exit(ExitCodes.GENERAL_ERROR)
1780
+
1781
+ @workflow_group.command(name="list")
1782
+ @click.option("--workspace", "-w", help="Filter by workspace name or ID")
1783
+ @click.option(
1784
+ "--take",
1785
+ "-t",
1786
+ type=int,
1787
+ default=25,
1788
+ show_default=True,
1789
+ help="Maximum number of workflows to display per page",
1790
+ )
1791
+ @click.option(
1792
+ "--format",
1793
+ "-f",
1794
+ type=click.Choice(["table", "json"]),
1795
+ default="table",
1796
+ show_default=True,
1797
+ help="Output format",
1798
+ )
1799
+ def list_workflows(
1800
+ workspace: Optional[str],
1801
+ take: int,
1802
+ format: str,
1803
+ ) -> None:
1804
+ """List workflows."""
1805
+ format_output = validate_output_format(format)
1806
+
1807
+ try:
1808
+ workspace_map = get_workspace_map()
1809
+ workspace_id = None
1810
+ workspace = get_effective_workspace(workspace)
1811
+ if workspace:
1812
+ workspace_id = resolve_workspace_filter(workspace, workspace_map)
1813
+
1814
+ if format_output == "json":
1815
+ all_workflows = _query_all_workflows(workspace_id, max_items=take)
1816
+ click.echo(json.dumps(all_workflows, indent=2))
1817
+ return
1818
+
1819
+ # Table: server-side pagination
1820
+ def _wf_fmt(wf: Dict[str, Any]) -> list:
1821
+ ws_name = get_workspace_display_name(wf.get("workspace", ""), workspace_map)
1822
+ return [
1823
+ wf.get("name", "Unknown"),
1824
+ ws_name,
1825
+ wf.get("id", ""),
1826
+ (wf.get("description", "") or "")[:30],
1827
+ ]
1828
+
1829
+ cont: Optional[str] = None
1830
+ displayed = 0
1831
+ while True:
1832
+ page, cont = _fetch_workflows_page(workspace_id, take, cont)
1833
+ if not page:
1834
+ if displayed == 0:
1835
+ click.echo("No workflows found.")
1836
+ break
1837
+ displayed += len(page)
1838
+ resp: Any = FilteredResponse({"workflows": page})
1839
+ UniversalResponseHandler.handle_list_response(
1840
+ resp=resp,
1841
+ data_key="workflows",
1842
+ item_name="workflow",
1843
+ format_output=format_output,
1844
+ formatter_func=_wf_fmt,
1845
+ headers=["Name", "Workspace", "ID", "Description"],
1846
+ column_widths=[40, 30, 36, 32],
1847
+ empty_message="No workflows found.",
1848
+ enable_pagination=False,
1849
+ )
1850
+ if not cont:
1851
+ break
1852
+ click.echo(f"\nShowing {displayed} workflow(s). More may be available.")
1853
+ if not click.confirm(f"Show next {take} results?", default=True):
1854
+ break
1855
+
1856
+ except Exception as exc:
1857
+ handle_api_error(exc)
1858
+
1859
+ @workflow_group.command(name="get")
1860
+ @click.option("--id", "-i", "workflow_id", help="Workflow ID")
1861
+ @click.option("--name", "-n", "workflow_name", help="Workflow name")
1862
+ @click.option(
1863
+ "--format",
1864
+ "-f",
1865
+ type=click.Choice(["table", "json"]),
1866
+ default="table",
1867
+ show_default=True,
1868
+ help="Output format",
1869
+ )
1870
+ def get_workflow(
1871
+ workflow_id: Optional[str],
1872
+ workflow_name: Optional[str],
1873
+ format: str,
1874
+ ) -> None:
1875
+ """Show workflow details by ID or name."""
1876
+ if not workflow_id and not workflow_name:
1877
+ click.echo("✗ Must provide either --id or --name.", err=True)
1878
+ sys.exit(ExitCodes.INVALID_INPUT)
1879
+ if workflow_id and workflow_name:
1880
+ click.echo("✗ Cannot specify both --id and --name.", err=True)
1881
+ sys.exit(ExitCodes.INVALID_INPUT)
1882
+
1883
+ format_output = validate_output_format(format)
1884
+
1885
+ try:
1886
+ if workflow_name:
1887
+ query_url = _wf_url("/query-workflows")
1888
+ query_resp = make_api_request(
1889
+ "POST",
1890
+ query_url,
1891
+ {"take": 1000, "filter": f'NAME == "{workflow_name}"'},
1892
+ )
1893
+ workflows = query_resp.json().get("workflows", [])
1894
+ matching = [w for w in workflows if w.get("name") == workflow_name]
1895
+ if not matching:
1896
+ click.echo(f"✗ Workflow '{workflow_name}' not found.", err=True)
1897
+ sys.exit(ExitCodes.NOT_FOUND)
1898
+ workflow_id = matching[0].get("id", "")
1899
+
1900
+ url = _wf_url(f"/workflows/{workflow_id}")
1901
+ resp = make_api_request("GET", url)
1902
+ wfl = resp.json()
1903
+
1904
+ if not wfl:
1905
+ click.echo(f"✗ Workflow '{workflow_id}' not found.", err=True)
1906
+ sys.exit(ExitCodes.NOT_FOUND)
1907
+
1908
+ if format_output == "json":
1909
+ click.echo(json.dumps(wfl, indent=2))
1910
+ return
1911
+
1912
+ workspace_map = get_workspace_map()
1913
+ ws_name = get_workspace_display_name(wfl.get("workspace", ""), workspace_map)
1914
+ click.echo("Workflow Details:")
1915
+ click.echo("=" * 50)
1916
+ click.echo(f"Name: {wfl.get('name', 'N/A')}")
1917
+ click.echo(f"ID: {wfl.get('id', 'N/A')}")
1918
+ click.echo(f"Workspace: {ws_name}")
1919
+ click.echo(f"Description: {wfl.get('description', 'N/A')}")
1920
+
1921
+ except Exception as exc:
1922
+ handle_api_error(exc)
1923
+
1924
+ @workflow_group.command(name="export")
1925
+ @click.option("--id", "-i", "workflow_id", help="Workflow ID to export")
1926
+ @click.option("--name", "-n", "workflow_name", help="Workflow name to export")
1927
+ @click.option("--output", "-o", help="Output JSON file (default: <name>.json)")
1928
+ def export_workflow(
1929
+ workflow_id: Optional[str],
1930
+ workflow_name: Optional[str],
1931
+ output: Optional[str],
1932
+ ) -> None:
1933
+ """Export a workflow to a JSON file."""
1934
+ if not workflow_id and not workflow_name:
1935
+ click.echo("✗ Must provide either --id or --name.", err=True)
1936
+ sys.exit(ExitCodes.INVALID_INPUT)
1937
+ if workflow_id and workflow_name:
1938
+ click.echo("✗ Cannot specify both --id and --name.", err=True)
1939
+ sys.exit(ExitCodes.INVALID_INPUT)
1940
+
1941
+ if workflow_name:
1942
+ query_url = _wf_url("/query-workflows")
1943
+ query_resp = make_api_request(
1944
+ "POST",
1945
+ query_url,
1946
+ {"take": 1000, "filter": f'NAME == "{workflow_name}"'},
1947
+ )
1948
+ workflows = query_resp.json().get("workflows", [])
1949
+ matching = [w for w in workflows if w.get("name") == workflow_name]
1950
+ if not matching:
1951
+ click.echo(f"✗ Workflow '{workflow_name}' not found.", err=True)
1952
+ sys.exit(ExitCodes.NOT_FOUND)
1953
+ workflow_id = matching[0].get("id", "")
1954
+
1955
+ url = _wf_url(f"/workflows/{workflow_id}")
1956
+ try:
1957
+ resp = make_api_request("GET", url)
1958
+ data = resp.json()
1959
+
1960
+ if not data:
1961
+ click.echo(f"✗ Workflow '{workflow_id}' not found.", err=True)
1962
+ sys.exit(ExitCodes.NOT_FOUND)
1963
+
1964
+ if not output:
1965
+ wf_name = data.get("name", f"workflow-{workflow_id}")
1966
+ safe = sanitize_filename(wf_name, f"workflow-{workflow_id}")
1967
+ output = f"{safe}.json"
1968
+
1969
+ save_json_file(data, output)
1970
+ click.echo(f"✓ Workflow exported to {output}")
1971
+ except Exception as exc:
1972
+ handle_api_error(exc)
1973
+
1974
+ @workflow_group.command(name="import")
1975
+ @click.option("--file", "input_file", required=True, help="Input JSON file")
1976
+ @click.option(
1977
+ "--workspace",
1978
+ "-w",
1979
+ help="Override workspace name or ID (uses value from file if not specified)",
1980
+ )
1981
+ def import_workflow(input_file: str, workspace: Optional[str]) -> None:
1982
+ """Import a workflow from JSON.
1983
+
1984
+ Workspace can be specified via --workspace or in the JSON file.
1985
+ The command line takes precedence over the file.
1986
+ """
1987
+ from .utils import check_readonly_mode
1988
+
1989
+ check_readonly_mode("import a workflow")
1990
+
1991
+ url = _wf_url("/workflows")
1992
+ allowed_fields = {"name", "description", "actions", "states", "workspace"}
1993
+
1994
+ try:
1995
+ data = load_json_file(input_file)
1996
+ filtered_data = {k: v for k, v in data.items() if k in allowed_fields}
1997
+
1998
+ if workspace:
1999
+ try:
2000
+ ws_id = get_workspace_id_with_fallback(workspace)
2001
+ filtered_data["workspace"] = ws_id
2002
+ except Exception as exc:
2003
+ click.echo(f"✗ Error resolving workspace '{workspace}': {exc}", err=True)
2004
+ sys.exit(ExitCodes.NOT_FOUND)
2005
+ elif "workspace" not in filtered_data or not filtered_data["workspace"]:
2006
+ workspace = get_effective_workspace(None)
2007
+ if workspace:
2008
+ try:
2009
+ ws_id = get_workspace_id_with_fallback(workspace)
2010
+ filtered_data["workspace"] = ws_id
2011
+ except Exception as exc:
2012
+ click.echo(f"✗ Error resolving workspace '{workspace}': {exc}", err=True)
2013
+ sys.exit(ExitCodes.NOT_FOUND)
2014
+ else:
2015
+ click.echo(
2016
+ "✗ Workspace required. Use --workspace or include 'workspace' in JSON.",
2017
+ err=True,
2018
+ )
2019
+ sys.exit(ExitCodes.INVALID_INPUT)
2020
+ elif filtered_data["workspace"] and not filtered_data["workspace"].startswith("//"):
2021
+ try:
2022
+ ws_id = get_workspace_id_with_fallback(filtered_data["workspace"])
2023
+ filtered_data["workspace"] = ws_id
2024
+ except Exception as exc:
2025
+ click.echo(
2026
+ f"✗ Error resolving workspace '{filtered_data['workspace']}': {exc}",
2027
+ err=True,
2028
+ )
2029
+ sys.exit(ExitCodes.NOT_FOUND)
2030
+
2031
+ try:
2032
+ resp = make_api_request("POST", url, filtered_data, handle_errors=False)
2033
+ if resp.status_code == 201:
2034
+ response_data = resp.json() if resp.text.strip() else {}
2035
+ wf_id = response_data.get("id", "")
2036
+ if wf_id:
2037
+ click.echo(f"✓ Workflow imported successfully with ID: {wf_id}")
2038
+ else:
2039
+ click.echo("✓ Workflow imported successfully.")
2040
+ else:
2041
+ response_data = resp.json() if resp.text.strip() else {}
2042
+ _handle_workflow_error_response(response_data, "Workflow import failed")
2043
+ except requests.exceptions.HTTPError as http_exc:
2044
+ if hasattr(http_exc, "response") and http_exc.response is not None:
2045
+ try:
2046
+ response_data = (
2047
+ http_exc.response.json() if http_exc.response.text.strip() else {}
2048
+ )
2049
+ _handle_workflow_error_response(response_data, "Workflow import failed")
2050
+ except Exception:
2051
+ handle_api_error(http_exc)
2052
+ else:
2053
+ handle_api_error(http_exc)
2054
+
2055
+ except Exception as exc:
2056
+ handle_api_error(exc)
2057
+
2058
+ @workflow_group.command(name="delete")
2059
+ @click.option("--id", "-i", "workflow_id", required=True, help="Workflow ID to delete")
2060
+ @click.confirmation_option(prompt="Are you sure you want to delete this workflow?")
2061
+ def delete_workflow(workflow_id: str) -> None:
2062
+ """Delete a workflow by ID."""
2063
+ from .utils import check_readonly_mode
2064
+
2065
+ check_readonly_mode("delete a workflow")
2066
+
2067
+ url = _wf_url("/delete-workflows")
2068
+ payload = {"ids": [workflow_id]}
2069
+ try:
2070
+ try:
2071
+ resp = make_api_request("POST", url, payload, handle_errors=False)
2072
+ if resp.status_code in (200, 204):
2073
+ response_data = resp.json() if resp.text.strip() else {}
2074
+ _handle_workflow_delete_response(response_data, workflow_id)
2075
+ else:
2076
+ response_data = resp.json() if resp.text.strip() else {}
2077
+ _handle_workflow_delete_response(response_data, workflow_id)
2078
+ except requests.exceptions.HTTPError as http_exc:
2079
+ if hasattr(http_exc, "response") and http_exc.response is not None:
2080
+ try:
2081
+ response_data = (
2082
+ http_exc.response.json() if http_exc.response.text.strip() else {}
2083
+ )
2084
+ _handle_workflow_delete_response(response_data, workflow_id)
2085
+ except Exception:
2086
+ handle_api_error(http_exc)
2087
+ else:
2088
+ handle_api_error(http_exc)
2089
+ except Exception as exc:
2090
+ handle_api_error(exc)
2091
+
2092
+ @workflow_group.command(name="update")
2093
+ @click.option("--id", "-i", "workflow_id", required=True, help="Workflow ID to update")
2094
+ @click.option(
2095
+ "--file",
2096
+ "-f",
2097
+ "input_file",
2098
+ required=True,
2099
+ help="JSON file with updated workflow data",
2100
+ )
2101
+ @click.option("--workspace", "-w", help="Override workspace name or ID")
2102
+ def update_workflow(workflow_id: str, input_file: str, workspace: Optional[str]) -> None:
2103
+ """Update a workflow from JSON."""
2104
+ from .utils import check_readonly_mode
2105
+
2106
+ check_readonly_mode("update a workflow")
2107
+
2108
+ url = _wf_url(f"/workflows/{workflow_id}")
2109
+ allowed_fields = {"name", "description", "actions", "states", "workspace"}
2110
+ try:
2111
+ data = load_json_file(input_file)
2112
+ filtered_data = {k: v for k, v in data.items() if k in allowed_fields}
2113
+
2114
+ if workspace:
2115
+ try:
2116
+ ws_id = get_workspace_id_with_fallback(workspace)
2117
+ filtered_data["workspace"] = ws_id
2118
+ except Exception as exc:
2119
+ click.echo(f"✗ Error resolving workspace '{workspace}': {exc}", err=True)
2120
+ sys.exit(ExitCodes.NOT_FOUND)
2121
+ elif "workspace" not in filtered_data or not filtered_data.get("workspace"):
2122
+ workspace = get_effective_workspace(None)
2123
+ if workspace:
2124
+ try:
2125
+ ws_id = get_workspace_id_with_fallback(workspace)
2126
+ filtered_data["workspace"] = ws_id
2127
+ except Exception as exc:
2128
+ click.echo(f"✗ Error resolving workspace '{workspace}': {exc}", err=True)
2129
+ sys.exit(ExitCodes.NOT_FOUND)
2130
+ elif (
2131
+ "workspace" in filtered_data
2132
+ and filtered_data["workspace"]
2133
+ and not filtered_data["workspace"].startswith("//")
2134
+ ):
2135
+ try:
2136
+ ws_id = get_workspace_id_with_fallback(filtered_data["workspace"])
2137
+ filtered_data["workspace"] = ws_id
2138
+ except Exception as exc:
2139
+ click.echo(
2140
+ f"✗ Error resolving workspace '{filtered_data['workspace']}': {exc}",
2141
+ err=True,
2142
+ )
2143
+ sys.exit(ExitCodes.NOT_FOUND)
2144
+
2145
+ try:
2146
+ resp = make_api_request("PUT", url, filtered_data, handle_errors=False)
2147
+ if resp.status_code == 200:
2148
+ click.echo(f"✓ Workflow {workflow_id} updated successfully.")
2149
+ else:
2150
+ response_data = resp.json() if resp.text.strip() else {}
2151
+ _handle_workflow_error_response(response_data, "Workflow update failed")
2152
+ except requests.exceptions.HTTPError as http_exc:
2153
+ if hasattr(http_exc, "response") and http_exc.response is not None:
2154
+ try:
2155
+ response_data = (
2156
+ http_exc.response.json() if http_exc.response.text.strip() else {}
2157
+ )
2158
+ _handle_workflow_error_response(response_data, "Workflow update failed")
2159
+ except Exception:
2160
+ handle_api_error(http_exc)
2161
+ else:
2162
+ handle_api_error(http_exc)
2163
+
2164
+ except Exception as exc:
2165
+ handle_api_error(exc)
2166
+
2167
+ @workflow_group.command(name="preview")
2168
+ @click.option("--id", "-i", "workflow_id", help="Workflow ID to preview")
2169
+ @click.option(
2170
+ "--file",
2171
+ "-f",
2172
+ "input_file",
2173
+ help="Local JSON file to preview (use '-' for stdin)",
2174
+ )
2175
+ @click.option("--output", "-o", help="Output file path (default: open in browser)")
2176
+ @click.option(
2177
+ "--format",
2178
+ type=click.Choice(["html", "mmd"]),
2179
+ default="html",
2180
+ show_default=True,
2181
+ help="Output format",
2182
+ )
2183
+ @click.option("--no-emoji", is_flag=True, default=False, help="Disable emoji in action labels")
2184
+ @click.option("--no-legend", is_flag=True, default=False, help="Disable legend in HTML output")
2185
+ @click.option(
2186
+ "--no-open",
2187
+ is_flag=True,
2188
+ default=False,
2189
+ help="Do not auto-open browser when no --output provided",
2190
+ )
2191
+ def preview_workflow(
2192
+ workflow_id: Optional[str],
2193
+ input_file: Optional[str],
2194
+ output: Optional[str],
2195
+ format: str,
2196
+ no_emoji: bool,
2197
+ no_legend: bool,
2198
+ no_open: bool,
2199
+ ) -> None:
2200
+ """Preview a workflow as an HTML diagram or Mermaid file."""
2201
+ if bool(workflow_id) == bool(input_file):
2202
+ click.echo(
2203
+ "✗ Must specify exactly one of --id or --file (use --file - for stdin)",
2204
+ err=True,
2205
+ )
2206
+ sys.exit(ExitCodes.INVALID_INPUT)
2207
+
2208
+ try:
2209
+ workflow_data: Dict[str, Any]
2210
+ if workflow_id:
2211
+ url = _wf_url(f"/workflows/{workflow_id}")
2212
+ resp = make_api_request("GET", url)
2213
+ workflow_data = resp.json()
2214
+ if not workflow_data:
2215
+ click.echo(f"✗ Workflow with ID {workflow_id} not found.", err=True)
2216
+ sys.exit(ExitCodes.NOT_FOUND)
2217
+ else:
2218
+ if input_file == "-":
2219
+ try:
2220
+ workflow_data = json.loads(sys.stdin.read())
2221
+ except json.JSONDecodeError as exc:
2222
+ click.echo(f"✗ Invalid JSON from stdin: {exc}", err=True)
2223
+ sys.exit(ExitCodes.INVALID_INPUT)
2224
+ else:
2225
+ assert input_file is not None
2226
+ workflow_data = load_json_file(input_file)
2227
+
2228
+ mermaid_code = workflow_preview.generate_mermaid_diagram(
2229
+ workflow_data, enable_emoji=not no_emoji
2230
+ )
2231
+
2232
+ if format == "mmd":
2233
+ if not output:
2234
+ output = f"workflow-{workflow_data.get('name', 'preview')}.mmd"
2235
+ with open(output, "w", encoding="utf-8") as fh:
2236
+ fh.write(mermaid_code)
2237
+ click.echo(f"✓ Mermaid diagram saved to {output}")
2238
+ else:
2239
+ html_content = workflow_preview.generate_html_with_mermaid(
2240
+ workflow_data, mermaid_code, include_legend=not no_legend
2241
+ )
2242
+ if output:
2243
+ with open(output, "w", encoding="utf-8") as fh:
2244
+ fh.write(html_content)
2245
+ click.echo(f"✓ HTML preview saved to {output}")
2246
+ elif not no_open:
2247
+ with tempfile.NamedTemporaryFile(
2248
+ mode="w", suffix=".html", delete=False, encoding="utf-8"
2249
+ ) as fh:
2250
+ fh.write(html_content)
2251
+ temp_file = fh.name
2252
+ webbrowser.open(f"file://{Path(temp_file).absolute()}")
2253
+ click.echo("✓ Opening workflow preview in browser...")
2254
+ else:
2255
+ click.echo(html_content)
2256
+
2257
+ except Exception as exc:
2258
+ handle_api_error(exc)