systemlink-cli 1.4.1__tar.gz → 1.4.3__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 (74) hide show
  1. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/PKG-INFO +1 -1
  2. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/pyproject.toml +1 -1
  3. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/_version.py +1 -1
  4. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/config_click.py +48 -11
  5. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/file_click.py +136 -11
  6. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/main.py +38 -20
  7. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/platform.py +253 -62
  8. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/skills/slcli/SKILL.md +1 -1
  9. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/system_click.py +11 -8
  10. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/LICENSE +0 -0
  11. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/dff-editor/editor.js +0 -0
  12. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/dff-editor/index.html +0 -0
  13. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/__init__.py +0 -0
  14. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/__main__.py +0 -0
  15. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/asset_click.py +0 -0
  16. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/cli_formatters.py +0 -0
  17. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/cli_utils.py +0 -0
  18. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/comment_click.py +0 -0
  19. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/completion_click.py +0 -0
  20. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/config.py +0 -0
  21. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/dff_click.py +0 -0
  22. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/dff_decorators.py +0 -0
  23. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/example_click.py +0 -0
  24. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/example_loader.py +0 -0
  25. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/example_provisioner.py +0 -0
  26. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/examples/README.md +0 -0
  27. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/examples/_schema/schema-v1.0.json +0 -0
  28. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/examples/demo-complete-workflow/README.md +0 -0
  29. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/examples/demo-complete-workflow/config.yaml +0 -0
  30. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/examples/demo-test-plans/README.md +0 -0
  31. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/examples/demo-test-plans/config.yaml +0 -0
  32. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/examples/exercise-5-1-parametric-insights/README.md +0 -0
  33. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/examples/exercise-5-1-parametric-insights/config.yaml +0 -0
  34. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/examples/exercise-7-1-test-plans/README.md +0 -0
  35. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/examples/exercise-7-1-test-plans/config.yaml +0 -0
  36. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/examples/spec-compliance-notebooks/README.md +0 -0
  37. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/examples/spec-compliance-notebooks/config.yaml +0 -0
  38. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/examples/spec-compliance-notebooks/notebooks/SpecAnalysis_ComplianceCalculation.ipynb +0 -0
  39. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/examples/spec-compliance-notebooks/notebooks/SpecComplianceCalculation.ipynb +0 -0
  40. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/examples/spec-compliance-notebooks/notebooks/SpecfileExtractionAndIngestion.ipynb +0 -0
  41. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/examples/spec-compliance-notebooks/spec_template.xlsx +0 -0
  42. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/feed_click.py +0 -0
  43. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/function_click.py +0 -0
  44. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/function_templates.py +0 -0
  45. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/mcp_click.py +0 -0
  46. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/mcp_server.py +0 -0
  47. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/notebook_click.py +0 -0
  48. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/policy_click.py +0 -0
  49. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/policy_utils.py +0 -0
  50. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/profiles.py +0 -0
  51. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/response_handlers.py +0 -0
  52. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/routine_click.py +0 -0
  53. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/skill_click.py +0 -0
  54. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/skills/slcli/references/analysis-recipes.md +0 -0
  55. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/skills/slcli/references/filtering.md +0 -0
  56. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/skills/systemlink-webapp/SKILL.md +0 -0
  57. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/skills/systemlink-webapp/references/deployment.md +0 -0
  58. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/skills/systemlink-webapp/references/nimble-angular.md +0 -0
  59. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/skills/systemlink-webapp/references/systemlink-services.md +0 -0
  60. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/ssl_trust.py +0 -0
  61. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/table_utils.py +0 -0
  62. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/tag_click.py +0 -0
  63. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/templates_click.py +0 -0
  64. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/testmonitor_click.py +0 -0
  65. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/universal_handlers.py +0 -0
  66. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/user_click.py +0 -0
  67. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/utils.py +0 -0
  68. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/web_editor.py +0 -0
  69. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/webapp_click.py +0 -0
  70. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/workflow_preview.py +0 -0
  71. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/workflows_click.py +0 -0
  72. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/workitem_click.py +0 -0
  73. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/workspace_click.py +0 -0
  74. {systemlink_cli-1.4.1 → systemlink_cli-1.4.3}/slcli/workspace_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: systemlink-cli
3
- Version: 1.4.1
3
+ Version: 1.4.3
4
4
  Summary: SystemLink Integrator CLI - cross-platform CLI for SystemLink workflows and templates.
5
5
  License-File: LICENSE
6
6
  Author: Fred Visser
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "systemlink-cli"
3
- version = "1.4.1"
3
+ version = "1.4.3"
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.4.1"
4
+ __version__ = "1.4.3"
@@ -8,7 +8,11 @@ from typing import Any, Optional
8
8
  import click
9
9
  import questionary
10
10
 
11
- from .platform import PLATFORM_SLE, PLATFORM_SLS, detect_platform
11
+ from .platform import (
12
+ PLATFORM_SLE,
13
+ PLATFORM_SLS,
14
+ check_service_status,
15
+ )
12
16
  from .profiles import ProfileConfig, Profile, check_config_file_permissions
13
17
  from .table_utils import output_formatted_list
14
18
  from .utils import ExitCodes
@@ -60,6 +64,7 @@ def _add_profile_impl(
60
64
  elif not url.startswith("https://"):
61
65
  click.echo("⚠️ Warning: Adding HTTPS protocol to URL.")
62
66
  url = f"https://{url}"
67
+ url = url.rstrip("/")
63
68
 
64
69
  # Get API key - either from flag or prompt
65
70
  if not api_key:
@@ -82,17 +87,49 @@ def _add_profile_impl(
82
87
  elif not web_url.startswith("https://"):
83
88
  click.echo("⚠️ Warning: Adding HTTPS protocol to web URL.")
84
89
  web_url = f"https://{web_url}"
85
-
86
- # Detect platform type
87
- click.echo("Detecting platform type...")
88
- platform = detect_platform(url, api_key.strip())
89
-
90
- if platform == PLATFORM_SLE:
91
- click.echo(" Platform: SystemLink Enterprise (Cloud)")
92
- elif platform == PLATFORM_SLS:
93
- click.echo(" Platform: SystemLink Server (On-Premises)")
90
+ web_url = web_url.rstrip("/")
91
+
92
+ # Detect platform type and check service status
93
+ click.echo("Checking server connectivity and services...")
94
+ status = check_service_status(url, api_key.strip())
95
+ platform = status["platform"]
96
+
97
+ if not status["server_reachable"]:
98
+ click.echo(" ⚠️ Could not connect to server", err=True)
99
+ click.echo(" Verify the URL is correct and the server is reachable.", err=True)
100
+ click.echo(
101
+ " Profile will be saved — run login again when the server is available.",
102
+ err=True,
103
+ )
94
104
  else:
95
- click.echo(" Platform: Unknown (will attempt all features)")
105
+ if platform == PLATFORM_SLE:
106
+ click.echo(" Platform: SystemLink Enterprise (Cloud)")
107
+ elif platform == PLATFORM_SLS:
108
+ click.echo(" Platform: SystemLink Server (On-Premises)")
109
+ else:
110
+ click.echo(" Platform: Unknown (will attempt all features)")
111
+
112
+ # Report authorization status
113
+ if status["auth_valid"] is False:
114
+ click.echo(" ⚠️ API key: Unauthorized — check that the key is valid", err=True)
115
+ elif status["auth_valid"] is True:
116
+ click.echo(" API key: ✓ Authorized")
117
+
118
+ if status.get("elasticsearch_available") is False:
119
+ click.echo(" File query: query-files-linq (Elasticsearch unavailable)")
120
+ click.echo(
121
+ " 'slcli file list' will fall back automatically; 'slcli file query' requires search-files."
122
+ )
123
+
124
+ # Report individual service status
125
+ services = status.get("services", {})
126
+ problem_services = [
127
+ name for name, svc_status in services.items() if svc_status == "unauthorized"
128
+ ]
129
+ if problem_services and status["auth_valid"] is not False:
130
+ # Only show per-service issues if overall auth isn't completely invalid
131
+ for svc_name in problem_services:
132
+ click.echo(f" ⚠️ {svc_name}: unauthorized", err=True)
96
133
 
97
134
  # Get default workspace (optional)
98
135
  if workspace is None:
@@ -10,9 +10,10 @@ from typing import Any, Dict, List, Optional
10
10
 
11
11
  import click
12
12
  import questionary
13
+ import requests as requests_lib
13
14
 
14
15
  from .cli_utils import validate_output_format
15
- from .universal_handlers import UniversalResponseHandler
16
+ from .universal_handlers import FilteredResponse, UniversalResponseHandler
16
17
  from .utils import (
17
18
  ExitCodes,
18
19
  format_success,
@@ -29,6 +30,120 @@ def _get_file_service_url() -> str:
29
30
  return f"{get_base_url()}/nifile/v1/service-groups/Default"
30
31
 
31
32
 
33
+ def _get_search_files_url() -> str:
34
+ """Get the search-files endpoint URL."""
35
+ return f"{_get_file_service_url()}/search-files"
36
+
37
+
38
+ def _is_search_files_unavailable(exc: requests_lib.HTTPError) -> bool:
39
+ """Return whether the search-files endpoint is unavailable."""
40
+ return exc.response is not None and exc.response.status_code in (404, 501)
41
+
42
+
43
+ def _exit_search_files_required() -> None:
44
+ """Exit with a user-facing message when search-files is unavailable."""
45
+ click.echo(
46
+ "✗ File query requires the search-files endpoint, but Elasticsearch is not available.",
47
+ err=True,
48
+ )
49
+ click.echo(
50
+ " Use 'slcli file list' for automatic fallback to query-files-linq.",
51
+ err=True,
52
+ )
53
+ sys.exit(ExitCodes.INVALID_INPUT)
54
+
55
+
56
+ def _search_files_with_fallback(
57
+ payload: Dict[str, Any],
58
+ workspace_id: Optional[str],
59
+ name_filter: Optional[str],
60
+ id_filter: Optional[str],
61
+ ) -> Any:
62
+ """Try search-files endpoint, fall back to query-files-linq if unavailable.
63
+
64
+ The search-files endpoint requires additional software (DataFinder) to be
65
+ installed. If it is not available (HTTP 404), we fall back to query-files-linq.
66
+
67
+ Args:
68
+ payload: The search-files request payload
69
+ workspace_id: Optional workspace ID filter
70
+ name_filter: Optional name filter string
71
+ id_filter: Optional comma-separated file IDs
72
+
73
+ Returns:
74
+ Response object (real or FilteredResponse)
75
+ """
76
+ try:
77
+ return make_api_request(
78
+ "POST", _get_search_files_url(), payload=payload, handle_errors=False
79
+ )
80
+ except requests_lib.HTTPError as exc:
81
+ if _is_search_files_unavailable(exc):
82
+ return _query_files_linq_fallback(
83
+ take=payload.get("take", 25),
84
+ workspace_id=workspace_id,
85
+ name_filter=name_filter,
86
+ id_filter=id_filter,
87
+ )
88
+ raise
89
+
90
+
91
+ def _query_files_linq_fallback(
92
+ take: int,
93
+ workspace_id: Optional[str],
94
+ name_filter: Optional[str],
95
+ id_filter: Optional[str],
96
+ ) -> Any:
97
+ """List files using query-files-linq as a fallback when search-files is unavailable.
98
+
99
+ Args:
100
+ take: Maximum number of files to return
101
+ workspace_id: Optional workspace ID filter
102
+ name_filter: Optional name filter string (applied client-side)
103
+ id_filter: Optional comma-separated file IDs
104
+
105
+ Returns:
106
+ Response or FilteredResponse with availableFiles data
107
+ """
108
+ base_url = _get_file_service_url()
109
+ url = f"{base_url}/query-files-linq"
110
+
111
+ # Build LINQ-style filter
112
+ filter_parts: List[str] = []
113
+
114
+ if id_filter:
115
+ ids = [fid.strip() for fid in id_filter.split(",")]
116
+ id_conditions = [f'id = "{fid}"' for fid in ids]
117
+ filter_parts.append("(" + " or ".join(id_conditions) + ")")
118
+
119
+ linq_payload: Dict[str, Any] = {}
120
+
121
+ # Request more if we need to filter client-side by name
122
+ if name_filter:
123
+ linq_payload["take"] = max(take * 4, 1000)
124
+ else:
125
+ linq_payload["take"] = take
126
+
127
+ if filter_parts:
128
+ linq_payload["filter"] = " and ".join(filter_parts)
129
+
130
+ # Workspace filtering via query parameter
131
+ if workspace_id:
132
+ url += f"?workspace={workspace_id}"
133
+
134
+ resp = make_api_request("POST", url, payload=linq_payload)
135
+
136
+ # Client-side name filtering (query-files-linq doesn't support name wildcards)
137
+ if name_filter:
138
+ data = resp.json()
139
+ files = data.get("availableFiles", [])
140
+ needle = name_filter.lower()
141
+ filtered = [f for f in files if needle in _get_file_name(f).lower()][:take]
142
+ return FilteredResponse({"availableFiles": filtered})
143
+
144
+ return resp
145
+
146
+
32
147
  def _format_file_size(size_bytes: Optional[int]) -> str:
33
148
  """Format file size in human-readable format.
34
149
 
@@ -178,9 +293,6 @@ def register_file_commands(cli: Any) -> None:
178
293
  """
179
294
  format_output = validate_output_format(format)
180
295
 
181
- # Use search-files endpoint for better performance
182
- url = f"{_get_file_service_url()}/search-files"
183
-
184
296
  try:
185
297
  # Resolve workspace name to ID if needed
186
298
  workspace_id = None
@@ -189,7 +301,7 @@ def register_file_commands(cli: Any) -> None:
189
301
  workspace_map = get_workspace_map()
190
302
  workspace_id = resolve_workspace_filter(workspace, workspace_map)
191
303
 
192
- # Build search filter
304
+ # Build search filter for search-files endpoint
193
305
  filter_parts = []
194
306
 
195
307
  if workspace_id:
@@ -221,7 +333,13 @@ def register_file_commands(cli: Any) -> None:
221
333
  if filter_parts:
222
334
  payload["filter"] = " AND ".join(filter_parts)
223
335
 
224
- resp = make_api_request("POST", url, payload=payload)
336
+ # Try search-files, fall back to query-files-linq if unavailable
337
+ resp = _search_files_with_fallback(
338
+ payload=payload,
339
+ workspace_id=workspace_id,
340
+ name_filter=name_filter,
341
+ id_filter=id_filter,
342
+ )
225
343
 
226
344
  def file_formatter(file_item: dict) -> list:
227
345
  name = _get_file_name(file_item)
@@ -567,11 +685,8 @@ def register_file_commands(cli: Any) -> None:
567
685
  """
568
686
  format_output = validate_output_format(format)
569
687
 
570
- # Use search-files endpoint for better performance
571
- url = f"{_get_file_service_url()}/search-files"
572
-
573
688
  try:
574
- # Build request body
689
+ # Build request body for search-files
575
690
  query_body: Dict[str, Any] = {
576
691
  "take": take if format_output.lower() == "json" else (take if take != 25 else 1000),
577
692
  "orderByDescending": descending,
@@ -584,6 +699,7 @@ def register_file_commands(cli: Any) -> None:
584
699
  filter_parts.append(filter_query)
585
700
 
586
701
  # Resolve workspace name to ID if needed
702
+ workspace_id = None
587
703
  workspace = get_effective_workspace(workspace)
588
704
  if workspace:
589
705
  workspace_map = get_workspace_map()
@@ -598,7 +714,12 @@ def register_file_commands(cli: Any) -> None:
598
714
  else:
599
715
  query_body["orderBy"] = "updated"
600
716
 
601
- resp = make_api_request("POST", url, payload=query_body)
717
+ resp = make_api_request(
718
+ "POST",
719
+ _get_search_files_url(),
720
+ payload=query_body,
721
+ handle_errors=False,
722
+ )
602
723
 
603
724
  def file_formatter(file_item: dict) -> list:
604
725
  name = _get_file_name(file_item)
@@ -620,6 +741,10 @@ def register_file_commands(cli: Any) -> None:
620
741
  page_size=take,
621
742
  )
622
743
 
744
+ except requests_lib.HTTPError as exc:
745
+ if _is_search_files_unavailable(exc):
746
+ _exit_search_files_required()
747
+ handle_api_error(exc)
623
748
  except Exception as exc:
624
749
  handle_api_error(exc)
625
750
 
@@ -21,7 +21,6 @@ from .function_click import register_function_commands
21
21
  from .mcp_click import register_mcp_commands
22
22
  from .notebook_click import register_notebook_commands
23
23
  from .platform import (
24
- PLATFORM_UNKNOWN,
25
24
  get_platform_info,
26
25
  )
27
26
  from .policy_click import register_policy_commands
@@ -295,11 +294,12 @@ def logout(profile: Optional[str], remove_all: bool, force: bool) -> None:
295
294
 
296
295
  @cli.command()
297
296
  @click.option("--format", "-f", type=click.Choice(["table", "json"]), default="table")
298
- def info(format: str) -> None:
297
+ @click.option("--skip-health", is_flag=True, default=False, help="Skip live service health checks.")
298
+ def info(format: str, skip_health: bool) -> None:
299
299
  """Show current configuration and detected platform."""
300
300
  from .profiles import ProfileConfig, get_active_profile
301
301
 
302
- platform_info = get_platform_info()
302
+ platform_info = get_platform_info(skip_health=skip_health)
303
303
 
304
304
  # Add profile information
305
305
  cfg = ProfileConfig.load()
@@ -333,7 +333,14 @@ def info(format: str) -> None:
333
333
  click.echo("├" + "─" * content_width + "┤")
334
334
 
335
335
  # Connection status
336
- status = "✓ Connected" if platform_info["logged_in"] else "✗ Not logged in"
336
+ if not platform_info["logged_in"]:
337
+ status = "✗ Not logged in"
338
+ elif platform_info.get("server_reachable") is False:
339
+ status = "✗ Server unreachable"
340
+ elif platform_info.get("auth_valid") is False:
341
+ status = "✗ API key unauthorized"
342
+ else:
343
+ status = "✓ Connected"
337
344
  click.echo(f"│ Status: {status:<48}│")
338
345
 
339
346
  # Profile information
@@ -361,23 +368,34 @@ def info(format: str) -> None:
361
368
  workspace_display = truncate(workspace)
362
369
  click.echo(f"│ Workspace: {workspace_display:<48}│")
363
370
 
364
- click.echo("" + "─" * content_width + "┤")
365
- click.echo("│" + "Feature Availability".center(content_width) + "│")
366
- click.echo("" + "─" * content_width + "┤")
367
-
368
- features = platform_info.get("features", {})
369
- if features:
370
- for feature_name, available in features.items():
371
- status_icon = "✓" if available else "✗"
372
- status_text = "Available" if available else "Not available"
373
- # Truncate feature name if needed
374
- display_name = truncate(feature_name, 29)
375
- click.echo(f"│ {status_icon} {display_name:<30} {status_text:<26}│")
376
- else:
377
- if platform_info["platform"] == PLATFORM_UNKNOWN:
378
- click.echo("│ Run 'slcli login' to detect platform features. │")
371
+ file_query_endpoint = platform_info.get("file_query_endpoint")
372
+ if file_query_endpoint:
373
+ if platform_info.get("elasticsearch_available") is False:
374
+ file_query_display = f"{file_query_endpoint} (Elasticsearch unavailable)"
379
375
  else:
380
- click.echo("│ No feature information available. │")
376
+ file_query_display = str(file_query_endpoint)
377
+ file_query_display = truncate(file_query_display)
378
+ click.echo(f"│ File Query:{file_query_display:<48}│")
379
+
380
+ # Service Health section (only when services were checked)
381
+ services = platform_info.get("services")
382
+ if services:
383
+ click.echo("├" + "─" * content_width + "┤")
384
+ click.echo("│" + "Service Health".center(content_width) + "│")
385
+ click.echo("├" + "─" * content_width + "┤")
386
+
387
+ status_display = {
388
+ "ok": ("✓", "OK"),
389
+ "fallback": ("!", "Fallback (no Elasticsearch)"),
390
+ "unauthorized": ("✗", "Unauthorized"),
391
+ "not_found": ("—", "Not available"),
392
+ "error": ("✗", "Error"),
393
+ "unreachable": ("✗", "Unreachable"),
394
+ }
395
+ for svc_name, svc_status in services.items():
396
+ icon, text = status_display.get(svc_status, ("?", svc_status))
397
+ display_name = truncate(svc_name, 29)
398
+ click.echo(f"│ {icon} {display_name:<30} {text:<26}│")
381
399
 
382
400
  click.echo("└" + "─" * content_width + "┘\n")
383
401
 
@@ -8,7 +8,7 @@ import json
8
8
  import os
9
9
  import sys
10
10
  from functools import lru_cache
11
- from typing import Any, Dict
11
+ from typing import Any, Dict, List, Optional
12
12
 
13
13
  import click
14
14
  import keyring
@@ -21,6 +21,7 @@ from .utils import ExitCodes, get_ssl_verify
21
21
  PLATFORM_SLE = "SLE" # SystemLink Enterprise (cloud)
22
22
  PLATFORM_SLS = "SLS" # SystemLink Server (on-premises)
23
23
  PLATFORM_UNKNOWN = "unknown"
24
+ PLATFORM_UNREACHABLE = "unreachable" # Server could not be contacted
24
25
 
25
26
  # Feature matrix: maps features to platform availability
26
27
  PLATFORM_FEATURES: Dict[str, Dict[str, bool]] = {
@@ -55,6 +56,9 @@ FEATURE_DISPLAY_NAMES: Dict[str, str] = {
55
56
  "webapp": "Web Applications",
56
57
  }
57
58
 
59
+ FILE_SEARCH_PATH = "/nifile/v1/service-groups/Default/search-files"
60
+ FILE_QUERY_LINQ_PATH = "/nifile/v1/service-groups/Default/query-files-linq"
61
+
58
62
 
59
63
  def _get_keyring_config() -> Dict[str, Any]:
60
64
  """Attempt to read a single JSON config entry from keyring.
@@ -80,64 +84,21 @@ def _get_keyring_config() -> Dict[str, Any]:
80
84
  def detect_platform(api_url: str, api_key: str) -> str:
81
85
  """Detect the SystemLink platform type by probing endpoints.
82
86
 
83
- Detection strategy:
84
- 1. Try SLE-only endpoint (/niworkorder/v1/query-testplan-templates)
85
- - If accessible -> SLE
86
- 2. Check URL pattern (*.systemlink.io, *.lifecyclesolutions.ni.com)
87
- - If matches -> SLE
88
- 3. Default to SLS for on-premises/custom URLs
87
+ Uses check_service_status to probe services and determine:
88
+ - Platform type (SLE vs SLS)
89
+ - Server reachability
90
+ Falls back to URL pattern matching when probe is inconclusive.
89
91
 
90
92
  Args:
91
93
  api_url: The SystemLink API base URL
92
94
  api_key: The API key for authentication
93
95
 
94
96
  Returns:
95
- Platform identifier (PLATFORM_SLE, PLATFORM_SLS, or PLATFORM_UNKNOWN)
97
+ Platform identifier (PLATFORM_SLE, PLATFORM_SLS, PLATFORM_UNREACHABLE,
98
+ or PLATFORM_UNKNOWN)
96
99
  """
97
- headers = {
98
- "x-ni-api-key": api_key,
99
- "Content-Type": "application/json",
100
- "User-Agent": "SystemLink-CLI/1.0 (cross-platform)",
101
- }
102
- ssl_verify = get_ssl_verify()
103
-
104
- # Strategy 1: Probe SLE-only endpoint (Work Order service)
105
- try:
106
- # This endpoint only exists on SLE
107
- workorder_url = f"{api_url}/niworkorder/v1/query-testplan-templates"
108
- resp = requests.post(
109
- workorder_url,
110
- headers=headers,
111
- json={"take": 1},
112
- verify=ssl_verify,
113
- timeout=10,
114
- )
115
- # If we get a 200 or 400 (bad request but endpoint exists), it's SLE
116
- if resp.status_code in (200, 400):
117
- return PLATFORM_SLE
118
- # 404 means endpoint doesn't exist -> likely SLS
119
- if resp.status_code == 404:
120
- return PLATFORM_SLS
121
- except requests.RequestException:
122
- # Connection error - continue with other detection methods
123
- pass
124
-
125
- # Strategy 2: URL pattern matching
126
- # SLE (cloud and hosted) service has specific URL patterns
127
- api_url_lower = api_url.lower()
128
- sle_patterns = [
129
- "api.systemlink.io", # SLE production
130
- "-api.lifecyclesolutions.ni.com", # SLE dev/demo with -api suffix
131
- "dev-api.lifecyclesolutions",
132
- "demo-api.lifecyclesolutions",
133
- ]
134
- for pattern in sle_patterns:
135
- if pattern in api_url_lower:
136
- return PLATFORM_SLE
137
-
138
- # Strategy 3: Default to SLS for on-premises deployments
139
- # This includes on-prem servers that may use *.systemlink.io subdomains
140
- return PLATFORM_SLS
100
+ status = check_service_status(api_url, api_key)
101
+ return status["platform"]
141
102
 
142
103
 
143
104
  def _detect_platform_from_url(api_url: str) -> str:
@@ -274,11 +235,220 @@ def require_feature(feature_name: str) -> None:
274
235
  sys.exit(ExitCodes.INVALID_INPUT)
275
236
 
276
237
 
277
- def get_platform_info() -> Dict[str, Any]:
238
+ # Services to probe during health checks.
239
+ # Each entry: (display_name, method, url_path)
240
+ SERVICE_CHECKS: List[List[str]] = [
241
+ ["Auth", "GET", "/niauth/v1/policies"],
242
+ ["Test Monitor", "GET", "/nitestmonitor/v2/results?take=0"],
243
+ ["Asset Management", "POST", "/niapm/v1/query-assets"],
244
+ ["Systems", "POST", "/nisysmgmt/v1/query-systems"],
245
+ ["Tag", "GET", "/nitag/v2/tags?take=0"],
246
+ ["File", "POST", FILE_SEARCH_PATH],
247
+ ["Notebook", "POST", "/ninotebook/v1/notebook/query"],
248
+ ["Web Application", "POST", "/niapp/v1/webapps/query"],
249
+ ["Dynamic Form Fields", "GET", "/nidynamicformfields/v1/groups"],
250
+ ["Work Order", "POST", "/niworkorder/v1/query-testplan-templates"],
251
+ ]
252
+
253
+
254
+ def get_file_query_capability(api_url: str, api_key: str) -> Dict[str, Any]:
255
+ """Determine which file query endpoint is available for this server."""
256
+ headers = {
257
+ "x-ni-api-key": api_key,
258
+ "Content-Type": "application/json",
259
+ "User-Agent": "SystemLink-CLI/1.0 (cross-platform)",
260
+ }
261
+ ssl_verify = get_ssl_verify()
262
+
263
+ try:
264
+ search_resp = requests.post(
265
+ f"{api_url}{FILE_SEARCH_PATH}",
266
+ headers=headers,
267
+ json={"take": 1},
268
+ verify=ssl_verify,
269
+ timeout=10,
270
+ )
271
+ if search_resp.status_code in (200, 400):
272
+ return {
273
+ "status": "ok",
274
+ "file_query_endpoint": "search-files",
275
+ "elasticsearch_available": True,
276
+ }
277
+ if search_resp.status_code in (401, 403):
278
+ return {
279
+ "status": "unauthorized",
280
+ "file_query_endpoint": "search-files",
281
+ "elasticsearch_available": True,
282
+ }
283
+ if search_resp.status_code not in (404, 501):
284
+ return {
285
+ "status": "error" if search_resp.status_code >= 500 else "not_found",
286
+ "file_query_endpoint": None,
287
+ "elasticsearch_available": True,
288
+ }
289
+ except requests.RequestException:
290
+ return {
291
+ "status": "unreachable",
292
+ "file_query_endpoint": None,
293
+ "elasticsearch_available": None,
294
+ }
295
+
296
+ try:
297
+ fallback_resp = requests.post(
298
+ f"{api_url}{FILE_QUERY_LINQ_PATH}",
299
+ headers=headers,
300
+ json={"take": 1},
301
+ verify=ssl_verify,
302
+ timeout=10,
303
+ )
304
+ if fallback_resp.status_code in (200, 400):
305
+ return {
306
+ "status": "fallback",
307
+ "file_query_endpoint": "query-files-linq",
308
+ "elasticsearch_available": False,
309
+ }
310
+ if fallback_resp.status_code in (401, 403):
311
+ return {
312
+ "status": "unauthorized",
313
+ "file_query_endpoint": "query-files-linq",
314
+ "elasticsearch_available": False,
315
+ }
316
+ if fallback_resp.status_code in (404, 501):
317
+ return {
318
+ "status": "not_found",
319
+ "file_query_endpoint": None,
320
+ "elasticsearch_available": False,
321
+ }
322
+ return {
323
+ "status": "error",
324
+ "file_query_endpoint": None,
325
+ "elasticsearch_available": False,
326
+ }
327
+ except requests.RequestException:
328
+ return {
329
+ "status": "unreachable",
330
+ "file_query_endpoint": None,
331
+ "elasticsearch_available": False,
332
+ }
333
+
334
+
335
+ def check_service_status(api_url: str, api_key: str) -> Dict[str, Any]:
336
+ """Probe key SystemLink services and report their status.
337
+
338
+ Checks reachability, authorization, and availability of core services.
339
+
340
+ Args:
341
+ api_url: The SystemLink API base URL.
342
+ api_key: The API key for authentication.
343
+
344
+ Returns:
345
+ Dictionary with:
346
+ - server_reachable: bool - whether any service responded
347
+ - auth_valid: bool | None - whether the API key is authorized (None if unreachable)
348
+ - services: dict mapping service name to status string
349
+ ("ok", "unauthorized", "not_found", "error", "unreachable")
350
+ - file_query_endpoint: selected file query endpoint, if available
351
+ - elasticsearch_available: bool | None - whether search-files is available
352
+ - platform: detected platform string (PLATFORM_SLE, PLATFORM_SLS,
353
+ PLATFORM_UNREACHABLE)
354
+ """
355
+ headers = {
356
+ "x-ni-api-key": api_key,
357
+ "Content-Type": "application/json",
358
+ "User-Agent": "SystemLink-CLI/1.0 (cross-platform)",
359
+ }
360
+ ssl_verify = get_ssl_verify()
361
+
362
+ services: Dict[str, str] = {}
363
+ any_responded = False
364
+ any_authorized = False
365
+ all_unauthorized = True
366
+
367
+ for display_name, method, url_path in SERVICE_CHECKS:
368
+ try:
369
+ full_url = f"{api_url}{url_path}"
370
+ if method == "POST":
371
+ resp = requests.post(
372
+ full_url,
373
+ headers=headers,
374
+ json={"take": 1},
375
+ verify=ssl_verify,
376
+ timeout=10,
377
+ )
378
+ else:
379
+ resp = requests.get(
380
+ full_url,
381
+ headers=headers,
382
+ verify=ssl_verify,
383
+ timeout=10,
384
+ )
385
+ any_responded = True
386
+
387
+ if resp.status_code in (200, 400):
388
+ services[display_name] = "ok"
389
+ any_authorized = True
390
+ all_unauthorized = False
391
+ elif resp.status_code == 401:
392
+ services[display_name] = "unauthorized"
393
+ elif resp.status_code == 403:
394
+ services[display_name] = "unauthorized"
395
+ elif resp.status_code == 404:
396
+ services[display_name] = "not_found"
397
+ all_unauthorized = False
398
+ else:
399
+ services[display_name] = "error"
400
+ all_unauthorized = False
401
+ except requests.RequestException:
402
+ services[display_name] = "unreachable"
403
+
404
+ # Determine overall status
405
+ if not any_responded:
406
+ return {
407
+ "server_reachable": False,
408
+ "auth_valid": None,
409
+ "services": services,
410
+ "file_query_endpoint": None,
411
+ "elasticsearch_available": None,
412
+ "platform": PLATFORM_UNREACHABLE,
413
+ }
414
+
415
+ # Determine auth status: valid if any service accepted the key
416
+ # If all responding services returned 401/403, the key is invalid
417
+ auth_valid = any_authorized if any_responded else None
418
+ if all_unauthorized and any_responded:
419
+ auth_valid = False
420
+
421
+ # Determine platform from service responses
422
+ workorder_status = services.get("Work Order")
423
+ if workorder_status in ("ok",):
424
+ platform = PLATFORM_SLE
425
+ elif workorder_status == "not_found":
426
+ platform = PLATFORM_SLS
427
+ else:
428
+ # Fall back to URL pattern matching
429
+ platform = _detect_platform_from_url(api_url)
430
+
431
+ file_capability = get_file_query_capability(api_url, api_key)
432
+ services["File"] = file_capability["status"]
433
+
434
+ return {
435
+ "server_reachable": True,
436
+ "auth_valid": auth_valid,
437
+ "services": services,
438
+ "file_query_endpoint": file_capability["file_query_endpoint"],
439
+ "elasticsearch_available": file_capability["elasticsearch_available"],
440
+ "platform": platform,
441
+ }
442
+
443
+
444
+ def get_platform_info(skip_health: bool = False) -> Dict[str, Any]:
278
445
  """Get detailed information about the current platform configuration.
279
446
 
447
+ Args:
448
+ skip_health: If True, skip live service health checks.
449
+
280
450
  Returns:
281
- Dictionary with platform info including URL, platform type, and features.
451
+ Dictionary with platform info including URL, platform type, and services.
282
452
  """
283
453
  from .utils import get_api_key, get_base_url, get_web_url
284
454
 
@@ -304,11 +474,28 @@ def get_platform_info() -> Dict[str, Any]:
304
474
 
305
475
  active_profile = get_active_profile()
306
476
  if active_profile and active_profile.platform:
307
- platform = active_profile.platform
477
+ stored_platform = active_profile.platform
308
478
  else:
309
479
  # Fall back to keyring config
310
480
  cfg = _get_keyring_config()
311
- platform = cfg.get("platform", PLATFORM_UNKNOWN)
481
+ stored_platform = cfg.get("platform", PLATFORM_UNKNOWN)
482
+
483
+ # Live service health check when logged in
484
+ server_reachable: Optional[bool] = None
485
+ auth_valid: Optional[bool] = None
486
+ services: Optional[Dict[str, str]] = None
487
+ platform = stored_platform
488
+ file_query_endpoint: Optional[str] = None
489
+ elasticsearch_available: Optional[bool] = None
490
+
491
+ if not skip_health and logged_in and isinstance(api_url, str) and api_url != "Not configured":
492
+ status = check_service_status(api_url, api_key)
493
+ server_reachable = status["server_reachable"]
494
+ auth_valid = status["auth_valid"]
495
+ services = status["services"]
496
+ platform = status["platform"]
497
+ file_query_endpoint = status.get("file_query_endpoint")
498
+ elasticsearch_available = status.get("elasticsearch_available")
312
499
 
313
500
  info: Dict[str, Any] = {
314
501
  "api_url": api_url,
@@ -316,14 +503,17 @@ def get_platform_info() -> Dict[str, Any]:
316
503
  "platform": platform,
317
504
  "platform_display": _get_platform_display_name(platform),
318
505
  "logged_in": logged_in,
506
+ "server_reachable": server_reachable,
507
+ "auth_valid": auth_valid,
319
508
  }
320
509
 
321
- # Add feature availability if platform is known
322
- if platform in PLATFORM_FEATURES:
323
- info["features"] = {}
324
- for feature, available in PLATFORM_FEATURES[platform].items():
325
- display_name = FEATURE_DISPLAY_NAMES.get(feature, feature)
326
- info["features"][display_name] = available
510
+ if file_query_endpoint is not None:
511
+ info["file_query_endpoint"] = file_query_endpoint
512
+ if elasticsearch_available is not None:
513
+ info["elasticsearch_available"] = elasticsearch_available
514
+
515
+ if services is not None:
516
+ info["services"] = services
327
517
 
328
518
  return info
329
519
 
@@ -341,5 +531,6 @@ def _get_platform_display_name(platform: str) -> str:
341
531
  PLATFORM_SLE: "SystemLink Enterprise",
342
532
  PLATFORM_SLS: "SystemLink Server",
343
533
  PLATFORM_UNKNOWN: "Unknown",
534
+ PLATFORM_UNREACHABLE: "Unreachable (could not connect to server)",
344
535
  }
345
536
  return names.get(platform, platform)
@@ -468,7 +468,7 @@ Manage named connection profiles (dev, test, prod). Credentials are stored in
468
468
  ```bash
469
469
  slcli login [--profile NAME] [--url URL] [--api-key KEY] [--web-url URL] [--workspace NAME]
470
470
  slcli logout [--profile NAME] [--all] [--force]
471
- slcli info [-f json] # Show active profile and feature availability
471
+ slcli info [-f json] [--skip-health] # Show active profile and service health
472
472
  slcli completion [--shell SHELL] [--install] # Generate or install shell tab completion
473
473
 
474
474
  slcli config list [-f json] # List all profiles
@@ -1715,14 +1715,17 @@ def register_system_commands(cli: Any) -> None:
1715
1715
  }
1716
1716
  click.echo(json.dumps(result, indent=2))
1717
1717
  else:
1718
- click.echo("\nSystem Fleet Summary")
1719
- click.echo("──────────────────────────────────────")
1720
- click.echo(f" Connected: {connected}")
1721
- click.echo(f" Disconnected: {disconnected}")
1722
- click.echo(f" Virtual: {virtual}")
1723
- click.echo(f" Pending: {pending}")
1724
- click.echo(" ─────────────────")
1725
- click.echo(f" Total: {total}")
1718
+ click.echo()
1719
+ click.echo("┌────────────────────────┐")
1720
+ click.echo("│ System Fleet Summary │")
1721
+ click.echo("├────────────────┬───────┤")
1722
+ click.echo(f"│ Connected │ {connected:>5}")
1723
+ click.echo(f"│ Disconnected │ {disconnected:>5}")
1724
+ click.echo(f"│ Virtual │ {virtual:>5} │")
1725
+ click.echo(f"│ Pending │ {pending:>5}")
1726
+ click.echo("├────────────────┼───────┤")
1727
+ click.echo(f"│ Total │ {total:>5} │")
1728
+ click.echo("└────────────────┴───────┘")
1726
1729
  click.echo()
1727
1730
 
1728
1731
  except Exception as exc: # noqa: BLE001
File without changes