systemlink-cli 1.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. slcli/__init__.py +1 -0
  2. slcli/__main__.py +23 -0
  3. slcli/_version.py +4 -0
  4. slcli/asset_click.py +1289 -0
  5. slcli/cli_formatters.py +218 -0
  6. slcli/cli_utils.py +504 -0
  7. slcli/comment_click.py +602 -0
  8. slcli/completion_click.py +418 -0
  9. slcli/config.py +81 -0
  10. slcli/config_click.py +498 -0
  11. slcli/dff_click.py +979 -0
  12. slcli/dff_decorators.py +24 -0
  13. slcli/example_click.py +404 -0
  14. slcli/example_loader.py +274 -0
  15. slcli/example_provisioner.py +2777 -0
  16. slcli/examples/README.md +134 -0
  17. slcli/examples/_schema/schema-v1.0.json +169 -0
  18. slcli/examples/demo-complete-workflow/README.md +323 -0
  19. slcli/examples/demo-complete-workflow/config.yaml +638 -0
  20. slcli/examples/demo-test-plans/README.md +132 -0
  21. slcli/examples/demo-test-plans/config.yaml +154 -0
  22. slcli/examples/exercise-5-1-parametric-insights/README.md +101 -0
  23. slcli/examples/exercise-5-1-parametric-insights/config.yaml +1589 -0
  24. slcli/examples/exercise-7-1-test-plans/README.md +93 -0
  25. slcli/examples/exercise-7-1-test-plans/config.yaml +323 -0
  26. slcli/examples/spec-compliance-notebooks/README.md +140 -0
  27. slcli/examples/spec-compliance-notebooks/config.yaml +112 -0
  28. slcli/examples/spec-compliance-notebooks/notebooks/SpecAnalysis_ComplianceCalculation.ipynb +1553 -0
  29. slcli/examples/spec-compliance-notebooks/notebooks/SpecComplianceCalculation.ipynb +1577 -0
  30. slcli/examples/spec-compliance-notebooks/notebooks/SpecfileExtractionAndIngestion.ipynb +912 -0
  31. slcli/examples/spec-compliance-notebooks/spec_template.xlsx +0 -0
  32. slcli/feed_click.py +892 -0
  33. slcli/file_click.py +932 -0
  34. slcli/function_click.py +1400 -0
  35. slcli/function_templates.py +85 -0
  36. slcli/main.py +406 -0
  37. slcli/mcp_click.py +269 -0
  38. slcli/mcp_server.py +748 -0
  39. slcli/notebook_click.py +1770 -0
  40. slcli/platform.py +345 -0
  41. slcli/policy_click.py +679 -0
  42. slcli/policy_utils.py +411 -0
  43. slcli/profiles.py +411 -0
  44. slcli/response_handlers.py +359 -0
  45. slcli/routine_click.py +763 -0
  46. slcli/skill_click.py +253 -0
  47. slcli/skills/slcli/SKILL.md +713 -0
  48. slcli/skills/slcli/references/analysis-recipes.md +474 -0
  49. slcli/skills/slcli/references/filtering.md +236 -0
  50. slcli/skills/systemlink-webapp/SKILL.md +744 -0
  51. slcli/skills/systemlink-webapp/references/deployment.md +123 -0
  52. slcli/skills/systemlink-webapp/references/nimble-angular.md +380 -0
  53. slcli/skills/systemlink-webapp/references/systemlink-services.md +192 -0
  54. slcli/ssl_trust.py +93 -0
  55. slcli/system_click.py +2216 -0
  56. slcli/table_utils.py +124 -0
  57. slcli/tag_click.py +794 -0
  58. slcli/templates_click.py +599 -0
  59. slcli/testmonitor_click.py +1667 -0
  60. slcli/universal_handlers.py +305 -0
  61. slcli/user_click.py +1218 -0
  62. slcli/utils.py +832 -0
  63. slcli/web_editor.py +295 -0
  64. slcli/webapp_click.py +981 -0
  65. slcli/workflow_preview.py +287 -0
  66. slcli/workflows_click.py +988 -0
  67. slcli/workitem_click.py +2258 -0
  68. slcli/workspace_click.py +576 -0
  69. slcli/workspace_utils.py +206 -0
  70. systemlink_cli-1.3.1.dist-info/METADATA +20 -0
  71. systemlink_cli-1.3.1.dist-info/RECORD +74 -0
  72. systemlink_cli-1.3.1.dist-info/WHEEL +4 -0
  73. systemlink_cli-1.3.1.dist-info/entry_points.txt +7 -0
  74. systemlink_cli-1.3.1.dist-info/licenses/LICENSE +21 -0
slcli/tag_click.py ADDED
@@ -0,0 +1,794 @@
1
+ """CLI commands for managing SystemLink tags.
2
+
3
+ Provides CLI commands for creating, reading, updating, deleting, and managing
4
+ tag values. All tag operations are scoped to workspaces with proper error handling.
5
+ """
6
+
7
+ import json
8
+ import shutil
9
+ import sys
10
+ import urllib.parse
11
+ from typing import Any, Dict, List, Optional, Tuple
12
+
13
+ import click
14
+ import questionary
15
+
16
+ from .cli_utils import validate_output_format
17
+ from .universal_handlers import FilteredResponse, UniversalResponseHandler
18
+ from .utils import (
19
+ ExitCodes,
20
+ format_success,
21
+ get_base_url,
22
+ handle_api_error,
23
+ make_api_request,
24
+ )
25
+ from .workspace_utils import resolve_workspace_id
26
+
27
+
28
+ def _tag_formatter(item: Dict[str, Any]) -> List[str]:
29
+ """Format a tag for table output.
30
+
31
+ Args:
32
+ item: Tag dictionary
33
+
34
+ Returns:
35
+ List of formatted column values
36
+ """
37
+ # item might be a TagWithValue object where tag props are in 'tag' field
38
+ tag_data = item.get("tag", item)
39
+
40
+ path = tag_data.get("path", "")
41
+ tag_type = tag_data.get("type", "")
42
+ last_updated = tag_data.get("lastUpdated", "")
43
+
44
+ # Get current value
45
+ current = item.get("current", {})
46
+ if current and current.get("value"):
47
+ value_obj = current.get("value", {})
48
+ value = str(value_obj.get("value", "N/A"))
49
+ else:
50
+ value = "N/A"
51
+
52
+ return [path, tag_type, value, last_updated]
53
+
54
+
55
+ def _calculate_column_widths() -> List[int]:
56
+ """Calculate dynamic column widths based on terminal size.
57
+
58
+ Returns:
59
+ List of column widths: [path_width, type_width, value_width, last_updated_width]
60
+ """
61
+ # Get terminal width, default to 120 if detection fails
62
+ try:
63
+ terminal_width = shutil.get_terminal_size().columns
64
+ except Exception:
65
+ terminal_width = 120
66
+
67
+ # Fixed widths for non-path columns
68
+ type_width = 12
69
+ value_width = 30
70
+ last_updated_width = 20
71
+
72
+ # Account for table borders and padding for 4 columns.
73
+ # Row layout: "│ {col1} │ {col2} │ {col3} │ {col4} │"
74
+ # This is 5 vertical bars (│) and 8 spaces (2 per column) = 13 characters.
75
+ # Using 14 to account for terminal rendering variations.
76
+ border_overhead = 14
77
+
78
+ # Calculate remaining space for path
79
+ fixed_columns = type_width + value_width + last_updated_width
80
+ path_width = terminal_width - fixed_columns - border_overhead
81
+
82
+ # Ensure minimum path width of 30, maximum of 100
83
+ path_width = max(30, min(100, path_width))
84
+
85
+ return [path_width, type_width, value_width, last_updated_width]
86
+
87
+
88
+ def _escape_query_value(value: str) -> str:
89
+ """Escape double quotes in query filter values.
90
+
91
+ Args:
92
+ value: Raw filter value
93
+
94
+ Returns:
95
+ Escaped value safe for use in query strings
96
+ """
97
+ return value.replace('"', '\\"')
98
+
99
+
100
+ def _parse_keywords(keywords: Optional[str]) -> List[str]:
101
+ """Parse comma-separated keywords string into list.
102
+
103
+ Args:
104
+ keywords: Comma-separated keywords string
105
+
106
+ Returns:
107
+ List of trimmed keyword strings
108
+ """
109
+ if not keywords:
110
+ return []
111
+ return [k.strip() for k in keywords.split(",") if k.strip()]
112
+
113
+
114
+ def _parse_properties(properties: tuple) -> Dict[str, str]:
115
+ """Parse properties tuple into dictionary.
116
+
117
+ Args:
118
+ properties: Tuple of key=value strings
119
+
120
+ Returns:
121
+ Dictionary of property key-value pairs
122
+
123
+ Raises:
124
+ ValueError: If property format is invalid
125
+ """
126
+ properties_dict: Dict[str, str] = {}
127
+ for prop in properties:
128
+ if "=" not in prop:
129
+ raise ValueError(f"Invalid property format: {prop}. Use key=value")
130
+ key, val = prop.split("=", 1)
131
+ properties_dict[key.strip()] = val.strip()
132
+ return properties_dict
133
+
134
+
135
+ def _detect_value_type(value_str: str) -> Tuple[Any, str]:
136
+ """Detect the type of a value from its string representation.
137
+
138
+ Args:
139
+ value_str: String representation of the value
140
+
141
+ Returns:
142
+ Tuple of (converted_value, type_string) where type_string is
143
+ 'BOOLEAN', 'INT', 'DOUBLE', or 'STRING'
144
+ """
145
+ # Check for boolean
146
+ if value_str.lower() in ("true", "false"):
147
+ is_true = value_str.lower() == "true"
148
+ return is_true, "BOOLEAN"
149
+
150
+ # Check for integer (excluding scientific notation)
151
+ if "." not in value_str and "e" not in value_str.lower():
152
+ try:
153
+ int_val = int(value_str)
154
+ return int_val, "INT"
155
+ except ValueError:
156
+ pass
157
+
158
+ # Check for double/float
159
+ try:
160
+ float_val = float(value_str)
161
+ return float_val, "DOUBLE"
162
+ except ValueError:
163
+ pass
164
+
165
+ # Default to string
166
+ return value_str, "STRING"
167
+
168
+
169
+ def register_tag_commands(cli: Any) -> None:
170
+ """Register the 'tag' command group and its subcommands."""
171
+
172
+ @cli.group()
173
+ def tag() -> None:
174
+ """Manage SystemLink tags."""
175
+ pass
176
+
177
+ @tag.command(name="list")
178
+ @click.option(
179
+ "--workspace",
180
+ "-w",
181
+ type=str,
182
+ default=None,
183
+ help="Workspace ID or name (defaults to default workspace)",
184
+ )
185
+ @click.option(
186
+ "--format",
187
+ "-f",
188
+ type=click.Choice(["table", "json"]),
189
+ default="table",
190
+ show_default=True,
191
+ help="Output format",
192
+ )
193
+ @click.option(
194
+ "--filter",
195
+ type=str,
196
+ default=None,
197
+ help="Filter by tag path substring (e.g., 'temperature')",
198
+ )
199
+ @click.option(
200
+ "--keywords",
201
+ type=str,
202
+ default=None,
203
+ help="Comma-separated keywords to filter by",
204
+ )
205
+ @click.option(
206
+ "--take",
207
+ "-t",
208
+ type=int,
209
+ default=None,
210
+ help="Limit number of results (table: 25, json: 1000)",
211
+ )
212
+ def list_tags(
213
+ workspace: Optional[str],
214
+ format: str,
215
+ filter: Optional[str],
216
+ keywords: Optional[str],
217
+ take: Optional[int],
218
+ ) -> None:
219
+ """List tags in a workspace with optional filtering."""
220
+ validate_output_format(format)
221
+
222
+ try:
223
+ ws_id = resolve_workspace_id(workspace)
224
+
225
+ # Build filter string
226
+ filter_parts = []
227
+
228
+ # Only add workspace filter if workspace is provided
229
+ if ws_id:
230
+ filter_parts.append(f'workspace = "{ws_id}"')
231
+
232
+ if filter:
233
+ escaped_filter = _escape_query_value(filter)
234
+ filter_parts.append(f'path = "*{escaped_filter}*"')
235
+
236
+ if keywords:
237
+ for k in keywords.split(","):
238
+ k_clean = k.strip()
239
+ if k_clean:
240
+ escaped_keyword = _escape_query_value(k_clean)
241
+ filter_parts.append(f'keywords.Contains("{escaped_keyword}")')
242
+
243
+ query_filter = " && ".join(filter_parts)
244
+
245
+ # Set defaults based on format
246
+ if take is None:
247
+ take = 25 if format == "table" else 1000
248
+
249
+ # Build query request
250
+ query_params: Dict[str, Any] = {
251
+ "filter": query_filter,
252
+ "take": take,
253
+ "orderBy": "TIMESTAMP",
254
+ "descending": True,
255
+ }
256
+
257
+ url = f"{get_base_url()}/nitag/v2/query-tags-with-values"
258
+ resp = make_api_request("POST", url, payload=query_params)
259
+ data = resp.json()
260
+
261
+ tags = data.get("tagsWithValues", [])
262
+ total_count = data.get("totalCount", len(tags))
263
+ continuation_token = data.get("continuationToken")
264
+
265
+ # For table format with continuation, show interactive pagination
266
+ if format == "table" and continuation_token:
267
+ from .table_utils import output_formatted_list
268
+
269
+ cumulative_count = 0
270
+ column_widths = _calculate_column_widths()
271
+
272
+ while True:
273
+ # Display current page
274
+ output_formatted_list(
275
+ items=tags,
276
+ output_format="table",
277
+ headers=["Path", "Type", "Value", "Last Updated"],
278
+ row_formatter_func=_tag_formatter,
279
+ column_widths=column_widths,
280
+ )
281
+
282
+ # Update cumulative count and show pagination info
283
+ cumulative_count += len(tags)
284
+ click.echo(f"\nShowing {cumulative_count} of {total_count} tags")
285
+
286
+ # Check if there are more results
287
+ if not continuation_token:
288
+ break
289
+
290
+ # Ask if user wants more
291
+ if questionary.confirm(f"Show next {take} results?", default=True).ask():
292
+ query_params["continuationToken"] = continuation_token
293
+ resp = make_api_request("POST", url, payload=query_params)
294
+ data = resp.json()
295
+ tags = data.get("tagsWithValues", [])
296
+ continuation_token = data.get("continuationToken")
297
+
298
+ if not tags:
299
+ click.echo("No more results.")
300
+ break
301
+ else:
302
+ break
303
+ else:
304
+ # No continuation or JSON format - use standard handler
305
+ column_widths = _calculate_column_widths()
306
+ combined_resp = FilteredResponse({"tagsWithValues": tags})
307
+ UniversalResponseHandler.handle_list_response(
308
+ resp=combined_resp,
309
+ data_key="tagsWithValues",
310
+ item_name="tag",
311
+ format_output=format,
312
+ formatter_func=_tag_formatter,
313
+ headers=["Path", "Type", "Value", "Last Updated"],
314
+ column_widths=column_widths,
315
+ enable_pagination=False,
316
+ page_size=25,
317
+ )
318
+
319
+ if format == "table" and total_count > len(tags):
320
+ click.echo(f"\nShowing {len(tags)} of {total_count} tags")
321
+
322
+ except Exception as exc:
323
+ handle_api_error(exc)
324
+
325
+ @tag.command(name="get")
326
+ @click.argument("tag_path")
327
+ @click.option(
328
+ "--workspace",
329
+ "-w",
330
+ type=str,
331
+ default=None,
332
+ help="Workspace ID or name (defaults to default workspace)",
333
+ )
334
+ @click.option(
335
+ "--include-aggregates",
336
+ is_flag=True,
337
+ help="Include min/max/avg/count aggregates",
338
+ )
339
+ def get_tag(tag_path: str, workspace: Optional[str], include_aggregates: bool) -> None:
340
+ """View tag metadata and current value.
341
+
342
+ TAG_PATH is the path identifier of the tag (e.g., 'system.temperature').
343
+ """
344
+ try:
345
+ ws_id = resolve_workspace_id(workspace)
346
+ encoded_path = urllib.parse.quote(tag_path, safe="")
347
+ ws_path = f"{ws_id}/" if ws_id else ""
348
+
349
+ # Get tag metadata
350
+ url = f"{get_base_url()}/nitag/v2/tags/{ws_path}{encoded_path}"
351
+ tag_resp = make_api_request("GET", url, payload=None)
352
+ tag_data = tag_resp.json()
353
+
354
+ # Get tag value with aggregates
355
+ value_url = f"{get_base_url()}/nitag/v2/tags/{ws_path}{encoded_path}/values"
356
+ value_resp = make_api_request("GET", value_url, payload=None)
357
+
358
+ # Handle 204 No Content (tag has no value yet)
359
+ value_data = {} if value_resp.status_code == 204 else value_resp.json()
360
+
361
+ click.echo(f"\nāœ“ Tag: {tag_path}")
362
+ click.echo("-" * 60)
363
+ click.echo(f" Type: {tag_data.get('type', 'Unknown')}")
364
+ click.echo(f" Workspace: {ws_id}")
365
+
366
+ keywords = tag_data.get("keywords", [])
367
+ if keywords:
368
+ click.echo(f" Keywords: {', '.join(keywords)}")
369
+
370
+ properties = tag_data.get("properties", {})
371
+ if properties:
372
+ click.echo(f" Properties:")
373
+ for key, val in properties.items():
374
+ click.echo(f" {key}: {val}")
375
+
376
+ click.echo(f" Last Updated: {tag_data.get('lastUpdated', 'N/A')}")
377
+ click.echo(f" Collect Aggregates: {tag_data.get('collectAggregates', False)}")
378
+
379
+ # Show current value
380
+ current = value_data.get("current")
381
+ if current:
382
+ value_obj = current.get("value", {})
383
+ click.echo(f"\n Current Value:")
384
+ click.echo(f" Value: {value_obj.get('value', 'N/A')}")
385
+ click.echo(f" Timestamp: {current.get('timestamp', 'N/A')}")
386
+ else:
387
+ click.echo(f"\n Current Value: No value assigned yet")
388
+
389
+ # Show aggregates if requested
390
+ if include_aggregates:
391
+ aggregates = value_data.get("aggregates", {})
392
+ if aggregates:
393
+ click.echo(f"\n Aggregates:")
394
+ click.echo(f" Min: {aggregates.get('min', 'N/A')}")
395
+ click.echo(f" Max: {aggregates.get('max', 'N/A')}")
396
+ click.echo(f" Avg: {aggregates.get('avg', 'N/A')}")
397
+ click.echo(f" Count: {aggregates.get('count', 'N/A')}")
398
+ click.echo()
399
+
400
+ except Exception as exc:
401
+ handle_api_error(exc)
402
+
403
+ @tag.command(name="create")
404
+ @click.argument("tag_path")
405
+ @click.option(
406
+ "--type",
407
+ "-t",
408
+ "tag_type",
409
+ type=click.Choice(["DOUBLE", "INT", "STRING", "BOOLEAN", "U_INT64", "DATE_TIME"]),
410
+ required=True,
411
+ help="Tag data type",
412
+ )
413
+ @click.option(
414
+ "--workspace",
415
+ "-w",
416
+ type=str,
417
+ default=None,
418
+ help="Workspace ID or name (defaults to default workspace)",
419
+ )
420
+ @click.option(
421
+ "--keywords",
422
+ "-k",
423
+ type=str,
424
+ default=None,
425
+ help="Comma-separated keywords",
426
+ )
427
+ @click.option(
428
+ "--properties",
429
+ "-p",
430
+ type=str,
431
+ multiple=True,
432
+ help="Properties as key=value (can be used multiple times)",
433
+ )
434
+ @click.option(
435
+ "--collect-aggregates",
436
+ is_flag=True,
437
+ help="Enable aggregate value collection",
438
+ )
439
+ def create_tag(
440
+ tag_path: str,
441
+ tag_type: str,
442
+ workspace: Optional[str],
443
+ keywords: Optional[str],
444
+ properties: tuple,
445
+ collect_aggregates: bool,
446
+ ) -> None:
447
+ """Create a new tag."""
448
+ from .utils import check_readonly_mode
449
+
450
+ check_readonly_mode("create a tag")
451
+
452
+ try:
453
+ ws_id = resolve_workspace_id(workspace)
454
+
455
+ # Parse keywords and properties
456
+ keywords_list = _parse_keywords(keywords)
457
+ properties_dict = _parse_properties(properties)
458
+
459
+ # Create tag payload
460
+ tag_payload = {
461
+ "path": tag_path,
462
+ "type": tag_type,
463
+ "workspace": ws_id,
464
+ "collectAggregates": collect_aggregates,
465
+ }
466
+
467
+ if keywords_list:
468
+ tag_payload["keywords"] = keywords_list
469
+
470
+ if properties_dict:
471
+ tag_payload["properties"] = properties_dict
472
+
473
+ encoded_path = urllib.parse.quote(tag_path, safe="")
474
+ ws_path = f"{ws_id}/" if ws_id else ""
475
+ url = f"{get_base_url()}/nitag/v2/tags/{ws_path}{encoded_path}"
476
+ make_api_request("PUT", url, payload=tag_payload)
477
+
478
+ format_success("Tag created", {"path": tag_path, "type": tag_type, "workspace": ws_id})
479
+
480
+ except Exception as exc:
481
+ handle_api_error(exc)
482
+
483
+ @tag.command(name="update")
484
+ @click.argument("tag_path")
485
+ @click.option(
486
+ "--workspace",
487
+ "-w",
488
+ type=str,
489
+ default=None,
490
+ help="Workspace ID or name (defaults to default workspace)",
491
+ )
492
+ @click.option(
493
+ "--keywords",
494
+ "-k",
495
+ type=str,
496
+ default=None,
497
+ help="Comma-separated keywords",
498
+ )
499
+ @click.option(
500
+ "--properties",
501
+ "-p",
502
+ type=str,
503
+ multiple=True,
504
+ help="Properties as key=value (can be used multiple times)",
505
+ )
506
+ @click.option(
507
+ "--merge",
508
+ is_flag=True,
509
+ help="Merge with existing keywords/properties (vs replace)",
510
+ )
511
+ def update_tag(
512
+ tag_path: str,
513
+ workspace: Optional[str],
514
+ keywords: Optional[str],
515
+ properties: tuple,
516
+ merge: bool,
517
+ ) -> None:
518
+ """Update tag metadata (keywords, properties)."""
519
+ from .utils import check_readonly_mode
520
+
521
+ check_readonly_mode("update a tag")
522
+
523
+ try:
524
+ ws_id = resolve_workspace_id(workspace)
525
+
526
+ # Parse keywords and properties
527
+ keywords_list = _parse_keywords(keywords)
528
+ properties_dict = _parse_properties(properties)
529
+
530
+ # Validate at least one field is provided
531
+ if not keywords_list and not properties_dict:
532
+ click.echo(
533
+ "āœ— Error: At least one of --keywords or --properties must be specified",
534
+ err=True,
535
+ )
536
+ sys.exit(ExitCodes.INVALID_INPUT)
537
+
538
+ # Create update payload
539
+ tag_update: Dict[str, Any] = {
540
+ "path": tag_path,
541
+ }
542
+ if keywords_list:
543
+ tag_update["keywords"] = keywords_list
544
+ if properties_dict:
545
+ tag_update["properties"] = properties_dict
546
+
547
+ update_payload = {
548
+ "tags": [tag_update],
549
+ "merge": merge,
550
+ }
551
+
552
+ url = f"{get_base_url()}/nitag/v2/update-tags"
553
+ make_api_request("POST", url, payload=update_payload)
554
+
555
+ format_success("Tag updated", {"path": tag_path, "workspace": ws_id})
556
+
557
+ except Exception as exc:
558
+ handle_api_error(exc)
559
+
560
+ @tag.command(name="delete")
561
+ @click.argument("tag_path")
562
+ @click.option(
563
+ "--workspace",
564
+ "-w",
565
+ type=str,
566
+ default=None,
567
+ help="Workspace ID or name (defaults to default workspace)",
568
+ )
569
+ @click.confirmation_option(prompt="Are you sure you want to delete this tag?")
570
+ def delete_tag(tag_path: str, workspace: Optional[str]) -> None:
571
+ """Delete a tag.
572
+
573
+ TAG_PATH is the path identifier of the tag to delete.
574
+ """
575
+ from .utils import check_readonly_mode
576
+
577
+ check_readonly_mode("delete a tag")
578
+
579
+ try:
580
+ ws_id = resolve_workspace_id(workspace)
581
+ encoded_path = urllib.parse.quote(tag_path, safe="")
582
+ ws_path = f"{ws_id}/" if ws_id else ""
583
+
584
+ url = f"{get_base_url()}/nitag/v2/tags/{ws_path}{encoded_path}"
585
+ make_api_request("DELETE", url, payload=None)
586
+
587
+ format_success("Tag deleted", {"path": tag_path, "workspace": ws_id})
588
+
589
+ except Exception as exc:
590
+ handle_api_error(exc)
591
+
592
+ @tag.command(name="set-value")
593
+ @click.argument("tag_path")
594
+ @click.argument("value")
595
+ @click.option(
596
+ "--workspace",
597
+ "-w",
598
+ type=str,
599
+ default=None,
600
+ help="Workspace ID or name (defaults to default workspace)",
601
+ )
602
+ @click.option(
603
+ "--type",
604
+ "-t",
605
+ "data_type",
606
+ type=click.Choice(["DOUBLE", "INT", "STRING", "BOOLEAN", "U_INT64", "DATE_TIME"]),
607
+ default=None,
608
+ help="Override the value data type (auto-detected from the tag definition by default)",
609
+ )
610
+ @click.option(
611
+ "--timestamp",
612
+ type=str,
613
+ default=None,
614
+ help="Timestamp in ISO-8601 format (defaults to now)",
615
+ )
616
+ def set_tag_value(
617
+ tag_path: str,
618
+ value: str,
619
+ workspace: Optional[str],
620
+ data_type: Optional[str],
621
+ timestamp: Optional[str],
622
+ ) -> None:
623
+ """Write a value to a tag.
624
+
625
+ TAG_PATH is the path identifier of the tag.
626
+ VALUE is the value to write.
627
+
628
+ If you receive a "Conflict" error, the inferred type does not match the
629
+ tag's registered data type. Use --type to specify the correct type explicitly.
630
+
631
+ \b
632
+ Auto-detected value type (when --type is not provided):
633
+ - 'true' or 'false' (case-insensitive) -> BOOLEAN
634
+ - Integer numbers -> INT
635
+ - Decimal numbers -> DOUBLE
636
+ - Everything else -> STRING
637
+ """
638
+ try:
639
+ ws_id = resolve_workspace_id(workspace)
640
+ encoded_path = urllib.parse.quote(tag_path, safe="")
641
+ ws_path = f"{ws_id}/" if ws_id else ""
642
+
643
+ if data_type:
644
+ # User explicitly specified the type — no need to fetch metadata
645
+ tag_type: Optional[str] = data_type
646
+ else:
647
+ # Retrieve tag metadata to align value type with the tag definition
648
+ tag_meta_url = f"{get_base_url()}/nitag/v2/tags/{ws_path}{encoded_path}"
649
+ tag_resp = make_api_request("GET", tag_meta_url, payload=None)
650
+ tag_data = tag_resp.json()
651
+ tag_type = tag_data.get("type")
652
+
653
+ # Detect value type and convert (used for converted_value and as fallback type)
654
+ converted_value, value_type = _detect_value_type(value)
655
+
656
+ # Always use the tag's registered type (or user-supplied --type) when available,
657
+ # so the API receives the correct type even if auto-detection disagrees.
658
+ if tag_type:
659
+ value_type = tag_type
660
+
661
+ # API expects value as string
662
+ api_value_str = value
663
+
664
+ # If the tag is U_INT64, enforce non-negative integer and set correct type
665
+ if tag_type == "U_INT64":
666
+ try:
667
+ numeric_val = int(value)
668
+ except ValueError:
669
+ click.echo(
670
+ "āœ— Error: U_INT64 tags require a non-negative integer value",
671
+ err=True,
672
+ )
673
+ sys.exit(ExitCodes.INVALID_INPUT)
674
+
675
+ if numeric_val < 0:
676
+ click.echo(
677
+ "āœ— Error: U_INT64 tags require a non-negative integer value",
678
+ err=True,
679
+ )
680
+ sys.exit(ExitCodes.INVALID_INPUT)
681
+
682
+ converted_value = numeric_val
683
+ api_value_str = value
684
+ elif tag_type == "DATE_TIME":
685
+ # For date-time tags, pass the value through as-is
686
+ converted_value = value
687
+ api_value_str = value
688
+ elif value_type == "BOOLEAN":
689
+ # Normalize boolean string values to lowercase
690
+ api_value_str = "true" if converted_value else "false"
691
+
692
+ # Create value payload
693
+ value_payload: Dict[str, Any] = {
694
+ "value": {
695
+ "value": api_value_str,
696
+ "type": value_type,
697
+ }
698
+ }
699
+
700
+ if timestamp:
701
+ value_payload["timestamp"] = timestamp
702
+
703
+ url = f"{get_base_url()}/nitag/v2/tags/{ws_path}{encoded_path}/values/current"
704
+ make_api_request("PUT", url, payload=value_payload)
705
+
706
+ # make_api_request raises on HTTP error status codes, so if we reach here it succeeded
707
+ format_success(
708
+ "Tag value updated",
709
+ {"path": tag_path, "value": converted_value, "type": value_type},
710
+ )
711
+
712
+ except Exception as exc:
713
+ handle_api_error(exc)
714
+
715
+ @tag.command(name="get-value")
716
+ @click.argument("tag_path")
717
+ @click.option(
718
+ "--workspace",
719
+ "-w",
720
+ type=str,
721
+ default=None,
722
+ help="Workspace ID or name (defaults to default workspace)",
723
+ )
724
+ @click.option(
725
+ "--include-aggregates",
726
+ is_flag=True,
727
+ help="Include min/max/avg/count aggregates",
728
+ )
729
+ @click.option(
730
+ "--format",
731
+ "-f",
732
+ type=click.Choice(["table", "json"]),
733
+ default="table",
734
+ show_default=True,
735
+ help="Output format",
736
+ )
737
+ def get_tag_value(
738
+ tag_path: str,
739
+ workspace: Optional[str],
740
+ include_aggregates: bool,
741
+ format: str,
742
+ ) -> None:
743
+ """Read the current value of a tag.
744
+
745
+ TAG_PATH is the path identifier of the tag.
746
+ """
747
+ validate_output_format(format)
748
+
749
+ try:
750
+ ws_id = resolve_workspace_id(workspace)
751
+ encoded_path = urllib.parse.quote(tag_path, safe="")
752
+ ws_path = f"{ws_id}/" if ws_id else ""
753
+
754
+ url = f"{get_base_url()}/nitag/v2/tags/{ws_path}{encoded_path}/values"
755
+ resp = make_api_request("GET", url, payload=None)
756
+
757
+ # Handle 204 No Content (tag has no value yet)
758
+ if resp.status_code == 204:
759
+ if format.lower() == "json":
760
+ click.echo("{}")
761
+ else:
762
+ click.echo("No value found")
763
+ return
764
+
765
+ value_data = resp.json()
766
+
767
+ if format.lower() == "json":
768
+ click.echo(json.dumps(value_data, indent=2))
769
+ else:
770
+ # Table format
771
+ current = value_data.get("current")
772
+ if not current:
773
+ click.echo("No value found")
774
+ return
775
+
776
+ value_obj = current.get("value", {})
777
+ click.echo(f"\nāœ“ Tag Value: {tag_path}")
778
+ click.echo("-" * 60)
779
+ click.echo(f" Value: {value_obj.get('value', 'N/A')}")
780
+ click.echo(f" Type: {value_obj.get('type', 'N/A')}")
781
+ click.echo(f" Timestamp: {current.get('timestamp', 'N/A')}")
782
+
783
+ if include_aggregates:
784
+ aggregates = value_data.get("aggregates", {})
785
+ if aggregates:
786
+ click.echo(f"\n Aggregates:")
787
+ click.echo(f" Min: {aggregates.get('min', 'N/A')}")
788
+ click.echo(f" Max: {aggregates.get('max', 'N/A')}")
789
+ click.echo(f" Avg: {aggregates.get('avg', 'N/A')}")
790
+ click.echo(f" Count: {aggregates.get('count', 'N/A')}")
791
+ click.echo()
792
+
793
+ except Exception as exc:
794
+ handle_api_error(exc)