bt-cli 0.4.39__tar.gz → 0.4.41__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 (207) hide show
  1. {bt_cli-0.4.39/src/bt_cli/data → bt_cli-0.4.41}/CLAUDE.md +13 -1
  2. {bt_cli-0.4.39 → bt_cli-0.4.41}/PKG-INFO +1 -1
  3. {bt_cli-0.4.39 → bt_cli-0.4.41}/pyproject.toml +1 -1
  4. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/__init__.py +1 -1
  5. bt_cli-0.4.41/src/bt_cli/core/prompts.py +165 -0
  6. {bt_cli-0.4.39 → bt_cli-0.4.41/src/bt_cli/data}/CLAUDE.md +13 -1
  7. {bt_cli-0.4.39/.claude → bt_cli-0.4.41/src/bt_cli/data}/skills/entitle/SKILL.md +49 -0
  8. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/entitle/client/base.py +44 -0
  9. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/entitle/commands/__init__.py +2 -1
  10. bt_cli-0.4.41/src/bt_cli/entitle/commands/requests.py +295 -0
  11. bt_cli-0.4.39/src/bt_cli/core/prompts.py +0 -87
  12. {bt_cli-0.4.39/src/bt_cli/data → bt_cli-0.4.41/.claude}/skills/bt/SKILL.md +0 -0
  13. {bt_cli-0.4.39/src/bt_cli/data → bt_cli-0.4.41/.claude}/skills/entitle/SKILL.md +0 -0
  14. {bt_cli-0.4.39/src/bt_cli/data → bt_cli-0.4.41/.claude}/skills/epml/SKILL.md +0 -0
  15. {bt_cli-0.4.39/src/bt_cli/data → bt_cli-0.4.41/.claude}/skills/epmw/SKILL.md +0 -0
  16. {bt_cli-0.4.39/src/bt_cli/data → bt_cli-0.4.41/.claude}/skills/pra/SKILL.md +0 -0
  17. {bt_cli-0.4.39/src/bt_cli/data → bt_cli-0.4.41/.claude}/skills/pws/SKILL.md +0 -0
  18. {bt_cli-0.4.39 → bt_cli-0.4.41}/.env.example +0 -0
  19. {bt_cli-0.4.39 → bt_cli-0.4.41}/.github/workflows/ci.yml +0 -0
  20. {bt_cli-0.4.39 → bt_cli-0.4.41}/.github/workflows/release.yml +0 -0
  21. {bt_cli-0.4.39 → bt_cli-0.4.41}/.gitignore +0 -0
  22. {bt_cli-0.4.39 → bt_cli-0.4.41}/README.md +0 -0
  23. {bt_cli-0.4.39 → bt_cli-0.4.41}/assets/cli-help.png +0 -0
  24. {bt_cli-0.4.39 → bt_cli-0.4.41}/assets/cli-output.png +0 -0
  25. {bt_cli-0.4.39 → bt_cli-0.4.41}/bt-cli.spec +0 -0
  26. {bt_cli-0.4.39 → bt_cli-0.4.41}/bt_entry.py +0 -0
  27. {bt_cli-0.4.39 → bt_cli-0.4.41}/epml-implementation-plan.md +0 -0
  28. {bt_cli-0.4.39 → bt_cli-0.4.41}/scripts/bt_entry.py +0 -0
  29. {bt_cli-0.4.39 → bt_cli-0.4.41}/scripts/sync-package-data.sh +0 -0
  30. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/cli.py +0 -0
  31. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/commands/__init__.py +0 -0
  32. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/commands/configure.py +0 -0
  33. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/commands/learn.py +0 -0
  34. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/commands/quick.py +0 -0
  35. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/core/__init__.py +0 -0
  36. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/core/auth.py +0 -0
  37. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/core/client.py +0 -0
  38. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/core/config.py +0 -0
  39. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/core/config_file.py +0 -0
  40. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/core/csv_utils.py +0 -0
  41. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/core/errors.py +0 -0
  42. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/core/output.py +0 -0
  43. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/core/rest_debug.py +0 -0
  44. {bt_cli-0.4.39/tests/pws → bt_cli-0.4.41/src/bt_cli/data}/__init__.py +0 -0
  45. {bt_cli-0.4.39/.claude → bt_cli-0.4.41/src/bt_cli/data}/skills/bt/SKILL.md +0 -0
  46. {bt_cli-0.4.39/.claude → bt_cli-0.4.41/src/bt_cli/data}/skills/epml/SKILL.md +0 -0
  47. {bt_cli-0.4.39/.claude → bt_cli-0.4.41/src/bt_cli/data}/skills/epmw/SKILL.md +0 -0
  48. {bt_cli-0.4.39/.claude → bt_cli-0.4.41/src/bt_cli/data}/skills/pra/SKILL.md +0 -0
  49. {bt_cli-0.4.39/.claude → bt_cli-0.4.41/src/bt_cli/data}/skills/pws/SKILL.md +0 -0
  50. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/entitle/__init__.py +0 -0
  51. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/entitle/client/__init__.py +0 -0
  52. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/entitle/commands/accounts.py +0 -0
  53. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/entitle/commands/applications.py +0 -0
  54. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/entitle/commands/auth.py +0 -0
  55. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/entitle/commands/bundles.py +0 -0
  56. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/entitle/commands/integrations.py +0 -0
  57. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/entitle/commands/permissions.py +0 -0
  58. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/entitle/commands/policies.py +0 -0
  59. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/entitle/commands/resources.py +0 -0
  60. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/entitle/commands/roles.py +0 -0
  61. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/entitle/commands/users.py +0 -0
  62. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/entitle/commands/workflows.py +0 -0
  63. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/entitle/models/__init__.py +0 -0
  64. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/entitle/models/bundle.py +0 -0
  65. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/entitle/models/common.py +0 -0
  66. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/entitle/models/integration.py +0 -0
  67. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/entitle/models/permission.py +0 -0
  68. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/entitle/models/policy.py +0 -0
  69. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/entitle/models/resource.py +0 -0
  70. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/entitle/models/role.py +0 -0
  71. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/entitle/models/user.py +0 -0
  72. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/entitle/models/workflow.py +0 -0
  73. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epml/__init__.py +0 -0
  74. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epml/client/__init__.py +0 -0
  75. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epml/client/base.py +0 -0
  76. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epml/commands/__init__.py +0 -0
  77. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epml/commands/audit.py +0 -0
  78. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epml/commands/auth.py +0 -0
  79. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epml/commands/client_pkg.py +0 -0
  80. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epml/commands/external_apis.py +0 -0
  81. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epml/commands/hosts.py +0 -0
  82. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epml/commands/iolog.py +0 -0
  83. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epml/commands/license.py +0 -0
  84. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epml/commands/quick.py +0 -0
  85. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epml/commands/rbp_cmdgrps.py +0 -0
  86. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epml/commands/rbp_entitlement.py +0 -0
  87. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epml/commands/rbp_hostgrps.py +0 -0
  88. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epml/commands/rbp_policy.py +0 -0
  89. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epml/commands/rbp_roles.py +0 -0
  90. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epml/commands/rbp_tests.py +0 -0
  91. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epml/commands/rbp_tmdategrps.py +0 -0
  92. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epml/commands/rbp_tx.py +0 -0
  93. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epml/commands/rbp_usergrps.py +0 -0
  94. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epml/commands/settings.py +0 -0
  95. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epml/commands/siems.py +0 -0
  96. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epml/commands/users.py +0 -0
  97. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epml/models/__init__.py +0 -0
  98. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epmw/__init__.py +0 -0
  99. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epmw/client/__init__.py +0 -0
  100. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epmw/client/base.py +0 -0
  101. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epmw/commands/__init__.py +0 -0
  102. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epmw/commands/audits.py +0 -0
  103. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epmw/commands/auth.py +0 -0
  104. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epmw/commands/computers.py +0 -0
  105. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epmw/commands/events.py +0 -0
  106. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epmw/commands/groups.py +0 -0
  107. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epmw/commands/policies.py +0 -0
  108. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epmw/commands/quick.py +0 -0
  109. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epmw/commands/requests.py +0 -0
  110. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epmw/commands/roles.py +0 -0
  111. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epmw/commands/tasks.py +0 -0
  112. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epmw/commands/users.py +0 -0
  113. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/epmw/models/__init__.py +0 -0
  114. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pra/__init__.py +0 -0
  115. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pra/client/__init__.py +0 -0
  116. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pra/client/base.py +0 -0
  117. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pra/commands/__init__.py +0 -0
  118. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pra/commands/auth.py +0 -0
  119. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pra/commands/import_export.py +0 -0
  120. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pra/commands/jump_clients.py +0 -0
  121. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pra/commands/jump_groups.py +0 -0
  122. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pra/commands/jump_items.py +0 -0
  123. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pra/commands/jumpoints.py +0 -0
  124. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pra/commands/policies.py +0 -0
  125. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pra/commands/quick.py +0 -0
  126. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pra/commands/teams.py +0 -0
  127. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pra/commands/users.py +0 -0
  128. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pra/commands/vault.py +0 -0
  129. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pra/models/__init__.py +0 -0
  130. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pra/models/common.py +0 -0
  131. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pra/models/jump_client.py +0 -0
  132. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pra/models/jump_group.py +0 -0
  133. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pra/models/jump_item.py +0 -0
  134. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pra/models/jumpoint.py +0 -0
  135. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pra/models/team.py +0 -0
  136. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pra/models/user.py +0 -0
  137. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pra/models/vault.py +0 -0
  138. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pws/__init__.py +0 -0
  139. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pws/client/__init__.py +0 -0
  140. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pws/client/base.py +0 -0
  141. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pws/client/beyondinsight.py +0 -0
  142. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pws/client/passwordsafe.py +0 -0
  143. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pws/commands/__init__.py +0 -0
  144. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pws/commands/accounts.py +0 -0
  145. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pws/commands/assets.py +0 -0
  146. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pws/commands/attributes.py +0 -0
  147. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pws/commands/auth.py +0 -0
  148. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pws/commands/clouds.py +0 -0
  149. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pws/commands/config.py +0 -0
  150. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pws/commands/credentials.py +0 -0
  151. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pws/commands/databases.py +0 -0
  152. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pws/commands/directories.py +0 -0
  153. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pws/commands/functional.py +0 -0
  154. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pws/commands/import_export.py +0 -0
  155. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pws/commands/platforms.py +0 -0
  156. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pws/commands/quick.py +0 -0
  157. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pws/commands/search.py +0 -0
  158. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pws/commands/secrets.py +0 -0
  159. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pws/commands/systems.py +0 -0
  160. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pws/commands/users.py +0 -0
  161. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pws/commands/workgroups.py +0 -0
  162. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pws/config.py +0 -0
  163. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pws/models/__init__.py +0 -0
  164. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pws/models/account.py +0 -0
  165. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pws/models/asset.py +0 -0
  166. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pws/models/common.py +0 -0
  167. {bt_cli-0.4.39 → bt_cli-0.4.41}/src/bt_cli/pws/models/system.py +0 -0
  168. {bt_cli-0.4.39 → bt_cli-0.4.41}/tests/__init__.py +0 -0
  169. {bt_cli-0.4.39 → bt_cli-0.4.41}/tests/conftest.py +0 -0
  170. {bt_cli-0.4.39 → bt_cli-0.4.41}/tests/core/__init__.py +0 -0
  171. {bt_cli-0.4.39 → bt_cli-0.4.41}/tests/core/test_auth.py +0 -0
  172. {bt_cli-0.4.39 → bt_cli-0.4.41}/tests/core/test_config.py +0 -0
  173. {bt_cli-0.4.39 → bt_cli-0.4.41}/tests/core/test_errors.py +0 -0
  174. {bt_cli-0.4.39 → bt_cli-0.4.41}/tests/core/test_rest_debug.py +0 -0
  175. {bt_cli-0.4.39 → bt_cli-0.4.41}/tests/entitle/__init__.py +0 -0
  176. {bt_cli-0.4.39 → bt_cli-0.4.41}/tests/entitle/test_client.py +0 -0
  177. {bt_cli-0.4.39 → bt_cli-0.4.41}/tests/entitle/test_commands.py +0 -0
  178. {bt_cli-0.4.39 → bt_cli-0.4.41}/tests/entitle-smoke-test.sh +0 -0
  179. {bt_cli-0.4.39 → bt_cli-0.4.41}/tests/epml/__init__.py +0 -0
  180. {bt_cli-0.4.39 → bt_cli-0.4.41}/tests/epml/test_client.py +0 -0
  181. {bt_cli-0.4.39 → bt_cli-0.4.41}/tests/epml/test_commands.py +0 -0
  182. {bt_cli-0.4.39 → bt_cli-0.4.41}/tests/epmw/__init__.py +0 -0
  183. {bt_cli-0.4.39 → bt_cli-0.4.41}/tests/epmw/test_client.py +0 -0
  184. {bt_cli-0.4.39 → bt_cli-0.4.41}/tests/epmw/test_commands.py +0 -0
  185. {bt_cli-0.4.39 → bt_cli-0.4.41}/tests/epmw-quick-test-plan.md +0 -0
  186. {bt_cli-0.4.39 → bt_cli-0.4.41}/tests/fixtures/__init__.py +0 -0
  187. {bt_cli-0.4.39 → bt_cli-0.4.41}/tests/fixtures/responses.py +0 -0
  188. {bt_cli-0.4.39 → bt_cli-0.4.41}/tests/integration/__init__.py +0 -0
  189. {bt_cli-0.4.39 → bt_cli-0.4.41}/tests/integration/conftest.py +0 -0
  190. {bt_cli-0.4.39 → bt_cli-0.4.41}/tests/integration/helpers.py +0 -0
  191. {bt_cli-0.4.39 → bt_cli-0.4.41}/tests/integration/test_entitle_integration.py +0 -0
  192. {bt_cli-0.4.39 → bt_cli-0.4.41}/tests/integration/test_epmw_integration.py +0 -0
  193. {bt_cli-0.4.39 → bt_cli-0.4.41}/tests/integration/test_epmw_lifecycle.py +0 -0
  194. {bt_cli-0.4.39 → bt_cli-0.4.41}/tests/integration/test_pra_integration.py +0 -0
  195. {bt_cli-0.4.39 → bt_cli-0.4.41}/tests/integration/test_pra_lifecycle.py +0 -0
  196. {bt_cli-0.4.39 → bt_cli-0.4.41}/tests/integration/test_pws_integration.py +0 -0
  197. {bt_cli-0.4.39 → bt_cli-0.4.41}/tests/integration/test_pws_lifecycle.py +0 -0
  198. {bt_cli-0.4.39 → bt_cli-0.4.41}/tests/pra/__init__.py +0 -0
  199. {bt_cli-0.4.39 → bt_cli-0.4.41}/tests/pra/test_client.py +0 -0
  200. {bt_cli-0.4.39 → bt_cli-0.4.41}/tests/pra/test_commands.py +0 -0
  201. {bt_cli-0.4.39 → bt_cli-0.4.41}/tests/pra-smoke-test.sh +0 -0
  202. {bt_cli-0.4.39 → bt_cli-0.4.41}/tests/pra-test-plan.md +0 -0
  203. {bt_cli-0.4.39/src/bt_cli/data → bt_cli-0.4.41/tests/pws}/__init__.py +0 -0
  204. {bt_cli-0.4.39 → bt_cli-0.4.41}/tests/pws/test_client.py +0 -0
  205. {bt_cli-0.4.39 → bt_cli-0.4.41}/tests/pws/test_commands.py +0 -0
  206. {bt_cli-0.4.39 → bt_cli-0.4.41}/tests/pws-quick-test-plan.md +0 -0
  207. {bt_cli-0.4.39 → bt_cli-0.4.41}/tests/pws-smoke-test.sh +0 -0
@@ -1,6 +1,6 @@
1
1
  # BT-CLI
2
2
 
3
- BeyondTrust Platform CLI for Password Safe, Entitle, PRA, EPM Windows, and EPM Linux. **Version: 0.4.39**
3
+ BeyondTrust Platform CLI for Password Safe, Entitle, PRA, EPM Windows, and EPM Linux. **Version: 0.4.41**
4
4
 
5
5
  ## Setup
6
6
 
@@ -10,6 +10,15 @@ source .venv/bin/activate && source .env
10
10
  bt whoami # Test all connections
11
11
  ```
12
12
 
13
+ Each product reads its own `BT_<PRODUCT>_*` env vars. EPM Linux is the odd one out — it uses a Personal Access Token (PAT) against the BeyondTrust public API gateway:
14
+
15
+ ```bash
16
+ export BT_EPML_API_URL=https://api.beyondtrust.io # default
17
+ export BT_EPML_SITE_ID=<site-uuid> # required
18
+ export BT_EPML_PAT=PAT_xxx # required (mint at app.beyondtrust.io)
19
+ export BT_EPML_DEFAULT_HOST=100 # default PMUL host id (optional)
20
+ ```
21
+
13
22
  ## Skills Available
14
23
 
15
24
  Use these slash commands for detailed product guidance:
@@ -72,6 +81,9 @@ PASSWORD=$(bt pws quick checkout -s server -a admin --raw)
72
81
  - Windows builds: Rich must be pinned to `<14.0.0` (Rich 14 has PyInstaller unicode issues)
73
82
  - **EPML URL transform**: spec paths start with `/api/...` but real URL is `https://api.beyondtrust.io/site/<site-id>/epm/linux/<spec-path-minus-/api/>`. Encapsulated in client `_build_url` — never bypass it.
74
83
  - **EPML two authorizers**: spec claims `nomine-authorizer` (PAT) globally but the gateway has IAM-only routes (return 403 with AWS-flavored body). PAT cannot reach those. Documented per-op in `src/bt_cli/data/skills/epml/SKILL.md`. Where `/v6/pbul/rbp/...` exists alongside legacy `/pbul/{hostid}/rbp/...`, prefer legacy — v6 is mostly IAM-only.
84
+ - **EPML body-shape gotchas**: role create needs `action ∈ {A,R}`; hostgrp/usergrp create needs `type ∈ {I,E}` (Internal=static / External=directory-resolved); role assignments need `--kind S|R|B` for hostgrps/usergrps (S=Submit user, R=Run-as user); collection deletes use `?id=N` query params (CLI loops); child collections (commands/hosts/users/tmdates) have **no per-item delete** — `clear` nukes all, use `replace` to keep some.
85
+ - **EPML role update is upsert-on-id (overwrite, not partial)**: hitting `POST /roles` with `{id, ...}` clears any field you don't include. The CLI's `roles update` does read-modify-write so it feels partial; child relations are filtered out of the merge so assignments survive. Direct `curl` users must send the full record.
86
+ - **EPML role banner format**: `bt epml rbp roles create --banner-text "Title"` builds a `############`-framed message using `%rbprole%`/`%event%` server substitutions, mirroring the appliance's existing roles. See the worked role example in the EPM-L SKILL.md.
75
87
 
76
88
  ## Functional vs Managed Accounts
77
89
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bt-cli
3
- Version: 0.4.39
3
+ Version: 0.4.41
4
4
  Summary: BeyondTrust Platform CLI (unofficial) - Password Safe, Entitle, PRA, EPM
5
5
  Author-email: Dave Grendysz <dgrendysz@beyondtrust.com>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "bt-cli"
7
- version = "0.4.39"
7
+ version = "0.4.41"
8
8
  description = "BeyondTrust Platform CLI (unofficial) - Password Safe, Entitle, PRA, EPM"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,3 +1,3 @@
1
1
  """BeyondTrust Unified Admin CLI."""
2
2
 
3
- __version__ = "0.4.39"
3
+ __version__ = "0.4.41"
@@ -0,0 +1,165 @@
1
+ """Reusable interactive prompts for CLI commands."""
2
+
3
+ from typing import Any, Callable, Optional, TypeVar
4
+
5
+ import typer
6
+ from rich.console import Console
7
+
8
+ console = Console()
9
+
10
+ T = TypeVar("T")
11
+ D = TypeVar("D")
12
+
13
+
14
+ def prompt_if_missing(
15
+ value: Optional[T],
16
+ prompt_text: str,
17
+ value_type: type = str,
18
+ ) -> T:
19
+ """Prompt for a value if it's missing.
20
+
21
+ Args:
22
+ value: Current value (may be None)
23
+ prompt_text: Text to show in prompt
24
+ value_type: Type for typer.prompt (str, int, float)
25
+
26
+ Returns:
27
+ The value (either original or prompted)
28
+ """
29
+ if value is None or value == "":
30
+ return typer.prompt(prompt_text, type=value_type)
31
+ return value
32
+
33
+
34
+ def prompt_from_list(
35
+ items: list[dict[str, Any]],
36
+ prompt_text: str,
37
+ id_key: str,
38
+ name_key: str,
39
+ title: str,
40
+ value_type: type = int,
41
+ ) -> Any:
42
+ """Show a list of items and prompt for selection.
43
+
44
+ Args:
45
+ items: List of dicts to display
46
+ prompt_text: Prompt text (e.g., "Workgroup ID")
47
+ id_key: Key for the ID field in each dict
48
+ name_key: Key for the display name field
49
+ title: Title to show above list
50
+ value_type: Type of the ID (int or str)
51
+
52
+ Returns:
53
+ The selected ID
54
+ """
55
+ console.print(f"\n[bold]{title}:[/bold]")
56
+ for item in items:
57
+ item_id = item.get(id_key, "")
58
+ item_name = item.get(name_key, "Unknown")
59
+ console.print(f" {item_id}: {item_name}")
60
+ return typer.prompt(prompt_text, type=value_type)
61
+
62
+
63
+ def prompt_choice(
64
+ prompt_text: str,
65
+ choices: list[tuple[str, str]],
66
+ default: Optional[str] = None,
67
+ ) -> str:
68
+ """Prompt for a choice from a list of options.
69
+
70
+ Args:
71
+ prompt_text: Text to show
72
+ choices: List of (value, description) tuples
73
+ default: Default value
74
+
75
+ Returns:
76
+ The selected value
77
+ """
78
+ console.print(f"\n[bold]{prompt_text}:[/bold]")
79
+ for value, desc in choices:
80
+ marker = " (default)" if value == default else ""
81
+ console.print(f" {value}: {desc}{marker}")
82
+
83
+ result = typer.prompt("Choice", default=default or choices[0][0])
84
+ valid_values = [v for v, _ in choices]
85
+ while result not in valid_values:
86
+ console.print(f"[red]Invalid choice. Options: {', '.join(valid_values)}[/red]")
87
+ result = typer.prompt("Choice", default=default or choices[0][0])
88
+ return result
89
+
90
+
91
+ def prompt_filtered_pick(
92
+ items: list[D],
93
+ label_fn: Callable[[D], str],
94
+ *,
95
+ title: str = "Select",
96
+ page_size: int = 20,
97
+ ) -> D:
98
+ """Paginated filter-then-pick selector.
99
+
100
+ User actions at the prompt:
101
+ - a number (1..N) picks the item shown on the current page
102
+ - any other text filters the list by case-insensitive substring of the label
103
+ - 'n' / 'p' moves to next / previous page
104
+ - empty input cancels (raises typer.Abort)
105
+
106
+ Args:
107
+ items: Source list to choose from
108
+ label_fn: Maps an item to the label shown in the list (also used for filtering)
109
+ title: Heading shown above the list
110
+ page_size: Items per page
111
+
112
+ Returns:
113
+ The chosen item from `items`.
114
+ """
115
+ if not items:
116
+ console.print(f"[yellow]No {title.lower()} available[/yellow]")
117
+ raise typer.Abort()
118
+
119
+ visible = list(items)
120
+ page = 0
121
+ while True:
122
+ total_pages = max(1, (len(visible) + page_size - 1) // page_size)
123
+ page = max(0, min(page, total_pages - 1))
124
+ start = page * page_size
125
+ chunk = visible[start : start + page_size]
126
+
127
+ match_count = len(visible)
128
+ suffix = f" — page {page + 1}/{total_pages}" if total_pages > 1 else ""
129
+ console.print(f"\n[bold]{title}[/bold] ({match_count} match{'es' if match_count != 1 else ''}{suffix}):")
130
+ for i, item in enumerate(chunk, start=1):
131
+ console.print(f" {i}. {label_fn(item)}")
132
+ console.print(
133
+ "[dim]Type a number to pick, text to filter, n/p for next/prev page, blank to cancel[/dim]"
134
+ )
135
+
136
+ ans = typer.prompt(">", default="", show_default=False).strip()
137
+ if ans == "":
138
+ raise typer.Abort()
139
+ low = ans.lower()
140
+ if low == "n":
141
+ if page + 1 < total_pages:
142
+ page += 1
143
+ else:
144
+ console.print("[yellow]Already on last page[/yellow]")
145
+ continue
146
+ if low == "p":
147
+ if page > 0:
148
+ page -= 1
149
+ else:
150
+ console.print("[yellow]Already on first page[/yellow]")
151
+ continue
152
+ if ans.isdigit():
153
+ idx = int(ans) - 1
154
+ if 0 <= idx < len(chunk):
155
+ return chunk[idx]
156
+ console.print(f"[red]Invalid number — must be 1..{len(chunk)}[/red]")
157
+ continue
158
+ # Filter against the full source list, not the previously filtered view
159
+ needle = low
160
+ filtered = [it for it in items if needle in label_fn(it).lower()]
161
+ if not filtered:
162
+ console.print("[yellow]No matches — keeping previous list[/yellow]")
163
+ continue
164
+ visible = filtered
165
+ page = 0
@@ -1,6 +1,6 @@
1
1
  # BT-CLI
2
2
 
3
- BeyondTrust Platform CLI for Password Safe, Entitle, PRA, EPM Windows, and EPM Linux. **Version: 0.4.39**
3
+ BeyondTrust Platform CLI for Password Safe, Entitle, PRA, EPM Windows, and EPM Linux. **Version: 0.4.41**
4
4
 
5
5
  ## Setup
6
6
 
@@ -10,6 +10,15 @@ source .venv/bin/activate && source .env
10
10
  bt whoami # Test all connections
11
11
  ```
12
12
 
13
+ Each product reads its own `BT_<PRODUCT>_*` env vars. EPM Linux is the odd one out — it uses a Personal Access Token (PAT) against the BeyondTrust public API gateway:
14
+
15
+ ```bash
16
+ export BT_EPML_API_URL=https://api.beyondtrust.io # default
17
+ export BT_EPML_SITE_ID=<site-uuid> # required
18
+ export BT_EPML_PAT=PAT_xxx # required (mint at app.beyondtrust.io)
19
+ export BT_EPML_DEFAULT_HOST=100 # default PMUL host id (optional)
20
+ ```
21
+
13
22
  ## Skills Available
14
23
 
15
24
  Use these slash commands for detailed product guidance:
@@ -72,6 +81,9 @@ PASSWORD=$(bt pws quick checkout -s server -a admin --raw)
72
81
  - Windows builds: Rich must be pinned to `<14.0.0` (Rich 14 has PyInstaller unicode issues)
73
82
  - **EPML URL transform**: spec paths start with `/api/...` but real URL is `https://api.beyondtrust.io/site/<site-id>/epm/linux/<spec-path-minus-/api/>`. Encapsulated in client `_build_url` — never bypass it.
74
83
  - **EPML two authorizers**: spec claims `nomine-authorizer` (PAT) globally but the gateway has IAM-only routes (return 403 with AWS-flavored body). PAT cannot reach those. Documented per-op in `src/bt_cli/data/skills/epml/SKILL.md`. Where `/v6/pbul/rbp/...` exists alongside legacy `/pbul/{hostid}/rbp/...`, prefer legacy — v6 is mostly IAM-only.
84
+ - **EPML body-shape gotchas**: role create needs `action ∈ {A,R}`; hostgrp/usergrp create needs `type ∈ {I,E}` (Internal=static / External=directory-resolved); role assignments need `--kind S|R|B` for hostgrps/usergrps (S=Submit user, R=Run-as user); collection deletes use `?id=N` query params (CLI loops); child collections (commands/hosts/users/tmdates) have **no per-item delete** — `clear` nukes all, use `replace` to keep some.
85
+ - **EPML role update is upsert-on-id (overwrite, not partial)**: hitting `POST /roles` with `{id, ...}` clears any field you don't include. The CLI's `roles update` does read-modify-write so it feels partial; child relations are filtered out of the merge so assignments survive. Direct `curl` users must send the full record.
86
+ - **EPML role banner format**: `bt epml rbp roles create --banner-text "Title"` builds a `############`-framed message using `%rbprole%`/`%event%` server substitutions, mirroring the appliance's existing roles. See the worked role example in the EPM-L SKILL.md.
75
87
 
76
88
  ## Functional vs Managed Accounts
77
89
 
@@ -115,6 +115,55 @@ bt entitle permissions revoke <permission_id>
115
115
  bt entitle accounts list --integration <integration_id>
116
116
  ```
117
117
 
118
+ ## Access Requests
119
+
120
+ Submit a JIT access request for a bundle or a single role. Hybrid UX — pass flags
121
+ for non-interactive use, or omit them to get a filter-then-pick menu at every step.
122
+
123
+ ```bash
124
+ # Fully interactive (asks: bundle/role -> target -> duration -> justification)
125
+ bt entitle requests create
126
+
127
+ # Non-interactive — bundle target
128
+ bt entitle requests create --bundle <bundle_id> --duration 3600 -j "On-call investigation"
129
+
130
+ # Non-interactive — role target
131
+ bt entitle requests create --role <role_id> -d 1800 -j "Reviewing prod logs"
132
+
133
+ # Look up a submitted request by id
134
+ bt entitle requests get <request_id>
135
+ ```
136
+
137
+ **Target shape (POST /accessRequests):**
138
+ ```json
139
+ {
140
+ "duration": 3600,
141
+ "justification": "...",
142
+ "target": {
143
+ "type": "bundle", // or "role"
144
+ "bundle": {"id": "<uuid>"} // or "role": {"id": "<uuid>"}
145
+ }
146
+ }
147
+ ```
148
+
149
+ **Duration menu** is curated 30-minute-aligned blocks (30m, 1h, 1h30m, 2h, 3h, 4h,
150
+ 8h, 12h, 1d, 2d, 3d, 7d). When the target is a bundle and that bundle has
151
+ `allowedDurations` set, the menu restricts to those values instead — bundles
152
+ enforce server-side.
153
+
154
+ **Picker UX:** at any selection prompt, type a number to pick, any text to filter
155
+ the list by substring, `n` / `p` to page, or blank to cancel.
156
+
157
+ **Gotchas:**
158
+ - The Entitle public API does **not** expose a list endpoint for access requests.
159
+ `bt entitle requests list` does not exist. Save the id printed by `requests
160
+ create` (or use the Entitle UI) to follow up on a request.
161
+ - A bogus or policy-blocked role returns the same 404 with message `Role with id
162
+ ... does not exist or unattainable.` — "unattainable" means the requester's
163
+ policy disallows it, not that the role is missing.
164
+ - The token used must have a user identity (personal API token). Pure read-only
165
+ org keys return `request.unauthenticated` on POST.
166
+
118
167
  ## Roles & Policies
119
168
 
120
169
  ```bash
@@ -433,6 +433,50 @@ class EntitleClient:
433
433
 
434
434
 
435
435
  # =========================================================================
436
+ # Access Requests
437
+ # =========================================================================
438
+ #
439
+ # Public API exposes:
440
+ # POST /accessRequests -- create
441
+ # GET /accessRequests/{id} -- show
442
+ # There is no list endpoint (verified: GET /accessRequests is 404). Callers
443
+ # who need to revisit a request must keep its id from the create response.
444
+
445
+ def create_access_request(
446
+ self,
447
+ target_type: str,
448
+ target_id: str,
449
+ duration: int,
450
+ justification: str,
451
+ ) -> dict[str, Any]:
452
+ """Create an access request for a bundle or role.
453
+
454
+ Args:
455
+ target_type: "bundle" or "role"
456
+ target_id: UUID of the bundle or role being requested
457
+ duration: Requested access duration in seconds (>= 1)
458
+ justification: Business justification (1..2048 chars)
459
+
460
+ Returns:
461
+ Created access request payload (includes the request id).
462
+ """
463
+ if target_type not in ("bundle", "role"):
464
+ raise ValueError(f"target_type must be 'bundle' or 'role', got {target_type!r}")
465
+ payload: dict[str, Any] = {
466
+ "duration": duration,
467
+ "justification": justification,
468
+ "target": {
469
+ "type": target_type,
470
+ target_type: {"id": target_id},
471
+ },
472
+ }
473
+ return self.post("/accessRequests", json=payload)
474
+
475
+ def get_access_request(self, request_id: str) -> dict[str, Any]:
476
+ """Get an access request by ID."""
477
+ return self.get(f"/accessRequests/{request_id}")
478
+
479
+
436
480
  def get_client() -> EntitleClient:
437
481
  """Create a configured Entitle client.
438
482
 
@@ -10,7 +10,7 @@ app = typer.Typer(
10
10
 
11
11
  # Import and register command groups
12
12
  from . import auth, integrations, resources, roles, bundles
13
- from . import workflows, users, permissions, policies, accounts
13
+ from . import workflows, users, permissions, policies, accounts, requests
14
14
 
15
15
  app.add_typer(auth.app, name="auth", help="Authentication commands")
16
16
  app.add_typer(integrations.app, name="integrations", help="Manage integrations")
@@ -22,3 +22,4 @@ app.add_typer(users.app, name="users", help="Manage users")
22
22
  app.add_typer(permissions.app, name="permissions", help="Manage permissions")
23
23
  app.add_typer(policies.app, name="policies", help="Manage policies")
24
24
  app.add_typer(accounts.app, name="accounts", help="Manage accounts")
25
+ app.add_typer(requests.app, name="requests", help="Manage access requests")
@@ -0,0 +1,295 @@
1
+ """Access request commands for Entitle.
2
+
3
+ Hybrid UX: pass --bundle or --role + --duration + --justification for a fully
4
+ non-interactive run, or omit any of those to drop into a filter-then-pick menu.
5
+ """
6
+
7
+ from typing import Any, Optional
8
+
9
+ import httpx
10
+ import typer
11
+
12
+ from ..client.base import EntitleClient, get_client
13
+ from ...core.output import console, print_json, print_success, print_api_error
14
+ from ...core.prompts import prompt_choice, prompt_filtered_pick
15
+
16
+ app = typer.Typer(no_args_is_help=True, help="Manage access requests")
17
+
18
+
19
+ # Curated 30-min-aligned duration menu (seconds)
20
+ DURATION_BLOCKS_SEC: list[int] = [
21
+ 1800, # 30m
22
+ 3600, # 1h
23
+ 5400, # 1h30m
24
+ 7200, # 2h
25
+ 10800, # 3h
26
+ 14400, # 4h
27
+ 28800, # 8h
28
+ 43200, # 12h
29
+ 86400, # 1d
30
+ 172800, # 2d
31
+ 259200, # 3d
32
+ 604800, # 7d
33
+ ]
34
+
35
+
36
+ def _fmt_duration(secs: int) -> str:
37
+ """Render a seconds value as a compact human label (e.g. 1h30m, 2d)."""
38
+ if secs <= 0:
39
+ return f"{secs}s"
40
+ days, rem = divmod(secs, 86400)
41
+ hours, rem = divmod(rem, 3600)
42
+ mins = rem // 60
43
+ parts: list[str] = []
44
+ if days:
45
+ parts.append(f"{days}d")
46
+ if hours:
47
+ parts.append(f"{hours}h")
48
+ if mins:
49
+ parts.append(f"{mins}m")
50
+ return "".join(parts) or f"{secs}s"
51
+
52
+
53
+ def _pick_target_interactive(client: EntitleClient) -> tuple[str, str, str]:
54
+ """Walk the user through choosing a bundle or a role.
55
+
56
+ Returns (target_type, target_id, human_label).
57
+ """
58
+ target_type = prompt_choice(
59
+ "Target type",
60
+ [
61
+ ("role", "A single role on a resource (drill down: integration → resource → role)"),
62
+ ("bundle", "An access bundle (a curated group of roles)"),
63
+ ],
64
+ default="role",
65
+ )
66
+
67
+ if target_type == "bundle":
68
+ bundles = client.list_bundles()
69
+ chosen = prompt_filtered_pick(
70
+ bundles,
71
+ lambda b: f"{b.get('name', '?')} [dim]{b.get('id', '')}[/dim]",
72
+ title="Pick a bundle",
73
+ )
74
+ return "bundle", chosen["id"], chosen.get("name") or chosen["id"]
75
+
76
+ integrations = client.list_integrations()
77
+ integration = prompt_filtered_pick(
78
+ integrations,
79
+ lambda x: f"{x.get('name', '?')} [dim]({(x.get('application') or {}).get('name', '-')})[/dim]",
80
+ title="Pick an integration",
81
+ )
82
+ resources = client.list_resources(integration_id=integration["id"])
83
+ resource = prompt_filtered_pick(
84
+ resources,
85
+ lambda x: x.get("name", "?"),
86
+ title="Pick a resource",
87
+ )
88
+ roles = client.list_roles(resource_id=resource["id"])
89
+ role = prompt_filtered_pick(
90
+ roles,
91
+ lambda x: x.get("name", "?"),
92
+ title="Pick a role",
93
+ )
94
+ label = f"{integration.get('name', '?')} / {resource.get('name', '?')} / {role.get('name', '?')}"
95
+ return "role", role["id"], label
96
+
97
+
98
+ def _pick_duration_interactive(
99
+ client: EntitleClient, target_type: str, target_id: str
100
+ ) -> int:
101
+ """Show a duration menu. For bundles with allowedDurations, restrict to those."""
102
+ options: list[int] = []
103
+
104
+ if target_type == "bundle":
105
+ try:
106
+ response = client.get_bundle(target_id)
107
+ bundle = response.get("result", response)
108
+ allowed = bundle.get("allowedDurations") or []
109
+ options = [int(d) for d in allowed if int(d) > 0]
110
+ except Exception:
111
+ # If the lookup fails for any reason, fall back to curated blocks
112
+ options = []
113
+
114
+ if not options:
115
+ options = list(DURATION_BLOCKS_SEC)
116
+
117
+ options.sort()
118
+ chosen = prompt_filtered_pick(
119
+ options,
120
+ lambda secs: f"{_fmt_duration(secs)} [dim]({secs}s)[/dim]",
121
+ title="Pick a duration",
122
+ )
123
+ return chosen
124
+
125
+
126
+ def _prompt_justification() -> str:
127
+ while True:
128
+ text = typer.prompt("Justification (business reason)").strip()
129
+ if 1 <= len(text) <= 2048:
130
+ return text
131
+ console.print("[red]Justification must be 1..2048 characters[/red]")
132
+
133
+
134
+ @app.command("create")
135
+ def create_access_request(
136
+ bundle: Optional[str] = typer.Option(None, "--bundle", "-b", help="Bundle ID to request"),
137
+ role: Optional[str] = typer.Option(None, "--role", "-r", help="Role ID to request"),
138
+ duration: Optional[int] = typer.Option(
139
+ None,
140
+ "--duration",
141
+ "-d",
142
+ help="Access duration in seconds (interactive picker uses 30-min blocks)",
143
+ ),
144
+ justification: Optional[str] = typer.Option(
145
+ None, "--justification", "-j", help="Business justification (1..2048 chars)"
146
+ ),
147
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the pre-submit confirmation"),
148
+ output: str = typer.Option("table", "--output", "-o", help="Output format: table, json"),
149
+ ) -> None:
150
+ """Create a new access request.
151
+
152
+ Pass --bundle OR --role plus --duration and --justification for non-interactive
153
+ use. Omit any of them to drop into a filter-then-pick menu.
154
+ """
155
+ if bundle and role:
156
+ console.print("[red]Pass either --bundle or --role, not both[/red]")
157
+ raise typer.Exit(1)
158
+
159
+ try:
160
+ with get_client() as client:
161
+ # Resolve target
162
+ if bundle:
163
+ target_type, target_id, target_label = "bundle", bundle, bundle
164
+ elif role:
165
+ target_type, target_id, target_label = "role", role, role
166
+ else:
167
+ target_type, target_id, target_label = _pick_target_interactive(client)
168
+
169
+ # Resolve duration
170
+ if duration is None:
171
+ duration = _pick_duration_interactive(client, target_type, target_id)
172
+ elif duration < 1:
173
+ console.print("[red]--duration must be >= 1 second[/red]")
174
+ raise typer.Exit(1)
175
+
176
+ # Resolve justification
177
+ if justification is None:
178
+ justification = _prompt_justification()
179
+ else:
180
+ if not (1 <= len(justification) <= 2048):
181
+ console.print("[red]--justification must be 1..2048 characters[/red]")
182
+ raise typer.Exit(1)
183
+
184
+ # Pre-submit summary
185
+ console.print("\n[bold]Access request summary[/bold]")
186
+ console.print(f" Target type : {target_type}")
187
+ console.print(f" Target : {target_label}")
188
+ console.print(f" Target ID : {target_id}")
189
+ console.print(f" Duration : {_fmt_duration(duration)} ({duration}s)")
190
+ console.print(f" Justification : {justification}")
191
+
192
+ if not yes:
193
+ typer.confirm("\nSubmit this request?", default=True, abort=True)
194
+
195
+ result = client.create_access_request(
196
+ target_type=target_type,
197
+ target_id=target_id,
198
+ duration=duration,
199
+ justification=justification,
200
+ )
201
+
202
+ request_id = (
203
+ (result.get("result") or {}).get("id")
204
+ if isinstance(result, dict) and "result" in result
205
+ else (result.get("id") if isinstance(result, dict) else None)
206
+ )
207
+ print_success("Access request submitted")
208
+ if request_id:
209
+ console.print(f"[dim]Follow up with:[/dim] bt entitle requests get {request_id}")
210
+
211
+ if output == "json":
212
+ print_json(result)
213
+ else:
214
+ print_json(result) # response shape isn't documented; show full payload
215
+ except typer.Abort:
216
+ raise
217
+ except httpx.HTTPStatusError as e:
218
+ print_api_error(e, "create access request")
219
+ raise typer.Exit(1)
220
+ except httpx.RequestError as e:
221
+ print_api_error(e, "create access request")
222
+ raise typer.Exit(1)
223
+ except Exception as e:
224
+ print_api_error(e, "create access request")
225
+ raise typer.Exit(1)
226
+
227
+
228
+ @app.command("get")
229
+ def get_access_request(
230
+ request_id: str = typer.Argument(..., help="Access request ID"),
231
+ output: str = typer.Option("table", "--output", "-o", help="Output format: table, json"),
232
+ ) -> None:
233
+ """Get an access request by ID."""
234
+ try:
235
+ with get_client() as client:
236
+ response = client.get_access_request(request_id)
237
+
238
+ data: Any = response.get("result", response) if isinstance(response, dict) else response
239
+
240
+ if output == "json":
241
+ print_json(response)
242
+ return
243
+
244
+ from rich.panel import Panel
245
+
246
+ if not isinstance(data, dict):
247
+ print_json(response)
248
+ return
249
+
250
+ # Build a friendly summary line for each target. Role targets join in via
251
+ # `roles[]` (with nested resource + integration); bundle targets via
252
+ # `bundles[]`. Fall back to whatever's inside `targets[]` itself.
253
+ target_lines: list[str] = []
254
+ roles_by_id = {r.get("id"): r for r in (data.get("roles") or []) if r.get("id")}
255
+ bundles_by_id = {b.get("id"): b for b in (data.get("bundles") or []) if b.get("id")}
256
+ for t in (data.get("targets") or []):
257
+ t_type = t.get("type") or "?"
258
+ inner = t.get(t_type) or {}
259
+ inner_id = inner.get("id")
260
+ if t_type == "role" and inner_id in roles_by_id:
261
+ role = roles_by_id[inner_id]
262
+ resource = role.get("resource") or {}
263
+ integration = resource.get("integration") or {}
264
+ target_lines.append(
265
+ f"role: {role.get('name', '?')} on {resource.get('name', '?')} ({integration.get('name', '?')})"
266
+ )
267
+ elif t_type == "bundle" and inner_id in bundles_by_id:
268
+ target_lines.append(f"bundle: {bundles_by_id[inner_id].get('name', '?')}")
269
+ else:
270
+ target_lines.append(f"{t_type}: {inner.get('name') or inner_id or '?'}")
271
+ targets_str = "\n ".join(target_lines) if target_lines else "-"
272
+
273
+ user = data.get("user") or data.get("behalfOf") or {}
274
+ requester = user.get("email") or user.get("id") or "-"
275
+ number = data.get("number")
276
+ id_line = f"{data.get('id', '-')}" + (f" [dim](#{number})[/dim]" if number else "")
277
+
278
+ body = (
279
+ f"[dim]ID:[/dim] {id_line}\n"
280
+ f"[dim]Status:[/dim] {data.get('status', '-')}\n"
281
+ f"[dim]Requester:[/dim] {requester}\n"
282
+ f"[dim]Duration:[/dim] {_fmt_duration(int(data.get('duration', 0))) if data.get('duration') else '-'}\n"
283
+ f"[dim]Targets:[/dim] {targets_str}\n"
284
+ f"[dim]Justification:[/dim] {data.get('justification', '-')}"
285
+ )
286
+ console.print(Panel(body, title="Access Request"))
287
+ except httpx.HTTPStatusError as e:
288
+ print_api_error(e, "get access request")
289
+ raise typer.Exit(1)
290
+ except httpx.RequestError as e:
291
+ print_api_error(e, "get access request")
292
+ raise typer.Exit(1)
293
+ except Exception as e:
294
+ print_api_error(e, "get access request")
295
+ raise typer.Exit(1)