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.
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/PKG-INFO +2 -1
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/pyproject.toml +2 -1
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/_version.py +1 -1
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/config_click.py +3 -1
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/file_click.py +336 -38
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/main.py +4 -1
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/platform.py +34 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/skills/slcli/SKILL.md +14 -1
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/system_click.py +483 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/LICENSE +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/dff-editor/editor.js +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/dff-editor/index.html +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/__init__.py +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/__main__.py +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/asset_click.py +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/cli_formatters.py +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/cli_utils.py +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/comment_click.py +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/completion_click.py +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/config.py +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/dff_click.py +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/dff_decorators.py +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/example_click.py +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/example_loader.py +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/example_provisioner.py +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/examples/README.md +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/examples/_schema/schema-v1.0.json +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/examples/demo-complete-workflow/README.md +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/examples/demo-complete-workflow/config.yaml +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/examples/demo-test-plans/README.md +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/examples/demo-test-plans/config.yaml +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/examples/exercise-5-1-parametric-insights/README.md +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/examples/exercise-5-1-parametric-insights/config.yaml +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/examples/exercise-7-1-test-plans/README.md +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/examples/exercise-7-1-test-plans/config.yaml +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/examples/spec-compliance-notebooks/README.md +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/examples/spec-compliance-notebooks/config.yaml +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/examples/spec-compliance-notebooks/notebooks/SpecAnalysis_ComplianceCalculation.ipynb +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/examples/spec-compliance-notebooks/notebooks/SpecComplianceCalculation.ipynb +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/examples/spec-compliance-notebooks/notebooks/SpecfileExtractionAndIngestion.ipynb +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/examples/spec-compliance-notebooks/spec_template.xlsx +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/feed_click.py +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/function_click.py +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/function_templates.py +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/mcp_click.py +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/mcp_server.py +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/notebook_click.py +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/policy_click.py +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/policy_utils.py +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/profiles.py +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/response_handlers.py +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/rich_output.py +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/routine_click.py +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/skill_click.py +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/skills/slcli/references/analysis-recipes.md +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/skills/slcli/references/filtering.md +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/skills/systemlink-webapp/SKILL.md +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/skills/systemlink-webapp/references/deployment.md +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/skills/systemlink-webapp/references/layout-patterns.md +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/skills/systemlink-webapp/references/nimble-angular.md +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/skills/systemlink-webapp/references/systemlink-services.md +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/ssl_trust.py +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/table_utils.py +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/tag_click.py +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/templates_click.py +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/testmonitor_click.py +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/universal_handlers.py +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/user_click.py +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/utils.py +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/web_editor.py +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/webapp_click.py +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/workflow_preview.py +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/workflows_click.py +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/workitem_click.py +0 -0
- {systemlink_cli-1.6.4 → systemlink_cli-1.7.0}/slcli/workspace_click.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
|
@@ -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("
|
|
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
|
|
39
|
-
"""
|
|
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
|
|
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
|
|
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
|
|
174
|
+
"""Try search-files, then query-files, then query-files-linq.
|
|
63
175
|
|
|
64
|
-
The search-files endpoint
|
|
65
|
-
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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":
|
|
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
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|