cortexapps-cli 1.22.0__tar.gz → 1.23.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 (152) hide show
  1. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/PKG-INFO +1 -1
  2. cortexapps_cli-1.23.0/cortexapps_cli/commands/solutions.py +682 -0
  3. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/cortex_client.py +23 -7
  4. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/.github/workflows/cortex-service-version.yaml +57 -0
  5. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/README.md +95 -0
  6. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/api-gateway-3.0.1.yaml +17 -0
  7. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/api-gateway-3.1.0.yaml +17 -0
  8. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/api-gateway-3.1.1.yaml +17 -0
  9. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/api-gateway.yaml +12 -0
  10. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/auth-2.1.0.yaml +17 -0
  11. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/auth-2.1.1.yaml +17 -0
  12. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/auth-2.2.0.yaml +17 -0
  13. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/auth.yaml +12 -0
  14. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/aws-dev-us-west-2.yaml +15 -0
  15. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/billing-1.1.0.yaml +10 -0
  16. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/billing-1.2.0.yaml +10 -0
  17. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/billing.yaml +11 -0
  18. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/cart-1.0.2.yaml +10 -0
  19. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/cart.yaml +10 -0
  20. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/checkout-2.2.0.yaml +10 -0
  21. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/checkout-2.3.0.yaml +10 -0
  22. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/checkout-2.4.0.yaml +10 -0
  23. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/checkout.yaml +12 -0
  24. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/gcp-prod-us-east-1.yaml +15 -0
  25. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/gcp-prod-us-west-1.yaml +15 -0
  26. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/gcp-staging-us-east-1.yaml +15 -0
  27. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/notifications-1.0.0.yaml +11 -0
  28. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/notifications-1.0.1.yaml +11 -0
  29. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/notifications-1.1.0.yaml +11 -0
  30. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/notifications.yaml +12 -0
  31. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/order-service-2.0.1.yaml +10 -0
  32. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/order-service-2.1.0.yaml +10 -0
  33. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/order-service.yaml +11 -0
  34. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/payments-1.6.1.yaml +20 -0
  35. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/payments-1.7.0.yaml +17 -0
  36. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/payments-1.7.1.yaml +17 -0
  37. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/payments.yaml +13 -0
  38. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/release-2026-06-20.yaml +20 -0
  39. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/release-2026-07-01.yaml +20 -0
  40. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/release-2026-07-09.yaml +20 -0
  41. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/release-2026-07-10.yaml +20 -0
  42. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/search-4.0.0.yaml +10 -0
  43. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/search-4.1.0.yaml +10 -0
  44. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/search.yaml +11 -0
  45. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/user-service-1.2.0.yaml +10 -0
  46. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/user-service-1.3.0.yaml +10 -0
  47. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/catalog/user-service.yaml +11 -0
  48. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/entity-relationship-types/environments.json +26 -0
  49. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/entity-relationship-types/versions.json +20 -0
  50. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/entity-types/environment.json +1 -0
  51. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/entity-types/release.json +1 -0
  52. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/entity-types/service-version.json +1 -0
  53. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/workflows/create-environment.yaml +71 -0
  54. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/workflows/create-release.yaml +88 -0
  55. cortexapps_cli-1.23.0/cortexapps_cli/solutions/environments/workflows/trigger-github-release.yaml +26 -0
  56. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/pyproject.toml +1 -1
  57. cortexapps_cli-1.22.0/cortexapps_cli/commands/solutions.py +0 -190
  58. cortexapps_cli-1.22.0/cortexapps_cli/solutions/github-starter/README.md +0 -50
  59. cortexapps_cli-1.22.0/cortexapps_cli/solutions/github-starter/scorecards/github-readiness.yaml +0 -15
  60. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/LICENSE +0 -0
  61. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/README.rst +0 -0
  62. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/__init__.py +0 -0
  63. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/cli.py +0 -0
  64. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/command_options.py +0 -0
  65. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/api_keys.py +0 -0
  66. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/audit_logs.py +0 -0
  67. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/backup.py +0 -0
  68. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/backup_commands/cortex_export.py +0 -0
  69. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/catalog.py +0 -0
  70. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/custom_data.py +0 -0
  71. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/custom_events.py +0 -0
  72. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/custom_metrics.py +0 -0
  73. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/dependencies.py +0 -0
  74. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/deploys.py +0 -0
  75. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/discovery_audit.py +0 -0
  76. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/docs.py +0 -0
  77. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/entity_relationship_types.py +0 -0
  78. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/entity_relationships.py +0 -0
  79. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/entity_types.py +0 -0
  80. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/gitops_logs.py +0 -0
  81. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/groups.py +0 -0
  82. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/initiatives.py +0 -0
  83. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations.py +0 -0
  84. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/apiiro.py +0 -0
  85. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/argocd.py +0 -0
  86. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/aws.py +0 -0
  87. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/azure_active_directory.py +0 -0
  88. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/azure_devops.py +0 -0
  89. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/azure_resources.py +0 -0
  90. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/bamboohr.py +0 -0
  91. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/bitbucket.py +0 -0
  92. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/bugsnag.py +0 -0
  93. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/buildkite.py +0 -0
  94. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/checkmarx_sast.py +0 -0
  95. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/circleci.py +0 -0
  96. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/clickup.py +0 -0
  97. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/codecov.py +0 -0
  98. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/coralogix.py +0 -0
  99. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/datadog.py +0 -0
  100. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/dynatrace.py +0 -0
  101. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/firehydrant.py +0 -0
  102. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/github.py +0 -0
  103. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/gitlab.py +0 -0
  104. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/incidentio.py +0 -0
  105. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/instana.py +0 -0
  106. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/jenkins.py +0 -0
  107. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/jira.py +0 -0
  108. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/launchdarkly.py +0 -0
  109. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/mend_sast.py +0 -0
  110. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/mend_sca.py +0 -0
  111. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/newrelic.py +0 -0
  112. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/okta.py +0 -0
  113. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/opsgenie.py +0 -0
  114. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/pagerduty.py +0 -0
  115. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/prometheus.py +0 -0
  116. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/rollbar.py +0 -0
  117. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/rootly.py +0 -0
  118. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/semgrep.py +0 -0
  119. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/sentry.py +0 -0
  120. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/servicenow.py +0 -0
  121. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/servicenow_cloud_observability.py +0 -0
  122. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/snyk.py +0 -0
  123. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/sonarqube.py +0 -0
  124. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/splunk_observability_cloud.py +0 -0
  125. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/splunk_on_call.py +0 -0
  126. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/sumologic.py +0 -0
  127. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/veracode.py +0 -0
  128. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/wiz.py +0 -0
  129. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/workday.py +0 -0
  130. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/integrations_commands/xmatters.py +0 -0
  131. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/ip_allowlist.py +0 -0
  132. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/on_call.py +0 -0
  133. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/packages.py +0 -0
  134. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/packages_commands/go.py +0 -0
  135. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/packages_commands/java.py +0 -0
  136. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/packages_commands/node.py +0 -0
  137. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/packages_commands/nuget.py +0 -0
  138. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/packages_commands/python.py +0 -0
  139. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/plugins.py +0 -0
  140. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/queries.py +0 -0
  141. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/rest.py +0 -0
  142. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/scim.py +0 -0
  143. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/scorecards.py +0 -0
  144. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/scorecards_commands/exemptions.py +0 -0
  145. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/secrets.py +0 -0
  146. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/teams.py +0 -0
  147. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/users.py +0 -0
  148. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/users_commands/roles.py +0 -0
  149. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/commands/workflows.py +0 -0
  150. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/models/team.py +0 -0
  151. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/solutions/__init__.py +0 -0
  152. {cortexapps_cli-1.22.0 → cortexapps_cli-1.23.0}/cortexapps_cli/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cortexapps-cli
3
- Version: 1.22.0
3
+ Version: 1.23.0
4
4
  Summary: Command Line Interface for cortexapps
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -0,0 +1,682 @@
1
+ import configparser
2
+ import contextlib
3
+ import io
4
+ import json
5
+ import logging
6
+ import os
7
+ import re
8
+ import sys
9
+ import threading
10
+ from concurrent.futures import ThreadPoolExecutor, as_completed
11
+ from importlib.resources import as_file, files
12
+ from pathlib import Path
13
+
14
+ import typer
15
+ import yaml
16
+ from rich.console import Console
17
+ from rich.markdown import Markdown
18
+ from rich.prompt import Prompt
19
+ from rich.rule import Rule
20
+ from rich.table import Table
21
+ from rich.theme import Theme
22
+
23
+ from cortexapps_cli.cortex_client import CortexClient
24
+
25
+ try:
26
+ import select as _select
27
+ import termios as _termios
28
+ import tty as _tty
29
+ _TTY_SUPPORT = True
30
+ except ImportError:
31
+ _TTY_SUPPORT = False
32
+
33
+ app = typer.Typer(help="Solutions commands", no_args_is_help=True)
34
+ console = Console(theme=Theme({"markdown.code": "cyan"}))
35
+
36
+
37
+ class _ToggleableCapture:
38
+ """Stdout proxy that buffers all output and optionally mirrors it live."""
39
+
40
+ def __init__(self, real_stdout):
41
+ self._real = real_stdout
42
+ self._buf = io.StringIO()
43
+ self._on = False
44
+ self._shown_pos = 0 # chars already written to real_stdout
45
+ self._lock = threading.Lock()
46
+
47
+ def write(self, text: str) -> int:
48
+ with self._lock:
49
+ self._buf.write(text)
50
+ if self._on:
51
+ self._real.write(text)
52
+ self._real.flush()
53
+ self._shown_pos += len(text)
54
+ return len(text)
55
+
56
+ def flush(self) -> None:
57
+ pass
58
+
59
+ def toggle(self) -> bool:
60
+ with self._lock:
61
+ self._on = not self._on
62
+ if self._on:
63
+ # Flush everything buffered since the last toggle-on
64
+ content = self._buf.getvalue()
65
+ pending = content[self._shown_pos:]
66
+ if pending:
67
+ self._real.write(pending)
68
+ self._real.flush()
69
+ self._shown_pos = len(content)
70
+ return self._on
71
+
72
+ def getvalue(self) -> str:
73
+ return self._buf.getvalue()
74
+
75
+
76
+ def _run_import_with_toggle(fn) -> str:
77
+ """Run fn() capturing stdout. On a TTY, Ctrl+o toggles live output."""
78
+ real_stdout = sys.stdout
79
+ capture = _ToggleableCapture(real_stdout)
80
+
81
+ if not (_TTY_SUPPORT and sys.stdin.isatty()):
82
+ with contextlib.redirect_stdout(capture):
83
+ fn()
84
+ return capture.getvalue()
85
+
86
+ done = threading.Event()
87
+ fd = sys.stdin.fileno()
88
+ old_settings = _termios.tcgetattr(fd)
89
+
90
+ def _listen() -> None:
91
+ try:
92
+ # setcbreak() keeps IEXTEN, which makes the kernel intercept \x0f
93
+ # as VDISCARD before it reaches the app. Manually disable ECHO,
94
+ # ICANON, and IEXTEN while keeping OPOST (output processing) and
95
+ # ISIG (so Ctrl+c still works).
96
+ mode = _termios.tcgetattr(fd)
97
+ mode[3] &= ~(_termios.ECHO | _termios.ICANON | _termios.IEXTEN)
98
+ mode[6][_termios.VMIN] = 1
99
+ mode[6][_termios.VTIME] = 0
100
+ _termios.tcsetattr(fd, _termios.TCSAFLUSH, mode)
101
+ while not done.is_set():
102
+ r, _, _ = _select.select([fd], [], [], 0.05)
103
+ if r:
104
+ ch = os.read(fd, 1)
105
+ if ch == b"\x0f": # Ctrl+o
106
+ on = capture.toggle()
107
+ label = "on" if on else "off"
108
+ real_stdout.write(f" -- output {label} --\n")
109
+ real_stdout.flush()
110
+ except Exception:
111
+ pass
112
+
113
+ t = threading.Thread(target=_listen, daemon=True)
114
+ t.start()
115
+ try:
116
+ with contextlib.redirect_stdout(capture):
117
+ fn()
118
+ finally:
119
+ done.set()
120
+ t.join(timeout=0.5)
121
+ try:
122
+ _termios.tcsetattr(fd, _termios.TCSADRAIN, old_settings)
123
+ except Exception:
124
+ pass
125
+
126
+ return capture.getvalue()
127
+
128
+
129
+ @app.callback()
130
+ def solutions_callback(
131
+ ctx: typer.Context,
132
+ solutions_dir: str | None = typer.Option(
133
+ None,
134
+ "--solutions-dir",
135
+ help="Path to a custom solutions directory (overrides the built-in solutions).",
136
+ show_default=False,
137
+ ),
138
+ ) -> None:
139
+ ctx.ensure_object(dict)
140
+ if solutions_dir:
141
+ ctx.obj["solutions_dir"] = solutions_dir
142
+
143
+
144
+ def _solutions_root(path: str | None = None):
145
+ if path:
146
+ return Path(path)
147
+ return files("cortexapps_cli.solutions")
148
+
149
+
150
+ def _list_solution_tags(path: str | None = None) -> list[str]:
151
+ root = _solutions_root(path)
152
+ return sorted(
153
+ item.name
154
+ for item in root.iterdir()
155
+ if item.is_dir() and not item.name.startswith("_")
156
+ )
157
+
158
+
159
+ def _parse_frontmatter(content: str) -> dict:
160
+ """Parse YAML frontmatter block from README content."""
161
+ match = re.match(r"^---\n(.*?)\n---\n", content, re.DOTALL)
162
+ if not match:
163
+ return {}
164
+ try:
165
+ return yaml.safe_load(match.group(1)) or {}
166
+ except yaml.YAMLError:
167
+ return {}
168
+
169
+
170
+ def _get_readme(tag: str, path: str | None = None) -> str | None:
171
+ """Return README.md content for a solution tag, or None if not found."""
172
+ try:
173
+ return (_solutions_root(path) / tag / "README.md").read_text(encoding="utf-8")
174
+ except Exception:
175
+ return None
176
+
177
+
178
+ def _extract_first_codeblock(text: str) -> str | None:
179
+ """Return content of the first fenced code block."""
180
+ m = re.search(r"```[^\n]*\n(.*?)```", text, re.DOTALL)
181
+ return m.group(1).rstrip("\n") if m else None
182
+
183
+
184
+ def _extract_section(text: str, heading: str) -> str | None:
185
+ """Return the body of the first section with the given heading text."""
186
+ body = re.sub(r"^---\n.*?\n---\n", "", text, flags=re.DOTALL)
187
+ pattern = re.compile(r"^(#{1,3})\s+" + re.escape(heading) + r"\s*$", re.MULTILINE)
188
+ m = pattern.search(body)
189
+ if not m:
190
+ return None
191
+ level = len(m.group(1))
192
+ start = m.end()
193
+ next_heading = re.compile(r"^#{1," + str(level) + r"}\s", re.MULTILINE)
194
+ m2 = next_heading.search(body, start + 1)
195
+ end = m2.start() if m2 else len(body)
196
+ return body[start:end].strip()
197
+
198
+
199
+ def _get_ui_url(ctx: typer.Context) -> str:
200
+ """Derive the Cortex app UI URL from the configured API URL."""
201
+ params = ctx.obj.get("_auth_params", {})
202
+ api_url = (params.get("url") or "https://api.getcortexapp.com").strip("\"' /")
203
+ if api_url == "https://api.getcortexapp.com":
204
+ return "https://app.getcortexapp.com"
205
+ return api_url.replace("://api.", "://app.")
206
+
207
+
208
+ def _build_client(ctx: typer.Context) -> CortexClient:
209
+ """Build a CortexClient from auth params stored by global_callback."""
210
+ params = ctx.obj.get("_auth_params", {})
211
+ api_key = params.get("api_key")
212
+ url = params.get("url")
213
+ config_file = params.get(
214
+ "config_file",
215
+ os.path.join(os.path.expanduser("~"), ".cortex", "config"),
216
+ )
217
+ tenant = params.get("tenant", "default")
218
+ log_level_str = params.get("log_level", "WARNING")
219
+ rate_limit = params.get("rate_limit")
220
+
221
+ if not os.path.isfile(config_file):
222
+ if not api_key:
223
+ typer.echo(
224
+ "Error: Authentication required. Run 'cortex login' first or set CORTEX_API_KEY."
225
+ )
226
+ raise typer.Exit(1)
227
+ else:
228
+ config = configparser.ConfigParser()
229
+ config.read(config_file)
230
+ if not api_key:
231
+ if tenant not in config:
232
+ typer.echo(
233
+ f"Error: Tenant '{tenant}' not found in config. Run 'cortex login' first."
234
+ )
235
+ raise typer.Exit(1)
236
+ api_key = config[tenant].get("api_key")
237
+ if not api_key:
238
+ typer.echo(
239
+ f"Error: No api_key found for tenant '{tenant}' in config. Run 'cortex login' first."
240
+ )
241
+ raise typer.Exit(1)
242
+ if not url:
243
+ url = config[tenant].get("base_url", "https://api.getcortexapp.com")
244
+
245
+ if not url:
246
+ url = "https://api.getcortexapp.com"
247
+
248
+ api_key = api_key.strip("\"' ")
249
+ url = url.strip("\"' /")
250
+
251
+ numeric_level = getattr(logging, log_level_str.upper(), logging.WARNING)
252
+ return CortexClient(api_key, tenant, numeric_level, url, rate_limit)
253
+
254
+
255
+ def _read_catalog_tag(content: str) -> str | None:
256
+ """Extract x-cortex-tag from a catalog YAML file."""
257
+ try:
258
+ data = yaml.safe_load(content)
259
+ return data.get("info", {}).get("x-cortex-tag") if isinstance(data, dict) else None
260
+ except Exception:
261
+ return None
262
+
263
+
264
+ def _read_resource_tag(content: str, kind: str) -> str | None:
265
+ """Extract tag/type identifier from a JSON or YAML resource file."""
266
+ try:
267
+ data = yaml.safe_load(content)
268
+ if not isinstance(data, dict):
269
+ data = json.loads(content)
270
+ return data.get("type") if kind == "entity-types" else data.get("tag")
271
+ except Exception:
272
+ return None
273
+
274
+
275
+ def _collect_solution_resources(path: Path) -> dict[str, list[str]]:
276
+ """Return {kind: [tag, ...]} for all deletable resources in a solution directory."""
277
+ resources: dict[str, list[str]] = {
278
+ "entity-types": [],
279
+ "entity-relationship-types": [],
280
+ "catalog": [],
281
+ "scorecards": [],
282
+ "workflows": [],
283
+ }
284
+ for kind in resources:
285
+ subdir = path / kind
286
+ if not subdir.exists() or not subdir.is_dir():
287
+ continue
288
+ for f in sorted(subdir.iterdir()):
289
+ if not f.is_file() or f.suffix not in (".json", ".yaml", ".yml"):
290
+ continue
291
+ try:
292
+ content = f.read_text(encoding="utf-8")
293
+ tag = (
294
+ _read_catalog_tag(content)
295
+ if kind == "catalog"
296
+ else _read_resource_tag(content, kind)
297
+ )
298
+ if tag:
299
+ resources[kind].append(tag)
300
+ except Exception:
301
+ pass
302
+ return resources
303
+
304
+
305
+ def _delete_parallel(client, tags: list[str], endpoint_fn) -> tuple[int, int]:
306
+ """Delete resources in parallel. Returns (removed, failed) counts."""
307
+ deleted = failed = 0
308
+ with ThreadPoolExecutor(max_workers=10) as executor:
309
+ future_to_tag = {
310
+ executor.submit(client.delete, endpoint_fn(tag)): tag for tag in tags
311
+ }
312
+ for future in as_completed(future_to_tag):
313
+ tag = future_to_tag[future]
314
+ try:
315
+ future.result()
316
+ typer.echo(f" Deleted: {tag}")
317
+ deleted += 1
318
+ except Exception as e:
319
+ err = str(e)
320
+ if "404" in err or "Not Found" in err:
321
+ typer.echo(f" Not found (skipped): {tag}")
322
+ deleted += 1
323
+ else:
324
+ typer.echo(f" Failed: {tag} — {err}")
325
+ failed += 1
326
+ return deleted, failed
327
+
328
+
329
+ def _run_uninstall(client, path: Path, yes: bool) -> None:
330
+ """Collect resources, confirm, then delete in reverse import order."""
331
+ resources = _collect_solution_resources(path)
332
+ total = sum(len(v) for v in resources.values())
333
+
334
+ if total == 0:
335
+ typer.echo("No resources found to remove.")
336
+ return
337
+
338
+ typer.echo("\nThis will remove the following resources:")
339
+ for kind in ("workflows", "scorecards", "catalog", "entity-relationship-types", "entity-types"):
340
+ count = len(resources[kind])
341
+ if count:
342
+ typer.echo(f" {kind}: {count}")
343
+ typer.echo()
344
+
345
+ if not yes:
346
+ confirmed = typer.confirm("Proceed with uninstall?", default=False)
347
+ if not confirmed:
348
+ typer.echo("Aborted.")
349
+ raise typer.Exit(0)
350
+
351
+ # Delete in reverse import order
352
+ steps = [
353
+ ("workflows", lambda t: f"api/v1/workflows/{t}"),
354
+ ("scorecards", lambda t: f"api/v1/scorecards/{t}"),
355
+ ("catalog", lambda t: f"api/v1/catalog/{t}"),
356
+ ("entity-relationship-types", lambda t: f"api/v1/relationship-types/{t}"),
357
+ ("entity-types", lambda t: f"api/v1/catalog/definitions/{t}"),
358
+ ]
359
+
360
+ stats: dict[str, tuple[int, int]] = {}
361
+ for kind, endpoint_fn in steps:
362
+ tags = resources[kind]
363
+ if not tags:
364
+ continue
365
+ typer.echo(f"\nRemoving {kind}...")
366
+ deleted, failed = _delete_parallel(client, tags, endpoint_fn)
367
+ stats[kind] = (deleted, failed)
368
+
369
+ total_removed = sum(d for d, _ in stats.values())
370
+ total_failed = sum(f for _, f in stats.values())
371
+
372
+ width = 80
373
+ typer.echo(f"\n{'=' * width}")
374
+ typer.echo("UNINSTALL SUMMARY")
375
+ typer.echo(f"{'=' * width}\n")
376
+ for kind, (d, f) in stats.items():
377
+ if d + f > 0:
378
+ typer.echo(f"{kind}:")
379
+ typer.echo(f" Removed: {d}")
380
+ if f:
381
+ typer.echo(f" Failed: {f}")
382
+ typer.echo(f"\nTOTAL: {total_removed} removed, {total_failed} failed\n")
383
+ typer.echo("=" * width)
384
+
385
+
386
+ @app.command("list")
387
+ def list_solutions(ctx: typer.Context):
388
+ """List all available solutions."""
389
+ solutions_dir = ctx.obj.get("solutions_dir") if ctx.obj else None
390
+ tags = _list_solution_tags(solutions_dir)
391
+ table = Table(title="Available Solutions")
392
+ table.add_column("Tag", style="cyan", no_wrap=True)
393
+ table.add_column("Name")
394
+ table.add_column("Description")
395
+ for tag in tags:
396
+ readme = _get_readme(tag, solutions_dir)
397
+ if readme is None:
398
+ continue
399
+ fm = _parse_frontmatter(readme)
400
+ table.add_row(tag, fm.get("name", tag), fm.get("description", ""))
401
+ console.print(table)
402
+
403
+
404
+ def _print_readme(text: str, plain: bool = False, entity_tags: set[str] | None = None, ui_url: str = "https://app.getcortexapp.com") -> None:
405
+ """Render README with left-justified headings and Rich Markdown body blocks.
406
+
407
+ When plain=True, markdown structure is preserved (bold, code blocks, etc.)
408
+ but all color is removed — suitable for light-mode terminals.
409
+ """
410
+ # Strip YAML frontmatter
411
+ body = re.sub(r"^---\n.*?\n---\n", "", text, flags=re.DOTALL).strip()
412
+
413
+ out = Console(no_color=True) if plain else console
414
+
415
+ heading_styles = {"# ": "bold", "## ": "bold underline", "### ": "bold"}
416
+ pending: list[str] = []
417
+ in_code_block = False
418
+
419
+ def flush() -> None:
420
+ block = "\n".join(pending).strip()
421
+ if block:
422
+ out.print(Markdown(block))
423
+ pending.clear()
424
+
425
+ for line in body.split("\n"):
426
+ if line.startswith("```"):
427
+ if not in_code_block:
428
+ flush()
429
+ in_code_block = not in_code_block
430
+ continue
431
+
432
+ if in_code_block:
433
+ if entity_tags and not plain:
434
+ linked = _apply_hyperlinks(line, entity_tags, ui_url)
435
+ if linked != line:
436
+ # Use print() not console.print(): Rich counts OSC 8 escape
437
+ # bytes as visible characters, shifting ASCII art alignment.
438
+ print(f" {linked}")
439
+ continue
440
+ out.print(f" [cyan]{line}[/cyan]" if line else "")
441
+ continue
442
+
443
+ for prefix, style in heading_styles.items():
444
+ if line.startswith(prefix):
445
+ flush()
446
+ out.print(f"\n[{style}]{line[len(prefix):]}[/{style}]")
447
+ break
448
+ else:
449
+ pending.append(line)
450
+
451
+ flush()
452
+
453
+
454
+ def _terminal_supports_hyperlinks() -> bool:
455
+ """Return True if the terminal is known to support OSC 8 hyperlinks."""
456
+ term_program = os.environ.get("TERM_PROGRAM", "")
457
+ if term_program == "Apple_Terminal":
458
+ return False
459
+ if term_program in ("iTerm.app", "vscode", "WarpTerminal", "Hyper"):
460
+ return True
461
+ if os.environ.get("WT_SESSION"): # Windows Terminal
462
+ return True
463
+ return False
464
+
465
+
466
+ def _osc8(url: str, text: str) -> str:
467
+ """Wrap text in an OSC 8 terminal hyperlink."""
468
+ return f"\033]8;;{url}\033\\{text}\033]8;;\033\\"
469
+
470
+
471
+ def _apply_hyperlinks(line: str, entity_tags: set[str], ui_url: str) -> str:
472
+ """Single-pass replacement: find non-overlapping tag matches, longest wins."""
473
+ covered: list[tuple[int, int]] = []
474
+ matches: list[tuple[int, int, str]] = []
475
+
476
+ for tag in sorted(entity_tags, key=len, reverse=True):
477
+ start = 0
478
+ while True:
479
+ pos = line.find(tag, start)
480
+ if pos == -1:
481
+ break
482
+ end = pos + len(tag)
483
+ if not any(pos < c_end and end > c_start for c_start, c_end in covered):
484
+ matches.append((pos, end, tag))
485
+ covered.append((pos, end))
486
+ start = pos + 1
487
+
488
+ if not matches:
489
+ return line
490
+
491
+ matches.sort()
492
+ parts = []
493
+ cur = 0
494
+ for start, end, tag in matches:
495
+ parts.append(line[cur:start])
496
+ parts.append(_osc8(f"{ui_url}/admin/resources?tag={tag}", tag))
497
+ cur = end
498
+ parts.append(line[cur:])
499
+ return "".join(parts)
500
+
501
+
502
+ def _show_diagram(readme: str, entity_tags: set[str] | None = None, ui_url: str = "https://app.getcortexapp.com") -> None:
503
+ block = _extract_first_codeblock(readme)
504
+ if not block:
505
+ return
506
+
507
+ links_supported = _terminal_supports_hyperlinks()
508
+
509
+ print()
510
+ for line in block.split("\n"):
511
+ if entity_tags and links_supported:
512
+ line = _apply_hyperlinks(line, entity_tags, ui_url)
513
+ # Use print() not console.print(): Rich counts OSC 8 escape bytes as
514
+ # visible characters, shifting ASCII art alignment.
515
+ print(f" {line}")
516
+
517
+ if entity_tags and not links_supported:
518
+ diagram_tags = sorted(
519
+ (tag for tag in entity_tags if tag in block),
520
+ key=lambda t: t.lower(),
521
+ )
522
+ if diagram_tags:
523
+ print()
524
+ table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2, 0, 0))
525
+ table.add_column("Entity")
526
+ table.add_column("URL")
527
+ for tag in diagram_tags:
528
+ table.add_row(tag, f"{ui_url}/admin/resources?tag={tag}")
529
+ console.print(table)
530
+
531
+
532
+ def _show_next_steps(readme: str) -> None:
533
+ section = _extract_section(readme, "After Installing")
534
+ if section:
535
+ if not _terminal_supports_hyperlinks():
536
+ section = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'\1 (\2)', section)
537
+ console.print()
538
+ console.print(Markdown(section))
539
+ console.print()
540
+ console.print(
541
+ "[magenta]Planned for Q4 2027:[/magenta] CQL metadata traversal will enable scorecard rules "
542
+ "across relationship chains — for example, a Vulnerability Scorecard checking that no "
543
+ "deployed service-version has open Snyk issues:"
544
+ )
545
+ console.print(
546
+ " [dim]entity.destinations(relationshipType = \"environments\", depth = 3)\n"
547
+ " .filter((d) => d.type == \"service-version\")\n"
548
+ " .all((sv) => sv.snyk.issues == 0)[/dim]"
549
+ )
550
+
551
+
552
+ def _post_install_menu(
553
+ readme: str,
554
+ import_report: str = "",
555
+ entity_tags: set[str] | None = None,
556
+ ui_url: str = "https://app.getcortexapp.com",
557
+ ) -> None:
558
+ options = [
559
+ ("1", "Data Model"),
560
+ ("2", "Next steps"),
561
+ ("3", "Import report"),
562
+ ("4", "Exit"),
563
+ ]
564
+ actions = {
565
+ "1": lambda: _show_diagram(readme, entity_tags=entity_tags, ui_url=ui_url),
566
+ "2": lambda: _show_next_steps(readme),
567
+ "3": lambda: (console.print(), typer.echo(import_report)),
568
+ }
569
+
570
+ while True:
571
+ console.print()
572
+ console.print(Rule(" What next? ", style="bold"))
573
+ for key, label in options:
574
+ console.print(f" [cyan]{key}[/cyan] {label}")
575
+ console.print()
576
+
577
+ choice = Prompt.ask("Choice", choices=[k for k, _ in options], show_choices=False)
578
+
579
+ if choice == "4":
580
+ break
581
+ actions[choice]()
582
+
583
+
584
+ @app.command()
585
+ def info(
586
+ ctx: typer.Context,
587
+ solution: str = typer.Option(..., "--solution", "-s", help="Solution tag"),
588
+ plain: bool = typer.Option(False, "--plain", help="Render README in plain black text (no colors)."),
589
+ ):
590
+ """Show README for a solution."""
591
+ solutions_dir = ctx.obj.get("solutions_dir") if ctx.obj else None
592
+ readme = _get_readme(solution, solutions_dir)
593
+ if readme is None:
594
+ avail = ", ".join(_list_solution_tags(solutions_dir))
595
+ typer.echo(f"Error: Solution '{solution}' not found. Available: {avail}")
596
+ raise typer.Exit(1)
597
+ _print_readme(readme, plain=plain)
598
+
599
+
600
+ @app.command()
601
+ def install(
602
+ ctx: typer.Context,
603
+ solution: str = typer.Option(..., "--solution", "-s", help="Solution tag"),
604
+ no_prompt: bool = typer.Option(False, "--no-prompt", help="Skip the post-install interactive menu"),
605
+ ):
606
+ """Install a solution."""
607
+ solutions_dir = ctx.obj.get("solutions_dir") if ctx.obj else None
608
+ if solution not in _list_solution_tags(solutions_dir):
609
+ avail = ", ".join(_list_solution_tags(solutions_dir))
610
+ typer.echo(f"Error: Solution '{solution}' not found. Available: {avail}")
611
+ raise typer.Exit(1)
612
+
613
+ ctx.obj["client"] = _build_client(ctx)
614
+
615
+ import cortexapps_cli.commands.backup as backup
616
+
617
+ root = _solutions_root(solutions_dir)
618
+ typer.echo(f"\nInstalling {solution}...")
619
+ if _TTY_SUPPORT and sys.stdin.isatty():
620
+ console.print(" [dim]Ctrl+o to show/hide import details[/dim]")
621
+
622
+ def _do_import() -> None:
623
+ if solutions_dir:
624
+ backup.import_tenant(ctx, directory=str(root / solution), force=False)
625
+ else:
626
+ with as_file(root / solution) as solution_path:
627
+ backup.import_tenant(ctx, directory=str(solution_path), force=False)
628
+
629
+ output = _run_import_with_toggle(_do_import)
630
+ total_match = re.search(r"TOTAL: (\d+) imported, (\d+) failed", output)
631
+ if total_match:
632
+ total_imported = int(total_match.group(1))
633
+ total_failed = int(total_match.group(2))
634
+ if total_failed:
635
+ failed_m = re.search(r"={20,}\nFAILED IMPORTS.*", output, re.DOTALL)
636
+ if failed_m:
637
+ typer.echo(failed_m.group(0))
638
+ typer.echo(f"\n {total_imported} imported, {total_failed} failed")
639
+ else:
640
+ typer.echo(f" {total_imported} resources imported")
641
+ else:
642
+ typer.echo(output)
643
+
644
+ if not no_prompt:
645
+ readme = _get_readme(solution, solutions_dir)
646
+ if readme:
647
+ entity_tags: set[str] = set()
648
+ ui_url = _get_ui_url(ctx)
649
+ try:
650
+ if solutions_dir:
651
+ resources = _collect_solution_resources(root / solution)
652
+ else:
653
+ with as_file(root / solution) as sp:
654
+ resources = _collect_solution_resources(sp)
655
+ entity_tags = set(resources.get("catalog", []))
656
+ except Exception:
657
+ pass
658
+ _post_install_menu(readme, import_report=output, entity_tags=entity_tags, ui_url=ui_url)
659
+
660
+
661
+ @app.command()
662
+ def uninstall(
663
+ ctx: typer.Context,
664
+ solution: str = typer.Option(..., "--solution", "-s", help="Solution tag"),
665
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
666
+ ):
667
+ """Remove all entities installed by a solution."""
668
+ solutions_dir = ctx.obj.get("solutions_dir") if ctx.obj else None
669
+ if solution not in _list_solution_tags(solutions_dir):
670
+ avail = ", ".join(_list_solution_tags(solutions_dir))
671
+ typer.echo(f"Error: Solution '{solution}' not found. Available: {avail}")
672
+ raise typer.Exit(1)
673
+
674
+ ctx.obj["client"] = _build_client(ctx)
675
+ client = ctx.obj["client"]
676
+
677
+ root = _solutions_root(solutions_dir)
678
+ if solutions_dir:
679
+ _run_uninstall(client, root / solution, yes)
680
+ else:
681
+ with as_file(root / solution) as solution_path:
682
+ _run_uninstall(client, solution_path, yes)