systemlink-cli 1.6.5__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.5 → systemlink_cli-1.7.0}/PKG-INFO +2 -1
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/pyproject.toml +2 -1
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/_version.py +1 -1
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/skills/slcli/SKILL.md +14 -1
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/system_click.py +483 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/LICENSE +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/dff-editor/editor.js +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/dff-editor/index.html +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/__init__.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/__main__.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/asset_click.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/cli_formatters.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/cli_utils.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/comment_click.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/completion_click.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/config.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/config_click.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/dff_click.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/dff_decorators.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/example_click.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/example_loader.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/example_provisioner.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/examples/README.md +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/examples/_schema/schema-v1.0.json +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/examples/demo-complete-workflow/README.md +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/examples/demo-complete-workflow/config.yaml +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/examples/demo-test-plans/README.md +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/examples/demo-test-plans/config.yaml +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/examples/exercise-5-1-parametric-insights/README.md +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/examples/exercise-5-1-parametric-insights/config.yaml +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/examples/exercise-7-1-test-plans/README.md +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/examples/exercise-7-1-test-plans/config.yaml +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/examples/spec-compliance-notebooks/README.md +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/examples/spec-compliance-notebooks/config.yaml +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/examples/spec-compliance-notebooks/notebooks/SpecAnalysis_ComplianceCalculation.ipynb +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/examples/spec-compliance-notebooks/notebooks/SpecComplianceCalculation.ipynb +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/examples/spec-compliance-notebooks/notebooks/SpecfileExtractionAndIngestion.ipynb +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/examples/spec-compliance-notebooks/spec_template.xlsx +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/feed_click.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/file_click.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/function_click.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/function_templates.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/main.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/mcp_click.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/mcp_server.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/notebook_click.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/platform.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/policy_click.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/policy_utils.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/profiles.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/response_handlers.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/rich_output.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/routine_click.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/skill_click.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/skills/slcli/references/analysis-recipes.md +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/skills/slcli/references/filtering.md +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/skills/systemlink-webapp/SKILL.md +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/skills/systemlink-webapp/references/deployment.md +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/skills/systemlink-webapp/references/layout-patterns.md +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/skills/systemlink-webapp/references/nimble-angular.md +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/skills/systemlink-webapp/references/systemlink-services.md +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/ssl_trust.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/table_utils.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/tag_click.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/templates_click.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/testmonitor_click.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/universal_handlers.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/user_click.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/utils.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/web_editor.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/webapp_click.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/workflow_preview.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/workflows_click.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/workitem_click.py +0 -0
- {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/workspace_click.py +0 -0
- {systemlink_cli-1.6.5 → 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
|
|
|
@@ -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
|
|
|
@@ -13,6 +13,7 @@ import re
|
|
|
13
13
|
import shutil
|
|
14
14
|
import sys
|
|
15
15
|
from typing import Any, Dict, List, Optional, Tuple
|
|
16
|
+
from urllib.parse import quote_plus
|
|
16
17
|
|
|
17
18
|
import click
|
|
18
19
|
import questionary
|
|
@@ -775,6 +776,7 @@ def _fetch_assets_for_system(system_id: str, take: int) -> Tuple[List[Dict[str,
|
|
|
775
776
|
"projection": (
|
|
776
777
|
"new(id,name,modelName,modelNumber,vendorName,vendorNumber,serialNumber,"
|
|
777
778
|
"workspace,properties,keywords,location.minionId,location.parent,"
|
|
779
|
+
"location.slotNumber,"
|
|
778
780
|
"location.physicalLocation,location.state.assetPresence,"
|
|
779
781
|
"location.state.systemConnection,discoveryType,supportsSelfTest,"
|
|
780
782
|
"supportsSelfCalibration,supportsReset,supportsExternalCalibration,"
|
|
@@ -1180,6 +1182,396 @@ def _get_job_display_fields(job: Dict[str, Any]) -> Dict[str, str]:
|
|
|
1180
1182
|
# ==================================================================
|
|
1181
1183
|
|
|
1182
1184
|
|
|
1185
|
+
def _resolve_system(identifier: str) -> Dict[str, Any]:
|
|
1186
|
+
"""Resolve a system by ID or alias.
|
|
1187
|
+
|
|
1188
|
+
First attempts a direct ID lookup. If that fails, queries by alias.
|
|
1189
|
+
|
|
1190
|
+
Args:
|
|
1191
|
+
identifier: System minion ID or alias.
|
|
1192
|
+
|
|
1193
|
+
Returns:
|
|
1194
|
+
System data dictionary.
|
|
1195
|
+
|
|
1196
|
+
Raises:
|
|
1197
|
+
SystemExit: If the system cannot be found or an API error occurs.
|
|
1198
|
+
"""
|
|
1199
|
+
import requests as _requests
|
|
1200
|
+
|
|
1201
|
+
# Try direct ID lookup first
|
|
1202
|
+
try:
|
|
1203
|
+
encoded_identifier = quote_plus(identifier)
|
|
1204
|
+
url = f"{_get_sysmgmt_base_url()}/systems?id={encoded_identifier}"
|
|
1205
|
+
resp = make_api_request("GET", url, handle_errors=False)
|
|
1206
|
+
resp.raise_for_status()
|
|
1207
|
+
data = resp.json()
|
|
1208
|
+
if isinstance(data, list) and data:
|
|
1209
|
+
return data[0]
|
|
1210
|
+
if isinstance(data, dict) and data.get("id"):
|
|
1211
|
+
return data
|
|
1212
|
+
except _requests.HTTPError as exc:
|
|
1213
|
+
if exc.response is not None and exc.response.status_code == 404:
|
|
1214
|
+
pass
|
|
1215
|
+
else:
|
|
1216
|
+
handle_api_error(exc)
|
|
1217
|
+
except _requests.RequestException as exc:
|
|
1218
|
+
handle_api_error(exc)
|
|
1219
|
+
|
|
1220
|
+
# Fall back to alias search
|
|
1221
|
+
escaped = _escape_filter_value(identifier)
|
|
1222
|
+
query_url = f"{_get_sysmgmt_base_url()}/query-systems"
|
|
1223
|
+
payload: Dict[str, Any] = {
|
|
1224
|
+
"filter": f'alias = "{escaped}"',
|
|
1225
|
+
"take": 2,
|
|
1226
|
+
}
|
|
1227
|
+
try:
|
|
1228
|
+
resp = make_api_request("POST", query_url, payload=payload)
|
|
1229
|
+
data = resp.json()
|
|
1230
|
+
systems = _parse_systems_response(data)
|
|
1231
|
+
if len(systems) == 1:
|
|
1232
|
+
return systems[0]
|
|
1233
|
+
if len(systems) > 1:
|
|
1234
|
+
click.echo(
|
|
1235
|
+
f"✗ Multiple systems match alias '{identifier}'. Use the system ID instead.",
|
|
1236
|
+
err=True,
|
|
1237
|
+
)
|
|
1238
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
1239
|
+
except SystemExit:
|
|
1240
|
+
raise
|
|
1241
|
+
except Exception as exc: # noqa: BLE001
|
|
1242
|
+
handle_api_error(exc)
|
|
1243
|
+
|
|
1244
|
+
click.echo(f"✗ System not found: {identifier}", err=True)
|
|
1245
|
+
sys.exit(ExitCodes.NOT_FOUND)
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
def _get_packages(system: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
|
|
1249
|
+
"""Extract the packages dictionary from a system record.
|
|
1250
|
+
|
|
1251
|
+
Args:
|
|
1252
|
+
system: System data dictionary.
|
|
1253
|
+
|
|
1254
|
+
Returns:
|
|
1255
|
+
Mapping of package name to package info.
|
|
1256
|
+
"""
|
|
1257
|
+
packages = system.get("packages")
|
|
1258
|
+
if isinstance(packages, dict):
|
|
1259
|
+
pkg_data = packages.get("data")
|
|
1260
|
+
if isinstance(pkg_data, dict):
|
|
1261
|
+
return pkg_data
|
|
1262
|
+
return {}
|
|
1263
|
+
|
|
1264
|
+
|
|
1265
|
+
def _compare_packages(
|
|
1266
|
+
pkgs_a: Dict[str, Dict[str, Any]],
|
|
1267
|
+
pkgs_b: Dict[str, Dict[str, Any]],
|
|
1268
|
+
alias_a: str,
|
|
1269
|
+
alias_b: str,
|
|
1270
|
+
format_output: str,
|
|
1271
|
+
) -> Dict[str, Any]:
|
|
1272
|
+
"""Compare software packages between two systems.
|
|
1273
|
+
|
|
1274
|
+
Args:
|
|
1275
|
+
pkgs_a: Packages from system A.
|
|
1276
|
+
pkgs_b: Packages from system B.
|
|
1277
|
+
alias_a: Display name for system A.
|
|
1278
|
+
alias_b: Display name for system B.
|
|
1279
|
+
format_output: Output format (table or json).
|
|
1280
|
+
|
|
1281
|
+
Returns:
|
|
1282
|
+
Comparison result dictionary for JSON output.
|
|
1283
|
+
"""
|
|
1284
|
+
all_keys = sorted(set(pkgs_a.keys()) | set(pkgs_b.keys()))
|
|
1285
|
+
only_a: List[str] = []
|
|
1286
|
+
only_b: List[str] = []
|
|
1287
|
+
version_diffs: List[Dict[str, str]] = []
|
|
1288
|
+
matching: List[str] = []
|
|
1289
|
+
|
|
1290
|
+
def _normalize_package_entry(entry: Any, key: str) -> Dict[str, str]:
|
|
1291
|
+
"""Normalize a package entry to a dict shape for safe comparison."""
|
|
1292
|
+
if isinstance(entry, dict):
|
|
1293
|
+
displayname = entry.get("displayname") or key
|
|
1294
|
+
version = entry.get("version") or ""
|
|
1295
|
+
displayversion = entry.get("displayversion") or ""
|
|
1296
|
+
return {
|
|
1297
|
+
"displayname": str(displayname),
|
|
1298
|
+
"version": str(version),
|
|
1299
|
+
"displayversion": str(displayversion),
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
if isinstance(entry, str):
|
|
1303
|
+
return {
|
|
1304
|
+
"displayname": key,
|
|
1305
|
+
"version": entry,
|
|
1306
|
+
"displayversion": entry,
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
if entry is None:
|
|
1310
|
+
normalized_value = ""
|
|
1311
|
+
else:
|
|
1312
|
+
normalized_value = str(entry)
|
|
1313
|
+
|
|
1314
|
+
return {
|
|
1315
|
+
"displayname": key,
|
|
1316
|
+
"version": normalized_value,
|
|
1317
|
+
"displayversion": normalized_value,
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
for key in all_keys:
|
|
1321
|
+
in_a = key in pkgs_a
|
|
1322
|
+
in_b = key in pkgs_b
|
|
1323
|
+
if in_a and not in_b:
|
|
1324
|
+
only_a.append(key)
|
|
1325
|
+
elif in_b and not in_a:
|
|
1326
|
+
only_b.append(key)
|
|
1327
|
+
else:
|
|
1328
|
+
pkg_a = _normalize_package_entry(pkgs_a[key], key)
|
|
1329
|
+
pkg_b = _normalize_package_entry(pkgs_b[key], key)
|
|
1330
|
+
ver_a = pkg_a.get("version") or pkg_a.get("displayversion") or ""
|
|
1331
|
+
ver_b = pkg_b.get("version") or pkg_b.get("displayversion") or ""
|
|
1332
|
+
if ver_a != ver_b:
|
|
1333
|
+
name = pkg_a.get("displayname") or pkg_b.get("displayname") or key
|
|
1334
|
+
version_diffs.append({"package": name, "version_a": ver_a, "version_b": ver_b})
|
|
1335
|
+
else:
|
|
1336
|
+
matching.append(key)
|
|
1337
|
+
|
|
1338
|
+
result: Dict[str, Any] = {
|
|
1339
|
+
"only_system_a": only_a,
|
|
1340
|
+
"only_system_b": only_b,
|
|
1341
|
+
"version_differences": version_diffs,
|
|
1342
|
+
"matching_count": len(matching),
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
if format_output.lower() != "json":
|
|
1346
|
+
click.echo("\n Software Comparison")
|
|
1347
|
+
click.echo(" " + "─" * 60)
|
|
1348
|
+
|
|
1349
|
+
if not only_a and not only_b and not version_diffs:
|
|
1350
|
+
click.echo(" ✓ Software is identical across both systems.")
|
|
1351
|
+
else:
|
|
1352
|
+
if only_a:
|
|
1353
|
+
click.echo(f"\n Only on {alias_a}:")
|
|
1354
|
+
for key in only_a:
|
|
1355
|
+
pkg_a = _normalize_package_entry(pkgs_a[key], key)
|
|
1356
|
+
name = pkg_a.get("displayname") or key
|
|
1357
|
+
ver = pkg_a.get("displayversion") or pkg_a.get("version") or ""
|
|
1358
|
+
click.echo(f" + {name} ({ver})" if ver else f" + {name}")
|
|
1359
|
+
|
|
1360
|
+
if only_b:
|
|
1361
|
+
click.echo(f"\n Only on {alias_b}:")
|
|
1362
|
+
for key in only_b:
|
|
1363
|
+
pkg_b = _normalize_package_entry(pkgs_b[key], key)
|
|
1364
|
+
name = pkg_b.get("displayname") or key
|
|
1365
|
+
ver = pkg_b.get("displayversion") or pkg_b.get("version") or ""
|
|
1366
|
+
click.echo(f" + {name} ({ver})" if ver else f" + {name}")
|
|
1367
|
+
|
|
1368
|
+
if version_diffs:
|
|
1369
|
+
click.echo("\n Version Differences:")
|
|
1370
|
+
for diff in version_diffs:
|
|
1371
|
+
ver_a = diff["version_a"]
|
|
1372
|
+
ver_b = diff["version_b"]
|
|
1373
|
+
# Determine which version is newer
|
|
1374
|
+
try:
|
|
1375
|
+
from packaging.version import Version
|
|
1376
|
+
|
|
1377
|
+
newer_is_a = Version(ver_a) > Version(ver_b)
|
|
1378
|
+
except Exception:
|
|
1379
|
+
newer_is_a = ver_a > ver_b
|
|
1380
|
+
if newer_is_a:
|
|
1381
|
+
mark_a, mark_b = "+", "-"
|
|
1382
|
+
else:
|
|
1383
|
+
mark_a, mark_b = "-", "+"
|
|
1384
|
+
click.echo(f" {diff['package']}:")
|
|
1385
|
+
click.echo(f" {mark_a} {alias_a}: {ver_a}")
|
|
1386
|
+
click.echo(f" {mark_b} {alias_b}: {ver_b}")
|
|
1387
|
+
|
|
1388
|
+
click.echo(
|
|
1389
|
+
f"\n Summary: {len(matching)} matching, "
|
|
1390
|
+
f"{len(only_a)} only on {alias_a}, "
|
|
1391
|
+
f"{len(only_b)} only on {alias_b}, "
|
|
1392
|
+
f"{len(version_diffs)} version differences"
|
|
1393
|
+
)
|
|
1394
|
+
|
|
1395
|
+
return result
|
|
1396
|
+
|
|
1397
|
+
|
|
1398
|
+
def _compare_assets(
|
|
1399
|
+
assets_a: List[Dict[str, Any]],
|
|
1400
|
+
assets_b: List[Dict[str, Any]],
|
|
1401
|
+
alias_a: str,
|
|
1402
|
+
alias_b: str,
|
|
1403
|
+
format_output: str,
|
|
1404
|
+
) -> Dict[str, Any]:
|
|
1405
|
+
"""Compare assets connected to two systems.
|
|
1406
|
+
|
|
1407
|
+
Assets are considered equivalent if they share the same model and vendor.
|
|
1408
|
+
A mismatch in the set of (model, vendor) pairs is listed under only_system_a
|
|
1409
|
+
or only_system_b. Matching assets in different slots are listed under
|
|
1410
|
+
slot_differences.
|
|
1411
|
+
|
|
1412
|
+
Args:
|
|
1413
|
+
assets_a: Assets connected to system A.
|
|
1414
|
+
assets_b: Assets connected to system B.
|
|
1415
|
+
alias_a: Display name for system A.
|
|
1416
|
+
alias_b: Display name for system B.
|
|
1417
|
+
format_output: Output format (table or json).
|
|
1418
|
+
|
|
1419
|
+
Returns:
|
|
1420
|
+
Comparison result dictionary for JSON output.
|
|
1421
|
+
"""
|
|
1422
|
+
|
|
1423
|
+
def _asset_identity(asset: Dict[str, Any]) -> Tuple[str, str]:
|
|
1424
|
+
return (asset.get("modelName") or "", asset.get("vendorName") or "")
|
|
1425
|
+
|
|
1426
|
+
def _asset_slot(asset: Dict[str, Any]) -> str:
|
|
1427
|
+
loc = asset.get("location")
|
|
1428
|
+
if isinstance(loc, dict):
|
|
1429
|
+
slot = loc.get("slotNumber")
|
|
1430
|
+
if slot is not None:
|
|
1431
|
+
return str(slot)
|
|
1432
|
+
return ""
|
|
1433
|
+
|
|
1434
|
+
# Group assets by (model, vendor) identity
|
|
1435
|
+
groups_a: Dict[Tuple[str, str], List[Dict[str, Any]]] = {}
|
|
1436
|
+
for a in assets_a:
|
|
1437
|
+
groups_a.setdefault(_asset_identity(a), []).append(a)
|
|
1438
|
+
|
|
1439
|
+
groups_b: Dict[Tuple[str, str], List[Dict[str, Any]]] = {}
|
|
1440
|
+
for b in assets_b:
|
|
1441
|
+
groups_b.setdefault(_asset_identity(b), []).append(b)
|
|
1442
|
+
|
|
1443
|
+
all_identities = sorted(set(groups_a.keys()) | set(groups_b.keys()))
|
|
1444
|
+
|
|
1445
|
+
only_a: List[Dict[str, Any]] = []
|
|
1446
|
+
only_b: List[Dict[str, Any]] = []
|
|
1447
|
+
count_mismatches: List[Dict[str, Any]] = []
|
|
1448
|
+
slot_diffs: List[Dict[str, Any]] = []
|
|
1449
|
+
matching: List[Dict[str, str]] = []
|
|
1450
|
+
|
|
1451
|
+
def _asset_slot_sort_key(asset: Dict[str, Any]) -> Tuple[int, int, str, str]:
|
|
1452
|
+
"""Build a deterministic sort key that compares numeric slot values numerically."""
|
|
1453
|
+
slot = _asset_slot(asset).strip()
|
|
1454
|
+
if slot.isdigit():
|
|
1455
|
+
return (0, int(slot), "", "")
|
|
1456
|
+
|
|
1457
|
+
stable_fallback = json.dumps(asset, sort_keys=True, default=str)
|
|
1458
|
+
return (1, sys.maxsize, slot, stable_fallback)
|
|
1459
|
+
|
|
1460
|
+
for identity in all_identities:
|
|
1461
|
+
model, vendor = identity
|
|
1462
|
+
in_a = groups_a.get(identity, [])
|
|
1463
|
+
in_b = groups_b.get(identity, [])
|
|
1464
|
+
|
|
1465
|
+
count_a = len(in_a)
|
|
1466
|
+
count_b = len(in_b)
|
|
1467
|
+
|
|
1468
|
+
if count_a and not count_b:
|
|
1469
|
+
only_a.append({"model": model, "vendor": vendor, "count": count_a})
|
|
1470
|
+
elif count_b and not count_a:
|
|
1471
|
+
only_b.append({"model": model, "vendor": vendor, "count": count_b})
|
|
1472
|
+
elif count_a != count_b:
|
|
1473
|
+
count_mismatches.append(
|
|
1474
|
+
{
|
|
1475
|
+
"model": model,
|
|
1476
|
+
"vendor": vendor,
|
|
1477
|
+
"count_system_a": count_a,
|
|
1478
|
+
"count_system_b": count_b,
|
|
1479
|
+
}
|
|
1480
|
+
)
|
|
1481
|
+
else:
|
|
1482
|
+
# Same count — compare slots pairwise (sorted by numeric slot when possible)
|
|
1483
|
+
sorted_a = sorted(in_a, key=_asset_slot_sort_key)
|
|
1484
|
+
sorted_b = sorted(in_b, key=_asset_slot_sort_key)
|
|
1485
|
+
all_match = True
|
|
1486
|
+
for a_item, b_item in zip(sorted_a, sorted_b):
|
|
1487
|
+
slot_a = _asset_slot(a_item)
|
|
1488
|
+
slot_b = _asset_slot(b_item)
|
|
1489
|
+
if slot_a != slot_b:
|
|
1490
|
+
all_match = False
|
|
1491
|
+
slot_diffs.append(
|
|
1492
|
+
{
|
|
1493
|
+
"model": model,
|
|
1494
|
+
"vendor": vendor,
|
|
1495
|
+
"slot_a": slot_a,
|
|
1496
|
+
"slot_b": slot_b,
|
|
1497
|
+
}
|
|
1498
|
+
)
|
|
1499
|
+
if all_match:
|
|
1500
|
+
matching.append({"model": model, "vendor": vendor})
|
|
1501
|
+
|
|
1502
|
+
result: Dict[str, Any] = {
|
|
1503
|
+
"only_system_a": only_a,
|
|
1504
|
+
"only_system_b": only_b,
|
|
1505
|
+
"count_mismatches": count_mismatches,
|
|
1506
|
+
"slot_differences": slot_diffs,
|
|
1507
|
+
"matching_count": len(matching),
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
if format_output.lower() != "json":
|
|
1511
|
+
click.echo("\n Asset Comparison")
|
|
1512
|
+
click.echo(" " + "─" * 60)
|
|
1513
|
+
|
|
1514
|
+
has_diffs = only_a or only_b or count_mismatches or slot_diffs
|
|
1515
|
+
if not has_diffs:
|
|
1516
|
+
click.echo(" ✓ Assets are identical across both systems.")
|
|
1517
|
+
else:
|
|
1518
|
+
if only_a:
|
|
1519
|
+
click.echo(f"\n Only on {alias_a}:")
|
|
1520
|
+
for item in only_a:
|
|
1521
|
+
label = (
|
|
1522
|
+
f"{item['model']} ({item['vendor']})"
|
|
1523
|
+
if item["vendor"]
|
|
1524
|
+
else item["model"] or "(unknown)"
|
|
1525
|
+
)
|
|
1526
|
+
suffix = f" x{item['count']}" if item["count"] > 1 else ""
|
|
1527
|
+
click.echo(f" + {label}{suffix}")
|
|
1528
|
+
|
|
1529
|
+
if only_b:
|
|
1530
|
+
click.echo(f"\n Only on {alias_b}:")
|
|
1531
|
+
for item in only_b:
|
|
1532
|
+
label = (
|
|
1533
|
+
f"{item['model']} ({item['vendor']})"
|
|
1534
|
+
if item["vendor"]
|
|
1535
|
+
else item["model"] or "(unknown)"
|
|
1536
|
+
)
|
|
1537
|
+
suffix = f" x{item['count']}" if item["count"] > 1 else ""
|
|
1538
|
+
click.echo(f" + {label}{suffix}")
|
|
1539
|
+
|
|
1540
|
+
if count_mismatches:
|
|
1541
|
+
click.echo("\n Count Mismatches:")
|
|
1542
|
+
for item in count_mismatches:
|
|
1543
|
+
label = (
|
|
1544
|
+
f"{item['model']} ({item['vendor']})"
|
|
1545
|
+
if item["vendor"]
|
|
1546
|
+
else item["model"] or "(unknown)"
|
|
1547
|
+
)
|
|
1548
|
+
click.echo(f" {label}:")
|
|
1549
|
+
click.echo(f" {alias_a}: {item['count_system_a']} installed")
|
|
1550
|
+
click.echo(f" {alias_b}: {item['count_system_b']} installed")
|
|
1551
|
+
|
|
1552
|
+
if slot_diffs:
|
|
1553
|
+
click.echo("\n Slot Differences:")
|
|
1554
|
+
for item in slot_diffs:
|
|
1555
|
+
label = (
|
|
1556
|
+
f"{item['model']} ({item['vendor']})"
|
|
1557
|
+
if item["vendor"]
|
|
1558
|
+
else item["model"] or "(unknown)"
|
|
1559
|
+
)
|
|
1560
|
+
click.echo(f" {label}:")
|
|
1561
|
+
click.echo(f" {alias_a}: slot {item['slot_a'] or '(none)'}")
|
|
1562
|
+
click.echo(f" {alias_b}: slot {item['slot_b'] or '(none)'}")
|
|
1563
|
+
|
|
1564
|
+
click.echo(
|
|
1565
|
+
f"\n Summary: {len(matching)} matching, "
|
|
1566
|
+
f"{len(only_a)} only on {alias_a}, "
|
|
1567
|
+
f"{len(only_b)} only on {alias_b}, "
|
|
1568
|
+
f"{len(count_mismatches)} count mismatches, "
|
|
1569
|
+
f"{len(slot_diffs)} slot differences"
|
|
1570
|
+
)
|
|
1571
|
+
|
|
1572
|
+
return result
|
|
1573
|
+
|
|
1574
|
+
|
|
1183
1575
|
def register_system_commands(cli: Any) -> None:
|
|
1184
1576
|
"""Register the 'system' command group and its subcommands.
|
|
1185
1577
|
|
|
@@ -2219,3 +2611,94 @@ def register_system_commands(cli: Any) -> None:
|
|
|
2219
2611
|
|
|
2220
2612
|
except Exception as exc: # noqa: BLE001
|
|
2221
2613
|
handle_api_error(exc)
|
|
2614
|
+
|
|
2615
|
+
@system.command(name="compare")
|
|
2616
|
+
@click.argument("system_a")
|
|
2617
|
+
@click.argument("system_b")
|
|
2618
|
+
@click.option(
|
|
2619
|
+
"--format",
|
|
2620
|
+
"-f",
|
|
2621
|
+
type=click.Choice(["table", "json"]),
|
|
2622
|
+
default="table",
|
|
2623
|
+
show_default=True,
|
|
2624
|
+
help="Output format.",
|
|
2625
|
+
)
|
|
2626
|
+
def compare_systems(
|
|
2627
|
+
system_a: str,
|
|
2628
|
+
system_b: str,
|
|
2629
|
+
format: str,
|
|
2630
|
+
) -> None:
|
|
2631
|
+
"""Compare two systems by software and connected assets.
|
|
2632
|
+
|
|
2633
|
+
SYSTEM_A and SYSTEM_B are system IDs or aliases. The command
|
|
2634
|
+
fetches installed software and connected assets for each system,
|
|
2635
|
+
then highlights differences in packages, versions, slot numbers,
|
|
2636
|
+
models, and vendors.
|
|
2637
|
+
"""
|
|
2638
|
+
format_output = validate_output_format(format)
|
|
2639
|
+
|
|
2640
|
+
try:
|
|
2641
|
+
# Resolve both systems (by ID or alias)
|
|
2642
|
+
sys_a = _resolve_system(system_a)
|
|
2643
|
+
sys_b = _resolve_system(system_b)
|
|
2644
|
+
|
|
2645
|
+
id_a = sys_a.get("id", system_a)
|
|
2646
|
+
id_b = sys_b.get("id", system_b)
|
|
2647
|
+
alias_a = sys_a.get("alias") or id_a
|
|
2648
|
+
alias_b = sys_b.get("alias") or id_b
|
|
2649
|
+
|
|
2650
|
+
# Fetch assets for both systems in parallel. The current helper
|
|
2651
|
+
# supports a take limit, so explicitly guard against silently
|
|
2652
|
+
# comparing truncated results when a system has more assets than
|
|
2653
|
+
# the fetch cap.
|
|
2654
|
+
asset_fetch_limit = 1000
|
|
2655
|
+
with concurrent.futures.ThreadPoolExecutor() as executor:
|
|
2656
|
+
future_assets_a = executor.submit(_fetch_assets_for_system, id_a, asset_fetch_limit)
|
|
2657
|
+
future_assets_b = executor.submit(_fetch_assets_for_system, id_b, asset_fetch_limit)
|
|
2658
|
+
|
|
2659
|
+
assets_a, total_assets_a = future_assets_a.result()
|
|
2660
|
+
assets_b, total_assets_b = future_assets_b.result()
|
|
2661
|
+
|
|
2662
|
+
truncated_systems: List[str] = []
|
|
2663
|
+
if total_assets_a > asset_fetch_limit:
|
|
2664
|
+
truncated_systems.append(
|
|
2665
|
+
f"{alias_a} ({total_assets_a} assets, limit {asset_fetch_limit})"
|
|
2666
|
+
)
|
|
2667
|
+
if total_assets_b > asset_fetch_limit:
|
|
2668
|
+
truncated_systems.append(
|
|
2669
|
+
f"{alias_b} ({total_assets_b} assets, limit {asset_fetch_limit})"
|
|
2670
|
+
)
|
|
2671
|
+
|
|
2672
|
+
if truncated_systems:
|
|
2673
|
+
click.echo(
|
|
2674
|
+
"✗ Error: system comparison would be incomplete because "
|
|
2675
|
+
"asset retrieval is limited to the first "
|
|
2676
|
+
f"{asset_fetch_limit} assets. Affected system(s): "
|
|
2677
|
+
+ ", ".join(truncated_systems),
|
|
2678
|
+
err=True,
|
|
2679
|
+
)
|
|
2680
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|
|
2681
|
+
|
|
2682
|
+
# Extract packages
|
|
2683
|
+
pkgs_a = _get_packages(sys_a)
|
|
2684
|
+
pkgs_b = _get_packages(sys_b)
|
|
2685
|
+
|
|
2686
|
+
if format_output.lower() == "json":
|
|
2687
|
+
output: Dict[str, Any] = {
|
|
2688
|
+
"system_a": {"id": id_a, "alias": alias_a},
|
|
2689
|
+
"system_b": {"id": id_b, "alias": alias_b},
|
|
2690
|
+
"software": _compare_packages(pkgs_a, pkgs_b, alias_a, alias_b, format_output),
|
|
2691
|
+
"assets": _compare_assets(assets_a, assets_b, alias_a, alias_b, format_output),
|
|
2692
|
+
}
|
|
2693
|
+
click.echo(json.dumps(output, indent=2))
|
|
2694
|
+
else:
|
|
2695
|
+
click.echo(f"\n Comparing: {alias_a} ↔ {alias_b}")
|
|
2696
|
+
click.echo(" " + "═" * 60)
|
|
2697
|
+
_compare_packages(pkgs_a, pkgs_b, alias_a, alias_b, format_output)
|
|
2698
|
+
_compare_assets(assets_a, assets_b, alias_a, alias_b, format_output)
|
|
2699
|
+
click.echo()
|
|
2700
|
+
|
|
2701
|
+
except SystemExit:
|
|
2702
|
+
raise
|
|
2703
|
+
except Exception as exc: # noqa: BLE001
|
|
2704
|
+
handle_api_error(exc)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/examples/demo-complete-workflow/README.md
RENAMED
|
File without changes
|
{systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/examples/demo-complete-workflow/config.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/examples/exercise-7-1-test-plans/README.md
RENAMED
|
File without changes
|
{systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/examples/exercise-7-1-test-plans/config.yaml
RENAMED
|
File without changes
|
{systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/examples/spec-compliance-notebooks/README.md
RENAMED
|
File without changes
|
{systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/examples/spec-compliance-notebooks/config.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/skills/slcli/references/analysis-recipes.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|