splent-cli 1.13.0__tar.gz → 1.14.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 (177) hide show
  1. {splent_cli-1.13.0/src/splent_cli.egg-info → splent_cli-1.14.0}/PKG-INFO +1 -1
  2. {splent_cli-1.13.0 → splent_cli-1.14.0}/pyproject.toml +1 -1
  3. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/cli.py +3 -0
  4. splent_cli-1.14.0/src/splent_cli/commands/check/check_contracts.py +206 -0
  5. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/feature/feature_clean.py +5 -35
  6. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/feature/feature_clone.py +2 -35
  7. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/feature/feature_contract.py +44 -1
  8. splent_cli-1.14.0/src/splent_cli/commands/feature/feature_info.py +214 -0
  9. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/feature/feature_install.py +5 -24
  10. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/feature/feature_outdated.py +5 -28
  11. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/feature/feature_release.py +251 -4
  12. splent_cli-1.14.0/src/splent_cli/commands/feature/feature_search.py +238 -0
  13. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/feature/feature_upgrade.py +9 -32
  14. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/feature/feature_versions.py +13 -83
  15. splent_cli-1.14.0/src/splent_cli/commands/marketplace/marketplace_index.py +192 -0
  16. splent_cli-1.14.0/src/splent_cli/services/marketplace.py +543 -0
  17. splent_cli-1.14.0/src/splent_cli/services/registry.py +242 -0
  18. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/services/release.py +3 -34
  19. splent_cli-1.14.0/src/splent_cli/utils/archetype.py +52 -0
  20. {splent_cli-1.13.0 → splent_cli-1.14.0/src/splent_cli.egg-info}/PKG-INFO +1 -1
  21. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli.egg-info/SOURCES.txt +6 -0
  22. splent_cli-1.13.0/src/splent_cli/commands/feature/feature_search.py +0 -144
  23. {splent_cli-1.13.0 → splent_cli-1.14.0}/LICENSE +0 -0
  24. {splent_cli-1.13.0 → splent_cli-1.14.0}/README.md +0 -0
  25. {splent_cli-1.13.0 → splent_cli-1.14.0}/setup.cfg +0 -0
  26. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/__init__.py +0 -0
  27. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/__main__.py +0 -0
  28. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/__init__.py +0 -0
  29. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/cache/__init__.py +0 -0
  30. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/cache/cache_clear.py +0 -0
  31. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/cache/cache_orphans.py +0 -0
  32. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/cache/cache_outdated.py +0 -0
  33. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/cache/cache_prune.py +0 -0
  34. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/cache/cache_size.py +0 -0
  35. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/cache/cache_status.py +0 -0
  36. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/cache/cache_usage.py +0 -0
  37. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/cache/cache_versions.py +0 -0
  38. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/check/__init__.py +0 -0
  39. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/check/check_deps.py +0 -0
  40. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/check/check_docker.py +0 -0
  41. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/check/check_env.py +0 -0
  42. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/check/check_features.py +0 -0
  43. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/check/check_github.py +0 -0
  44. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/check/check_infra.py +0 -0
  45. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/check/check_product.py +0 -0
  46. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/check/check_pypi.py +0 -0
  47. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/check/check_pyproject.py +0 -0
  48. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/clear/__init__.py +0 -0
  49. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/clear/clear_build.py +0 -0
  50. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/clear/clear_log.py +0 -0
  51. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/clear/clear_uploads.py +0 -0
  52. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/command/__init__.py +0 -0
  53. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/command/command_create.py +0 -0
  54. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/coverage.py +0 -0
  55. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/database/db_console.py +0 -0
  56. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/database/db_dump.py +0 -0
  57. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/database/db_migrate.py +0 -0
  58. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/database/db_reset.py +0 -0
  59. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/database/db_restore.py +0 -0
  60. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/database/db_rollback.py +0 -0
  61. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/database/db_seed.py +0 -0
  62. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/database/db_status.py +0 -0
  63. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/database/db_upgrade.py +0 -0
  64. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/doctor.py +0 -0
  65. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/env/env_list.py +0 -0
  66. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/env/env_set.py +0 -0
  67. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/env/env_show.py +0 -0
  68. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/export/__init__.py +0 -0
  69. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/export/export_puml.py +0 -0
  70. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/feature/feature_add.py +0 -0
  71. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/feature/feature_attach.py +0 -0
  72. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/feature/feature_compat.py +0 -0
  73. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/feature/feature_compile.py +0 -0
  74. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/feature/feature_create.py +0 -0
  75. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/feature/feature_delete.py +0 -0
  76. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/feature/feature_detach.py +0 -0
  77. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/feature/feature_discard.py +0 -0
  78. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/feature/feature_drift.py +0 -0
  79. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/feature/feature_env.py +0 -0
  80. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/feature/feature_fork.py +0 -0
  81. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/feature/feature_git.py +0 -0
  82. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/feature/feature_hook_add.py +0 -0
  83. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/feature/feature_hook_remove.py +0 -0
  84. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/feature/feature_hooks.py +0 -0
  85. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/feature/feature_impact.py +0 -0
  86. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/feature/feature_inject_config.py +0 -0
  87. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/feature/feature_list.py +0 -0
  88. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/feature/feature_order.py +0 -0
  89. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/feature/feature_pin.py +0 -0
  90. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/feature/feature_pip_install.py +0 -0
  91. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/feature/feature_pull.py +0 -0
  92. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/feature/feature_refine.py +0 -0
  93. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/feature/feature_remove.py +0 -0
  94. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/feature/feature_rename.py +0 -0
  95. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/feature/feature_status.py +0 -0
  96. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/feature/feature_sync_template.py +0 -0
  97. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/feature/feature_test.py +0 -0
  98. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/feature/feature_translate.py +0 -0
  99. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/feature/feature_unlock.py +0 -0
  100. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/feature/feature_xray.py +0 -0
  101. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/lint.py +0 -0
  102. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/locust.py +0 -0
  103. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/product/__init__.py +0 -0
  104. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/product/product_auto_require.py +0 -0
  105. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/product/product_build.py +0 -0
  106. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/product/product_clean.py +0 -0
  107. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/product/product_commands.py +0 -0
  108. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/product/product_config.py +0 -0
  109. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/product/product_configure.py +0 -0
  110. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/product/product_console.py +0 -0
  111. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/product/product_containers.py +0 -0
  112. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/product/product_create.py +0 -0
  113. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/product/product_deploy.py +0 -0
  114. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/product/product_derive.py +0 -0
  115. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/product/product_deselect.py +0 -0
  116. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/product/product_down.py +0 -0
  117. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/product/product_drift.py +0 -0
  118. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/product/product_env.py +0 -0
  119. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/product/product_list.py +0 -0
  120. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/product/product_logs.py +0 -0
  121. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/product/product_missing.py +0 -0
  122. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/product/product_port.py +0 -0
  123. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/product/product_release.py +0 -0
  124. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/product/product_resolve.py +0 -0
  125. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/product/product_restart.py +0 -0
  126. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/product/product_routes.py +0 -0
  127. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/product/product_run.py +0 -0
  128. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/product/product_select.py +0 -0
  129. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/product/product_shell.py +0 -0
  130. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/product/product_signals.py +0 -0
  131. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/product/product_sync_template.py +0 -0
  132. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/product/product_test.py +0 -0
  133. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/product/product_up.py +0 -0
  134. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/product/product_validate.py +0 -0
  135. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/release/__init__.py +0 -0
  136. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/release/release_core.py +0 -0
  137. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/selenium.py +0 -0
  138. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/spl/__init__.py +0 -0
  139. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/spl/spl_add_constraints.py +0 -0
  140. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/spl/spl_add_feature.py +0 -0
  141. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/spl/spl_compare.py +0 -0
  142. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/spl/spl_configurations.py +0 -0
  143. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/spl/spl_create.py +0 -0
  144. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/spl/spl_deps.py +0 -0
  145. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/spl/spl_features.py +0 -0
  146. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/spl/spl_fetch.py +0 -0
  147. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/spl/spl_info.py +0 -0
  148. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/spl/spl_list.py +0 -0
  149. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/spl/spl_utils.py +0 -0
  150. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/tokens_setup.py +0 -0
  151. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/uvl/uvl_utils.py +0 -0
  152. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/commands/version.py +0 -0
  153. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/services/__init__.py +0 -0
  154. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/services/compose.py +0 -0
  155. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/services/context.py +0 -0
  156. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/services/preflight.py +0 -0
  157. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/utils/__init__.py +0 -0
  158. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/utils/cache_utils.py +0 -0
  159. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/utils/command_loader.py +0 -0
  160. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/utils/contract_freshness.py +0 -0
  161. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/utils/db_utils.py +0 -0
  162. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/utils/decorators.py +0 -0
  163. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/utils/dynamic_imports.py +0 -0
  164. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/utils/feature_installer.py +0 -0
  165. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/utils/feature_utils.py +0 -0
  166. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/utils/git_url.py +0 -0
  167. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/utils/integrity.py +0 -0
  168. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/utils/io_utils.py +0 -0
  169. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/utils/lifecycle.py +0 -0
  170. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/utils/manifest.py +0 -0
  171. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/utils/path_utils.py +0 -0
  172. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/utils/proc.py +0 -0
  173. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli/utils/template_drift.py +0 -0
  174. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli.egg-info/dependency_links.txt +0 -0
  175. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli.egg-info/entry_points.txt +0 -0
  176. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli.egg-info/requires.txt +0 -0
  177. {splent_cli-1.13.0 → splent_cli-1.14.0}/src/splent_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: splent_cli
3
- Version: 1.13.0
3
+ Version: 1.14.0
4
4
  Summary: SPLENT-CLI is a CLI to be able to work on your development more easily.
5
5
  Author-email: DiversoLab <diversolab@us.es>
6
6
  Project-URL: Homepage, https://github.com/diverso-lab/splent_cli
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "splent_cli"
7
- version = "1.13.0"
7
+ version = "1.14.0"
8
8
  description = "SPLENT-CLI is a CLI to be able to work on your development more easily."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -109,6 +109,9 @@ class SPLENTCLI(click.Group):
109
109
  "🧬 SPL & Variability": [
110
110
  cmd for cmd in all_cmds if cmd.startswith(("spl:", "uvl:"))
111
111
  ],
112
+ "🛒 Marketplace": [
113
+ cmd for cmd in all_cmds if cmd.startswith("marketplace:")
114
+ ],
112
115
  "🧱 Database": [cmd for cmd in all_cmds if cmd.startswith("db:")],
113
116
  "💾 Cache": [cmd for cmd in all_cmds if cmd.startswith("cache:")],
114
117
  "🔍 Checks": [cmd for cmd in all_cmds if cmd.startswith("check:")],
@@ -0,0 +1,206 @@
1
+ """
2
+ check:contracts — Verify that feature contracts and SPL UVL models agree.
3
+
4
+ The dependency truth must not be split: a feature's requires.features (in
5
+ [tool.splent.contract]) and the SPL's UVL implications (A => B) describe
6
+ the same reality from two sides. This check reports:
7
+
8
+ - contract dependencies with no matching UVL constraint (ERROR: the SPL
9
+ would let a product select A without B and break at runtime), and
10
+ - UVL implications not backed by any contract (WARNING: the dependency
11
+ is invisible to feature:install and the marketplace).
12
+ """
13
+
14
+ from pathlib import Path
15
+
16
+ import click
17
+ import tomllib
18
+
19
+ from splent_cli.services import context, marketplace
20
+
21
+
22
+ def _snapshot_version_key(pyproject_path: Path) -> tuple:
23
+ """Semver sort key for a cache snapshot dir like splent_feature_x@v1.10.0."""
24
+ import re
25
+
26
+ _, _, version = pyproject_path.parent.name.partition("@")
27
+ m = re.match(r"v?(\d+)\.(\d+)\.(\d+)", version)
28
+ if not m:
29
+ return (-1, -1, -1)
30
+ return (int(m.group(1)), int(m.group(2)), int(m.group(3)))
31
+
32
+
33
+ def _local_contract_requires(workspace: str, package: str) -> list[str] | None:
34
+ """requires.features of a feature found locally (workspace root, else the
35
+ newest cache snapshot by semver). None when not available locally."""
36
+ candidates = [Path(workspace) / package / "pyproject.toml"]
37
+ cache = Path(workspace) / ".splent_cache" / "features"
38
+ if cache.is_dir():
39
+ candidates.extend(
40
+ sorted(
41
+ cache.glob(f"*/{package}@*/pyproject.toml"),
42
+ key=_snapshot_version_key,
43
+ reverse=True,
44
+ )
45
+ )
46
+
47
+ for path in candidates:
48
+ if not path.is_file():
49
+ continue
50
+ try:
51
+ data = tomllib.loads(path.read_text())
52
+ except (OSError, tomllib.TOMLDecodeError):
53
+ continue
54
+ requires = (
55
+ data.get("tool", {})
56
+ .get("splent", {})
57
+ .get("contract", {})
58
+ .get("requires", {})
59
+ )
60
+ return {
61
+ "features": requires.get("features", []),
62
+ "optional": requires.get("features_optional", []),
63
+ }
64
+ return None
65
+
66
+
67
+ @click.command(
68
+ "check:contracts",
69
+ short_help="Check that feature contracts and SPL UVL constraints agree.",
70
+ )
71
+ @click.option("--spl", "spl_name", default=None, help="Check a single SPL.")
72
+ def check_contracts(spl_name):
73
+ """
74
+ Cross-check requires.features in every contract against the UVL
75
+ constraints of each SPL in splent_catalog.
76
+
77
+ \b
78
+ Examples:
79
+ splent check:contracts
80
+ splent check:contracts --spl cms_spl
81
+ """
82
+ workspace = str(context.workspace())
83
+ catalog = Path(workspace) / "splent_catalog"
84
+
85
+ if spl_name:
86
+ spl_dirs = [catalog / spl_name]
87
+ if not spl_dirs[0].is_dir():
88
+ click.secho(f"❌ SPL not found: {spl_dirs[0]}", fg="red")
89
+ raise SystemExit(1)
90
+ else:
91
+ spl_dirs = sorted(d for d in catalog.glob("*") if (d / "metadata.toml").is_file())
92
+
93
+ if not spl_dirs:
94
+ click.secho("❌ No SPLs found in splent_catalog.", fg="red")
95
+ raise SystemExit(1)
96
+
97
+ total_errors = 0
98
+ total_warnings = 0
99
+
100
+ for spl_dir in spl_dirs:
101
+ spl_errors = 0
102
+ spl_warnings = 0
103
+ uvl_path = spl_dir / f"{spl_dir.name}.uvl"
104
+ click.echo()
105
+ click.echo(click.style(f" check:contracts — {spl_dir.name}", bold=True))
106
+ click.echo(click.style(f" {'─' * 60}", fg="bright_black"))
107
+
108
+ if not uvl_path.is_file():
109
+ click.secho(
110
+ f" ⚠ UVL not on disk ({uvl_path.name}) — run: splent spl:fetch "
111
+ f"{spl_dir.name}",
112
+ fg="yellow",
113
+ )
114
+ total_warnings += 1
115
+ continue
116
+
117
+ model = marketplace.parse_uvl_structure(uvl_path.read_text())
118
+ shorts = model["features"] # short -> {package, org, ...}
119
+ implications = {(a, b) for a, b in model["constraints"]}
120
+
121
+ contracts: dict[str, list[str] | None] = {}
122
+ for short, meta in shorts.items():
123
+ contracts[short] = _local_contract_requires(workspace, meta["package"])
124
+
125
+ missing_local = sorted(s for s, req in contracts.items() if req is None)
126
+
127
+ # 1. Contract deps that the UVL does not enforce.
128
+ for short, req in sorted(contracts.items()):
129
+ for dep in (req or {}).get("features", []):
130
+ if dep not in shorts:
131
+ # Dependency on a feature outside this SPL: the SPL simply
132
+ # cannot offer this feature safely without it.
133
+ click.secho(
134
+ f" ✗ {short} requires '{dep}' (contract), but '{dep}' "
135
+ f"is not a feature of {spl_dir.name}",
136
+ fg="red",
137
+ )
138
+ spl_errors += 1
139
+ elif (short, dep) not in implications:
140
+ click.secho(
141
+ f" ✗ {short} requires '{dep}' (contract), but the UVL "
142
+ f"has no '{short} => {dep}' constraint",
143
+ fg="red",
144
+ )
145
+ click.echo(
146
+ click.style(
147
+ f" fix: add '{short} => {dep}' to the UVL "
148
+ f"constraints (splent spl:add-constraints "
149
+ f"{spl_dir.name} is interactive)",
150
+ fg="bright_black",
151
+ )
152
+ )
153
+ spl_errors += 1
154
+
155
+ # 2. UVL implications with no contract behind them.
156
+ for a, b in sorted(implications):
157
+ req = contracts.get(a)
158
+ if a not in shorts or b not in shorts:
159
+ continue # constraint over infra features (redis, nginx…)
160
+ if req is None:
161
+ continue # feature not local — cannot verify
162
+ if b in req["optional"]:
163
+ continue # SPL hardening a soft (graceful) dependency — fine
164
+ if b not in req["features"]:
165
+ click.secho(
166
+ f" ⚠ UVL says '{a} => {b}' but {a}'s contract does not "
167
+ f"require '{b}'",
168
+ fg="yellow",
169
+ )
170
+ click.echo(
171
+ click.style(
172
+ f" fix: add \"{b}\" to requires.features_manual in "
173
+ f"{shorts[a]['package']}/pyproject.toml (preserved on "
174
+ "regeneration), or confirm it is SPL-level policy",
175
+ fg="bright_black",
176
+ )
177
+ )
178
+ spl_warnings += 1
179
+
180
+ if missing_local:
181
+ click.echo(
182
+ click.style(
183
+ f" ℹ not local (skipped): {', '.join(missing_local)}",
184
+ fg="bright_black",
185
+ )
186
+ )
187
+
188
+ if not spl_errors and not spl_warnings:
189
+ click.secho(" ✅ Contracts and UVL agree.", fg="green")
190
+ total_errors += spl_errors
191
+ total_warnings += spl_warnings
192
+
193
+ click.echo()
194
+ if total_errors or total_warnings:
195
+ click.echo(
196
+ click.style(
197
+ f" {total_errors} error(s), {total_warnings} warning(s).",
198
+ bold=True,
199
+ )
200
+ )
201
+ click.echo()
202
+ if total_errors:
203
+ raise SystemExit(1)
204
+
205
+
206
+ cli_command = check_contracts
@@ -12,20 +12,16 @@ from pathlib import Path
12
12
  import click
13
13
 
14
14
  from splent_cli.services import context
15
+ from splent_cli.utils.archetype import ( # noqa: F401 (re-exported for callers)
16
+ ARCHETYPES,
17
+ ARCHETYPE_LABELS,
18
+ detect_archetype as _detect_archetype,
19
+ )
15
20
  from splent_cli.utils.feature_utils import normalize_namespace
16
21
 
17
22
 
18
23
  DEFAULT_NAMESPACE = os.getenv("SPLENT_DEFAULT_NAMESPACE", "splent_io")
19
24
 
20
- ARCHETYPES = ("full", "light", "config", "service")
21
-
22
- ARCHETYPE_LABELS = {
23
- "full": "Complete domain feature (models, routes, services, templates, migrations)",
24
- "light": "Lightweight UI feature (routes, hooks, templates)",
25
- "config": "Infrastructure feature (config.py only)",
26
- "service": "Backend service (services, config, commands, signals)",
27
- }
28
-
29
25
  # Files that are ALWAYS kept regardless of archetype
30
26
  PROTECTED = {"__init__.py"}
31
27
 
@@ -115,32 +111,6 @@ def _is_stub_dir(dirpath, dirname):
115
111
  return False
116
112
 
117
113
 
118
- # ── Archetype detection ──────────────────────────────────────────────
119
-
120
-
121
- def _detect_archetype(src_dir):
122
- """Detect the real archetype from source file contents."""
123
- models = _read(os.path.join(src_dir, "models.py"))
124
- routes = _read(os.path.join(src_dir, "routes.py"))
125
- services = _read(os.path.join(src_dir, "services.py"))
126
-
127
- has_models = models and re.search(r"db\.Column", models)
128
- has_routes = routes and re.search(r"@\w+\.route\s*\(", routes)
129
- has_services = services and re.search(
130
- r"def\s+(?!__)\w+\s*\(.*\):\s*\n\s+(?!pass\b|\.\.\.|\s*#)",
131
- services,
132
- re.MULTILINE,
133
- )
134
-
135
- if has_models:
136
- return "full"
137
- if has_routes:
138
- return "light"
139
- if has_services:
140
- return "service"
141
- return "config"
142
-
143
-
144
114
  # ── Expected files per archetype ─────────────────────────────────────
145
115
 
146
116
 
@@ -1,9 +1,8 @@
1
1
  import os
2
2
  import re
3
3
  import shutil
4
- import requests
5
4
  import click
6
- from splent_cli.services import context
5
+ from splent_cli.services import context, registry
7
6
  from splent_cli.utils.cache_utils import make_feature_readonly
8
7
  from splent_cli.utils.feature_utils import normalize_namespace
9
8
  from splent_cli.utils.proc import require_tool
@@ -17,38 +16,6 @@ DEFAULT_NAMESPACE = os.getenv("SPLENT_DEFAULT_NAMESPACE", "splent_io")
17
16
  # =====================================================================
18
17
 
19
18
 
20
- def _get_latest_tag(namespace, repo) -> str | None:
21
- """Fetch the highest semver tag from GitHub. Returns None if unreachable or no tags."""
22
- api_url = f"https://api.github.com/repos/{namespace}/{repo}/tags?per_page=100"
23
- # Authenticate when a token is present so version resolution also works for
24
- # PRIVATE feature repos (an unauthenticated call 404s on those) and to raise
25
- # the API rate limit.
26
- headers = {}
27
- token = os.getenv("GITHUB_TOKEN")
28
- if token:
29
- headers["Authorization"] = f"Bearer {token}"
30
- try:
31
- r = requests.get(api_url, headers=headers, timeout=5)
32
- r.raise_for_status()
33
- tags = r.json()
34
- if not tags:
35
- return None
36
- versions = []
37
- for tag in tags:
38
- name = tag.get("name", "")
39
- m = re.match(r"v?(\d+)\.(\d+)\.(\d+)", name)
40
- if m:
41
- versions.append(
42
- (int(m.group(1)), int(m.group(2)), int(m.group(3)), name)
43
- )
44
- if not versions:
45
- return tags[0]["name"]
46
- versions.sort(reverse=True)
47
- return versions[0][3]
48
- except (requests.RequestException, KeyError, IndexError, ValueError):
49
- return None
50
-
51
-
52
19
  def _parse_full_name(full_name: str):
53
20
  """
54
21
  full_name = <namespace>/<repo>[@version]
@@ -104,7 +71,7 @@ def feature_clone(full_name):
104
71
  click.echo(
105
72
  f"🔍 No version provided → fetching latest tag for {namespace}/{repo}..."
106
73
  )
107
- version = _get_latest_tag(namespace, repo)
74
+ version = registry.latest_semver_tag(namespace, repo)
108
75
  if not version:
109
76
  click.secho(
110
77
  f"❌ Could not fetch tags for {namespace}/{repo}. Is the repo reachable and does it have tags?",
@@ -90,6 +90,9 @@ def _read_current_contract(pyproject_path: Path) -> dict:
90
90
  raw = data.get("tool", {}).get("splent", {}).get("contract", {})
91
91
  ext = raw.get("extensible", {})
92
92
  return {
93
+ "archetype": raw.get("archetype"),
94
+ "category": raw.get("category"),
95
+ "tags": raw.get("tags", []),
93
96
  "routes": raw.get("provides", {}).get("routes", []),
94
97
  "blueprints": raw.get("provides", {}).get("blueprints", []),
95
98
  "models": raw.get("provides", {}).get("models", []),
@@ -100,6 +103,12 @@ def _read_current_contract(pyproject_path: Path) -> dict:
100
103
  "translations": raw.get("provides", {}).get("translations", []),
101
104
  "docker": raw.get("provides", {}).get("docker", []),
102
105
  "requires_features": raw.get("requires", {}).get("features", []),
106
+ "requires_features_manual": raw.get("requires", {}).get(
107
+ "features_manual", []
108
+ ),
109
+ "requires_features_optional": raw.get("requires", {}).get(
110
+ "features_optional", []
111
+ ),
103
112
  "env_vars": raw.get("requires", {}).get("env_vars", []),
104
113
  "requires_signals": raw.get("requires", {}).get("signals", []),
105
114
  "extensible_services": ext.get("services", []),
@@ -135,6 +144,10 @@ def _print_contract(contract: dict, feature_name: str) -> None:
135
144
  return "[" + ", ".join(f'"{i}"' for i in items) + "]"
136
145
 
137
146
  click.echo()
147
+ if contract.get("archetype"):
148
+ click.echo(click.style(" [tool.splent.contract]", fg="bright_black"))
149
+ click.echo(f" archetype = \"{contract['archetype']}\"")
150
+ click.echo()
138
151
  click.echo(click.style(" [tool.splent.contract.provides]", fg="bright_black"))
139
152
  click.echo(f" routes = {_fmt(contract['routes'])}")
140
153
  click.echo(f" blueprints = {_fmt(contract['blueprints'])}")
@@ -148,6 +161,9 @@ def _print_contract(contract: dict, feature_name: str) -> None:
148
161
  click.echo()
149
162
  click.echo(click.style(" [tool.splent.contract.requires]", fg="bright_black"))
150
163
  click.echo(f" features = {_fmt(contract['requires_features'])}")
164
+ click.echo(
165
+ f" features_optional = {_fmt(contract.get('requires_features_optional', []))}"
166
+ )
151
167
  click.echo(f" env_vars = {_fmt(contract['env_vars'])}")
152
168
  click.echo(f" signals = {_fmt(contract.get('requires_signals', []))}")
153
169
  click.echo()
@@ -190,6 +206,7 @@ def _print_diff(current: dict, inferred: dict) -> bool:
190
206
  ("services", "services"),
191
207
  ("docker", "docker"),
192
208
  ("requires_features", "requires.features"),
209
+ ("requires_features_optional", "requires.features_optional"),
193
210
  ("env_vars", "requires.env_vars"),
194
211
  ("signals", "provides.signals"),
195
212
  ("translations", "provides.translations"),
@@ -205,6 +222,17 @@ def _print_diff(current: dict, inferred: dict) -> bool:
205
222
  ]
206
223
 
207
224
  diff_lines = []
225
+
226
+ # archetype is a scalar, not a list — compare it directly
227
+ old_arch = current.get("archetype")
228
+ new_arch = inferred.get("archetype")
229
+ if new_arch and old_arch != new_arch:
230
+ if old_arch:
231
+ diff_lines.append(
232
+ click.style(f" - archetype: {old_arch}", fg="red")
233
+ )
234
+ diff_lines.append(click.style(f" + archetype: {new_arch}", fg="green"))
235
+
208
236
  for key, label in FIELDS:
209
237
  diff_lines.extend(
210
238
  _diff_field(label, current.get(key, []), inferred.get(key, []))
@@ -268,6 +296,22 @@ def feature_contract(feature_ref, write):
268
296
  click.echo(" 🔍 Scanning source code…")
269
297
  inferred = infer_contract(str(cache_path), ns, name)
270
298
 
299
+ # Deps declared in requires.features_manual are preserved by
300
+ # write_contract; merge them here too so the dry-run diff shows exactly
301
+ # what --write would produce (including real drops of stale inferred deps).
302
+ current = _read_current_contract(pyproject_path)
303
+ manual = sorted(set(current.get("requires_features_manual", [])))
304
+ if manual:
305
+ inferred["requires_features"] = sorted(
306
+ set(inferred["requires_features"]) | set(manual)
307
+ )
308
+ click.echo(
309
+ click.style(
310
+ " Preserving requires.features_manual: ", fg="bright_black"
311
+ )
312
+ + ", ".join(manual)
313
+ )
314
+
271
315
  # Show inferred contract
272
316
  click.echo(click.style(" Inferred contract:", bold=True))
273
317
  _print_contract(inferred, name)
@@ -280,7 +324,6 @@ def feature_contract(feature_ref, write):
280
324
  inferred["docker_networks"] = dc.get("networks", [])
281
325
 
282
326
  # Compare with current
283
- current = _read_current_contract(pyproject_path)
284
327
  has_changes = _print_diff(current, inferred)
285
328
 
286
329
  if not has_changes:
@@ -0,0 +1,214 @@
1
+ """
2
+ feature:info — Show a feature's contract card without cloning anything.
3
+
4
+ Data comes from the marketplace index (built with `marketplace:index` or
5
+ fetched from SPLENT_INDEX_URL). When no index is available, falls back to
6
+ reading the feature's pyproject.toml straight from GitHub at its latest
7
+ released tag — always the real contract, never a hand-maintained copy.
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import tomllib
13
+
14
+ import click
15
+
16
+ from splent_cli.services import context, marketplace, registry
17
+ from splent_cli.utils.feature_utils import (
18
+ get_normalize_feature_name_in_splent_format,
19
+ load_product_features,
20
+ )
21
+
22
+
23
+ def _fetch_live_entry(org: str, repo: str, token: str | None) -> dict | None:
24
+ """Build an index-shaped entry for one feature straight from GitHub."""
25
+ version = registry.latest_semver_tag(org, repo, token)
26
+ if not version:
27
+ return None
28
+ text = registry.fetch_file(org, repo, "pyproject.toml", ref=version, token=token)
29
+ if text is None:
30
+ return None
31
+ try:
32
+ return marketplace.feature_entry_from_pyproject(
33
+ text, org=org, repo=repo, source="github", version=version
34
+ )
35
+ except tomllib.TOMLDecodeError:
36
+ return None
37
+
38
+
39
+ def _product_feature_shorts() -> set[str]:
40
+ """Short names of the features in the active product ('' set when detached)."""
41
+ product = context.active_app()
42
+ if not product:
43
+ return set()
44
+ try:
45
+ entries = load_product_features(
46
+ os.path.join(str(context.workspace()), product), os.getenv("SPLENT_ENV")
47
+ )
48
+ except (FileNotFoundError, SystemExit):
49
+ return set()
50
+ return {
51
+ e.split("@")[0].split("/")[-1].removeprefix("splent_feature_")
52
+ for e in entries
53
+ }
54
+
55
+
56
+ def _print_list(label: str, items: list, color: str | None = None) -> None:
57
+ if not items:
58
+ return
59
+ click.echo(f" {label:<14}" + click.style(", ".join(items), fg=color))
60
+
61
+
62
+ @click.command(
63
+ "feature:info",
64
+ short_help="Show a feature's contract, dependencies and usage without cloning it.",
65
+ )
66
+ @click.argument("feature_ref")
67
+ @click.option("--refresh", is_flag=True, help="Re-fetch the index from SPLENT_INDEX_URL.")
68
+ @click.option("--json", "as_json", is_flag=True, help="Print the raw entry as JSON.")
69
+ def feature_info(feature_ref, refresh, as_json):
70
+ """
71
+ Show everything the marketplace knows about a feature.
72
+
73
+ \b
74
+ Examples:
75
+ splent feature:info auth
76
+ splent feature:info splent-io/splent_feature_events
77
+ splent feature:info media --json
78
+ """
79
+ workspace = str(context.workspace())
80
+ token = registry.github_token()
81
+
82
+ index, origin = marketplace.resolve_index(workspace, refresh=refresh)
83
+
84
+ entry = None
85
+ if index:
86
+ entry = marketplace.find_feature(index, feature_ref)
87
+
88
+ if entry is None:
89
+ # Live fallback: one feature, straight from GitHub.
90
+ if "/" in feature_ref:
91
+ org, _, repo = feature_ref.partition("/")
92
+ else:
93
+ org = os.getenv("SPLENT_DEFAULT_ORG", "splent-io")
94
+ repo = feature_ref
95
+ repo = get_normalize_feature_name_in_splent_format(repo.split("@")[0])
96
+ entry = _fetch_live_entry(org, repo, token)
97
+ origin = "github (live)"
98
+
99
+ if entry is None:
100
+ click.secho(f"❌ Feature '{feature_ref}' not found.", fg="red")
101
+ click.secho(
102
+ " Build an index with: splent marketplace:index "
103
+ "(or check the name/org and your network).",
104
+ fg="yellow",
105
+ )
106
+ raise SystemExit(1)
107
+
108
+ if as_json:
109
+ click.echo(json.dumps(entry, indent=2, ensure_ascii=False))
110
+ return
111
+
112
+ installed = _product_feature_shorts()
113
+ provides = entry.get("provides", {})
114
+ requires = entry.get("requires", {})
115
+
116
+ click.echo()
117
+ header = f" {entry['id']}"
118
+ if entry.get("version"):
119
+ header += click.style(f" {entry['version']}", fg="green")
120
+ if entry.get("archetype"):
121
+ header += click.style(f" [{entry['archetype']}]", fg="cyan")
122
+ if entry["short"] in installed:
123
+ header += click.style(" ● installed", fg="green")
124
+ click.echo(click.style(header, bold=True))
125
+ click.echo(click.style(f" {'─' * 60}", fg="bright_black"))
126
+
127
+ if entry.get("description"):
128
+ click.echo(f" {entry['description']}")
129
+ click.echo()
130
+
131
+ if entry.get("category"):
132
+ click.echo(f" {'category':<14}{entry['category']}")
133
+ _print_list("tags", entry.get("tags", []))
134
+ if entry.get("env"):
135
+ click.echo(
136
+ f" {'env':<14}" + click.style(f"{entry['env']} only", fg="yellow")
137
+ )
138
+
139
+ _print_list("models", provides.get("models", []))
140
+ _print_list("services", provides.get("services", []))
141
+ _print_list("routes", provides.get("routes", []))
142
+ _print_list("hooks", provides.get("hooks", []))
143
+ _print_list("signals", provides.get("signals", []))
144
+ _print_list("commands", provides.get("commands", []))
145
+ _print_list("translations", provides.get("translations", []))
146
+ if entry.get("docker"):
147
+ _print_list("docker", entry["docker"].get("services", []), color="cyan")
148
+
149
+ req_features = requires.get("features", [])
150
+ if req_features:
151
+ click.echo()
152
+ decorated = []
153
+ for dep in req_features:
154
+ if dep in installed:
155
+ decorated.append(dep + click.style(" ✔", fg="green"))
156
+ else:
157
+ decorated.append(dep + click.style(" ✗ missing", fg="yellow"))
158
+ click.echo(f" {'requires':<14}" + ", ".join(decorated))
159
+ _print_list("env vars", requires.get("env_vars", []), color="bright_black")
160
+ _print_list("needs signals", requires.get("signals", []), color="bright_black")
161
+
162
+ if index:
163
+ closure = marketplace.dependency_closure(index, entry["short"])
164
+ extra = sorted(set(closure) - set(req_features))
165
+ if extra:
166
+ _print_list("transitively", extra, color="bright_black")
167
+
168
+ if entry.get("used_by"):
169
+ click.echo()
170
+ _print_list("used by", entry["used_by"], color="cyan")
171
+
172
+ if entry.get("refinement"):
173
+ refines = entry["refinement"].get("refines")
174
+ if refines:
175
+ click.echo()
176
+ click.echo(
177
+ f" {'refines':<14}" + click.style(refines, fg="magenta")
178
+ )
179
+
180
+ pypi = entry.get("pypi")
181
+ if pypi is not None:
182
+ click.echo()
183
+ if pypi.get("published"):
184
+ label = f"published (latest {pypi.get('latest')})"
185
+ color = "green" if pypi.get("has_current") else "yellow"
186
+ if not pypi.get("has_current"):
187
+ label += " — current tag not on PyPI yet"
188
+ click.echo(f" {'pypi':<14}" + click.style(label, fg=color))
189
+ else:
190
+ click.echo(
191
+ f" {'pypi':<14}"
192
+ + click.style("not published (dev-only via GitHub)", fg="yellow")
193
+ )
194
+
195
+ gh = entry.get("github") or {}
196
+ if gh.get("url"):
197
+ click.echo(f" {'repo':<14}{gh['url']}")
198
+
199
+ click.echo()
200
+ click.echo(
201
+ click.style(f" source: {origin or entry.get('source', '?')}", fg="bright_black")
202
+ )
203
+ if entry["short"] not in installed and context.active_app():
204
+ click.echo()
205
+ click.echo(
206
+ " install with: "
207
+ + click.style(
208
+ f"splent feature:install {entry['org']}/{entry['repo']}", bold=True
209
+ )
210
+ )
211
+ click.echo()
212
+
213
+
214
+ cli_command = feature_info