systemlink-cli 1.13.3__tar.gz → 1.13.5__tar.gz

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 (90) hide show
  1. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/PKG-INFO +1 -1
  2. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/dff-editor/editor.js +96 -21
  3. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/pyproject.toml +1 -1
  4. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/_version.py +1 -1
  5. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/comment_click.py +1 -1
  6. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/completion_click.py +1 -1
  7. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/config_click.py +1 -1
  8. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/dataframe_click.py +1 -1
  9. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/dff_click.py +1 -1
  10. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/example_click.py +1 -1
  11. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/feed_click.py +1 -1
  12. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/file_click.py +1 -1
  13. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/main.py +52 -3
  14. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/mcp_click.py +1 -1
  15. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/notebook_click.py +61 -6
  16. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/policy_click.py +1 -1
  17. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/routine_click.py +1 -1
  18. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/skill_click.py +1 -1
  19. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/spec_click.py +1 -1
  20. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/templates_click.py +1 -1
  21. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/testmonitor_click.py +1 -1
  22. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/web_editor.py +123 -16
  23. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/webapp_click.py +1 -1
  24. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/workitem_click.py +1 -1
  25. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/workspace_click.py +1 -1
  26. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/LICENSE +0 -0
  27. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/dff-editor/index.html +0 -0
  28. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/__init__.py +0 -0
  29. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/__main__.py +0 -0
  30. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/asset_click.py +0 -0
  31. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/cli_formatters.py +0 -0
  32. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/cli_utils.py +0 -0
  33. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/config.py +0 -0
  34. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/dff_decorators.py +0 -0
  35. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/example_loader.py +0 -0
  36. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/example_provisioner.py +0 -0
  37. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/examples/README.md +0 -0
  38. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/examples/_schema/schema-v1.0.json +0 -0
  39. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/examples/demo-complete-workflow/README.md +0 -0
  40. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/examples/demo-complete-workflow/config.yaml +0 -0
  41. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/examples/demo-test-plans/README.md +0 -0
  42. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/examples/demo-test-plans/config.yaml +0 -0
  43. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/examples/exercise-5-1-parametric-insights/README.md +0 -0
  44. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/examples/exercise-5-1-parametric-insights/config.yaml +0 -0
  45. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/examples/exercise-7-1-test-plans/README.md +0 -0
  46. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/examples/exercise-7-1-test-plans/config.yaml +0 -0
  47. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/examples/spec-compliance-notebooks/README.md +0 -0
  48. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/examples/spec-compliance-notebooks/config.yaml +0 -0
  49. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/examples/spec-compliance-notebooks/notebooks/SpecAnalysis_ComplianceCalculation.ipynb +0 -0
  50. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/examples/spec-compliance-notebooks/notebooks/SpecComplianceCalculation.ipynb +0 -0
  51. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/examples/spec-compliance-notebooks/notebooks/SpecfileExtractionAndIngestion.ipynb +0 -0
  52. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/examples/spec-compliance-notebooks/spec_template.xlsx +0 -0
  53. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/function_click.py +0 -0
  54. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/function_templates.py +0 -0
  55. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/mcp_reachability.py +0 -0
  56. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/mcp_server.py +0 -0
  57. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/platform.py +0 -0
  58. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/policy_utils.py +0 -0
  59. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/profiles.py +0 -0
  60. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/response_handlers.py +0 -0
  61. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/rich_output.py +0 -0
  62. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/skills/nipkg-file-package/SKILL.md +0 -0
  63. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/skills/slcli/SKILL.md +0 -0
  64. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/skills/slcli/references/analysis-recipes.md +0 -0
  65. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/skills/slcli/references/commands.md +0 -0
  66. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/skills/slcli/references/datasheet-workflow.md +0 -0
  67. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/skills/slcli/references/filtering.md +0 -0
  68. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/skills/slcli/references/troubleshooting.md +0 -0
  69. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/skills/systemlink-job-debugging/SKILL.md +0 -0
  70. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/skills/systemlink-notebook/SKILL.md +0 -0
  71. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/skills/systemlink-notebook/references/interfaces.md +0 -0
  72. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/skills/systemlink-notebook/references/notebook-patterns.md +0 -0
  73. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/skills/systemlink-python-test/SKILL.md +0 -0
  74. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/skills/systemlink-webapp/SKILL.md +0 -0
  75. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/skills/systemlink-webapp/references/deployment.md +0 -0
  76. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/skills/systemlink-webapp/references/layout-patterns.md +0 -0
  77. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/skills/systemlink-webapp/references/nimble-angular.md +0 -0
  78. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/skills/systemlink-webapp/references/systemlink-services.md +0 -0
  79. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/ssl_trust.py +0 -0
  80. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/state_click.py +0 -0
  81. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/system_click.py +0 -0
  82. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/system_query_utils.py +0 -0
  83. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/table_utils.py +0 -0
  84. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/tag_click.py +0 -0
  85. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/universal_handlers.py +0 -0
  86. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/user_click.py +0 -0
  87. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/utils.py +0 -0
  88. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/workflow_preview.py +0 -0
  89. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/workflows_click.py +0 -0
  90. {systemlink_cli-1.13.3 → systemlink_cli-1.13.5}/slcli/workspace_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: systemlink-cli
3
- Version: 1.13.3
3
+ Version: 1.13.5
4
4
  Summary: SystemLink Integrator CLI - cross-platform CLI for SystemLink workflows and templates.
5
5
  License-File: LICENSE
6
6
  Author: Fred Visser
@@ -651,9 +651,7 @@ function downloadConfiguration() {
651
651
  const a = document.createElement('a');
652
652
  a.href = url;
653
653
  a.download = 'dff-configuration.json';
654
- document.body.appendChild(a);
655
654
  a.click();
656
- document.body.removeChild(a);
657
655
  URL.revokeObjectURL(url);
658
656
  showStatus('Configuration downloaded', 'success');
659
657
  } catch (e) {
@@ -682,53 +680,130 @@ function refreshTree() {
682
680
 
683
681
  const config = currentConfig;
684
682
  if (!config) return;
685
-
686
- let html = '<div class="tree-node" onclick="selectTreeNode(\'root\')"><span class="tree-icon">📄</span><span>Root Configuration</span></div>';
687
-
688
- // Configurations
683
+
684
+ treeView.replaceChildren();
685
+ treeView.appendChild(createTreeNode({
686
+ nodeId: 'root',
687
+ icon: '📄',
688
+ label: 'Root Configuration'
689
+ }));
690
+
689
691
  if (config.configurations && config.configurations.length > 0) {
690
692
  config.configurations.forEach((conf, i) => {
691
693
  const confLabel = conf.displayText || conf.name || conf.key || ('Configuration ' + (i + 1));
692
- html += `<div class="tree-node indent-1" onclick="selectTreeNode('config-${i}')"><span class="tree-icon">⚙️</span><span>${confLabel}</span><button class="edit-btn" aria-label="Edit configuration: ${confLabel}" title="Edit configuration" onclick="event.stopPropagation(); showEditDialog('configuration', ${i})">✎</button></div>`;
693
- // Show views under each configuration
694
+ treeView.appendChild(createTreeNode({
695
+ nodeId: `config-${i}`,
696
+ icon: '⚙️',
697
+ label: confLabel,
698
+ indent: 1,
699
+ editLabel: `Edit configuration: ${confLabel}`,
700
+ editTitle: 'Edit configuration',
701
+ onEdit: () => showEditDialog('configuration', i)
702
+ }));
703
+
694
704
  if (conf.views && conf.views.length > 0) {
695
705
  conf.views.forEach((view, vi) => {
696
- html += `<div class="tree-node indent-2" onclick="selectTreeNode('config-${i}-view-${vi}')"><span class="tree-icon">👁️</span><span>${view.displayText || view.key}</span><button class="edit-btn" aria-label="Edit view: ${view.displayText || view.key}" title="Edit view" onclick="event.stopPropagation(); showEditDialog('view', ${i}, ${vi})">✎</button></div>`;
706
+ const viewLabel = view.displayText || view.key;
707
+ treeView.appendChild(createTreeNode({
708
+ nodeId: `config-${i}-view-${vi}`,
709
+ icon: '👁️',
710
+ label: viewLabel,
711
+ indent: 2,
712
+ editLabel: `Edit view: ${viewLabel}`,
713
+ editTitle: 'Edit view',
714
+ onEdit: () => showEditDialog('view', i, vi)
715
+ }));
697
716
  });
698
717
  }
699
718
  });
700
719
  }
701
-
702
- // Groups
720
+
703
721
  if (config.groups && config.groups.length > 0) {
704
- html += '<div class="tree-node indent-1"><span class="tree-icon">📁</span><span>Groups (' + config.groups.length + ')</span></div>';
722
+ treeView.appendChild(createTreeSummaryNode('📁', `Groups (${config.groups.length})`, 1));
705
723
  config.groups.forEach((group, i) => {
706
724
  const groupLabel = group.displayText || group.key;
707
- html += `<div class="tree-node indent-2" onclick="selectTreeNode('group-${i}')"><span class="tree-icon">📦</span><span>${groupLabel}</span><button class="edit-btn" aria-label="Edit group: ${groupLabel}" title="Edit group" onclick="event.stopPropagation(); showEditDialog('group', ${i})">✎</button></div>`;
725
+ treeView.appendChild(createTreeNode({
726
+ nodeId: `group-${i}`,
727
+ icon: '📦',
728
+ label: groupLabel,
729
+ indent: 2,
730
+ editLabel: `Edit group: ${groupLabel}`,
731
+ editTitle: 'Edit group',
732
+ onEdit: () => showEditDialog('group', i)
733
+ }));
708
734
  });
709
735
  }
710
-
711
- // Fields
736
+
712
737
  if (config.fields && config.fields.length > 0) {
713
- html += '<div class="tree-node indent-1"><span class="tree-icon">📁</span><span>Fields (' + config.fields.length + ')</span></div>';
738
+ treeView.appendChild(createTreeSummaryNode('📁', `Fields (${config.fields.length})`, 1));
714
739
  config.fields.forEach((field, i) => {
715
740
  const icon = field.required ? '🏷️' : '🔖';
716
741
  const fieldLabel = field.displayText || field.key;
717
- html += `<div class="tree-node indent-2" onclick="selectTreeNode('field-${i}')"><span class="tree-icon">${icon}</span><span>${fieldLabel}</span><button class="edit-btn" aria-label="Edit field: ${fieldLabel}" title="Edit field" onclick="event.stopPropagation(); showEditDialog('field', ${i})">✎</button></div>`;
742
+ treeView.appendChild(createTreeNode({
743
+ nodeId: `field-${i}`,
744
+ icon,
745
+ label: fieldLabel,
746
+ indent: 2,
747
+ editLabel: `Edit field: ${fieldLabel}`,
748
+ editTitle: 'Edit field',
749
+ onEdit: () => showEditDialog('field', i)
750
+ }));
718
751
  });
719
752
  }
720
-
721
- treeView.innerHTML = html;
722
753
  }
723
754
 
724
- function selectTreeNode(nodeId) {
755
+ function createTreeSummaryNode(icon, label, indent = 0) {
756
+ const node = document.createElement('div');
757
+ node.className = 'tree-node';
758
+ if (indent > 0) {
759
+ node.classList.add(`indent-${indent}`);
760
+ }
761
+
762
+ const iconElement = document.createElement('span');
763
+ iconElement.className = 'tree-icon';
764
+ iconElement.textContent = icon;
765
+ node.appendChild(iconElement);
766
+
767
+ const labelElement = document.createElement('span');
768
+ labelElement.textContent = label;
769
+ node.appendChild(labelElement);
770
+
771
+ return node;
772
+ }
773
+
774
+ function createTreeNode({ nodeId, icon, label, indent = 0, editLabel = '', editTitle = '', onEdit = null }) {
775
+ const node = createTreeSummaryNode(icon, label, indent);
776
+ node.dataset.nodeId = nodeId;
777
+ node.addEventListener('click', () => selectTreeNode(nodeId, node));
778
+
779
+ if (onEdit) {
780
+ const editButton = document.createElement('button');
781
+ editButton.className = 'edit-btn';
782
+ editButton.type = 'button';
783
+ editButton.textContent = '✎';
784
+ editButton.setAttribute('aria-label', editLabel);
785
+ editButton.title = editTitle;
786
+ editButton.addEventListener('click', (event) => {
787
+ event.stopPropagation();
788
+ onEdit();
789
+ });
790
+ node.appendChild(editButton);
791
+ }
792
+
793
+ return node;
794
+ }
795
+
796
+ function selectTreeNode(nodeId, nodeElement = null) {
725
797
  selectedTreeNode = nodeId;
726
798
 
727
799
  // Update visual selection
728
800
  document.querySelectorAll('.tree-node').forEach(node => {
729
801
  node.classList.remove('selected');
730
802
  });
731
- event.currentTarget.classList.add('selected');
803
+ const activeNode = nodeElement || document.querySelector(`[data-node-id="${nodeId}"]`);
804
+ if (activeNode) {
805
+ activeNode.classList.add('selected');
806
+ }
732
807
 
733
808
  // Highlight corresponding JSON in editor
734
809
  selectNodeInEditor(nodeId);
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "systemlink-cli"
3
- version = "1.13.3"
3
+ version = "1.13.5"
4
4
  description = "SystemLink Integrator CLI - cross-platform CLI for SystemLink workflows and templates."
5
5
  authors = ["Fred Visser <fred.visser@emerson.com>"]
6
6
  packages = [{ include = "slcli" }]
@@ -1,4 +1,4 @@
1
1
  """Version information for slcli."""
2
2
 
3
3
  # This file is auto-generated. Do not edit manually.
4
- __version__ = "1.13.3"
4
+ __version__ = "1.13.5"
@@ -188,7 +188,7 @@ def register_comment_commands(cli: Any) -> None:
188
188
  @cli.group()
189
189
  @click.pass_context
190
190
  def comment(ctx: click.Context) -> None:
191
- """Manage comments on SystemLink resources.
191
+ """Manage SystemLink comments.
192
192
 
193
193
  Comments can be attached to any resource identified by a resource type
194
194
  and resource ID. Known resource types: testmonitor:Result, niapm:Asset,
@@ -386,7 +386,7 @@ def register_completion_command(cli: Any) -> None:
386
386
  help="Install completion script to shell config file",
387
387
  )
388
388
  def completion(shell: Optional[str], install: bool) -> None:
389
- """Generate and optionally install shell completion scripts.
389
+ """Generate shell completion scripts and optionally install them.
390
390
 
391
391
  Examples:
392
392
  # Generate bash completion script
@@ -225,7 +225,7 @@ def register_config_commands(cli: Any) -> None:
225
225
 
226
226
  @cli.group()
227
227
  def config() -> None:
228
- """Manage slcli configuration and profiles.
228
+ """Manage slcli settings and profiles.
229
229
 
230
230
  Profiles allow you to configure multiple SystemLink environments
231
231
  (dev, test, prod) and switch between them easily.
@@ -646,7 +646,7 @@ def register_dataframe_commands(cli: Any) -> None:
646
646
  @cli.group()
647
647
  @click.pass_context
648
648
  def dataframe(ctx: click.Context) -> None:
649
- """Manage SystemLink DataFrame tables and row data."""
649
+ """Manage SystemLink DataFrame tables and rows."""
650
650
  if ctx.invoked_subcommand is not None:
651
651
  require_feature("dataframe_service")
652
652
 
@@ -312,7 +312,7 @@ def register_dff_commands(cli: Any) -> None:
312
312
  @cli.group(name="customfield")
313
313
  @click.pass_context
314
314
  def dff(ctx: click.Context) -> None:
315
- """Manage custom field (DFF) configurations."""
315
+ """Manage SystemLink custom field configurations."""
316
316
  # Check for platform feature availability
317
317
  # Only check if a subcommand is being invoked (not just --help)
318
318
  if ctx.invoked_subcommand is not None:
@@ -121,7 +121,7 @@ def register_example_commands(cli: Any) -> None:
121
121
 
122
122
  @cli.group()
123
123
  def example() -> None:
124
- """Manage example resource configurations.
124
+ """Browse and provision example SystemLink resource configurations.
125
125
 
126
126
  Examples help you quickly set up demo systems for training,
127
127
  testing, or evaluation. Each example includes systems, assets,
@@ -436,7 +436,7 @@ def register_feed_commands(cli: Any) -> None:
436
436
 
437
437
  @cli.group()
438
438
  def feed() -> None:
439
- """Manage NI Package Manager feeds and their packages.
439
+ """Manage SystemLink package feeds and packages.
440
440
 
441
441
  Feeds are package repositories used by NI Package Manager to install
442
442
  software on test systems. Supports Windows (.nipkg) and NI Linux RT
@@ -508,7 +508,7 @@ def register_file_commands(cli: Any) -> None:
508
508
 
509
509
  @cli.group()
510
510
  def file() -> None:
511
- """Manage files in SystemLink File Service."""
511
+ """Manage SystemLink files."""
512
512
  pass
513
513
 
514
514
  @file.command(name="list")
@@ -49,6 +49,55 @@ else:
49
49
  click = rich_click_module
50
50
 
51
51
 
52
+ def _configure_rich_click_command_groups() -> None:
53
+ """Configure top-level help command groups when rich-click is available."""
54
+ rich_click_config = getattr(click, "rich_click", None)
55
+ if rich_click_config is None:
56
+ return
57
+
58
+ # Keep the command-name/help split consistent across top-level panels so
59
+ # descriptions start at the same column in every group.
60
+ rich_click_config.STYLE_COMMANDS_TABLE_EXPAND = True
61
+ rich_click_config.STYLE_COMMANDS_TABLE_COLUMN_WIDTH_RATIO = (1, 5)
62
+
63
+ rich_click_config.COMMAND_GROUPS = {
64
+ "slcli": [
65
+ {
66
+ "name": "Configure",
67
+ "commands": ["config", "login", "logout", "info", "completion"],
68
+ },
69
+ {
70
+ "name": "Administer",
71
+ "commands": ["auth", "user", "workspace"],
72
+ },
73
+ {
74
+ "name": "Operate",
75
+ "commands": [
76
+ "asset",
77
+ "system",
78
+ "state",
79
+ "tag",
80
+ "file",
81
+ "feed",
82
+ "comment",
83
+ "dataframe",
84
+ ],
85
+ },
86
+ {
87
+ "name": "Build & Automate",
88
+ "commands": ["notebook", "routine", "webapp", "customfield", "skill", "mcp"],
89
+ },
90
+ {
91
+ "name": "Validate & Plan",
92
+ "commands": ["testmonitor", "template", "spec", "workitem", "example"],
93
+ },
94
+ ]
95
+ }
96
+
97
+
98
+ _configure_rich_click_command_groups()
99
+
100
+
52
101
  def get_version() -> str:
53
102
  """Get version from _version.py (built binary) or pyproject.toml (development)."""
54
103
  try:
@@ -193,7 +242,7 @@ def login(
193
242
  set_current: bool,
194
243
  readonly: bool,
195
244
  ) -> None:
196
- """Save SystemLink credentials to a profile.
245
+ """Create or update a SystemLink profile with credentials.
197
246
 
198
247
  This is an alias for 'slcli config add'. Use that command
199
248
  for the same functionality and more configuration options.
@@ -225,7 +274,7 @@ def login(
225
274
  @click.option("--all", "remove_all", is_flag=True, help="Remove all profiles")
226
275
  @click.option("--force", "-f", is_flag=True, help="Skip confirmation prompt")
227
276
  def logout(profile: Optional[str], remove_all: bool, force: bool) -> None:
228
- """Remove stored SystemLink credentials.
277
+ """Remove stored SystemLink profiles and credentials.
229
278
 
230
279
  By default, removes the current profile. Use --profile to remove a specific
231
280
  profile, or --all to remove all profiles.
@@ -309,7 +358,7 @@ def logout(profile: Optional[str], remove_all: bool, force: bool) -> None:
309
358
  @click.option("--format", "-f", type=click.Choice(["table", "json"]), default="table")
310
359
  @click.option("--skip-health", is_flag=True, default=False, help="Skip live service health checks.")
311
360
  def info(format: str, skip_health: bool) -> None:
312
- """Show current configuration and detected platform."""
361
+ """Show the active profile, configuration, and platform status."""
313
362
  from .profiles import ProfileConfig, get_active_profile
314
363
 
315
364
  platform_info = get_platform_info(skip_health=skip_health)
@@ -143,7 +143,7 @@ def register_mcp_commands(cli: Any) -> None:
143
143
 
144
144
  @cli.group()
145
145
  def mcp() -> None:
146
- """MCP (Model Context Protocol) server integration for AI assistants."""
146
+ """Run and configure the SystemLink MCP server for AI assistants."""
147
147
 
148
148
  @mcp.command(name="serve")
149
149
  @click.option(
@@ -6,6 +6,7 @@ All commands use Click for robust CLI interfaces and error handling.
6
6
 
7
7
  import datetime
8
8
  import json
9
+ import re
9
10
  import sys
10
11
  import time
11
12
  import urllib.parse
@@ -50,6 +51,56 @@ PREDEFINED_NOTEBOOK_INTERFACES = [
50
51
  "Work Item Scheduler",
51
52
  ]
52
53
 
54
+ WINDOWS_RESERVED_FILENAMES = {
55
+ "CON",
56
+ "PRN",
57
+ "AUX",
58
+ "NUL",
59
+ "COM1",
60
+ "COM2",
61
+ "COM3",
62
+ "COM4",
63
+ "COM5",
64
+ "COM6",
65
+ "COM7",
66
+ "COM8",
67
+ "COM9",
68
+ "LPT1",
69
+ "LPT2",
70
+ "LPT3",
71
+ "LPT4",
72
+ "LPT5",
73
+ "LPT6",
74
+ "LPT7",
75
+ "LPT8",
76
+ "LPT9",
77
+ }
78
+
79
+
80
+ def _get_safe_notebook_download_name(notebook_name: str, suffix: str) -> str:
81
+ """Build a local filename from remote notebook metadata without honoring path segments."""
82
+ name_segment = notebook_name.replace("\\", "/").split("/")[-1].strip()
83
+ if name_segment in {"", ".", ".."}:
84
+ name_segment = "notebook"
85
+
86
+ stem = name_segment[:-6] if name_segment.lower().endswith(".ipynb") else name_segment
87
+ safe_stem = re.sub(r"[^A-Za-z0-9._ -]+", "_", stem).strip(" ._") or "notebook"
88
+ if safe_stem.upper() in WINDOWS_RESERVED_FILENAMES:
89
+ safe_stem = f"{safe_stem}_file"
90
+ normalized_suffix = suffix if suffix.startswith(".") else f".{suffix}"
91
+
92
+ return f"{safe_stem}{normalized_suffix}"
93
+
94
+
95
+ def _get_safe_notebook_download_path(notebook_name: str, suffix: str) -> Path:
96
+ """Build a safe local output path for remote-derived notebook filenames."""
97
+ safe_filename = _get_safe_notebook_download_name(notebook_name, suffix)
98
+ working_directory = Path.cwd().resolve()
99
+ output_path = (working_directory / safe_filename).resolve()
100
+ if output_path.parent != working_directory:
101
+ raise ValueError("Notebook download path must stay within the current working directory")
102
+ return output_path
103
+
53
104
 
54
105
  def _normalize_sls_notebook(notebook: Dict[str, Any]) -> None:
55
106
  """Normalize SLS notebook response to include id/name fields.
@@ -529,9 +580,10 @@ def _download_notebook_content_and_metadata(
529
580
  if download_type in ("content", "both"):
530
581
  try:
531
582
  content = _get_notebook_content_http(notebook_id)
532
- output_path = output or (
533
- notebook_name if notebook_name.endswith(".ipynb") else f"{notebook_name}.ipynb"
534
- )
583
+ if output:
584
+ output_path = Path(output)
585
+ else:
586
+ output_path = _get_safe_notebook_download_path(notebook_name, ".ipynb")
535
587
  with open(output_path, "wb") as f:
536
588
  f.write(content)
537
589
  click.echo(f"Notebook content downloaded to {output_path}")
@@ -543,14 +595,17 @@ def _download_notebook_content_and_metadata(
543
595
  if download_type in ("metadata", "both"):
544
596
  try:
545
597
  meta = _get_notebook_http(notebook_id)
546
- meta_path = (output or notebook_name.replace(".ipynb", "")) + ".json"
598
+ if output:
599
+ meta_path = Path(f"{output}.json")
600
+ else:
601
+ meta_path = _get_safe_notebook_download_path(notebook_name, ".json")
547
602
 
548
603
  def _json_default(obj: Any) -> str:
549
604
  if isinstance(obj, (datetime.datetime, datetime.date)):
550
605
  return obj.isoformat()
551
606
  return str(obj)
552
607
 
553
- save_json_file(meta, meta_path, _json_default)
608
+ save_json_file(meta, str(meta_path), _json_default)
554
609
  click.echo(f"Notebook metadata downloaded to {meta_path}")
555
610
  except Exception as exc:
556
611
  click.echo(f"Failed to download notebook metadata: {exc}")
@@ -574,7 +629,7 @@ def register_notebook_commands(cli: Any) -> None:
574
629
 
575
630
  @cli.group()
576
631
  def notebook() -> None: # pragma: no cover - Click wiring
577
- """Manage notebooks (init locally, manage remotely, run)."""
632
+ """Create, run, and manage SystemLink notebooks."""
578
633
  pass
579
634
 
580
635
  # ------------------------------------------------------------------
@@ -32,7 +32,7 @@ def register_policy_commands(cli: Any) -> None:
32
32
 
33
33
  @cli.group(name="auth")
34
34
  def auth() -> None:
35
- """Manage SystemLink auth policies and policy templates."""
35
+ """Manage SystemLink authorization policies and policy templates."""
36
36
  pass
37
37
 
38
38
  @auth.group(name="policy")
@@ -127,7 +127,7 @@ def register_routine_commands(cli: Any) -> None:
127
127
 
128
128
  @cli.group()
129
129
  def routine() -> None:
130
- """Manage SystemLink routines (v1: notebook scheduling, v2: event-action)."""
130
+ """Manage SystemLink routines."""
131
131
  pass
132
132
 
133
133
  # ------------------------------------------------------------------
@@ -200,7 +200,7 @@ def register_skill_commands(cli: Any) -> None:
200
200
 
201
201
  @cli.group()
202
202
  def skill() -> None:
203
- """Manage AI agent skills for most agents and Claude."""
203
+ """Install and manage AI assistant skills."""
204
204
 
205
205
  @skill.command(name="install")
206
206
  @click.option(
@@ -1263,7 +1263,7 @@ def register_spec_commands(cli: Any) -> None:
1263
1263
 
1264
1264
  @cli.group(name="spec")
1265
1265
  def spec() -> None:
1266
- """Manage specifications."""
1266
+ """Manage SystemLink specifications."""
1267
1267
  pass
1268
1268
 
1269
1269
  # -- list ---------------------------------------------------------------
@@ -120,7 +120,7 @@ def register_templates_commands(cli: Any) -> None:
120
120
  @cli.group()
121
121
  @click.pass_context
122
122
  def template(ctx: click.Context) -> None:
123
- """Manage test plan templates."""
123
+ """Manage SystemLink test plan templates."""
124
124
  # Check for platform feature availability
125
125
  # Only check if a subcommand is being invoked (not just --help)
126
126
  if ctx.invoked_subcommand is not None:
@@ -735,7 +735,7 @@ def register_testmonitor_commands(cli: Any) -> None:
735
735
 
736
736
  @cli.group()
737
737
  def testmonitor() -> None:
738
- """Commands for test monitor products and results."""
738
+ """Manage SystemLink Test Monitor products and results."""
739
739
 
740
740
  @testmonitor.group()
741
741
  def product() -> None:
@@ -43,10 +43,9 @@ def _build_proxy_url(
43
43
  origin_scheme: str,
44
44
  origin_netloc: str,
45
45
  target_path: str,
46
- query: str = "",
47
46
  ) -> str:
48
47
  """Build a proxy URL from a validated origin and allowlisted path."""
49
- return urllib.parse.urlunsplit((origin_scheme, origin_netloc, target_path, query, ""))
48
+ return urllib.parse.urlunsplit((origin_scheme, origin_netloc, target_path, "", ""))
50
49
 
51
50
 
52
51
  def _validated_proxy_path(request_path: str) -> str:
@@ -59,6 +58,118 @@ def _validated_proxy_path(request_path: str) -> str:
59
58
  return decoded_path
60
59
 
61
60
 
61
+ def _validated_proxy_query_params(query: str) -> dict[str, list[str]]:
62
+ """Return parsed proxy query parameters."""
63
+ if not query:
64
+ return {}
65
+
66
+ try:
67
+ return urllib.parse.parse_qs(
68
+ query,
69
+ keep_blank_values=True,
70
+ strict_parsing=True,
71
+ separator="&",
72
+ )
73
+ except ValueError as exc:
74
+ raise ValueError("Editor proxy received an invalid query string") from exc
75
+
76
+
77
+ def _validated_single_query_value(
78
+ query_params: dict[str, list[str]],
79
+ name: str,
80
+ ) -> str:
81
+ """Return a single query value and reject repeated parameters."""
82
+ values = query_params.get(name)
83
+ if not values:
84
+ raise ValueError(f"Editor proxy requires query parameter: {name}")
85
+ if len(values) != 1:
86
+ raise ValueError(f"Editor proxy rejects repeated query parameter: {name}")
87
+ return values[0]
88
+
89
+
90
+ def _validated_integer_query_value(
91
+ query_params: dict[str, list[str]],
92
+ name: str,
93
+ *,
94
+ minimum: int,
95
+ maximum: Optional[int] = None,
96
+ ) -> str:
97
+ """Return a validated integer query value preserved as a string."""
98
+ raw_value = _validated_single_query_value(query_params, name)
99
+
100
+ try:
101
+ parsed_value = int(raw_value)
102
+ except ValueError as exc:
103
+ raise ValueError(f"Editor proxy requires an integer query parameter: {name}") from exc
104
+
105
+ if parsed_value < minimum or (maximum is not None and parsed_value > maximum):
106
+ raise ValueError(f"Editor proxy rejected out-of-range query parameter: {name}")
107
+
108
+ return str(parsed_value)
109
+
110
+
111
+ def _validated_identifier_query_value(query_params: dict[str, list[str]], name: str) -> str:
112
+ """Return a single identifier-like query value safe for proxy forwarding."""
113
+ value = _validated_single_query_value(query_params, name)
114
+ if not value.strip():
115
+ raise ValueError(f"Editor proxy requires a non-empty query parameter: {name}")
116
+ if len(value) > 256:
117
+ raise ValueError(f"Editor proxy rejected oversized query parameter: {name}")
118
+ if any(ord(character) < 32 for character in value):
119
+ raise ValueError(f"Editor proxy rejected invalid query parameter: {name}")
120
+ if any(character in value for character in "/\\?#"):
121
+ raise ValueError(f"Editor proxy rejected invalid query parameter: {name}")
122
+ return value
123
+
124
+
125
+ def _resolve_proxy_target(
126
+ method: str,
127
+ request_path: str,
128
+ query: str,
129
+ ) -> Optional[tuple[str, dict[str, str]]]:
130
+ """Resolve a frontend route to a fixed upstream path and validated params."""
131
+ query_params = _validated_proxy_query_params(query)
132
+
133
+ if method == "POST":
134
+ post_route_map = {
135
+ "/api/dff/configurations": "/nidynamicformfields/v1/configurations",
136
+ "/api/dff/update-configurations": "/nidynamicformfields/v1/update-configurations",
137
+ "/nidynamicformfields/v1/update-configurations": "/nidynamicformfields/v1/update-configurations",
138
+ }
139
+ target_path = post_route_map.get(request_path)
140
+ if target_path is None:
141
+ return None
142
+ if query_params:
143
+ raise ValueError("Editor proxy does not forward query parameters for this route")
144
+ return target_path, {}
145
+
146
+ if method == "GET" and request_path == "/niuser/v1/workspaces":
147
+ unexpected_params = set(query_params) - {"take", "skip"}
148
+ if unexpected_params:
149
+ raise ValueError("Editor proxy rejected unsupported workspace query parameters")
150
+
151
+ safe_params: dict[str, str] = {}
152
+ if "take" in query_params:
153
+ safe_params["take"] = _validated_integer_query_value(
154
+ query_params, "take", minimum=1, maximum=1000
155
+ )
156
+ if "skip" in query_params:
157
+ safe_params["skip"] = _validated_integer_query_value(query_params, "skip", minimum=0)
158
+ return "/niuser/v1/workspaces", safe_params
159
+
160
+ if method == "GET" and request_path == "/nidynamicformfields/v1/resolved-configuration":
161
+ unexpected_params = set(query_params) - {"configurationId"}
162
+ if unexpected_params:
163
+ raise ValueError(
164
+ "Editor proxy rejected unsupported resolved-configuration query parameters"
165
+ )
166
+ return "/nidynamicformfields/v1/resolved-configuration", {
167
+ "configurationId": _validated_identifier_query_value(query_params, "configurationId")
168
+ }
169
+
170
+ return None
171
+
172
+
62
173
  class DFFWebEditor:
63
174
  """Web-based editor for custom fields configurations."""
64
175
 
@@ -224,21 +335,17 @@ class DFFWebEditor:
224
335
  self.send_error(404, "Config file not found")
225
336
  return True
226
337
 
227
- # Handle API proxying
228
- path_map = {
229
- "/api/dff/configurations": "/nidynamicformfields/v1/configurations",
230
- "/api/dff/update-configurations": "/nidynamicformfields/v1/update-configurations",
231
- }
232
-
233
- if request_path in path_map:
234
- target_path = path_map[request_path]
235
- elif request_path.startswith("/nidynamicformfields/v1/"):
236
- target_path = request_path
237
- elif request_path.startswith("/niuser/v1/workspaces"):
238
- target_path = request_path
239
- else:
338
+ try:
339
+ resolved_target = _resolve_proxy_target(method, request_path, parsed.query)
340
+ except ValueError as exc:
341
+ self.send_error(400, str(exc))
342
+ return True
343
+
344
+ if resolved_target is None:
240
345
  return False
241
346
 
347
+ target_path, target_params = resolved_target
348
+
242
349
  # Require per-session secret on all proxied routes
243
350
  req_secret = self.headers.get("X-Editor-Secret")
244
351
  if not secret or req_secret != secret:
@@ -249,7 +356,6 @@ class DFFWebEditor:
249
356
  origin_scheme=api_scheme,
250
357
  origin_netloc=api_netloc,
251
358
  target_path=target_path,
252
- query=parsed.query,
253
359
  )
254
360
 
255
361
  headers = dict(default_headers)
@@ -266,6 +372,7 @@ class DFFWebEditor:
266
372
  url=target_url,
267
373
  headers=headers,
268
374
  data=data,
375
+ params=target_params or None,
269
376
  verify=ssl_verify,
270
377
  )
271
378
  except requests.RequestException as exc: # pragma: no cover
@@ -893,7 +893,7 @@ def register_webapp_commands(cli: Any) -> None:
893
893
 
894
894
  @cli.group()
895
895
  def webapp() -> None: # pragma: no cover - Click wiring
896
- """Manage web applications (init/pack locally, publish/CRUD remotely)."""
896
+ """Build, publish, and manage SystemLink web applications."""
897
897
 
898
898
  @webapp.command(name="init")
899
899
  @click.argument(
@@ -431,7 +431,7 @@ def register_workitem_commands(cli: Any) -> None:
431
431
 
432
432
  @cli.group()
433
433
  def workitem() -> None:
434
- """Manage work items, templates, and workflows."""
434
+ """Manage SystemLink work items, templates, and workflows."""
435
435
 
436
436
  # -----------------------------------------------------------------------
437
437
  # workitem list
@@ -59,7 +59,7 @@ def register_workspace_commands(cli: Any) -> None:
59
59
 
60
60
  @cli.group()
61
61
  def workspace() -> None:
62
- """Manage workspaces."""
62
+ """Manage SystemLink workspaces."""
63
63
  pass
64
64
 
65
65
  @workspace.command(name="list")
File without changes