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.
Files changed (76) hide show
  1. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/PKG-INFO +2 -1
  2. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/pyproject.toml +2 -1
  3. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/_version.py +1 -1
  4. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/skills/slcli/SKILL.md +14 -1
  5. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/system_click.py +483 -0
  6. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/LICENSE +0 -0
  7. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/dff-editor/editor.js +0 -0
  8. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/dff-editor/index.html +0 -0
  9. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/__init__.py +0 -0
  10. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/__main__.py +0 -0
  11. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/asset_click.py +0 -0
  12. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/cli_formatters.py +0 -0
  13. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/cli_utils.py +0 -0
  14. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/comment_click.py +0 -0
  15. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/completion_click.py +0 -0
  16. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/config.py +0 -0
  17. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/config_click.py +0 -0
  18. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/dff_click.py +0 -0
  19. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/dff_decorators.py +0 -0
  20. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/example_click.py +0 -0
  21. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/example_loader.py +0 -0
  22. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/example_provisioner.py +0 -0
  23. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/examples/README.md +0 -0
  24. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/examples/_schema/schema-v1.0.json +0 -0
  25. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/examples/demo-complete-workflow/README.md +0 -0
  26. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/examples/demo-complete-workflow/config.yaml +0 -0
  27. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/examples/demo-test-plans/README.md +0 -0
  28. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/examples/demo-test-plans/config.yaml +0 -0
  29. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/examples/exercise-5-1-parametric-insights/README.md +0 -0
  30. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/examples/exercise-5-1-parametric-insights/config.yaml +0 -0
  31. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/examples/exercise-7-1-test-plans/README.md +0 -0
  32. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/examples/exercise-7-1-test-plans/config.yaml +0 -0
  33. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/examples/spec-compliance-notebooks/README.md +0 -0
  34. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/examples/spec-compliance-notebooks/config.yaml +0 -0
  35. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/examples/spec-compliance-notebooks/notebooks/SpecAnalysis_ComplianceCalculation.ipynb +0 -0
  36. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/examples/spec-compliance-notebooks/notebooks/SpecComplianceCalculation.ipynb +0 -0
  37. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/examples/spec-compliance-notebooks/notebooks/SpecfileExtractionAndIngestion.ipynb +0 -0
  38. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/examples/spec-compliance-notebooks/spec_template.xlsx +0 -0
  39. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/feed_click.py +0 -0
  40. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/file_click.py +0 -0
  41. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/function_click.py +0 -0
  42. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/function_templates.py +0 -0
  43. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/main.py +0 -0
  44. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/mcp_click.py +0 -0
  45. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/mcp_server.py +0 -0
  46. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/notebook_click.py +0 -0
  47. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/platform.py +0 -0
  48. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/policy_click.py +0 -0
  49. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/policy_utils.py +0 -0
  50. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/profiles.py +0 -0
  51. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/response_handlers.py +0 -0
  52. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/rich_output.py +0 -0
  53. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/routine_click.py +0 -0
  54. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/skill_click.py +0 -0
  55. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/skills/slcli/references/analysis-recipes.md +0 -0
  56. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/skills/slcli/references/filtering.md +0 -0
  57. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/skills/systemlink-webapp/SKILL.md +0 -0
  58. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/skills/systemlink-webapp/references/deployment.md +0 -0
  59. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/skills/systemlink-webapp/references/layout-patterns.md +0 -0
  60. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/skills/systemlink-webapp/references/nimble-angular.md +0 -0
  61. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/skills/systemlink-webapp/references/systemlink-services.md +0 -0
  62. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/ssl_trust.py +0 -0
  63. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/table_utils.py +0 -0
  64. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/tag_click.py +0 -0
  65. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/templates_click.py +0 -0
  66. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/testmonitor_click.py +0 -0
  67. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/universal_handlers.py +0 -0
  68. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/user_click.py +0 -0
  69. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/utils.py +0 -0
  70. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/web_editor.py +0 -0
  71. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/webapp_click.py +0 -0
  72. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/workflow_preview.py +0 -0
  73. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/workflows_click.py +0 -0
  74. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/workitem_click.py +0 -0
  75. {systemlink_cli-1.6.5 → systemlink_cli-1.7.0}/slcli/workspace_click.py +0 -0
  76. {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.6.5
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.5"
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.5"
4
+ __version__ = "1.7.0"
@@ -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
 
@@ -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