systemlink-cli 1.6.4__tar.gz → 1.7.0__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 (76) hide show
  1. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/PKG-INFO +2 -1
  2. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/pyproject.toml +2 -1
  3. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/_version.py +1 -1
  4. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/config_click.py +3 -1
  5. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/file_click.py +336 -38
  6. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/main.py +4 -1
  7. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/platform.py +34 -0
  8. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/skills/slcli/SKILL.md +14 -1
  9. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/system_click.py +483 -0
  10. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/LICENSE +0 -0
  11. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/dff-editor/editor.js +0 -0
  12. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/dff-editor/index.html +0 -0
  13. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/__init__.py +0 -0
  14. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/__main__.py +0 -0
  15. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/asset_click.py +0 -0
  16. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/cli_formatters.py +0 -0
  17. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/cli_utils.py +0 -0
  18. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/comment_click.py +0 -0
  19. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/completion_click.py +0 -0
  20. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/config.py +0 -0
  21. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/dff_click.py +0 -0
  22. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/dff_decorators.py +0 -0
  23. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/example_click.py +0 -0
  24. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/example_loader.py +0 -0
  25. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/example_provisioner.py +0 -0
  26. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/examples/README.md +0 -0
  27. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/examples/_schema/schema-v1.0.json +0 -0
  28. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/examples/demo-complete-workflow/README.md +0 -0
  29. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/examples/demo-complete-workflow/config.yaml +0 -0
  30. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/examples/demo-test-plans/README.md +0 -0
  31. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/examples/demo-test-plans/config.yaml +0 -0
  32. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/examples/exercise-5-1-parametric-insights/README.md +0 -0
  33. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/examples/exercise-5-1-parametric-insights/config.yaml +0 -0
  34. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/examples/exercise-7-1-test-plans/README.md +0 -0
  35. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/examples/exercise-7-1-test-plans/config.yaml +0 -0
  36. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/examples/spec-compliance-notebooks/README.md +0 -0
  37. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/examples/spec-compliance-notebooks/config.yaml +0 -0
  38. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/examples/spec-compliance-notebooks/notebooks/SpecAnalysis_ComplianceCalculation.ipynb +0 -0
  39. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/examples/spec-compliance-notebooks/notebooks/SpecComplianceCalculation.ipynb +0 -0
  40. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/examples/spec-compliance-notebooks/notebooks/SpecfileExtractionAndIngestion.ipynb +0 -0
  41. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/examples/spec-compliance-notebooks/spec_template.xlsx +0 -0
  42. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/feed_click.py +0 -0
  43. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/function_click.py +0 -0
  44. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/function_templates.py +0 -0
  45. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/mcp_click.py +0 -0
  46. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/mcp_server.py +0 -0
  47. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/notebook_click.py +0 -0
  48. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/policy_click.py +0 -0
  49. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/policy_utils.py +0 -0
  50. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/profiles.py +0 -0
  51. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/response_handlers.py +0 -0
  52. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/rich_output.py +0 -0
  53. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/routine_click.py +0 -0
  54. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/skill_click.py +0 -0
  55. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/skills/slcli/references/analysis-recipes.md +0 -0
  56. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/skills/slcli/references/filtering.md +0 -0
  57. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/skills/systemlink-webapp/SKILL.md +0 -0
  58. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/skills/systemlink-webapp/references/deployment.md +0 -0
  59. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/skills/systemlink-webapp/references/layout-patterns.md +0 -0
  60. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/skills/systemlink-webapp/references/nimble-angular.md +0 -0
  61. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/skills/systemlink-webapp/references/systemlink-services.md +0 -0
  62. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/ssl_trust.py +0 -0
  63. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/table_utils.py +0 -0
  64. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/tag_click.py +0 -0
  65. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/templates_click.py +0 -0
  66. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/testmonitor_click.py +0 -0
  67. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/universal_handlers.py +0 -0
  68. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/user_click.py +0 -0
  69. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/utils.py +0 -0
  70. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/web_editor.py +0 -0
  71. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/webapp_click.py +0 -0
  72. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/workflow_preview.py +0 -0
  73. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/workflows_click.py +0 -0
  74. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/workitem_click.py +0 -0
  75. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/workspace_click.py +0 -0
  76. {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/workspace_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: systemlink-cli
3
- Version: 1.6.4
3
+ Version: 1.7.0
4
4
  Summary: SystemLink Integrator CLI - cross-platform CLI for SystemLink workflows and templates.
5
5
  License-File: LICENSE
6
6
  Author: Fred Visser
@@ -12,6 +12,7 @@ Classifier: Programming Language :: Python :: 3.13
12
12
  Classifier: Programming Language :: Python :: 3.14
13
13
  Requires-Dist: click (>=7.1.2)
14
14
  Requires-Dist: keyring (>=25.6.0,<26.0.0)
15
+ Requires-Dist: packaging (>=21.0)
15
16
  Requires-Dist: pyyaml (>=6.0.3,<7.0.0)
16
17
  Requires-Dist: questionary (>=2.1.1,<3.0.0)
17
18
  Requires-Dist: requests (>=2.32.4,<3.0.0)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "systemlink-cli"
3
- version = "1.6.4"
3
+ version = "1.7.0"
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" }]
@@ -35,6 +35,7 @@ truststore = ">=0.9,<0.11"
35
35
  watchdog = "^6.0.0"
36
36
  pyyaml = "^6.0.3"
37
37
  questionary = "^2.1.1"
38
+ packaging = ">=21.0"
38
39
  rich = ">=13.7,<15"
39
40
  rich-click = ">=1.8,<2"
40
41
 
@@ -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.6.4"
4
+ __version__ = "1.7.0"
@@ -116,7 +116,9 @@ def _add_profile_impl(
116
116
  elif status["auth_valid"] is True:
117
117
  click.echo(" API key: ✓ Authorized")
118
118
 
119
- if status.get("elasticsearch_available") is False:
119
+ if status.get("file_query_endpoint") == "query-files":
120
+ click.echo(" File query: query-files")
121
+ elif status.get("elasticsearch_available") is False:
120
122
  click.echo(" File query: query-files-linq (Elasticsearch unavailable)")
121
123
  click.echo(
122
124
  " 'slcli file list' will fall back automatically; 'slcli file query' requires search-files."
@@ -1,12 +1,14 @@
1
1
  """CLI commands for managing SystemLink files."""
2
2
 
3
3
  import json
4
+ import re
4
5
  import shutil
5
6
  import sys
6
7
  import threading
7
8
  import time
8
9
  from pathlib import Path
9
- from typing import Any, Dict, List, Optional
10
+ from typing import Any, Dict, List, Optional, Tuple
11
+ from urllib.parse import urlencode
10
12
 
11
13
  import click
12
14
  import questionary
@@ -35,37 +37,149 @@ def _get_search_files_url() -> str:
35
37
  return f"{_get_file_service_url()}/search-files"
36
38
 
37
39
 
38
- def _is_search_files_unavailable(exc: requests_lib.HTTPError) -> bool:
39
- """Return whether the search-files endpoint is unavailable."""
40
+ def _get_query_files_url() -> str:
41
+ """Get the query-files endpoint URL."""
42
+ return f"{_get_file_service_url()}/query-files"
43
+
44
+
45
+ def _is_file_query_endpoint_unavailable(exc: requests_lib.HTTPError) -> bool:
46
+ """Return whether a file query endpoint is unavailable."""
40
47
  return exc.response is not None and exc.response.status_code in (404, 501)
41
48
 
42
49
 
43
50
  def _exit_search_files_required() -> None:
44
51
  """Exit with a user-facing message when search-files is unavailable."""
45
52
  click.echo(
46
- "✗ File query requires the search-files endpoint, but Elasticsearch is not available.",
53
+ "✗ File query requires search-files syntax, but this server does not support it.",
47
54
  err=True,
48
55
  )
49
56
  click.echo(
50
- " Use 'slcli file list' for automatic fallback to query-files-linq.",
57
+ " Use 'slcli file list' for automatic endpoint fallback, or use a simpler filter.",
51
58
  err=True,
52
59
  )
53
60
  sys.exit(ExitCodes.INVALID_INPUT)
54
61
 
55
62
 
63
+ def _build_query_files_url(take: int, workspace_id: Optional[str]) -> str:
64
+ """Build the query-files URL with supported query parameters."""
65
+ params: Dict[str, Any] = {"take": take}
66
+ if workspace_id:
67
+ params["workspace"] = workspace_id
68
+ return f"{_get_query_files_url()}?{urlencode(params)}"
69
+
70
+
71
+ def _get_structured_query_take(take: int, has_client_side_filters: bool) -> int:
72
+ """Return the query-files page size to use for fallback filtering."""
73
+ if has_client_side_filters:
74
+ return max(take * 4, 1000)
75
+ return take
76
+
77
+
78
+ def _get_file_extension(file_item: dict) -> str:
79
+ """Extract the file extension from file metadata without the leading dot."""
80
+ file_name = _get_file_name(file_item)
81
+ return Path(file_name).suffix.lstrip(".")
82
+
83
+
84
+ def _filter_files_by_name_or_extension(
85
+ files: List[dict],
86
+ needle: str,
87
+ take: int,
88
+ ) -> List[dict]:
89
+ """Filter files by name or extension using case-insensitive contains matching."""
90
+ needle_lower = needle.lower()
91
+ extension_needle = needle_lower.lstrip(".")
92
+ filtered: List[dict] = []
93
+
94
+ for file_item in files:
95
+ file_name = _get_file_name(file_item).lower()
96
+ file_extension = _get_file_extension(file_item).lower()
97
+ if needle_lower in file_name or extension_needle in file_extension:
98
+ filtered.append(file_item)
99
+ if len(filtered) >= take:
100
+ break
101
+
102
+ return filtered
103
+
104
+
105
+ def _build_structured_file_query(
106
+ workspace_id: Optional[str],
107
+ name_filter: Optional[str],
108
+ id_filter: Optional[str],
109
+ ) -> Dict[str, Any]:
110
+ """Build a query-files request body from high-level file filters.
111
+
112
+ Args:
113
+ workspace_id: Optional workspace ID filter (handled in URL)
114
+ name_filter: Optional file name substring filter
115
+ id_filter: Optional comma-separated file IDs
116
+
117
+ Returns:
118
+ Structured query-files request body
119
+ """
120
+ del workspace_id
121
+
122
+ payload: Dict[str, Any] = {}
123
+
124
+ if id_filter:
125
+ ids = [file_id.strip() for file_id in id_filter.split(",") if file_id.strip()]
126
+ if len(ids) == 1:
127
+ payload["idQuery"] = {"operation": "EQUAL", "value": ids[0]}
128
+ elif ids:
129
+ payload["idsQuery"] = {"operation": "EQUAL", "ids": ids}
130
+
131
+ del name_filter
132
+
133
+ return payload
134
+
135
+
136
+ def _query_files_structured(
137
+ take: int,
138
+ workspace_id: Optional[str],
139
+ name_filter: Optional[str],
140
+ id_filter: Optional[str],
141
+ ) -> Any:
142
+ """Query files using the SLS query-files endpoint."""
143
+ has_client_side_filters = bool(name_filter)
144
+ url = _build_query_files_url(
145
+ take=_get_structured_query_take(take, has_client_side_filters),
146
+ workspace_id=workspace_id,
147
+ )
148
+ payload = _build_structured_file_query(
149
+ workspace_id=workspace_id,
150
+ name_filter=name_filter,
151
+ id_filter=id_filter,
152
+ )
153
+ resp = make_api_request("POST", url, payload=payload, handle_errors=False)
154
+
155
+ if not name_filter:
156
+ return resp
157
+
158
+ data = resp.json()
159
+ filtered = _filter_files_by_name_or_extension(
160
+ data.get("availableFiles", []),
161
+ name_filter,
162
+ take,
163
+ )
164
+ return FilteredResponse({"availableFiles": filtered})
165
+
166
+
56
167
  def _search_files_with_fallback(
57
168
  payload: Dict[str, Any],
169
+ take: int,
58
170
  workspace_id: Optional[str],
59
171
  name_filter: Optional[str],
60
172
  id_filter: Optional[str],
61
173
  ) -> Any:
62
- """Try search-files endpoint, fall back to query-files-linq if unavailable.
174
+ """Try search-files, then query-files, then query-files-linq.
63
175
 
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.
176
+ The preferred search-files endpoint is available on SLE. On SLS, the file
177
+ service exposes query-files instead. Older systems may still require the
178
+ query-files-linq fallback.
66
179
 
67
180
  Args:
68
181
  payload: The search-files request payload
182
+ take: Maximum number of files to return for fallback endpoints
69
183
  workspace_id: Optional workspace ID filter
70
184
  name_filter: Optional name filter string
71
185
  id_filter: Optional comma-separated file IDs
@@ -78,13 +192,23 @@ def _search_files_with_fallback(
78
192
  "POST", _get_search_files_url(), payload=payload, handle_errors=False
79
193
  )
80
194
  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
- )
195
+ if _is_file_query_endpoint_unavailable(exc):
196
+ try:
197
+ return _query_files_structured(
198
+ take=take,
199
+ workspace_id=workspace_id,
200
+ name_filter=name_filter,
201
+ id_filter=id_filter,
202
+ )
203
+ except requests_lib.HTTPError as structured_exc:
204
+ if _is_file_query_endpoint_unavailable(structured_exc):
205
+ return _query_files_linq_fallback(
206
+ take=take,
207
+ workspace_id=workspace_id,
208
+ name_filter=name_filter,
209
+ id_filter=id_filter,
210
+ )
211
+ raise
88
212
  raise
89
213
 
90
214
 
@@ -144,6 +268,152 @@ def _query_files_linq_fallback(
144
268
  return resp
145
269
 
146
270
 
271
+ def _get_file_by_id_via_query_files(file_id: str) -> Optional[dict]:
272
+ """Get file metadata by ID using the query-files endpoint."""
273
+ url = _build_query_files_url(take=1, workspace_id=None)
274
+ payload = {"idQuery": {"operation": "EQUAL", "value": file_id}}
275
+ resp = make_api_request("POST", url, payload=payload, handle_errors=False)
276
+ data = resp.json()
277
+ files = data.get("availableFiles", [])
278
+ if files:
279
+ return files[0]
280
+ return None
281
+
282
+
283
+ def _get_file_by_id_via_query_files_linq(file_id: str) -> Optional[dict]:
284
+ """Get file metadata by ID using the query-files-linq endpoint."""
285
+ url = f"{_get_file_service_url()}/query-files-linq"
286
+ payload = {
287
+ "filter": f'id = "{file_id}"',
288
+ "take": 1,
289
+ }
290
+ resp = make_api_request("POST", url, payload=payload)
291
+ data = resp.json()
292
+ files = data.get("availableFiles", [])
293
+ if files:
294
+ return files[0]
295
+ return None
296
+
297
+
298
+ def _extract_search_filter_values(expression: str) -> List[str]:
299
+ """Extract quoted values from a search-files filter clause."""
300
+ return [value for value in re.findall(r'"([^"]*)"', expression) if value]
301
+
302
+
303
+ def _parse_case_insensitive_filter_value(field_name: str, value: str) -> Tuple[str, str]:
304
+ """Parse a supported name or extension value into a client-side filter."""
305
+ if field_name == "extension":
306
+ if value.startswith("*") and value.endswith("*") and len(value) >= 2:
307
+ return ("contains", value[1:-1].lstrip("."))
308
+ if "*" in value:
309
+ raise ValueError(
310
+ "Only contains wildcards of the form '*value*' are supported on this server."
311
+ )
312
+ return ("equal", value.lstrip("."))
313
+
314
+ if value.startswith("*") and value.endswith("*") and len(value) >= 2:
315
+ return ("contains", value[1:-1])
316
+ if "*" in value:
317
+ raise ValueError(
318
+ "Only contains wildcards of the form '*value*' are supported on this server."
319
+ )
320
+ return ("equal", value)
321
+
322
+
323
+ def _matches_case_insensitive_filter(
324
+ file_item: dict,
325
+ field_name: str,
326
+ operation: str,
327
+ value: str,
328
+ ) -> bool:
329
+ """Return whether a file matches a parsed case-insensitive client-side filter."""
330
+ candidate = (
331
+ _get_file_name(file_item) if field_name == "name" else _get_file_extension(file_item)
332
+ )
333
+ candidate_lower = candidate.lower()
334
+ value_lower = value.lower()
335
+
336
+ if operation == "contains":
337
+ return value_lower in candidate_lower
338
+ return candidate_lower == value_lower
339
+
340
+
341
+ def _apply_case_insensitive_query_filters(
342
+ files: List[dict],
343
+ client_filters: List[Tuple[str, str, str]],
344
+ take: int,
345
+ ) -> List[dict]:
346
+ """Apply parsed name/extension filters to a query-files response."""
347
+ if not client_filters:
348
+ return files[:take]
349
+
350
+ filtered: List[dict] = []
351
+ for file_item in files:
352
+ if all(
353
+ _matches_case_insensitive_filter(file_item, field_name, operation, value)
354
+ for field_name, operation, value in client_filters
355
+ ):
356
+ filtered.append(file_item)
357
+ if len(filtered) >= take:
358
+ break
359
+
360
+ return filtered
361
+
362
+
363
+ def _convert_search_filter_to_query_files(
364
+ filter_query: Optional[str],
365
+ workspace_id: Optional[str],
366
+ ) -> Tuple[Dict[str, Any], Optional[str], List[Tuple[str, str, str]]]:
367
+ """Convert supported search-files expressions to a query-files request body."""
368
+ payload: Dict[str, Any] = {}
369
+ resolved_workspace = workspace_id
370
+ client_filters: List[Tuple[str, str, str]] = []
371
+
372
+ if not filter_query:
373
+ return payload, resolved_workspace, client_filters
374
+
375
+ clauses = [part.strip() for part in re.split(r"\s+AND\s+", filter_query, flags=re.IGNORECASE)]
376
+
377
+ for clause in clauses:
378
+ match = re.match(r"^(name|extension|id|workspaceId):\((.+)\)$", clause)
379
+ if not match:
380
+ raise ValueError(
381
+ "Only name, extension, id, and workspaceId filters joined by AND are supported on this server."
382
+ )
383
+
384
+ field_name, expression = match.groups()
385
+ if re.search(r"\s+OR\s+", expression, flags=re.IGNORECASE):
386
+ raise ValueError(
387
+ "Only name, extension, id, and workspaceId filters joined by AND are supported on this server."
388
+ )
389
+ values = _extract_search_filter_values(expression)
390
+ if not values:
391
+ raise ValueError(f"Could not parse filter clause: {clause}")
392
+
393
+ if field_name == "workspaceId":
394
+ if len(values) != 1:
395
+ raise ValueError("workspaceId filters must specify exactly one workspace ID.")
396
+ if resolved_workspace and resolved_workspace != values[0]:
397
+ raise ValueError("Conflicting workspace filters were provided.")
398
+ resolved_workspace = values[0]
399
+ continue
400
+
401
+ if field_name == "id":
402
+ if len(values) == 1:
403
+ payload["idQuery"] = {"operation": "EQUAL", "value": values[0]}
404
+ else:
405
+ payload["idsQuery"] = {"operation": "EQUAL", "ids": values}
406
+ continue
407
+
408
+ if len(values) != 1:
409
+ raise ValueError(f"{field_name} filters support exactly one value on this server.")
410
+
411
+ operation, parsed_value = _parse_case_insensitive_filter_value(field_name, values[0])
412
+ client_filters.append((field_name, operation, parsed_value))
413
+
414
+ return payload, resolved_workspace, client_filters
415
+
416
+
147
417
  def _format_file_size(size_bytes: Optional[int]) -> str:
148
418
  """Format file size in human-readable format.
149
419
 
@@ -217,10 +487,7 @@ def _get_file_size(file_item: dict) -> Optional[int]:
217
487
 
218
488
 
219
489
  def _get_file_by_id(file_id: str) -> Optional[dict]:
220
- """Get file metadata by ID using query-files-linq endpoint.
221
-
222
- The API doesn't have a GET /files/{id} endpoint, so we use
223
- query-files-linq with an ID filter instead.
490
+ """Get file metadata by ID using the best available query endpoint.
224
491
 
225
492
  Args:
226
493
  file_id: The file ID to look up
@@ -228,17 +495,12 @@ def _get_file_by_id(file_id: str) -> Optional[dict]:
228
495
  Returns:
229
496
  File metadata dictionary or None if not found
230
497
  """
231
- url = f"{_get_file_service_url()}/query-files-linq"
232
- payload = {
233
- "filter": f'id = "{file_id}"',
234
- "take": 1,
235
- }
236
- resp = make_api_request("POST", url, payload=payload)
237
- data = resp.json()
238
- files = data.get("availableFiles", [])
239
- if files:
240
- return files[0]
241
- return None
498
+ try:
499
+ return _get_file_by_id_via_query_files(file_id)
500
+ except requests_lib.HTTPError as exc:
501
+ if _is_file_query_endpoint_unavailable(exc):
502
+ return _get_file_by_id_via_query_files_linq(file_id)
503
+ raise
242
504
 
243
505
 
244
506
  def register_file_commands(cli: Any) -> None:
@@ -336,6 +598,7 @@ def register_file_commands(cli: Any) -> None:
336
598
  # Try search-files, fall back to query-files-linq if unavailable
337
599
  resp = _search_files_with_fallback(
338
600
  payload=payload,
601
+ take=api_take,
339
602
  workspace_id=workspace_id,
340
603
  name_filter=name_filter,
341
604
  id_filter=id_filter,
@@ -687,8 +950,9 @@ def register_file_commands(cli: Any) -> None:
687
950
 
688
951
  try:
689
952
  # Build request body for search-files
953
+ api_take = take if format_output.lower() == "json" else (take if take != 25 else 1000)
690
954
  query_body: Dict[str, Any] = {
691
- "take": take if format_output.lower() == "json" else (take if take != 25 else 1000),
955
+ "take": api_take,
692
956
  "orderByDescending": descending,
693
957
  }
694
958
 
@@ -714,12 +978,43 @@ def register_file_commands(cli: Any) -> None:
714
978
  else:
715
979
  query_body["orderBy"] = "updated"
716
980
 
717
- resp = make_api_request(
718
- "POST",
719
- _get_search_files_url(),
720
- payload=query_body,
721
- handle_errors=False,
722
- )
981
+ resp: Any
982
+ try:
983
+ resp = make_api_request(
984
+ "POST",
985
+ _get_search_files_url(),
986
+ payload=query_body,
987
+ handle_errors=False,
988
+ )
989
+ except requests_lib.HTTPError as exc:
990
+ if not _is_file_query_endpoint_unavailable(exc):
991
+ raise
992
+
993
+ structured_query, structured_workspace, client_filters = (
994
+ _convert_search_filter_to_query_files(
995
+ filter_query=filter_query,
996
+ workspace_id=workspace_id,
997
+ )
998
+ )
999
+ structured_take = _get_structured_query_take(api_take, bool(client_filters))
1000
+ structured_resp = make_api_request(
1001
+ "POST",
1002
+ _build_query_files_url(structured_take, structured_workspace),
1003
+ payload=structured_query,
1004
+ )
1005
+ if client_filters:
1006
+ structured_data = structured_resp.json()
1007
+ resp = FilteredResponse(
1008
+ {
1009
+ "availableFiles": _apply_case_insensitive_query_filters(
1010
+ structured_data.get("availableFiles", []),
1011
+ client_filters,
1012
+ api_take,
1013
+ )
1014
+ }
1015
+ )
1016
+ else:
1017
+ resp = structured_resp
723
1018
 
724
1019
  def file_formatter(file_item: dict) -> list:
725
1020
  name = _get_file_name(file_item)
@@ -742,9 +1037,12 @@ def register_file_commands(cli: Any) -> None:
742
1037
  )
743
1038
 
744
1039
  except requests_lib.HTTPError as exc:
745
- if _is_search_files_unavailable(exc):
1040
+ if _is_file_query_endpoint_unavailable(exc):
746
1041
  _exit_search_files_required()
747
1042
  handle_api_error(exc)
1043
+ except ValueError as exc:
1044
+ click.echo(f"✗ {exc}", err=True)
1045
+ sys.exit(ExitCodes.INVALID_INPUT)
748
1046
  except Exception as exc:
749
1047
  handle_api_error(exc)
750
1048
 
@@ -367,7 +367,10 @@ def info(format: str, skip_health: bool) -> None:
367
367
 
368
368
  file_query_endpoint = platform_info.get("file_query_endpoint")
369
369
  if file_query_endpoint:
370
- if platform_info.get("elasticsearch_available") is False:
370
+ if (
371
+ file_query_endpoint == "query-files-linq"
372
+ and platform_info.get("elasticsearch_available") is False
373
+ ):
371
374
  file_query_display = f"{file_query_endpoint} (Elasticsearch unavailable)"
372
375
  else:
373
376
  file_query_display = str(file_query_endpoint)
@@ -57,6 +57,7 @@ FEATURE_DISPLAY_NAMES: Dict[str, str] = {
57
57
  }
58
58
 
59
59
  FILE_SEARCH_PATH = "/nifile/v1/service-groups/Default/search-files"
60
+ FILE_QUERY_PATH = "/nifile/v1/service-groups/Default/query-files"
60
61
  FILE_QUERY_LINQ_PATH = "/nifile/v1/service-groups/Default/query-files-linq"
61
62
 
62
63
 
@@ -246,6 +247,39 @@ def get_file_query_capability(api_url: str, api_key: str) -> Dict[str, Any]:
246
247
  "elasticsearch_available": None,
247
248
  }
248
249
 
250
+ try:
251
+ query_resp = requests.post(
252
+ f"{api_url}{FILE_QUERY_PATH}",
253
+ headers=headers,
254
+ json={},
255
+ verify=ssl_verify,
256
+ timeout=10,
257
+ )
258
+ if query_resp.status_code in (200, 400):
259
+ return {
260
+ "status": "ok",
261
+ "file_query_endpoint": "query-files",
262
+ "elasticsearch_available": False,
263
+ }
264
+ if query_resp.status_code in (401, 403):
265
+ return {
266
+ "status": "unauthorized",
267
+ "file_query_endpoint": "query-files",
268
+ "elasticsearch_available": False,
269
+ }
270
+ if query_resp.status_code not in (404, 501):
271
+ return {
272
+ "status": "error" if query_resp.status_code >= 500 else "not_found",
273
+ "file_query_endpoint": None,
274
+ "elasticsearch_available": False,
275
+ }
276
+ except requests.RequestException:
277
+ return {
278
+ "status": "unreachable",
279
+ "file_query_endpoint": None,
280
+ "elasticsearch_available": False,
281
+ }
282
+
249
283
  try:
250
284
  fallback_resp = requests.post(
251
285
  f"{api_url}{FILE_QUERY_LINQ_PATH}",
@@ -227,6 +227,15 @@ slcli system report --type [SOFTWARE|HARDWARE] -o FILE # Generate CSV report
227
227
  slcli system update <SYSTEM_ID> [OPTIONS] # Update system metadata
228
228
  slcli system remove <SYSTEM_ID> # Remove a system
229
229
 
230
+ # Compare two systems (by ID or alias)
231
+ slcli system compare <SYSTEM_A> <SYSTEM_B> [-f json]
232
+ # Compares installed software (packages, versions) and connected assets
233
+ # (model, vendor, slot number). Accepts system IDs or alias names.
234
+ # Output sections:
235
+ # Software: packages unique to each system, version differences
236
+ # Assets: assets unique to each system, count mismatches, slot differences
237
+ # Assets are matched by (modelName, vendorName) identity.
238
+
230
239
  # System jobs
231
240
  slcli system job list [OPTIONS]
232
241
  slcli system job get <JOB_ID>
@@ -249,6 +258,7 @@ slcli tag delete <TAG_PATH> # Delete a tag
249
258
  ### routine — Event-action and notebook routine management
250
259
 
251
260
  Two API versions are supported:
261
+
252
262
  - **v2** (default): General event-action routines — monitor tags, work-item changes, and more; trigger alarms, emails, or notebook executions.
253
263
  - **v1**: Notebook-execution routines with SCHEDULED or TRIGGERED types.
254
264
 
@@ -580,7 +590,7 @@ slcli customfield edit [--directory DIR] # Interactively edit +
580
590
  ### template — Test plan template management
581
591
 
582
592
  > **Note:** Work item templates are managed separately via `slcli workitem template`.
583
- > The `slcli template` command manages test plan *configuration* templates used
593
+ > The `slcli template` command manages test plan _configuration_ templates used
584
594
  > when provisioning new test plan instances.
585
595
 
586
596
  ```bash
@@ -641,6 +651,7 @@ slcli workitem workflow preview [--file PATH] [--id WORKFLOW_ID] [--html] [--no-
641
651
  ```
642
652
 
643
653
  **Create work item options:**
654
+
644
655
  ```bash
645
656
  slcli workitem create \
646
657
  --name "Battery Cycle Test" \
@@ -696,11 +707,13 @@ slcli skill install --skill [slcli|systemlink-webapp|all] --client [agents|claud
696
707
  ```
697
708
 
698
709
  Client paths:
710
+
699
711
  - `agents` — personal: `~/.agents/skills/`, project: `.agents/skills/` (most agents)
700
712
  - `claude` — personal: `~/.claude/skills/`, project: `.claude/skills/`
701
713
  - `all` — install to both the `agents` and `claude` locations for the selected scope
702
714
 
703
715
  Notes:
716
+
704
717
  - `agents` is the default client in interactive mode.
705
718
  - `webapp init` installs project-scoped skills into `.agents/skills/` by default.
706
719