systemlink-cli 1.13.14__tar.gz → 1.13.16__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 (93) hide show
  1. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/PKG-INFO +1 -1
  2. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/pyproject.toml +1 -1
  3. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/_version.py +1 -1
  4. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/config_click.py +61 -2
  5. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/main.py +18 -0
  6. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/platform.py +42 -19
  7. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/utils.py +143 -73
  8. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/LICENSE +0 -0
  9. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/dff-editor/editor.js +0 -0
  10. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/dff-editor/index.html +0 -0
  11. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/__init__.py +0 -0
  12. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/__main__.py +0 -0
  13. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/asset_click.py +0 -0
  14. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/cli_formatters.py +0 -0
  15. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/cli_utils.py +0 -0
  16. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/comment_click.py +0 -0
  17. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/completion_click.py +0 -0
  18. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/config.py +0 -0
  19. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/dataframe_click.py +0 -0
  20. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/dff_click.py +0 -0
  21. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/dff_decorators.py +0 -0
  22. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/example_click.py +0 -0
  23. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/example_loader.py +0 -0
  24. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/example_provisioner.py +0 -0
  25. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/examples/README.md +0 -0
  26. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/examples/_schema/schema-v1.0.json +0 -0
  27. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/examples/demo-complete-workflow/README.md +0 -0
  28. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/examples/demo-complete-workflow/config.yaml +0 -0
  29. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/examples/demo-test-plans/README.md +0 -0
  30. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/examples/demo-test-plans/config.yaml +0 -0
  31. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/examples/exercise-5-1-parametric-insights/README.md +0 -0
  32. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/examples/exercise-5-1-parametric-insights/config.yaml +0 -0
  33. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/examples/exercise-7-1-test-plans/README.md +0 -0
  34. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/examples/exercise-7-1-test-plans/config.yaml +0 -0
  35. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/examples/spec-compliance-notebooks/README.md +0 -0
  36. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/examples/spec-compliance-notebooks/config.yaml +0 -0
  37. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/examples/spec-compliance-notebooks/notebooks/SpecAnalysis_ComplianceCalculation.ipynb +0 -0
  38. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/examples/spec-compliance-notebooks/notebooks/SpecComplianceCalculation.ipynb +0 -0
  39. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/examples/spec-compliance-notebooks/notebooks/SpecfileExtractionAndIngestion.ipynb +0 -0
  40. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/examples/spec-compliance-notebooks/spec_template.xlsx +0 -0
  41. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/feed_click.py +0 -0
  42. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/file_click.py +0 -0
  43. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/function_click.py +0 -0
  44. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/function_templates.py +0 -0
  45. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/mcp_click.py +0 -0
  46. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/mcp_reachability.py +0 -0
  47. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/mcp_server.py +0 -0
  48. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/notebook_click.py +0 -0
  49. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/policy_click.py +0 -0
  50. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/policy_utils.py +0 -0
  51. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/profiles.py +0 -0
  52. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/response_handlers.py +0 -0
  53. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/rich_output.py +0 -0
  54. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/routine_click.py +0 -0
  55. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/skill_click.py +0 -0
  56. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/skills/nipkg-file-package/SKILL.md +0 -0
  57. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/skills/slcli/SKILL.md +0 -0
  58. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/skills/slcli/references/analysis-recipes.md +0 -0
  59. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/skills/slcli/references/commands.md +0 -0
  60. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/skills/slcli/references/datasheet-workflow.md +0 -0
  61. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/skills/slcli/references/filtering.md +0 -0
  62. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/skills/slcli/references/routine-examples.md +0 -0
  63. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/skills/slcli/references/troubleshooting.md +0 -0
  64. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/skills/systemlink-job-debugging/SKILL.md +0 -0
  65. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/skills/systemlink-notebook/SKILL.md +0 -0
  66. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/skills/systemlink-notebook/references/interfaces.md +0 -0
  67. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/skills/systemlink-notebook/references/notebook-patterns.md +0 -0
  68. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/skills/systemlink-python-test/SKILL.md +0 -0
  69. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/skills/systemlink-webapp/SKILL.md +0 -0
  70. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/skills/systemlink-webapp/references/angular-ui-packages.md +0 -0
  71. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/skills/systemlink-webapp/references/deployment.md +0 -0
  72. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/skills/systemlink-webapp/references/layout-patterns.md +0 -0
  73. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/skills/systemlink-webapp/references/nimble-angular.md +0 -0
  74. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/skills/systemlink-webapp/references/systemlink-services.md +0 -0
  75. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/skills/systemlink-webapp/references/troubleshooting.md +0 -0
  76. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/spec_click.py +0 -0
  77. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/ssl_trust.py +0 -0
  78. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/state_click.py +0 -0
  79. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/system_click.py +0 -0
  80. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/system_query_utils.py +0 -0
  81. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/table_utils.py +0 -0
  82. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/tag_click.py +0 -0
  83. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/templates_click.py +0 -0
  84. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/testmonitor_click.py +0 -0
  85. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/universal_handlers.py +0 -0
  86. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/user_click.py +0 -0
  87. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/web_editor.py +0 -0
  88. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/webapp_click.py +0 -0
  89. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/workflow_preview.py +0 -0
  90. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/workflows_click.py +0 -0
  91. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/workitem_click.py +0 -0
  92. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/workspace_click.py +0 -0
  93. {systemlink_cli-1.13.14 → systemlink_cli-1.13.16}/slcli/workspace_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: systemlink-cli
3
- Version: 1.13.14
3
+ Version: 1.13.16
4
4
  Summary: SystemLink Integrator CLI - cross-platform CLI for SystemLink workflows and templates.
5
5
  License-File: LICENSE
6
6
  Author: Fred Visser
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "systemlink-cli"
3
- version = "1.13.14"
3
+ version = "1.13.16"
4
4
  description = "SystemLink Integrator CLI - cross-platform CLI for SystemLink workflows and templates."
5
5
  authors = ["Fred Visser <fred.visser@emerson.com>"]
6
6
  packages = [{ include = "slcli" }]
@@ -1,4 +1,4 @@
1
1
  """Version information for slcli."""
2
2
 
3
3
  # This file is auto-generated. Do not edit manually.
4
- __version__ = "1.13.14"
4
+ __version__ = "1.13.16"
@@ -18,7 +18,14 @@ from .platform import (
18
18
  from .profiles import ProfileConfig, Profile, check_config_file_permissions
19
19
  from .rich_output import render_table
20
20
  from .table_utils import output_formatted_list
21
- from .utils import ExitCodes
21
+ from .utils import (
22
+ ExitCodes,
23
+ describe_config_source,
24
+ get_api_key_resolution,
25
+ get_base_url_resolution,
26
+ get_web_url_resolution,
27
+ source_is_env,
28
+ )
22
29
 
23
30
  API_KEY_LENGTH = 42
24
31
  API_KEY_PATTERN = re.compile(rf"^[A-Za-z0-9_-]{{{API_KEY_LENGTH}}}$")
@@ -372,6 +379,38 @@ def register_config_commands(cli: Any) -> None:
372
379
  """View the full configuration."""
373
380
  cfg = ProfileConfig.load()
374
381
 
382
+ effective: dict[str, Any] = {}
383
+ env_overrides: list[str] = []
384
+
385
+ try:
386
+ api_url_resolution = get_base_url_resolution()
387
+ effective["api-url"] = api_url_resolution.value
388
+ effective["api-url-source"] = api_url_resolution.source
389
+ if source_is_env(api_url_resolution.source):
390
+ env_overrides.append("API URL")
391
+ except Exception:
392
+ pass
393
+
394
+ try:
395
+ api_key_resolution = get_api_key_resolution(emit_error=False)
396
+ effective["api-key-source"] = api_key_resolution.source
397
+ if source_is_env(api_key_resolution.source):
398
+ env_overrides.append("API Key")
399
+ except Exception:
400
+ pass
401
+
402
+ try:
403
+ web_url_resolution = get_web_url_resolution()
404
+ effective["web-url"] = web_url_resolution.value
405
+ effective["web-url-source"] = web_url_resolution.source
406
+ if source_is_env(web_url_resolution.source):
407
+ env_overrides.append("Web URL")
408
+ except Exception:
409
+ pass
410
+
411
+ if env_overrides:
412
+ effective["env-overrides"] = env_overrides
413
+
375
414
  if format == "json":
376
415
  data: dict = {}
377
416
  if cfg.current_profile:
@@ -388,6 +427,8 @@ def register_config_commands(cli: Any) -> None:
388
427
  data["profiles"][name] = profile_dict
389
428
  if cfg.settings:
390
429
  data.update(cfg.settings)
430
+ if effective:
431
+ data["effective"] = effective
391
432
  click.echo(json.dumps(data, indent=2))
392
433
  return
393
434
 
@@ -412,7 +453,7 @@ def register_config_commands(cli: Any) -> None:
412
453
  api_key_display = (
413
454
  "****" + profile.api_key[-4:] if len(profile.api_key) >= 4 else "****"
414
455
  )
415
- rows.append(["API Key", api_key_display])
456
+ rows.append(["Profile API Key", api_key_display])
416
457
 
417
458
  if profile.workspace:
418
459
  rows.append(["Workspace", profile.workspace])
@@ -420,6 +461,24 @@ def register_config_commands(cli: Any) -> None:
420
461
  if profile.readonly:
421
462
  rows.append(["Readonly", "enabled"])
422
463
 
464
+ if effective:
465
+ api_url_source = effective.get("api-url-source")
466
+ if isinstance(api_url_source, str):
467
+ rows.append(["Effective API URL", str(effective.get("api-url", ""))])
468
+ rows.append(["API URL Source", describe_config_source(api_url_source)])
469
+
470
+ api_key_source = effective.get("api-key-source")
471
+ if isinstance(api_key_source, str):
472
+ rows.append(["API Key Source", describe_config_source(api_key_source)])
473
+
474
+ web_url_source = effective.get("web-url-source")
475
+ if isinstance(web_url_source, str):
476
+ rows.append(["Effective Web URL", str(effective.get("web-url", ""))])
477
+ rows.append(["Web URL Source", describe_config_source(web_url_source)])
478
+
479
+ if env_overrides:
480
+ rows.append(["Active Overrides", ", ".join(env_overrides)])
481
+
423
482
  click.echo("slcli Configuration:")
424
483
  render_table(
425
484
  headers=["SETTING", "VALUE"],
@@ -41,6 +41,7 @@ from .tag_click import register_tag_commands
41
41
  from .templates_click import register_templates_commands
42
42
  from .testmonitor_click import register_testmonitor_commands
43
43
  from .user_click import register_user_commands
44
+ from .utils import describe_config_source
44
45
  from .webapp_click import register_webapp_commands
45
46
  from .workitem_click import register_workitem_commands
46
47
  from .workspace_click import register_workspace_commands
@@ -596,14 +597,31 @@ def info(format: str, skip_health: bool, debug: bool) -> None:
596
597
  api_url = truncate(platform_info.get("api_url", "Not configured"))
597
598
  web_url = truncate(platform_info.get("web_url", "Not configured"))
598
599
 
600
+ api_url_source = truncate(
601
+ describe_config_source(platform_info.get("api_url_source", "Unknown"))
602
+ )
603
+ api_key_source = truncate(
604
+ describe_config_source(platform_info.get("api_key_source", "Unknown"))
605
+ )
606
+ web_url_source = truncate(
607
+ describe_config_source(platform_info.get("web_url_source", "Unknown"))
608
+ )
609
+
599
610
  info_rows = [
600
611
  ["Status", status],
601
612
  ["Profile", profile_display],
602
613
  ["Platform", platform_display],
603
614
  ["API URL", api_url],
615
+ ["API URL Source", api_url_source],
616
+ ["API Key Source", api_key_source],
604
617
  ["Web URL", web_url],
618
+ ["Web URL Source", web_url_source],
605
619
  ]
606
620
 
621
+ env_overrides = platform_info.get("env_overrides")
622
+ if isinstance(env_overrides, list) and env_overrides:
623
+ info_rows.append(["Overrides", truncate(", ".join(str(item) for item in env_overrides))])
624
+
607
625
  workspace = platform_info.get("active_profile_workspace")
608
626
  if workspace:
609
627
  workspace_display = truncate(workspace)
@@ -249,22 +249,19 @@ def _get_cached_service_status(service_name: str, api_url: str, api_key: str) ->
249
249
 
250
250
  def _get_current_api_context() -> Optional[Tuple[Optional[str], str, str]]:
251
251
  """Resolve the current profile name, API URL, and key for service probing."""
252
- api_url = os.environ.get("SYSTEMLINK_API_URL")
253
- api_key = os.environ.get("SYSTEMLINK_API_KEY")
254
- if api_url and api_key:
255
- return None, api_url, api_key
252
+ try:
253
+ from .utils import get_api_key_resolution, get_base_url_resolution
256
254
 
257
- cfg = _get_keyring_config()
258
- config_api_url = cfg.get("api_url") if cfg else None
259
- config_api_key = cfg.get("api_key") if cfg else None
260
- if config_api_url and config_api_key:
261
- return None, str(config_api_url), str(config_api_key)
255
+ api_url_resolution = get_base_url_resolution()
256
+ api_key_resolution = get_api_key_resolution(emit_error=False)
262
257
 
263
- try:
264
- from .profiles import get_active_profile_name
265
- from .utils import get_api_key, get_base_url
258
+ profile_name: Optional[str] = None
259
+ if api_url_resolution.source.startswith("profile:") and (
260
+ api_url_resolution.source == api_key_resolution.source
261
+ ):
262
+ profile_name = api_url_resolution.source.split(":", 1)[1]
266
263
 
267
- return get_active_profile_name(), get_base_url(), get_api_key()
264
+ return profile_name, api_url_resolution.value, api_key_resolution.value
268
265
  except Exception:
269
266
  return None
270
267
 
@@ -376,7 +373,7 @@ def _get_runtime_platform() -> str:
376
373
  if platform != PLATFORM_UNKNOWN:
377
374
  return platform
378
375
 
379
- status_snapshot = _get_service_status_snapshot(force_refresh=False)
376
+ status_snapshot = _get_service_status_snapshot(force_refresh=True)
380
377
  if status_snapshot is None:
381
378
  return platform
382
379
 
@@ -779,23 +776,36 @@ def get_platform_info(skip_health: bool = False) -> Dict[str, Any]:
779
776
  Returns:
780
777
  Dictionary with platform info including URL, platform type, and services.
781
778
  """
782
- from .utils import get_api_key, get_base_url, get_web_url
779
+ from .utils import (
780
+ get_api_key_resolution,
781
+ get_base_url_resolution,
782
+ get_web_url_resolution,
783
+ source_is_env,
784
+ )
783
785
 
784
- # Use profile-aware functions instead of keyring directly
785
786
  try:
786
- api_url = get_base_url()
787
+ api_url_resolution = get_base_url_resolution()
788
+ api_url = api_url_resolution.value
789
+ api_url_source = api_url_resolution.source
787
790
  except Exception:
788
791
  api_url = "Not configured"
792
+ api_url_source = "unresolved"
789
793
 
790
794
  try:
791
- web_url = get_web_url()
795
+ web_url_resolution = get_web_url_resolution()
796
+ web_url = web_url_resolution.value
797
+ web_url_source = web_url_resolution.source
792
798
  except Exception:
793
799
  web_url = "Not configured"
800
+ web_url_source = "unresolved"
794
801
 
795
802
  try:
796
- api_key = get_api_key()
803
+ api_key_resolution = get_api_key_resolution(emit_error=False)
804
+ api_key = api_key_resolution.value
805
+ api_key_source = api_key_resolution.source
797
806
  logged_in = bool(api_key)
798
807
  except Exception:
808
+ api_key_source = "unresolved"
799
809
  logged_in = False
800
810
 
801
811
  # Get platform from profile or keyring config
@@ -833,7 +843,10 @@ def get_platform_info(skip_health: bool = False) -> Dict[str, Any]:
833
843
 
834
844
  info: Dict[str, Any] = {
835
845
  "api_url": api_url,
846
+ "api_url_source": api_url_source,
836
847
  "web_url": web_url,
848
+ "web_url_source": web_url_source,
849
+ "api_key_source": api_key_source,
837
850
  "platform": platform,
838
851
  "platform_display": _get_platform_display_name(platform),
839
852
  "logged_in": logged_in,
@@ -841,6 +854,16 @@ def get_platform_info(skip_health: bool = False) -> Dict[str, Any]:
841
854
  "auth_valid": auth_valid,
842
855
  }
843
856
 
857
+ env_overrides = []
858
+ if source_is_env(api_url_source):
859
+ env_overrides.append("API URL")
860
+ if source_is_env(api_key_source):
861
+ env_overrides.append("API Key")
862
+ if source_is_env(web_url_source):
863
+ env_overrides.append("Web URL")
864
+ if env_overrides:
865
+ info["env_overrides"] = env_overrides
866
+
844
867
  if file_query_endpoint is not None:
845
868
  info["file_query_endpoint"] = file_query_endpoint
846
869
  if elasticsearch_available is not None:
@@ -4,7 +4,8 @@ import datetime
4
4
  import json
5
5
  import os
6
6
  import sys
7
- from typing import Dict, List, Any, Optional, Callable
7
+ from dataclasses import dataclass
8
+ from typing import Dict, List, Any, Optional, Callable, Sequence
8
9
 
9
10
  import click
10
11
  import keyring
@@ -29,6 +30,14 @@ class SystemLinkConfig:
29
30
  self.ssl_verify = ssl_verify
30
31
 
31
32
 
33
+ @dataclass(frozen=True)
34
+ class ResolvedConfigValue:
35
+ """Resolved configuration value plus the source that supplied it."""
36
+
37
+ value: str
38
+ source: str
39
+
40
+
32
41
  class ExitCodes:
33
42
  """Standard exit codes for CLI operations."""
34
43
 
@@ -309,100 +318,195 @@ def get_http_configuration() -> SystemLinkConfig:
309
318
  )
310
319
 
311
320
 
312
- def get_base_url() -> str:
313
- """Retrieve the SystemLink API base URL.
321
+ def _get_env_override(env_names: Sequence[str]) -> Optional[ResolvedConfigValue]:
322
+ """Return the first non-empty environment override from the provided names."""
323
+ for env_name in env_names:
324
+ value = os.environ.get(env_name)
325
+ if value:
326
+ return ResolvedConfigValue(value=value, source=f"env:{env_name}")
327
+ return None
328
+
329
+
330
+ def source_is_env(source: str) -> bool:
331
+ """Return True when the resolved source came from an environment variable."""
332
+ return source.startswith("env:")
333
+
334
+
335
+ def describe_config_source(source: str) -> str:
336
+ """Return a user-facing description of a resolved configuration source."""
337
+ if source.startswith("env:"):
338
+ return f"Environment ({source.split(':', 1)[1]})"
339
+ if source.startswith("profile:"):
340
+ return f"Profile '{source.split(':', 1)[1]}'"
341
+ if source.startswith("keyring:"):
342
+ return f"Legacy keyring ({source.split(':', 1)[1]})"
343
+ if source.startswith("default:"):
344
+ return f"Default ({source.split(':', 1)[1]})"
345
+ if source.startswith("derived:"):
346
+ derived_from = source.split(":", 1)[1]
347
+ return f"Derived from API URL via {describe_config_source(derived_from)}"
348
+ return source
349
+
350
+
351
+ def get_base_url_resolution() -> ResolvedConfigValue:
352
+ """Resolve the SystemLink API base URL and record where it came from.
314
353
 
315
354
  Preference order:
316
- 1. Environment variable SYSTEMLINK_API_URL (for runtime overrides/testing)
355
+ 1. Environment variable SLCLI_API_URL (preferred) or SYSTEMLINK_API_URL
317
356
  2. Active profile from config file
318
357
  3. Combined keyring config (legacy)
319
358
  4. Legacy keyring entry SYSTEMLINK_API_URL
320
359
  5. Default fallback to localhost
321
360
  """
322
- # First, check environment variable (highest priority for runtime overrides)
323
- url = os.environ.get("SYSTEMLINK_API_URL")
324
- if url:
325
- return url
361
+ override = _get_env_override(("SLCLI_API_URL", "SYSTEMLINK_API_URL"))
362
+ if override is not None:
363
+ return ResolvedConfigValue(override.value.rstrip("/"), override.source)
326
364
 
327
- # Second, try the active profile
328
365
  try:
329
366
  from .profiles import get_active_profile
330
367
 
331
368
  profile = get_active_profile()
332
369
  if profile and profile.server:
333
- return profile.server
370
+ return ResolvedConfigValue(profile.server.rstrip("/"), f"profile:{profile.name}")
334
371
  except (FileNotFoundError, json.JSONDecodeError, KeyError, AttributeError):
335
- # Profile file missing, corrupted, or malformed - fall back to keyring
336
372
  pass
337
373
 
338
- # Third, try the combined keyring config (legacy)
339
374
  cfg = _get_keyring_config()
340
375
  if cfg and isinstance(cfg, dict):
341
376
  config_url = cfg.get("api_url")
342
377
  if config_url:
343
- return config_url
378
+ return ResolvedConfigValue(str(config_url).rstrip("/"), "keyring:SYSTEMLINK_CONFIG")
344
379
 
345
- # Fourth, try legacy keyring entry
346
- url = keyring.get_password("systemlink-cli", "SYSTEMLINK_API_URL")
380
+ try:
381
+ url = keyring.get_password("systemlink-cli", "SYSTEMLINK_API_URL")
382
+ except Exception:
383
+ url = None
347
384
  if url:
348
- return url
385
+ return ResolvedConfigValue(url.rstrip("/"), "keyring:SYSTEMLINK_API_URL")
349
386
 
350
- return "http://localhost:8000"
387
+ return ResolvedConfigValue("http://localhost:8000", "default:localhost")
351
388
 
352
389
 
353
- def get_web_url() -> str:
354
- """Return the SystemLink primary web UI URL.
390
+ def get_web_url_resolution() -> ResolvedConfigValue:
391
+ """Resolve the SystemLink web UI URL and record where it came from.
355
392
 
356
393
  Preference order:
357
- 1. Environment variable SYSTEMLINK_WEB_URL
394
+ 1. Environment variable SLCLI_WEB_URL (preferred) or SYSTEMLINK_WEB_URL
358
395
  2. Active profile from config file
359
396
  3. Combined keyring config (legacy)
360
397
  4. Legacy keyring entry SYSTEMLINK_WEB_URL
361
- 5. Derived from get_base_url()
398
+ 5. Derived from the effective API base URL
362
399
  """
363
- # 1) Explicit override via environment variable
364
- url = os.environ.get("SYSTEMLINK_WEB_URL")
365
- if url:
366
- return url.rstrip("/")
400
+ override = _get_env_override(("SLCLI_WEB_URL", "SYSTEMLINK_WEB_URL"))
401
+ if override is not None:
402
+ return ResolvedConfigValue(override.value.rstrip("/"), override.source)
367
403
 
368
- # 2) Try the active profile
369
404
  try:
370
405
  from .profiles import get_active_profile
371
406
 
372
407
  profile = get_active_profile()
373
408
  if profile and profile.web_url:
374
- return profile.web_url.rstrip("/")
409
+ return ResolvedConfigValue(profile.web_url.rstrip("/"), f"profile:{profile.name}")
375
410
  except (FileNotFoundError, json.JSONDecodeError, KeyError, AttributeError):
376
- # Profile unavailable or misconfigured, fall back to other sources
377
411
  pass
378
412
 
379
- # 3) Combined keyring config entry (legacy)
380
413
  cfg = _get_keyring_config()
381
414
  if cfg and isinstance(cfg, dict):
382
415
  maybe = cfg.get("web_url") or cfg.get("webUrl") or cfg.get("web_ui_url")
383
416
  if maybe:
384
- return str(maybe).rstrip("/")
417
+ return ResolvedConfigValue(str(maybe).rstrip("/"), "keyring:SYSTEMLINK_CONFIG")
385
418
 
386
- # 4) Legacy keyring entry fallback
387
419
  try:
388
420
  url = keyring.get_password("systemlink-cli", "SYSTEMLINK_WEB_URL")
389
421
  except Exception:
390
422
  url = None
391
423
  if url:
392
- return url.rstrip("/")
424
+ return ResolvedConfigValue(url.rstrip("/"), "keyring:SYSTEMLINK_WEB_URL")
393
425
 
394
- # Derive from API base URL
395
- base = get_base_url()
426
+ base_resolution = get_base_url_resolution()
427
+ base = base_resolution.value
396
428
  try:
397
429
  from urllib.parse import urlparse
398
430
 
399
431
  parsed = urlparse(base if base.startswith("http") else "https://" + base)
400
432
  host = parsed.netloc or parsed.path
401
433
  if not host:
402
- return "https://localhost"
403
- return f"https://{host.rstrip('/')}"
434
+ return ResolvedConfigValue("https://localhost", f"derived:{base_resolution.source}")
435
+ return ResolvedConfigValue(
436
+ f"https://{host.rstrip('/')}",
437
+ f"derived:{base_resolution.source}",
438
+ )
404
439
  except Exception:
405
- return "https://localhost"
440
+ return ResolvedConfigValue("https://localhost", f"derived:{base_resolution.source}")
441
+
442
+
443
+ def get_api_key_resolution(emit_error: bool = True) -> ResolvedConfigValue:
444
+ """Resolve the SystemLink API key and record where it came from.
445
+
446
+ Preference order:
447
+ 1. Environment variable SLCLI_API_KEY (preferred) or SYSTEMLINK_API_KEY
448
+ 2. Active profile from config file
449
+ 3. Combined keyring config (legacy)
450
+ 4. Legacy keyring entry SYSTEMLINK_API_KEY
451
+ """
452
+ override = _get_env_override(("SLCLI_API_KEY", "SYSTEMLINK_API_KEY"))
453
+ if override is not None:
454
+ return override
455
+
456
+ try:
457
+ from .profiles import get_active_profile
458
+
459
+ profile = get_active_profile()
460
+ if profile and profile.api_key:
461
+ return ResolvedConfigValue(profile.api_key, f"profile:{profile.name}")
462
+ except (FileNotFoundError, json.JSONDecodeError, KeyError, AttributeError):
463
+ pass
464
+
465
+ cfg = _get_keyring_config()
466
+ if cfg and isinstance(cfg, dict):
467
+ maybe = cfg.get("api_key") or cfg.get("apiKey") or cfg.get("apiToken")
468
+ if maybe:
469
+ return ResolvedConfigValue(str(maybe), "keyring:SYSTEMLINK_CONFIG")
470
+
471
+ try:
472
+ api_key = keyring.get_password("systemlink-cli", "SYSTEMLINK_API_KEY")
473
+ except Exception:
474
+ api_key = None
475
+ if api_key:
476
+ return ResolvedConfigValue(api_key, "keyring:SYSTEMLINK_API_KEY")
477
+
478
+ if emit_error:
479
+ raise click.ClickException(
480
+ "API key not found. Please set the SLCLI_API_KEY environment variable "
481
+ "(or legacy SYSTEMLINK_API_KEY) or run 'slcli login --profile <name>'."
482
+ )
483
+ raise click.ClickException("API key not found.")
484
+
485
+
486
+ def get_base_url() -> str:
487
+ """Retrieve the SystemLink API base URL.
488
+
489
+ Preference order:
490
+ 1. Environment variable SLCLI_API_URL (preferred) or SYSTEMLINK_API_URL
491
+ 2. Active profile from config file
492
+ 3. Combined keyring config (legacy)
493
+ 4. Legacy keyring entry SYSTEMLINK_API_URL
494
+ 5. Default fallback to localhost
495
+ """
496
+ return get_base_url_resolution().value
497
+
498
+
499
+ def get_web_url() -> str:
500
+ """Return the SystemLink primary web UI URL.
501
+
502
+ Preference order:
503
+ 1. Environment variable SLCLI_WEB_URL (preferred) or SYSTEMLINK_WEB_URL
504
+ 2. Active profile from config file
505
+ 3. Combined keyring config (legacy)
506
+ 4. Legacy keyring entry SYSTEMLINK_WEB_URL
507
+ 5. Derived from get_base_url()
508
+ """
509
+ return get_web_url_resolution().value
406
510
 
407
511
 
408
512
  def _get_keyring_config() -> Dict[str, Any]:
@@ -430,46 +534,12 @@ def get_api_key() -> str:
430
534
  """Retrieve the SystemLink API key.
431
535
 
432
536
  Preference order:
433
- 1. Environment variable SYSTEMLINK_API_KEY (for runtime overrides/testing)
537
+ 1. Environment variable SLCLI_API_KEY (preferred) or SYSTEMLINK_API_KEY
434
538
  2. Active profile from config file
435
539
  3. Combined keyring config (legacy)
436
540
  4. Legacy keyring entry SYSTEMLINK_API_KEY
437
541
  """
438
- import click
439
-
440
- # First, check environment variable (highest priority for runtime overrides)
441
- api_key = os.environ.get("SYSTEMLINK_API_KEY")
442
- if api_key:
443
- return api_key
444
-
445
- # Second, try the active profile
446
- try:
447
- from .profiles import get_active_profile
448
-
449
- profile = get_active_profile()
450
- if profile and profile.api_key:
451
- return profile.api_key
452
- except (FileNotFoundError, json.JSONDecodeError, KeyError, AttributeError):
453
- # Profile lookup error, fall back to keyring-based configuration
454
- pass
455
-
456
- # Third, consult combined keyring config if present (legacy)
457
- cfg = _get_keyring_config()
458
- if cfg and isinstance(cfg, dict):
459
- maybe = cfg.get("api_key") or cfg.get("apiKey") or cfg.get("apiToken")
460
- if maybe:
461
- return str(maybe)
462
-
463
- # Fourth, try legacy keyring entry
464
- api_key = keyring.get_password("systemlink-cli", "SYSTEMLINK_API_KEY")
465
- if api_key:
466
- return api_key
467
-
468
- click.echo(
469
- "Error: API key not found. Please set the SYSTEMLINK_API_KEY "
470
- "environment variable or run 'slcli login --profile <name>'."
471
- )
472
- raise click.ClickException("API key not found.")
542
+ return get_api_key_resolution().value
473
543
 
474
544
 
475
545
  def get_headers(content_type: str = "") -> Dict[str, str]: