bt-cli 0.4.64__tar.gz → 0.4.65__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 (247) hide show
  1. {bt_cli-0.4.64 → bt_cli-0.4.65}/CLAUDE.md +2 -2
  2. {bt_cli-0.4.64 → bt_cli-0.4.65}/PKG-INFO +1 -1
  3. {bt_cli-0.4.64 → bt_cli-0.4.65}/pyproject.toml +1 -1
  4. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/__init__.py +1 -1
  5. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/data/skills/pws/SKILL.md +20 -11
  6. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pws/commands/sessions.py +55 -17
  7. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/pws/test_commands.py +42 -2
  8. {bt_cli-0.4.64 → bt_cli-0.4.65}/.claude/skills/bt/SKILL.md +0 -0
  9. {bt_cli-0.4.64 → bt_cli-0.4.65}/.claude/skills/entitle/SKILL.md +0 -0
  10. {bt_cli-0.4.64 → bt_cli-0.4.65}/.claude/skills/epml/SKILL.md +0 -0
  11. {bt_cli-0.4.64 → bt_cli-0.4.65}/.claude/skills/epmw/SKILL.md +0 -0
  12. {bt_cli-0.4.64 → bt_cli-0.4.65}/.claude/skills/pra/SKILL.md +0 -0
  13. {bt_cli-0.4.64 → bt_cli-0.4.65}/.claude/skills/pws/SKILL.md +0 -0
  14. {bt_cli-0.4.64 → bt_cli-0.4.65}/.env.example +0 -0
  15. {bt_cli-0.4.64 → bt_cli-0.4.65}/.github/workflows/ci.yml +0 -0
  16. {bt_cli-0.4.64 → bt_cli-0.4.65}/.github/workflows/release.yml +0 -0
  17. {bt_cli-0.4.64 → bt_cli-0.4.65}/.gitignore +0 -0
  18. {bt_cli-0.4.64 → bt_cli-0.4.65}/README.md +0 -0
  19. {bt_cli-0.4.64 → bt_cli-0.4.65}/assets/cli-help.png +0 -0
  20. {bt_cli-0.4.64 → bt_cli-0.4.65}/assets/cli-output.png +0 -0
  21. {bt_cli-0.4.64 → bt_cli-0.4.65}/bt-cli.spec +0 -0
  22. {bt_cli-0.4.64 → bt_cli-0.4.65}/bt_entry.py +0 -0
  23. {bt_cli-0.4.64 → bt_cli-0.4.65}/epml-clients-server-side-filters-plan.md +0 -0
  24. {bt_cli-0.4.64 → bt_cli-0.4.65}/epml-implementation-plan.md +0 -0
  25. {bt_cli-0.4.64 → bt_cli-0.4.65}/pf-implementation-plan.md +0 -0
  26. {bt_cli-0.4.64 → bt_cli-0.4.65}/scripts/bt_entry.py +0 -0
  27. {bt_cli-0.4.64 → bt_cli-0.4.65}/scripts/pf_onboard.py +0 -0
  28. {bt_cli-0.4.64 → bt_cli-0.4.65}/scripts/sync-package-data.sh +0 -0
  29. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/cli.py +0 -0
  30. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/commands/__init__.py +0 -0
  31. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/commands/configure.py +0 -0
  32. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/commands/learn.py +0 -0
  33. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/commands/quick.py +0 -0
  34. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/core/__init__.py +0 -0
  35. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/core/auth.py +0 -0
  36. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/core/client.py +0 -0
  37. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/core/config.py +0 -0
  38. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/core/config_file.py +0 -0
  39. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/core/csv_utils.py +0 -0
  40. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/core/errors.py +0 -0
  41. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/core/output.py +0 -0
  42. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/core/prompts.py +0 -0
  43. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/core/rest_debug.py +0 -0
  44. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/data/CLAUDE.md +0 -0
  45. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/data/__init__.py +0 -0
  46. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/data/skills/bt/SKILL.md +0 -0
  47. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/data/skills/entitle/SKILL.md +0 -0
  48. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/data/skills/epml/SKILL.md +0 -0
  49. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/data/skills/epmw/SKILL.md +0 -0
  50. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/data/skills/pf/SKILL.md +0 -0
  51. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/data/skills/pra/SKILL.md +0 -0
  52. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/data/skills/secrets/SKILL.md +0 -0
  53. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/entitle/__init__.py +0 -0
  54. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/entitle/client/__init__.py +0 -0
  55. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/entitle/client/base.py +0 -0
  56. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/entitle/commands/__init__.py +0 -0
  57. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/entitle/commands/accounts.py +0 -0
  58. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/entitle/commands/applications.py +0 -0
  59. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/entitle/commands/auth.py +0 -0
  60. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/entitle/commands/bundles.py +0 -0
  61. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/entitle/commands/integrations.py +0 -0
  62. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/entitle/commands/permissions.py +0 -0
  63. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/entitle/commands/policies.py +0 -0
  64. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/entitle/commands/requests.py +0 -0
  65. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/entitle/commands/resources.py +0 -0
  66. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/entitle/commands/roles.py +0 -0
  67. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/entitle/commands/users.py +0 -0
  68. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/entitle/commands/workflows.py +0 -0
  69. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/entitle/models/__init__.py +0 -0
  70. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/entitle/models/bundle.py +0 -0
  71. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/entitle/models/common.py +0 -0
  72. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/entitle/models/integration.py +0 -0
  73. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/entitle/models/permission.py +0 -0
  74. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/entitle/models/policy.py +0 -0
  75. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/entitle/models/resource.py +0 -0
  76. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/entitle/models/role.py +0 -0
  77. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/entitle/models/user.py +0 -0
  78. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/entitle/models/workflow.py +0 -0
  79. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epml/__init__.py +0 -0
  80. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epml/client/__init__.py +0 -0
  81. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epml/client/base.py +0 -0
  82. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epml/commands/__init__.py +0 -0
  83. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epml/commands/audit.py +0 -0
  84. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epml/commands/auth.py +0 -0
  85. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epml/commands/client_pkg.py +0 -0
  86. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epml/commands/clients.py +0 -0
  87. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epml/commands/external_apis.py +0 -0
  88. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epml/commands/hosts.py +0 -0
  89. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epml/commands/iolog.py +0 -0
  90. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epml/commands/license.py +0 -0
  91. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epml/commands/quick.py +0 -0
  92. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epml/commands/rbp_cmdgrps.py +0 -0
  93. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epml/commands/rbp_entitlement.py +0 -0
  94. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epml/commands/rbp_hostgrps.py +0 -0
  95. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epml/commands/rbp_policy.py +0 -0
  96. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epml/commands/rbp_roles.py +0 -0
  97. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epml/commands/rbp_tests.py +0 -0
  98. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epml/commands/rbp_tmdategrps.py +0 -0
  99. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epml/commands/rbp_tx.py +0 -0
  100. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epml/commands/rbp_usergrps.py +0 -0
  101. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epml/commands/settings.py +0 -0
  102. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epml/commands/siems.py +0 -0
  103. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epml/commands/users.py +0 -0
  104. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epml/models/__init__.py +0 -0
  105. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epmw/__init__.py +0 -0
  106. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epmw/client/__init__.py +0 -0
  107. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epmw/client/base.py +0 -0
  108. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epmw/commands/__init__.py +0 -0
  109. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epmw/commands/audits.py +0 -0
  110. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epmw/commands/auth.py +0 -0
  111. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epmw/commands/computers.py +0 -0
  112. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epmw/commands/events.py +0 -0
  113. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epmw/commands/groups.py +0 -0
  114. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epmw/commands/policies.py +0 -0
  115. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epmw/commands/quick.py +0 -0
  116. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epmw/commands/requests.py +0 -0
  117. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epmw/commands/roles.py +0 -0
  118. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epmw/commands/tasks.py +0 -0
  119. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epmw/commands/users.py +0 -0
  120. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/epmw/models/__init__.py +0 -0
  121. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pf/__init__.py +0 -0
  122. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pf/client/__init__.py +0 -0
  123. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pf/client/base.py +0 -0
  124. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pf/commands/__init__.py +0 -0
  125. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pf/commands/auth.py +0 -0
  126. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pf/commands/machines.py +0 -0
  127. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pf/commands/tokens.py +0 -0
  128. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pf/commands/user.py +0 -0
  129. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pf/commands/users.py +0 -0
  130. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pra/__init__.py +0 -0
  131. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pra/client/__init__.py +0 -0
  132. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pra/client/base.py +0 -0
  133. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pra/commands/__init__.py +0 -0
  134. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pra/commands/auth.py +0 -0
  135. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pra/commands/group_policies.py +0 -0
  136. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pra/commands/import_export.py +0 -0
  137. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pra/commands/jump_clients.py +0 -0
  138. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pra/commands/jump_groups.py +0 -0
  139. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pra/commands/jump_items.py +0 -0
  140. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pra/commands/jumpoints.py +0 -0
  141. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pra/commands/policies.py +0 -0
  142. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pra/commands/quick.py +0 -0
  143. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pra/commands/teams.py +0 -0
  144. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pra/commands/users.py +0 -0
  145. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pra/commands/vault.py +0 -0
  146. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pra/models/__init__.py +0 -0
  147. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pra/models/common.py +0 -0
  148. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pra/models/group_policy.py +0 -0
  149. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pra/models/jump_client.py +0 -0
  150. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pra/models/jump_group.py +0 -0
  151. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pra/models/jump_item.py +0 -0
  152. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pra/models/jumpoint.py +0 -0
  153. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pra/models/team.py +0 -0
  154. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pra/models/user.py +0 -0
  155. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pra/models/vault.py +0 -0
  156. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pws/__init__.py +0 -0
  157. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pws/client/__init__.py +0 -0
  158. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pws/client/base.py +0 -0
  159. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pws/client/beyondinsight.py +0 -0
  160. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pws/client/passwordsafe.py +0 -0
  161. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pws/commands/__init__.py +0 -0
  162. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pws/commands/accounts.py +0 -0
  163. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pws/commands/assets.py +0 -0
  164. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pws/commands/attributes.py +0 -0
  165. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pws/commands/audit.py +0 -0
  166. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pws/commands/auth.py +0 -0
  167. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pws/commands/clouds.py +0 -0
  168. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pws/commands/config.py +0 -0
  169. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pws/commands/credentials.py +0 -0
  170. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pws/commands/databases.py +0 -0
  171. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pws/commands/directories.py +0 -0
  172. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pws/commands/functional.py +0 -0
  173. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pws/commands/import_export.py +0 -0
  174. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pws/commands/platforms.py +0 -0
  175. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pws/commands/quick.py +0 -0
  176. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pws/commands/search.py +0 -0
  177. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pws/commands/secrets.py +0 -0
  178. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pws/commands/systems.py +0 -0
  179. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pws/commands/users.py +0 -0
  180. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pws/commands/workgroups.py +0 -0
  181. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pws/config.py +0 -0
  182. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pws/models/__init__.py +0 -0
  183. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pws/models/account.py +0 -0
  184. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pws/models/asset.py +0 -0
  185. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pws/models/common.py +0 -0
  186. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/pws/models/system.py +0 -0
  187. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/secrets/__init__.py +0 -0
  188. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/secrets/client/__init__.py +0 -0
  189. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/secrets/client/base.py +0 -0
  190. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/secrets/commands/__init__.py +0 -0
  191. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/secrets/commands/_hints.py +0 -0
  192. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/secrets/commands/auth.py +0 -0
  193. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/secrets/commands/dynamic.py +0 -0
  194. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/secrets/commands/folders.py +0 -0
  195. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/secrets/commands/integrations.py +0 -0
  196. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/secrets/commands/leases.py +0 -0
  197. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/secrets/commands/static.py +0 -0
  198. {bt_cli-0.4.64 → bt_cli-0.4.65}/src/bt_cli/secrets/models/__init__.py +0 -0
  199. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/__init__.py +0 -0
  200. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/conftest.py +0 -0
  201. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/core/__init__.py +0 -0
  202. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/core/test_auth.py +0 -0
  203. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/core/test_config.py +0 -0
  204. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/core/test_config_file.py +0 -0
  205. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/core/test_errors.py +0 -0
  206. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/core/test_output.py +0 -0
  207. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/core/test_rest_debug.py +0 -0
  208. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/entitle/__init__.py +0 -0
  209. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/entitle/test_client.py +0 -0
  210. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/entitle/test_commands.py +0 -0
  211. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/entitle-smoke-test.sh +0 -0
  212. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/epml/__init__.py +0 -0
  213. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/epml/test_client.py +0 -0
  214. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/epml/test_commands.py +0 -0
  215. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/epmw/__init__.py +0 -0
  216. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/epmw/test_client.py +0 -0
  217. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/epmw/test_commands.py +0 -0
  218. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/epmw-quick-test-plan.md +0 -0
  219. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/fixtures/__init__.py +0 -0
  220. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/fixtures/responses.py +0 -0
  221. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/integration/__init__.py +0 -0
  222. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/integration/conftest.py +0 -0
  223. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/integration/helpers.py +0 -0
  224. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/integration/test_entitle_integration.py +0 -0
  225. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/integration/test_epmw_integration.py +0 -0
  226. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/integration/test_epmw_lifecycle.py +0 -0
  227. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/integration/test_pra_integration.py +0 -0
  228. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/integration/test_pra_lifecycle.py +0 -0
  229. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/integration/test_pws_integration.py +0 -0
  230. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/integration/test_pws_lifecycle.py +0 -0
  231. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/pf/__init__.py +0 -0
  232. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/pf/test_client.py +0 -0
  233. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/pf/test_commands.py +0 -0
  234. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/pra/__init__.py +0 -0
  235. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/pra/test_client.py +0 -0
  236. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/pra/test_commands.py +0 -0
  237. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/pra/test_jump_item_fixes.py +0 -0
  238. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/pra-smoke-test.sh +0 -0
  239. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/pra-test-plan.md +0 -0
  240. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/pws/__init__.py +0 -0
  241. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/pws/test_client.py +0 -0
  242. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/pws/test_quick_sessions.py +0 -0
  243. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/pws-quick-test-plan.md +0 -0
  244. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/pws-smoke-test.sh +0 -0
  245. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/secrets/__init__.py +0 -0
  246. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/secrets/test_client.py +0 -0
  247. {bt_cli-0.4.64 → bt_cli-0.4.65}/tests/secrets/test_commands.py +0 -0
@@ -1,6 +1,6 @@
1
1
  # BT-CLI
2
2
 
3
- BeyondTrust Platform CLI for Password Safe, Entitle, PRA, EPM Windows, EPM Linux, and the BeyondTrust Secrets API. **Version: 0.4.64**
3
+ BeyondTrust Platform CLI for Password Safe, Entitle, PRA, EPM Windows, EPM Linux, and the BeyondTrust Secrets API. **Version: 0.4.65**
4
4
 
5
5
  ## Setup
6
6
 
@@ -88,7 +88,7 @@ PASSWORD=$(bt pws quick checkout -s server -a admin --raw)
88
88
  - **PWS `/Sessions` ignores `limit`/`offset`** and returns the full session *history* every call — never `paginate()` it (infinite loop); `list_sessions` does a single GET and `bt pws sessions list` caps display client-side. `Status` codes (live-confirmed): 0=Not started, 1=Active, 2=Completed, 5=Locked, 7=Terminated; `--active`=Status 1. Active sessions have `EndTime`==`StartTime` (placeholder, not null) — identify live sessions by Status, not timestamps. **`terminate` = `POST /Sessions/{id}/Terminate`** (symmetric w/ `/Lock`); `/Sessions/{id}` is GET/PUT only (DELETE → 405).
89
89
  - **PWS SSH/RDP session establishment = `POST /Requests/{id}/Sessions`** (`{"SessionType":"SSH"|"RDP"}`), NOT `POST /Sessions` (collection is GET-only → 405). Create the request with matching `AccessType` first. **SSH** returns a ready `Command` (`ssh -p 4422 <ticket>@<proxyhost>`) + `Link`; **RDP** returns `Host`/`Port`/`Ticket` only — the `.rdp` is built client-side (`full address` = proxy `Host:Port`, `username` = `Ticket`); there's no server-side launch file (`/Sessions/RDPFile` is audit-playback → 403). Ticket is the proxy username; password is proxy-injected. `bt pws quick checkout --access-type ssh|rdp` and `bt pws quick ssh`/`quick rdp` (with `--launch`); session commands do **not** auto-checkin.
90
90
  - **PWS session keystroke transcript = `GET /Sessions/{id}/Keystrokes`** → events `{KeystrokeID, TimeMarker (epoch ms), Type, Data}`. **Type 1 = user input** (typed keys; special keys as glyphs `⌫`/`→`/`↑`), **Type 2 = terminal output** (SSH stdout). SSH captures a full input+output transcript; **RDP is sparse** (typed keys / window events only, off by default → often empty). Surfaced as `bt pws sessions transcript <id>` (default = time-ordered readable transcript; `--input-only`; text/json/csv). Text trail, not the visual recording. (There is **no** `bt pws sessions export` — session *metadata* evidence is `bt pws audit export`.)
91
- - **PWS session recording download = headless replay under `/pbsm/replay`** (there is **no** video-export API — `?video/?mp4/?webm` return JSON, not media). Flow: `GET /Sessions/{id}` for `Token`+`RecordKey`+`Protocol` → `POST /pbsm/replay` `{id:Token, record_key, protocol:"RDP"|"SSH", headless:true}` → `PUT /pbsm/replay/{rid}` `{offset:<ms>}` (seek) → `GET /pbsm/replay/{rid}?png=<scale>` (one PNG per offset) → `DELETE /pbsm/replay/{rid}`. **Warm-up:** the first `PUT` 404s (`"no such file or directory"`) until the renderer loads — retry ~0.5s. The seek response is state `{offset, next, eof, duration}`; **`next` = next recorded frame's offset**, enabling event-driven stepping. Surfaced as `bt pws sessions replay <id>` (event-driven default; `--fps`/`--interval` fixed sampling; `--from/--to`, `--scale`, `--max-frames`; `--mp4` stitches via ffmpeg, else prints the command + install hint). Always `DELETE`s the replay in `finally`.
91
+ - **PWS session recording download = headless replay under `/pbsm/replay`** (there is **no** video-export API — `?video/?mp4/?webm` return JSON, not media). Flow: `GET /Sessions/{id}` for `Token`+`RecordKey`+`Protocol` → `POST /pbsm/replay` `{id:Token, record_key, protocol:"RDP"|"SSH", headless:true}` → `PUT /pbsm/replay/{rid}` `{offset:<ms>}` (seek) → `GET /pbsm/replay/{rid}?png=<scale>` (one PNG per offset) → `DELETE /pbsm/replay/{rid}`. **Warm-up:** the first `PUT` 404s (`"no such file or directory"`) until the renderer loads — retry ~0.5s. The seek response is state `{offset, next, eof, duration}`; **`next` = next recorded frame's offset**, enabling event-driven stepping. Surfaced as `bt pws sessions replay <id>`. **Default = even coverage + dedup**: ~60 evenly-spaced frames across the whole session (`--frames N`) with consecutive-identical frames skipped (`--dedup`, sha1-based) — because event-driven over-samples RDP (it records many deltas as the screen paints, so `next`-stepping clusters at the login and burns the frame budget before covering the timeline). Modes are mutually exclusive: `--frames` (default) / `--fps` / `--interval` / `--all-frames` (event-driven forensic). Also `--from/--to`, `--scale`, `--max-frames`, `--no-dedup`; `--mp4` stitches via ffmpeg, else prints the command + install hint. Always `DELETE`s the replay in `finally`.
92
92
  - Entitle permissions: Use server-side filters (`--resource`, `--user`, `--integration`) - dataset can be 20k+ records
93
93
  - Entitle users: Use `--all` flag only when needed - default fetches first page only
94
94
  - Windows builds: Rich must be pinned to `<14.0.0` (Rich 14 has PyInstaller unicode issues)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bt-cli
3
- Version: 0.4.64
3
+ Version: 0.4.65
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.64"
7
+ version = "0.4.65"
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.64"
3
+ __version__ = "0.4.65"
@@ -215,22 +215,31 @@ recording. For session *metadata* evidence, use `bt pws audit export`.
215
215
 
216
216
  ```bash
217
217
  # Download the VISUAL recording as PNG frames (RDP screen / SSH terminal)
218
- bt pws sessions replay <session_id> # event-driven frames + index.json
219
- bt pws sessions replay <session_id> --mp4 # also stitch an MP4 (needs ffmpeg)
218
+ bt pws sessions replay <session_id> # ~60 frames across the session, deduped
219
+ bt pws sessions replay <session_id> --frames 30 # ~30 evenly-spaced frames
220
220
  bt pws sessions replay <session_id> --fps 1 --to 120 # 1 fps for the first 2 minutes
221
- bt pws sessions replay <session_id> --out ./review --scale 1 --max-frames 500
221
+ bt pws sessions replay <session_id> --all-frames --no-dedup # every recorded frame, verbatim
222
+ bt pws sessions replay <session_id> --mp4 # also stitch an MP4 (needs ffmpeg)
222
223
  ```
223
224
 
224
225
  `replay` drives the headless renderer at `/pbsm/replay` (there is **no**
225
226
  video-export API): create replay → seek by offset → pull one PNG per frame →
226
- delete. By default it steps **event-driven** (following the replay state's
227
- `next`/`eof` fields, one frame per recorded change); `--fps`/`--interval` force
228
- fixed-rate sampling. **It's one network round-trip per frame, so long sessions
229
- are slow** - bound them with `--fps`, `--from/--to`, or `--max-frames` (default
230
- 3600). `--mp4` stitches with ffmpeg (H.264); if ffmpeg is missing it prints the
231
- exact command + install instructions and leaves the frames. Works for RDP
232
- (screen) and SSH (rendered terminal). Gotcha: the renderer needs ~0.5s warm-up
233
- (first seek 404s), handled automatically.
227
+ delete. **By default it captures ~60 evenly-spaced frames across the whole
228
+ session and skips identical frames** (`--dedup`), so you see the full timeline
229
+ rather than just the busy start. Sampling modes are mutually exclusive:
230
+
231
+ - `--frames N` (default, N=60) — N frames spread evenly across the session
232
+ - `--fps F` / `--interval MS` — fixed-rate sampling
233
+ - `--all-frames` — event-driven, one frame per recorded change (forensic; can be
234
+ huge — RDP records many deltas while the screen paints, which is why this
235
+ isn't the default)
236
+
237
+ **One network round-trip per frame**, so bound long captures with `--frames`,
238
+ `--from/--to`, or `--max-frames` (default 3600). `--no-dedup` keeps every
239
+ sampled frame. `--mp4` stitches with ffmpeg (H.264); if ffmpeg is missing it
240
+ prints the exact command + install instructions and leaves the frames. Works for
241
+ RDP (screen) and SSH (rendered terminal). Gotcha: the renderer needs ~0.5s
242
+ warm-up (first seek 404s), handled automatically.
234
243
 
235
244
  **Gotchas:**
236
245
  - `/Sessions` returns session **history**, not just active sessions, and **ignores `limit`/`offset`** (returns the full set every call - the client does a single GET, the CLI caps display client-side). Use `--active` for the live ones.
@@ -1,5 +1,6 @@
1
1
  """CLI commands for managing active Password Safe sessions."""
2
2
 
3
+ import hashlib
3
4
  import json
4
5
  import shutil
5
6
  import subprocess
@@ -365,8 +366,11 @@ def _warmup_seek(client, replay_id: str, offset_ms: int, quiet: bool, attempts:
365
366
  def session_replay(
366
367
  session_id: int = typer.Argument(..., help="Session ID (from `sessions list`)"),
367
368
  out: str = typer.Option(None, "--out", help="Output directory (default: ./session-<id>-replay)"),
368
- fps: float = typer.Option(None, "--fps", help="Fixed sampling rate (frames/sec). Default: event-driven (one frame per recorded change)"),
369
- interval: int = typer.Option(None, "--interval", help="Fixed sampling interval in ms (alternative to --fps)"),
369
+ frames: int = typer.Option(None, "--frames", help="Target number of evenly-spaced frames across the session (default: 60)"),
370
+ fps: float = typer.Option(None, "--fps", help="Fixed sampling rate in frames/sec (alternative to --frames)"),
371
+ interval: int = typer.Option(None, "--interval", help="Fixed sampling interval in ms (alternative to --frames)"),
372
+ all_frames: bool = typer.Option(False, "--all-frames", help="Capture every recorded frame change (event-driven; forensic, can be very large)"),
373
+ dedup: bool = typer.Option(True, "--dedup/--no-dedup", help="Skip consecutive identical frames (on by default)"),
370
374
  scale: int = typer.Option(1, "--scale", help="PNG scale factor (1 = full size)"),
371
375
  start: float = typer.Option(0, "--from", help="Start offset in seconds"),
372
376
  end: float = typer.Option(None, "--to", help="End offset in seconds (default: end of session)"),
@@ -381,21 +385,34 @@ def session_replay(
381
385
  Password Safe has no video-export API, so this drives the headless replay
382
386
  renderer: it seeks through the recording and pulls one PNG per frame, then
383
387
  (with --mp4) stitches them with ffmpeg. Works for RDP (screen) and SSH
384
- (rendered terminal). Note: one network round-trip per frame, so long
385
- sessions take a while - use --fps/--interval, --from/--to, or --max-frames.
388
+ (rendered terminal).
389
+
390
+ By default it captures ~60 evenly-spaced frames across the whole session
391
+ (so you see the full timeline, not just the busy start) and skips identical
392
+ frames (--dedup). Use --frames/--fps/--interval to change the sampling rate,
393
+ or --all-frames for the forensic every-recorded-change mode (can be huge -
394
+ RDP records many deltas while the screen paints). One network round-trip per
395
+ frame, so bound long captures with --frames, --from/--to, or --max-frames.
386
396
 
387
397
  Examples:
388
- bt pws sessions replay 1460 # event-driven frames + index
389
- bt pws sessions replay 1460 --mp4 # also build an MP4
398
+ bt pws sessions replay 1460 # ~60 frames across the session, deduped
399
+ bt pws sessions replay 1460 --frames 30 # ~30 evenly-spaced frames
390
400
  bt pws sessions replay 1460 --fps 1 --to 120 # 1 fps for the first 2 minutes
391
- bt pws sessions replay 1460 --out ./review --mp4 --mp4-fps 8
401
+ bt pws sessions replay 1460 --all-frames --no-dedup # every recorded frame, verbatim
402
+ bt pws sessions replay 1460 --mp4 # also build an MP4
392
403
  """
393
404
  quiet = output == "json"
394
- fixed_interval = None
395
- if fps is not None and interval is not None:
396
- print_error("Use --fps or --interval, not both.")
405
+ # Resolve sampling mode (mutually exclusive). Default = even-coverage frames.
406
+ n_modes = sum(x is not None for x in (frames, fps, interval)) + int(all_frames)
407
+ if n_modes > 1:
408
+ print_error("Choose one of --frames / --fps / --interval / --all-frames.")
397
409
  raise typer.Exit(2)
398
- if fps is not None:
410
+
411
+ fixed_interval = None # set for fps/interval; computed later for frames mode
412
+ frames_target = None # set for even-coverage frames mode
413
+ if all_frames:
414
+ pass # event-driven: fixed_interval stays None
415
+ elif fps is not None:
399
416
  if fps <= 0:
400
417
  print_error("--fps must be positive.")
401
418
  raise typer.Exit(2)
@@ -405,6 +422,11 @@ def session_replay(
405
422
  print_error("--interval must be positive.")
406
423
  raise typer.Exit(2)
407
424
  fixed_interval = interval
425
+ else:
426
+ frames_target = frames if frames is not None else 60
427
+ if frames_target <= 0:
428
+ print_error("--frames must be positive.")
429
+ raise typer.Exit(2)
408
430
 
409
431
  out_dir = Path(out) if out else Path(f"session-{session_id}-replay")
410
432
  replay_id = None
@@ -435,7 +457,15 @@ def session_replay(
435
457
  total_ms = state.get("duration") or int(duration_s * 1000)
436
458
  end_ms = int(end * 1000) if end is not None else total_ms
437
459
 
460
+ # Even-coverage mode: spread the target frame count across the span
461
+ # (endpoints included), then capture like a fixed interval.
462
+ if frames_target is not None:
463
+ span = max(0, end_ms - start_ms)
464
+ fixed_interval = max(1, span // frames_target) if frames_target > 1 else span + 1
465
+
438
466
  frames: list[dict] = []
467
+ deduped = 0
468
+ last_hash = None
439
469
  offset = start_ms
440
470
  settle = settle_ms / 1000.0
441
471
  while True:
@@ -450,13 +480,19 @@ def session_replay(
450
480
  if fixed_interval is None:
451
481
  break
452
482
  else:
453
- idx = len(frames)
454
- fname = f"frame_{idx:04d}.png"
455
- (out_dir / fname).write_bytes(png)
456
- frames.append({"file": fname, "offset_ms": offset})
483
+ digest = hashlib.sha1(png).digest()
484
+ if dedup and digest == last_hash:
485
+ deduped += 1 # identical to the previous kept frame - skip
486
+ else:
487
+ last_hash = digest
488
+ idx = len(frames)
489
+ fname = f"frame_{idx:04d}.png"
490
+ (out_dir / fname).write_bytes(png)
491
+ frames.append({"file": fname, "offset_ms": offset})
457
492
  if not quiet:
458
493
  pct = min(100, int(offset / total_ms * 100)) if total_ms else 0
459
- console.print(f"\r[cyan] {len(frames)} frames {offset/1000:.1f}s ({pct}%)[/cyan]", end="")
494
+ dd = f" (-{deduped} dup)" if deduped else ""
495
+ console.print(f"\r[cyan] {len(frames)} frames {offset/1000:.1f}s ({pct}%){dd}[/cyan]", end="")
460
496
 
461
497
  # Advance
462
498
  if fixed_interval is not None:
@@ -515,13 +551,15 @@ def session_replay(
515
551
  "protocol": protocol,
516
552
  "output_dir": str(out_dir.resolve()),
517
553
  "frame_count": len(frames),
554
+ "deduped": deduped,
518
555
  "mp4": str(mp4_path.resolve()) if mp4_path else None,
519
556
  }, default=str))
520
557
  else:
521
558
  if not frames:
522
559
  console.print(f"[yellow]No frames captured for session {session_id}.[/yellow]")
523
560
  else:
524
- console.print(f"[green]Captured {len(frames)} frame(s).[/green]")
561
+ dd = f" ({deduped} duplicate(s) skipped)" if deduped else ""
562
+ console.print(f"[green]Captured {len(frames)} frame(s).{dd}[/green]")
525
563
  console.print(f" Frames: [cyan]{out_dir.resolve()}[/cyan]")
526
564
  if mp4_path:
527
565
  console.print(f" MP4: [cyan]{mp4_path.resolve()}[/cyan]")
@@ -428,7 +428,7 @@ class TestPWSSessionsReplay:
428
428
 
429
429
  @respx.mock
430
430
  def test_replay_fixed_interval_captures_frames(self, runner, mock_pws_config, tmp_path):
431
- """Fixed --interval captures a bounded set of frames + index.json."""
431
+ """Fixed --interval captures a bounded set of frames + index.json (dedup off)."""
432
432
  self._mock_replay({
433
433
  "SessionID": 1460, "Token": "tok", "RecordKey": "rk",
434
434
  "Protocol": 0, "Duration": 3, "AssetName": "win", "ManagedAccountName": "admin",
@@ -437,7 +437,7 @@ class TestPWSSessionsReplay:
437
437
  with patch("bt_cli.pws.client.base.load_pws_config", return_value=mock_pws_config):
438
438
  result = runner.invoke(app, [
439
439
  "pws", "sessions", "replay", "1460",
440
- "--interval", "1000", "--to", "3", "--settle", "0", "--out", str(out),
440
+ "--interval", "1000", "--to", "3", "--settle", "0", "--no-dedup", "--out", str(out),
441
441
  ])
442
442
 
443
443
  assert result.exit_code == 0, result.output
@@ -448,6 +448,46 @@ class TestPWSSessionsReplay:
448
448
  assert index["frame_count"] == 4
449
449
  assert index["protocol"] == "RDP"
450
450
 
451
+ @respx.mock
452
+ def test_replay_dedup_collapses_identical_frames(self, runner, mock_pws_config, tmp_path):
453
+ """Default dedup keeps only one of a run of identical frames."""
454
+ self._mock_replay({
455
+ "SessionID": 1460, "Token": "tok", "RecordKey": "rk",
456
+ "Protocol": 0, "Duration": 3, "AssetName": "win", "ManagedAccountName": "admin",
457
+ })
458
+ out = tmp_path / "r"
459
+ with patch("bt_cli.pws.client.base.load_pws_config", return_value=mock_pws_config):
460
+ result = runner.invoke(app, [
461
+ "pws", "sessions", "replay", "1460",
462
+ "--interval", "1000", "--to", "3", "--settle", "0", "--out", str(out), "-o", "json",
463
+ ])
464
+
465
+ assert result.exit_code == 0, result.output
466
+ summary = json.loads(result.output[result.output.index("{"):])
467
+ assert summary["frame_count"] == 1 # all 4 mock frames are identical
468
+ assert summary["deduped"] == 3
469
+
470
+ @respx.mock
471
+ def test_replay_frames_mode_even_coverage(self, runner, mock_pws_config, tmp_path):
472
+ """Default --frames spreads capture across the whole session span (3000ms per the mock state)."""
473
+ self._mock_replay({
474
+ "SessionID": 1460, "Token": "tok", "RecordKey": "rk",
475
+ "Protocol": 0, "Duration": 3, "AssetName": "win", "ManagedAccountName": "admin",
476
+ })
477
+ out = tmp_path / "r"
478
+ with patch("bt_cli.pws.client.base.load_pws_config", return_value=mock_pws_config):
479
+ result = runner.invoke(app, [
480
+ "pws", "sessions", "replay", "1460",
481
+ "--frames", "5", "--settle", "0", "--no-dedup", "--out", str(out),
482
+ ])
483
+
484
+ assert result.exit_code == 0, result.output
485
+ index = json.loads((out / "index.json").read_text())
486
+ offsets = [f["offset_ms"] for f in index["frames"]]
487
+ # frames spread across the 3000ms span, reaching near the end (not clustered at 0)
488
+ assert offsets[0] == 0
489
+ assert offsets[-1] >= 2400
490
+
451
491
  @respx.mock
452
492
  def test_replay_no_recording(self, runner, mock_pws_config):
453
493
  """A session without a recording (no Token/RecordKey) exits 1."""
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes