bt-cli 0.4.65__tar.gz → 0.4.68__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 (248) hide show
  1. {bt_cli-0.4.65 → bt_cli-0.4.68}/CLAUDE.md +3 -2
  2. {bt_cli-0.4.65 → bt_cli-0.4.68}/PKG-INFO +1 -1
  3. {bt_cli-0.4.65 → bt_cli-0.4.68}/pyproject.toml +1 -1
  4. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/__init__.py +1 -1
  5. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/cli.py +9 -5
  6. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/data/skills/pws/SKILL.md +26 -0
  7. bt_cli-0.4.68/src/bt_cli/data/skills/session-analysis/SKILL.md +64 -0
  8. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/sessions.py +314 -1
  9. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/pws/test_commands.py +57 -0
  10. {bt_cli-0.4.65 → bt_cli-0.4.68}/.claude/skills/bt/SKILL.md +0 -0
  11. {bt_cli-0.4.65 → bt_cli-0.4.68}/.claude/skills/entitle/SKILL.md +0 -0
  12. {bt_cli-0.4.65 → bt_cli-0.4.68}/.claude/skills/epml/SKILL.md +0 -0
  13. {bt_cli-0.4.65 → bt_cli-0.4.68}/.claude/skills/epmw/SKILL.md +0 -0
  14. {bt_cli-0.4.65 → bt_cli-0.4.68}/.claude/skills/pra/SKILL.md +0 -0
  15. {bt_cli-0.4.65 → bt_cli-0.4.68}/.claude/skills/pws/SKILL.md +0 -0
  16. {bt_cli-0.4.65 → bt_cli-0.4.68}/.env.example +0 -0
  17. {bt_cli-0.4.65 → bt_cli-0.4.68}/.github/workflows/ci.yml +0 -0
  18. {bt_cli-0.4.65 → bt_cli-0.4.68}/.github/workflows/release.yml +0 -0
  19. {bt_cli-0.4.65 → bt_cli-0.4.68}/.gitignore +0 -0
  20. {bt_cli-0.4.65 → bt_cli-0.4.68}/README.md +0 -0
  21. {bt_cli-0.4.65 → bt_cli-0.4.68}/assets/cli-help.png +0 -0
  22. {bt_cli-0.4.65 → bt_cli-0.4.68}/assets/cli-output.png +0 -0
  23. {bt_cli-0.4.65 → bt_cli-0.4.68}/bt-cli.spec +0 -0
  24. {bt_cli-0.4.65 → bt_cli-0.4.68}/bt_entry.py +0 -0
  25. {bt_cli-0.4.65 → bt_cli-0.4.68}/epml-clients-server-side-filters-plan.md +0 -0
  26. {bt_cli-0.4.65 → bt_cli-0.4.68}/epml-implementation-plan.md +0 -0
  27. {bt_cli-0.4.65 → bt_cli-0.4.68}/pf-implementation-plan.md +0 -0
  28. {bt_cli-0.4.65 → bt_cli-0.4.68}/scripts/bt_entry.py +0 -0
  29. {bt_cli-0.4.65 → bt_cli-0.4.68}/scripts/pf_onboard.py +0 -0
  30. {bt_cli-0.4.65 → bt_cli-0.4.68}/scripts/sync-package-data.sh +0 -0
  31. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/commands/__init__.py +0 -0
  32. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/commands/configure.py +0 -0
  33. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/commands/learn.py +0 -0
  34. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/commands/quick.py +0 -0
  35. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/core/__init__.py +0 -0
  36. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/core/auth.py +0 -0
  37. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/core/client.py +0 -0
  38. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/core/config.py +0 -0
  39. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/core/config_file.py +0 -0
  40. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/core/csv_utils.py +0 -0
  41. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/core/errors.py +0 -0
  42. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/core/output.py +0 -0
  43. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/core/prompts.py +0 -0
  44. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/core/rest_debug.py +0 -0
  45. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/data/CLAUDE.md +0 -0
  46. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/data/__init__.py +0 -0
  47. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/data/skills/bt/SKILL.md +0 -0
  48. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/data/skills/entitle/SKILL.md +0 -0
  49. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/data/skills/epml/SKILL.md +0 -0
  50. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/data/skills/epmw/SKILL.md +0 -0
  51. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/data/skills/pf/SKILL.md +0 -0
  52. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/data/skills/pra/SKILL.md +0 -0
  53. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/data/skills/secrets/SKILL.md +0 -0
  54. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/__init__.py +0 -0
  55. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/client/__init__.py +0 -0
  56. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/client/base.py +0 -0
  57. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/commands/__init__.py +0 -0
  58. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/commands/accounts.py +0 -0
  59. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/commands/applications.py +0 -0
  60. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/commands/auth.py +0 -0
  61. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/commands/bundles.py +0 -0
  62. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/commands/integrations.py +0 -0
  63. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/commands/permissions.py +0 -0
  64. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/commands/policies.py +0 -0
  65. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/commands/requests.py +0 -0
  66. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/commands/resources.py +0 -0
  67. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/commands/roles.py +0 -0
  68. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/commands/users.py +0 -0
  69. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/commands/workflows.py +0 -0
  70. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/models/__init__.py +0 -0
  71. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/models/bundle.py +0 -0
  72. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/models/common.py +0 -0
  73. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/models/integration.py +0 -0
  74. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/models/permission.py +0 -0
  75. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/models/policy.py +0 -0
  76. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/models/resource.py +0 -0
  77. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/models/role.py +0 -0
  78. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/models/user.py +0 -0
  79. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/models/workflow.py +0 -0
  80. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/__init__.py +0 -0
  81. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/client/__init__.py +0 -0
  82. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/client/base.py +0 -0
  83. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/__init__.py +0 -0
  84. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/audit.py +0 -0
  85. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/auth.py +0 -0
  86. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/client_pkg.py +0 -0
  87. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/clients.py +0 -0
  88. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/external_apis.py +0 -0
  89. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/hosts.py +0 -0
  90. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/iolog.py +0 -0
  91. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/license.py +0 -0
  92. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/quick.py +0 -0
  93. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/rbp_cmdgrps.py +0 -0
  94. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/rbp_entitlement.py +0 -0
  95. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/rbp_hostgrps.py +0 -0
  96. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/rbp_policy.py +0 -0
  97. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/rbp_roles.py +0 -0
  98. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/rbp_tests.py +0 -0
  99. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/rbp_tmdategrps.py +0 -0
  100. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/rbp_tx.py +0 -0
  101. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/rbp_usergrps.py +0 -0
  102. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/settings.py +0 -0
  103. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/siems.py +0 -0
  104. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/users.py +0 -0
  105. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/models/__init__.py +0 -0
  106. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epmw/__init__.py +0 -0
  107. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epmw/client/__init__.py +0 -0
  108. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epmw/client/base.py +0 -0
  109. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epmw/commands/__init__.py +0 -0
  110. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epmw/commands/audits.py +0 -0
  111. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epmw/commands/auth.py +0 -0
  112. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epmw/commands/computers.py +0 -0
  113. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epmw/commands/events.py +0 -0
  114. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epmw/commands/groups.py +0 -0
  115. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epmw/commands/policies.py +0 -0
  116. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epmw/commands/quick.py +0 -0
  117. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epmw/commands/requests.py +0 -0
  118. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epmw/commands/roles.py +0 -0
  119. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epmw/commands/tasks.py +0 -0
  120. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epmw/commands/users.py +0 -0
  121. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epmw/models/__init__.py +0 -0
  122. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pf/__init__.py +0 -0
  123. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pf/client/__init__.py +0 -0
  124. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pf/client/base.py +0 -0
  125. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pf/commands/__init__.py +0 -0
  126. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pf/commands/auth.py +0 -0
  127. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pf/commands/machines.py +0 -0
  128. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pf/commands/tokens.py +0 -0
  129. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pf/commands/user.py +0 -0
  130. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pf/commands/users.py +0 -0
  131. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/__init__.py +0 -0
  132. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/client/__init__.py +0 -0
  133. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/client/base.py +0 -0
  134. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/commands/__init__.py +0 -0
  135. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/commands/auth.py +0 -0
  136. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/commands/group_policies.py +0 -0
  137. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/commands/import_export.py +0 -0
  138. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/commands/jump_clients.py +0 -0
  139. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/commands/jump_groups.py +0 -0
  140. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/commands/jump_items.py +0 -0
  141. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/commands/jumpoints.py +0 -0
  142. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/commands/policies.py +0 -0
  143. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/commands/quick.py +0 -0
  144. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/commands/teams.py +0 -0
  145. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/commands/users.py +0 -0
  146. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/commands/vault.py +0 -0
  147. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/models/__init__.py +0 -0
  148. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/models/common.py +0 -0
  149. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/models/group_policy.py +0 -0
  150. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/models/jump_client.py +0 -0
  151. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/models/jump_group.py +0 -0
  152. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/models/jump_item.py +0 -0
  153. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/models/jumpoint.py +0 -0
  154. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/models/team.py +0 -0
  155. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/models/user.py +0 -0
  156. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/models/vault.py +0 -0
  157. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/__init__.py +0 -0
  158. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/client/__init__.py +0 -0
  159. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/client/base.py +0 -0
  160. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/client/beyondinsight.py +0 -0
  161. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/client/passwordsafe.py +0 -0
  162. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/__init__.py +0 -0
  163. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/accounts.py +0 -0
  164. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/assets.py +0 -0
  165. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/attributes.py +0 -0
  166. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/audit.py +0 -0
  167. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/auth.py +0 -0
  168. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/clouds.py +0 -0
  169. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/config.py +0 -0
  170. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/credentials.py +0 -0
  171. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/databases.py +0 -0
  172. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/directories.py +0 -0
  173. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/functional.py +0 -0
  174. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/import_export.py +0 -0
  175. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/platforms.py +0 -0
  176. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/quick.py +0 -0
  177. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/search.py +0 -0
  178. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/secrets.py +0 -0
  179. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/systems.py +0 -0
  180. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/users.py +0 -0
  181. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/workgroups.py +0 -0
  182. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/config.py +0 -0
  183. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/models/__init__.py +0 -0
  184. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/models/account.py +0 -0
  185. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/models/asset.py +0 -0
  186. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/models/common.py +0 -0
  187. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/models/system.py +0 -0
  188. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/secrets/__init__.py +0 -0
  189. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/secrets/client/__init__.py +0 -0
  190. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/secrets/client/base.py +0 -0
  191. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/secrets/commands/__init__.py +0 -0
  192. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/secrets/commands/_hints.py +0 -0
  193. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/secrets/commands/auth.py +0 -0
  194. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/secrets/commands/dynamic.py +0 -0
  195. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/secrets/commands/folders.py +0 -0
  196. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/secrets/commands/integrations.py +0 -0
  197. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/secrets/commands/leases.py +0 -0
  198. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/secrets/commands/static.py +0 -0
  199. {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/secrets/models/__init__.py +0 -0
  200. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/__init__.py +0 -0
  201. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/conftest.py +0 -0
  202. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/core/__init__.py +0 -0
  203. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/core/test_auth.py +0 -0
  204. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/core/test_config.py +0 -0
  205. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/core/test_config_file.py +0 -0
  206. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/core/test_errors.py +0 -0
  207. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/core/test_output.py +0 -0
  208. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/core/test_rest_debug.py +0 -0
  209. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/entitle/__init__.py +0 -0
  210. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/entitle/test_client.py +0 -0
  211. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/entitle/test_commands.py +0 -0
  212. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/entitle-smoke-test.sh +0 -0
  213. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/epml/__init__.py +0 -0
  214. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/epml/test_client.py +0 -0
  215. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/epml/test_commands.py +0 -0
  216. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/epmw/__init__.py +0 -0
  217. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/epmw/test_client.py +0 -0
  218. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/epmw/test_commands.py +0 -0
  219. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/epmw-quick-test-plan.md +0 -0
  220. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/fixtures/__init__.py +0 -0
  221. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/fixtures/responses.py +0 -0
  222. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/integration/__init__.py +0 -0
  223. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/integration/conftest.py +0 -0
  224. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/integration/helpers.py +0 -0
  225. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/integration/test_entitle_integration.py +0 -0
  226. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/integration/test_epmw_integration.py +0 -0
  227. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/integration/test_epmw_lifecycle.py +0 -0
  228. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/integration/test_pra_integration.py +0 -0
  229. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/integration/test_pra_lifecycle.py +0 -0
  230. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/integration/test_pws_integration.py +0 -0
  231. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/integration/test_pws_lifecycle.py +0 -0
  232. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/pf/__init__.py +0 -0
  233. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/pf/test_client.py +0 -0
  234. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/pf/test_commands.py +0 -0
  235. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/pra/__init__.py +0 -0
  236. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/pra/test_client.py +0 -0
  237. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/pra/test_commands.py +0 -0
  238. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/pra/test_jump_item_fixes.py +0 -0
  239. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/pra-smoke-test.sh +0 -0
  240. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/pra-test-plan.md +0 -0
  241. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/pws/__init__.py +0 -0
  242. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/pws/test_client.py +0 -0
  243. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/pws/test_quick_sessions.py +0 -0
  244. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/pws-quick-test-plan.md +0 -0
  245. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/pws-smoke-test.sh +0 -0
  246. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/secrets/__init__.py +0 -0
  247. {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/secrets/test_client.py +0 -0
  248. {bt_cli-0.4.65 → bt_cli-0.4.68}/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.65**
3
+ BeyondTrust Platform CLI for Password Safe, Entitle, PRA, EPM Windows, EPM Linux, and the BeyondTrust Secrets API. **Version: 0.4.68**
4
4
 
5
5
  ## Setup
6
6
 
@@ -33,6 +33,7 @@ Use these slash commands for detailed product guidance:
33
33
  | `/epml` | EPM Linux - RBP roles/cmdgrps/usergrps, policy, test suites, transactions |
34
34
  | `/secrets` | Secrets API - folders, static, dynamic AWS credentials, leases, integrations |
35
35
  | `/pf` | Pathfinder platform - whoami, site/product access, PAT/MCP tokens, machines |
36
+ | `/session-analysis` | Summarize a recorded RDP/SSH session from a `replay` dump (keyframe selection, keystroke focus, cost estimate) |
36
37
 
37
38
  ## Command Structure
38
39
 
@@ -88,7 +89,7 @@ PASSWORD=$(bt pws quick checkout -s server -a admin --raw)
88
89
  - **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
90
  - **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
91
  - **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>`. **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
+ - **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. **`bt pws sessions analyze <id>` (or `--dir`)** preps a replay dump for LLM analysis without calling an LLM: reads `index.json`, selects **keyframes** (RDP frames whose PNG byte-size changed ≥`--threshold`%, dependency-free — no Pillow, UNION the frame that renders each **typed keystroke burst**, anchored via the recording `StartTime` → each keyframe tagged `screen-change`/`after-typing` with the `typed` text, so the model reads targeted spots not every frame), and prints a token/USD **cost estimate** (`--model opus|sonnet|haiku`) for focused (keyframes) vs all-frames; writes `analysis.json`. SSH → recommends the transcript over frames. Consumed by the `/session-analysis` skill (interactive; the model reads only the keyframes). 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
93
  - Entitle permissions: Use server-side filters (`--resource`, `--user`, `--integration`) - dataset can be 20k+ records
93
94
  - Entitle users: Use `--all` flag only when needed - default fetches first page only
94
95
  - 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.65
3
+ Version: 0.4.68
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.65"
7
+ version = "0.4.68"
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.65"
3
+ __version__ = "0.4.68"
@@ -668,11 +668,15 @@ def skills(
668
668
  These skills help AI agents (like Claude Code) navigate bt-cli commands.
669
669
 
670
670
  Skills included:
671
- /bt - Cross-product commands (PASM workflows)
672
- /pws - Password Safe commands
673
- /pra - PRA commands
674
- /entitle - Entitle commands
675
- /epmw - EPM Windows commands
671
+ /bt - Cross-product commands (PASM workflows)
672
+ /pws - Password Safe commands
673
+ /pra - PRA commands
674
+ /entitle - Entitle commands
675
+ /epmw - EPM Windows commands
676
+ /epml - EPM Linux commands
677
+ /secrets - Secrets API commands
678
+ /pf - Pathfinder platform commands
679
+ /session-analysis - Summarize a recorded RDP/SSH session from a replay dump
676
680
 
677
681
  Examples:
678
682
  bt skills # Install to current directory
@@ -189,6 +189,11 @@ bt pws sessions list --active # only live sessions (started, not ended)
189
189
  bt pws sessions list --all -o json # full history
190
190
  bt pws sessions get <session_id>
191
191
 
192
+ # "The session from today" — date filters match on StartTime (client-side)
193
+ bt pws sessions list --today
194
+ bt pws sessions list --on 2026-07-02
195
+ bt pws sessions list --since 2026-06-01 --until 2026-06-30 # a range (e.g. "this week")
196
+
192
197
  # Suspend = lock (user disconnected, session can be resumed)
193
198
  bt pws sessions suspend <session_id>
194
199
 
@@ -241,6 +246,27 @@ prints the exact command + install instructions and leaves the frames. Works for
241
246
  RDP (screen) and SSH (rendered terminal). Gotcha: the renderer needs ~0.5s
242
247
  warm-up (first seek 404s), handled automatically.
243
248
 
249
+ ### "What happened in this session?" — summarize efficiently
250
+
251
+ `replay` only *downloads* frames. To turn a session into a **timeline summary of
252
+ what the user did**, don't read every frame — use the analysis pipeline:
253
+
254
+ ```bash
255
+ bt pws sessions replay <session_id> # 1. download the frames
256
+ bt pws sessions analyze <session_id> # 2. pick keyframes + anchor keystrokes + estimate cost
257
+ ```
258
+
259
+ `analyze` is deterministic prep (no LLM): it selects the handful of frames worth
260
+ reading — screen-change frames **plus** the frames that render what the user
261
+ typed (keystroke-anchored, tagged `after-typing` with the `typed` text) — pulls
262
+ the transcript, and prints a token/USD cost estimate. Then **invoke the
263
+ `/session-analysis` skill**, which tells you (the agent) to read only those
264
+ keyframes (after-typing first) and produce a timeline + auditor highlights (EPM
265
+ elevation prompts, privileged tools, service/account changes) + a secrets-exposure
266
+ note. For **SSH**, skip frames entirely — `analyze` points you at the transcript,
267
+ which is the full input+output and far cheaper. This is the intended path for
268
+ "show me / summarize the session from today".
269
+
244
270
  **Gotchas:**
245
271
  - `/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.
246
272
  - `Status` codes confirmed live: **0 = Not started, 1 = Active, 2 = Completed, 5 = Locked, 7 = Terminated** (3/9 are other terminal states). `--active` filters on Status == 1.
@@ -0,0 +1,64 @@
1
+ ---
2
+ name: session-analysis
3
+ description: Analyze a dumped Password Safe session (replay frames + keystrokes) into a timeline summary with a cost estimate. Use when the user points at a `session-<id>-replay` folder or asks to summarize/analyze a recorded RDP or SSH session.
4
+ ---
5
+
6
+ # Session Analysis
7
+
8
+ Turn a bt-cli session dump into a **timeline summary of what happened**, efficiently, with a **cost estimate**. Works for RDP (screen recording → frames) and SSH (keystroke transcript). This is interactive analysis — *you* (the model) read the frames/transcript; there is no LLM call inside the CLI.
9
+
10
+ ## The one rule: never read every frame
11
+
12
+ An RDP session sampled at ~1 frame per 2s is dozens of near-identical frames. Reading them all wastes tokens. Let the CLI pick the frames worth reading — the ones where the screen changed **and** the ones that render what the user typed — and read only those, guided by the per-frame `typed`/`reason` tags.
13
+
14
+ ## Recipe
15
+
16
+ ### 1. Prep with the CLI (does the heavy lifting)
17
+
18
+ ```bash
19
+ # If the user has a replay folder already:
20
+ bt pws sessions analyze --dir <folder>
21
+ # If they have the session id (reads ./session-<id>-replay):
22
+ bt pws sessions analyze <id> --model opus # or --model sonnet for a cheaper estimate
23
+ ```
24
+
25
+ `analyze` writes `analysis.json` into the folder and prints:
26
+ - **RDP:** the **keyframes** to read (a handful, not all). Each keyframe is tagged with a **reason** — `screen-change` (the picture moved) and/or `after-typing` (it renders the result of something the user typed). `after-typing` frames also carry the exact `typed` text.
27
+ - **SSH:** a pointer to the transcript (analyze it instead of frames).
28
+ - **`typed_bursts`** — typed input grouped into phrases with recording-relative offsets (`@14.9s "powershell"`), already anchored to the keyframes above.
29
+ - A **cost estimate**: `focused` (keyframes only) vs `all frames`.
30
+
31
+ If the user only has a folder (no live API access), `analyze --dir` still works — keystrokes need session metadata (`StartTime`) to anchor, so offline it falls back to `screen-change` keyframes only, with a note. Keyframes + cost are still computed.
32
+
33
+ ### 2. Read the right inputs — targeted, in `analysis.json` order
34
+
35
+ - **RDP:** `Read` only the keyframe PNGs the CLI listed. **Prioritize the `after-typing` frames** — they're where a user action produced a visible result (a launched app, a command's output, an EPM prompt). For each, the `typed` field tells you what to look for *before* you open the image, so you read with intent instead of scanning. The `screen-change`-only frames fill in transitions the keystrokes don't cover (mouse-driven actions: menus, service restarts, window closes). If a keyframe implies something happened between it and the next, that's fine — the summary is event-level, not frame-level.
36
+ - **SSH:** run `bt pws sessions transcript <id>` and read that — it's the full input+output, far richer and cheaper than frames. Frames are rarely worth reading for SSH.
37
+
38
+ ### 3. Anchoring is already done
39
+
40
+ The CLI has correlated keystrokes → frames for you (via the recording `StartTime`): the `typed` field on each keyframe and the `typed_bursts` list *are* the timeline anchors. Just narrate them — "at ~0:14 typed `powershell`, frame at 16s shows PowerShell 7 open". If RDP keystroke capture was off (bursts empty), fall back to reading the screen-change frames and inferring actions from the pictures alone.
41
+
42
+ ### 4. Write the summary
43
+
44
+ Produce:
45
+ - **Timeline table** — approximate time → event (app launched, window opened, command run, dialog shown, service changed, logoff).
46
+ - **Highlights an auditor cares about** — EPM/Defendpoint elevation or justification prompts, privileged tools (`dsa.msc`, `services.msc`, PowerShell), AD/account/OU changes, service start/stop, file transfers.
47
+ - **Security note** — explicitly state whether any credentials, passwords, tokens, or secret values were visible in the frames read.
48
+ - **Cost** — report the estimate from `analysis.json` (and, if you want, the actual: images read × ~`(w*h)/750` tokens + output).
49
+
50
+ ## Efficiency notes
51
+
52
+ - Two selection signals combine: PNG-size delta (`screen-change`, dependency-free) and keystroke anchoring (`after-typing`, when RDP keystroke capture is on). Anchoring is what makes the read *targeted* — it guarantees the frame that shows each typed action is included, even if its size barely moved. If a session looks under-sampled (transitions missed), re-run `analyze --threshold 1.5` for more keyframes; over-sampled → raise it.
53
+ - Frame token cost ≈ `(width × height) / 750` per image (1024×768 ≈ ~1,050 tokens). The CLI's estimate already accounts for this.
54
+ - Pricing (per 1M tokens, for the estimate): Opus 4.8 $5/$25, Sonnet 4.6 $3/$15, Haiku 4.5 $1/$5. Use the `claude-api` skill for current numbers.
55
+
56
+ ## Scaling up (optional, not built)
57
+
58
+ For summarizing **many** sessions unattended, this interactive recipe doesn't scale — that would be a `bt pws sessions summarize` command calling the Claude API (vision) with the same keyframe/transcript inputs and the Batch API for volume. Mention it if the user wants automation; today, analysis is interactive.
59
+
60
+ ## Related
61
+
62
+ - `bt pws sessions replay <id>` — produce the frames (defaults to even coverage + dedup).
63
+ - `bt pws sessions transcript <id>` — the keystroke transcript (SSH gold; RDP sparse).
64
+ - `/pws` skill — Password Safe command reference.
@@ -5,12 +5,13 @@ import json
5
5
  import shutil
6
6
  import subprocess
7
7
  import time
8
- from datetime import datetime, timezone
8
+ from datetime import date, datetime, timezone
9
9
  from pathlib import Path
10
10
 
11
11
  import httpx
12
12
  import typer
13
13
  from rich.console import Console
14
+ from rich.markup import escape as rich_escape
14
15
  from rich.table import Table
15
16
 
16
17
  from ...core.output import print_api_error, print_error, print_warning
@@ -34,6 +35,41 @@ SESSION_STATUS = {
34
35
  }
35
36
 
36
37
 
38
+ def _norm_date(value: str) -> str:
39
+ """Validate a YYYY-MM-DD (or ISO) date arg and return its date portion."""
40
+ try:
41
+ datetime.strptime(value.strip()[:10], "%Y-%m-%d")
42
+ except (ValueError, AttributeError):
43
+ print_error(f"Invalid date '{value}'. Use YYYY-MM-DD.")
44
+ raise typer.Exit(2)
45
+ return value.strip()[:10]
46
+
47
+
48
+ def _resolve_date_window(today: bool, on: str, since: str, until: str) -> tuple:
49
+ """Collapse the date flags into an inclusive (lo, hi) date window (YYYY-MM-DD
50
+ strings, either may be None). --today/--on pin both ends to one day."""
51
+ if today:
52
+ d = date.today().isoformat()
53
+ return d, d
54
+ if on:
55
+ d = _norm_date(on)
56
+ return d, d
57
+ return (_norm_date(since) if since else None, _norm_date(until) if until else None)
58
+
59
+
60
+ def _started_in_window(s: dict, lo: str, hi: str) -> bool:
61
+ """True if the session's StartTime date falls within [lo, hi] (ISO date
62
+ strings sort lexicographically, so string compare on the date is correct)."""
63
+ d = (s.get("StartTime") or "")[:10]
64
+ if not d:
65
+ return False
66
+ if lo and d < lo:
67
+ return False
68
+ if hi and d > hi:
69
+ return False
70
+ return True
71
+
72
+
37
73
  def is_active(s: dict) -> bool:
38
74
  """A session is live (suspend/terminate-able) when Status == 1.
39
75
 
@@ -98,6 +134,10 @@ def list_sessions(
98
134
  system: int = typer.Option(None, "--system", help="Filter by managed system ID"),
99
135
  active: bool = typer.Option(False, "--active", help="Show only active (started, not ended) sessions"),
100
136
  status: str = typer.Option(None, "--status", "-s", help="Filter by raw numeric status code"),
137
+ today: bool = typer.Option(False, "--today", help="Only sessions that started today"),
138
+ on: str = typer.Option(None, "--on", help="Only sessions that started on this date (YYYY-MM-DD)"),
139
+ since: str = typer.Option(None, "--since", help="Only sessions on/after this date (YYYY-MM-DD)"),
140
+ until: str = typer.Option(None, "--until", help="Only sessions on/before this date (YYYY-MM-DD)"),
101
141
  limit: int = typer.Option(25, "--limit", "-l", help="Max rows to show (most recent first)"),
102
142
  fetch_all: bool = typer.Option(False, "--all", help="Show all sessions (the endpoint returns full history)"),
103
143
  output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
@@ -109,11 +149,17 @@ def list_sessions(
109
149
  suspend/terminate), or --all to see everything. Output is newest-first and
110
150
  capped at --limit by default.
111
151
 
152
+ Date filters (--today/--on/--since/--until) match on StartTime's date and are
153
+ applied client-side (the endpoint returns the full history every call).
154
+
112
155
  Examples:
113
156
  bt pws sessions list --active
157
+ bt pws sessions list --today
158
+ bt pws sessions list --since 2026-06-01 --until 2026-06-30
114
159
  bt pws sessions list --system 13
115
160
  bt pws sessions list --all -o json
116
161
  """
162
+ lo, hi = _resolve_date_window(today, on, since, until)
117
163
  try:
118
164
  with get_client() as client:
119
165
  client.authenticate()
@@ -121,6 +167,8 @@ def list_sessions(
121
167
 
122
168
  if active:
123
169
  sessions = [s for s in sessions if is_active(s)]
170
+ if lo or hi:
171
+ sessions = [s for s in sessions if _started_in_window(s, lo, hi)]
124
172
 
125
173
  total = len(sessions)
126
174
  shown = sessions if fetch_all else sessions[:limit]
@@ -586,6 +634,271 @@ def session_replay(
586
634
  pass
587
635
 
588
636
 
637
+ # Vision cost estimate: input/output USD per 1M tokens (from claude-api pricing).
638
+ COST_MODELS = {
639
+ "opus": ("claude-opus-4-8", 5.0, 25.0),
640
+ "sonnet": ("claude-sonnet-4-6", 3.0, 15.0),
641
+ "haiku": ("claude-haiku-4-5", 1.0, 5.0),
642
+ }
643
+
644
+
645
+ def _png_dims(path: Path) -> tuple:
646
+ """Read (width, height) from a PNG IHDR without any image library."""
647
+ try:
648
+ head = path.read_bytes()[:24]
649
+ if head[:8] == b"\x89PNG\r\n\x1a\n":
650
+ return int.from_bytes(head[16:20], "big"), int.from_bytes(head[20:24], "big")
651
+ except Exception:
652
+ pass
653
+ return (1024, 768)
654
+
655
+
656
+ def _image_tokens(w: int, h: int) -> int:
657
+ """Anthropic vision token estimate for an image (~(w*h)/750)."""
658
+ return (w * h + 749) // 750
659
+
660
+
661
+ def _select_keyframes(folder: Path, frames: list[dict], threshold_pct: float) -> list[dict]:
662
+ """Pick frames whose PNG size differs from the previous kept frame by
663
+ >= threshold_pct. A new window/screen changes the encoded size sharply, so
664
+ this cheaply approximates 'the screen meaningfully changed' without decoding
665
+ pixels. First and last frames are always kept."""
666
+ keyframes: list[dict] = []
667
+ prev_size = None
668
+ for i, f in enumerate(frames):
669
+ try:
670
+ size = (folder / f["file"]).stat().st_size
671
+ except OSError:
672
+ continue
673
+ is_last = i == len(frames) - 1
674
+ if prev_size is None or is_last or abs(size - prev_size) / max(prev_size, 1) * 100 >= threshold_pct:
675
+ keyframes.append(f)
676
+ prev_size = size
677
+ return keyframes
678
+
679
+
680
+ def _epoch_ms(value) -> int:
681
+ """Parse an ISO-8601 timestamp (or raw epoch ms) to epoch milliseconds.
682
+ Naive timestamps are treated as UTC. Returns None if unparseable."""
683
+ if value in (None, "", "-"):
684
+ return None
685
+ try:
686
+ return int(value) # already epoch ms
687
+ except (TypeError, ValueError):
688
+ pass
689
+ try:
690
+ dt = datetime.fromisoformat(str(value).strip().replace("Z", "+00:00"))
691
+ if dt.tzinfo is None:
692
+ dt = dt.replace(tzinfo=timezone.utc)
693
+ return int(dt.timestamp() * 1000)
694
+ except (TypeError, ValueError):
695
+ return None
696
+
697
+
698
+ def _keystroke_bursts(keystrokes: list[dict], start_epoch: int, gap_ms: int = 2500) -> list[dict]:
699
+ """Group typed-input keystrokes (Type 1) into bursts — one phrase the user
700
+ typed — each carrying `offset_ms` (ms from recording start) so it can be
701
+ anchored to a frame. Consecutive inputs within `gap_ms` join one burst.
702
+ Needs `start_epoch` (recording start, epoch ms) to convert the absolute
703
+ keystroke TimeMarkers into recording-relative offsets; returns [] without it
704
+ (e.g. offline `--dir` with no session metadata)."""
705
+ if start_epoch is None:
706
+ return []
707
+ inputs = sorted(
708
+ (k for k in keystrokes if k.get("Type") == 1 and k.get("TimeMarker") is not None),
709
+ key=lambda k: (k.get("TimeMarker", 0), k.get("KeystrokeID", 0)),
710
+ )
711
+ bursts: list[dict] = []
712
+ for k in inputs:
713
+ rel = int(k["TimeMarker"]) - start_epoch
714
+ data = k.get("Data", "")
715
+ if bursts and rel - bursts[-1]["offset_ms"] <= gap_ms:
716
+ bursts[-1]["text"] += data
717
+ bursts[-1]["offset_ms"] = rel
718
+ else:
719
+ bursts.append({"text": data, "offset_ms": rel})
720
+ return bursts
721
+
722
+
723
+ def _frame_after(frames: list[dict], offset_ms: int) -> dict:
724
+ """The frame at or just after `offset_ms` — i.e. the screen state that
725
+ results from an action taken at that time. Falls back to the last frame."""
726
+ after = [f for f in frames if f.get("offset_ms", 0) >= offset_ms]
727
+ if after:
728
+ return min(after, key=lambda f: f.get("offset_ms", 0))
729
+ return frames[-1] if frames else None
730
+
731
+
732
+ def _build_keyframes(
733
+ folder: Path, frames: list[dict], threshold_pct: float, bursts: list[dict]
734
+ ) -> list[dict]:
735
+ """Keyframes = screen-change frames (PNG-size delta) UNION the frame that
736
+ renders each typed burst, sorted by time. Each kept frame is annotated with
737
+ `reasons` (why it was picked) and, when applicable, `typed` (what the user
738
+ typed just before it) so the model reads each frame with intent instead of
739
+ scanning blindly."""
740
+ size_kfs = _select_keyframes(folder, frames, threshold_pct)
741
+ size_files = {f["file"] for f in size_kfs}
742
+ typed_by_file: dict[str, list[str]] = {}
743
+ for b in bursts:
744
+ fr = _frame_after(frames, b["offset_ms"])
745
+ if fr and b["text"].strip():
746
+ typed_by_file.setdefault(fr["file"], []).append(b["text"])
747
+
748
+ selected = {f["file"]: dict(f) for f in size_kfs}
749
+ for f in frames:
750
+ if f["file"] in typed_by_file and f["file"] not in selected:
751
+ selected[f["file"]] = dict(f)
752
+
753
+ keyframes = sorted(selected.values(), key=lambda f: f.get("offset_ms", 0))
754
+ for f in keyframes:
755
+ reasons = []
756
+ if f["file"] in size_files:
757
+ reasons.append("screen-change")
758
+ if f["file"] in typed_by_file:
759
+ reasons.append("after-typing")
760
+ f["typed"] = typed_by_file[f["file"]]
761
+ f["reasons"] = reasons
762
+ return keyframes
763
+
764
+
765
+ @app.command("analyze")
766
+ def session_analyze(
767
+ session_id: int = typer.Argument(None, help="Session ID (defaults to reading ./session-<id>-replay)"),
768
+ folder: str = typer.Option(None, "--dir", help="Replay folder (with index.json + frames). Default: ./session-<id>-replay"),
769
+ threshold: float = typer.Option(3.0, "--threshold", help="Keyframe sensitivity: min %% PNG-size change vs previous kept frame (RDP)"),
770
+ model: str = typer.Option("opus", "--model", help="Model for the cost estimate: opus | sonnet | haiku"),
771
+ output: str = typer.Option("text", "--output", "-o", help="Output format: text or json"),
772
+ ) -> None:
773
+ """Prep a dumped session for LLM analysis: pick keyframes, pull keystrokes, estimate cost.
774
+
775
+ Reads a `replay` folder (frames + index.json) and writes an `analysis.json`
776
+ manifest that a model can consume efficiently:
777
+ - RDP: selects keyframes (frames where the screen changed) so you read a
778
+ handful instead of every frame, and pulls the sparse typed-keystroke
779
+ metadata to focus the read.
780
+ - SSH: the keystroke transcript is the primary signal (cheap text) - frames
781
+ are rarely needed.
782
+ Also prints a token/cost estimate. It does NOT call an LLM itself - feed the
783
+ keyframes/transcript to Claude (see the session-analysis skill).
784
+
785
+ Examples:
786
+ bt pws sessions replay 1491 && bt pws sessions analyze 1491
787
+ bt pws sessions analyze --dir ./session-1491-replay
788
+ bt pws sessions analyze 1491 --model sonnet -o json
789
+ """
790
+ if model not in COST_MODELS:
791
+ print_error(f"Unknown --model '{model}'. Choose: {', '.join(COST_MODELS)}")
792
+ raise typer.Exit(2)
793
+
794
+ base = Path(folder) if folder else (Path(f"session-{session_id}-replay") if session_id else None)
795
+ if base is None:
796
+ print_error("Provide a session id or --dir.")
797
+ raise typer.Exit(2)
798
+ idx_path = base / "index.json"
799
+ if not idx_path.exists():
800
+ print_error(f"No index.json in {base} - run `bt pws sessions replay` first.")
801
+ raise typer.Exit(1)
802
+
803
+ idx = json.loads(idx_path.read_text())
804
+ protocol = idx.get("protocol", "RDP")
805
+ frames = idx.get("frames", [])
806
+ sid = session_id or idx.get("session_id")
807
+
808
+ # Pull keystrokes + session meta (best-effort; folder may be analyzed offline)
809
+ keystrokes: list[dict] = []
810
+ meta: dict = {}
811
+ if sid:
812
+ try:
813
+ with get_client() as client:
814
+ client.authenticate()
815
+ meta = client.get_session(sid)
816
+ keystrokes = client.get_keystrokes(sid)
817
+ except Exception as e:
818
+ if output != "json":
819
+ print_warning(f"Could not pull keystrokes for session {sid} ({e}); continuing with frames only.")
820
+
821
+ decoded_ks = [
822
+ {"time": _fmt_marker(k.get("TimeMarker")), "type": _keystroke_type(k.get("Type")), "data": k.get("Data", "")}
823
+ for k in sorted(keystrokes, key=lambda k: (k.get("TimeMarker", 0), k.get("KeystrokeID", 0)))
824
+ ]
825
+
826
+ w, h = _png_dims(base / frames[0]["file"]) if frames else (1024, 768)
827
+ per_img = _image_tokens(w, h)
828
+ ks_tokens = max(1, len(json.dumps(decoded_ks)) // 4)
829
+
830
+ # Correlate typed keystrokes to the recording timeline so we can target the
831
+ # frames that render each action (needs the recording's start time).
832
+ start_epoch = _epoch_ms(meta.get("StartTime"))
833
+ bursts = _keystroke_bursts(keystrokes, start_epoch)
834
+
835
+ # For SSH the transcript carries the session; frames are optional.
836
+ if protocol.upper() == "SSH":
837
+ keyframes = frames[:0] # recommend transcript-first
838
+ recommended = "transcript"
839
+ else:
840
+ keyframes = _build_keyframes(base, frames, threshold, bursts)
841
+ recommended = "keyframes"
842
+
843
+ model_id, price_in, price_out = COST_MODELS[model]
844
+ overhead, out_tokens = 1500, 1000
845
+
846
+ def _cost(n_imgs: int) -> dict:
847
+ inp = n_imgs * per_img + ks_tokens + overhead
848
+ usd = inp / 1e6 * price_in + out_tokens / 1e6 * price_out
849
+ return {"images": n_imgs, "input_tokens": inp, "output_tokens": out_tokens, "usd": round(usd, 4)}
850
+
851
+ est_focused = _cost(len(keyframes))
852
+ est_all = _cost(len(frames))
853
+
854
+ manifest = {
855
+ "session_id": sid,
856
+ "protocol": protocol,
857
+ "frame_dims": [w, h],
858
+ "frame_count": len(frames),
859
+ "keyframe_count": len(keyframes),
860
+ "keyframes": keyframes,
861
+ "keystroke_count": len(decoded_ks),
862
+ "keystrokes": decoded_ks,
863
+ "typed_bursts": bursts,
864
+ "recommended_input": recommended,
865
+ "cost_estimate": {"model": model_id, "focused": est_focused, "all_frames": est_all},
866
+ }
867
+ (base / "analysis.json").write_text(json.dumps(manifest, indent=2))
868
+
869
+ if output == "json":
870
+ console.print_json(json.dumps(manifest, default=str))
871
+ return
872
+
873
+ acct = meta.get("ManagedAccountName", "?")
874
+ sysn = meta.get("AssetName", "?")
875
+ console.print(f"[bold]Session {sid}[/bold] {protocol} {acct}@{sysn}")
876
+ console.print(f" Frames: {len(frames)} ({w}x{h}) Keystroke events: {len(decoded_ks)}")
877
+ if protocol.upper() == "SSH":
878
+ console.print(" [green]SSH → analyze the transcript[/green] (cheap, full input+output):")
879
+ console.print(f" [cyan]bt pws sessions transcript {sid}[/cyan]")
880
+ else:
881
+ anchored = sum(1 for k in keyframes if "after-typing" in k.get("reasons", []))
882
+ hint = f" ([cyan]{anchored}[/cyan] anchored to typed input)" if anchored else ""
883
+ console.print(f" [green]{len(keyframes)} keyframes[/green] (of {len(frames)}) — read these, not every frame{hint}:")
884
+ for k in keyframes:
885
+ typed_here = " [yellow]⌨ " + rich_escape(" ".join(k["typed"])) + "[/yellow]" if k.get("typed") else ""
886
+ reasons = ",".join(k.get("reasons", [])) or "screen-change"
887
+ console.print(f" {base / k['file']} [dim]@ {k['offset_ms']/1000:.1f}s · {reasons}[/dim]{typed_here}")
888
+ if bursts:
889
+ console.print(" [green]Typed input[/green] (recording-relative — anchors above):")
890
+ for b in bursts[:12]:
891
+ console.print(f" [dim]@ {b['offset_ms']/1000:.1f}s[/dim] {rich_escape(b['text'])}")
892
+ elif protocol.upper() != "SSH" and [k for k in decoded_ks if k["type"] == "input"]:
893
+ console.print(" [dim]Typed input present but no StartTime to anchor it to frames (offline?).[/dim]")
894
+ console.print(
895
+ f"\n [bold]Cost estimate[/bold] ({model_id}): "
896
+ f"focused ≈ [green]${est_focused['usd']:.4f}[/green] ({est_focused['images']} imgs) · "
897
+ f"all frames ≈ ${est_all['usd']:.4f} ({est_all['images']} imgs)"
898
+ )
899
+ console.print(f" [dim]Manifest: {base / 'analysis.json'}[/dim]")
900
+
901
+
589
902
  @app.command("suspend")
590
903
  def suspend_session(
591
904
  session_id: int = typer.Argument(..., help="Session ID to lock/suspend"),
@@ -488,6 +488,63 @@ class TestPWSSessionsReplay:
488
488
  assert offsets[0] == 0
489
489
  assert offsets[-1] >= 2400
490
490
 
491
+ @staticmethod
492
+ def _write_dump(tmp_path, protocol, sizes):
493
+ """Create a fake replay folder: index.json + PNG frames of given byte sizes."""
494
+ folder = tmp_path / "dump"
495
+ folder.mkdir()
496
+ frames = []
497
+ for i, size in enumerate(sizes):
498
+ hdr = b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\x0dIHDR" + (1024).to_bytes(4, "big") + (768).to_bytes(4, "big")
499
+ (folder / f"frame_{i:04d}.png").write_bytes(hdr + b"\x00" * max(0, size - len(hdr)))
500
+ frames.append({"file": f"frame_{i:04d}.png", "offset_ms": i * 1000})
501
+ (folder / "index.json").write_text(json.dumps(
502
+ {"session_id": 77, "protocol": protocol, "frames": frames, "frame_count": len(frames)}
503
+ ))
504
+ return folder
505
+
506
+ def _mock_session_meta(self, keystrokes=None):
507
+ mock_auth_endpoints()
508
+ respx.get("https://mock-pws.example.com/api/v3/Sessions/77").mock(
509
+ return_value=httpx.Response(200, json={"SessionID": 77, "AssetName": "win", "ManagedAccountName": "admin"})
510
+ )
511
+ respx.get("https://mock-pws.example.com/api/v3/Sessions/77/Keystrokes").mock(
512
+ return_value=httpx.Response(200, json=keystrokes or [])
513
+ )
514
+
515
+ @respx.mock
516
+ def test_analyze_rdp_selects_keyframes(self, runner, mock_pws_config, tmp_path):
517
+ """RDP analyze picks keyframes on size change and writes analysis.json + cost."""
518
+ folder = self._write_dump(tmp_path, "RDP", sizes=[1000, 1000, 2000, 2000, 1000])
519
+ self._mock_session_meta()
520
+ with patch("bt_cli.pws.client.base.load_pws_config", return_value=mock_pws_config):
521
+ result = runner.invoke(app, ["pws", "sessions", "analyze", "--dir", str(folder), "-o", "json"])
522
+
523
+ assert result.exit_code == 0, result.output
524
+ manifest = json.loads(result.output[result.output.index("{"):])
525
+ # keyframes: first(0), the jump to 2000(idx2), and last(idx4) -> 3
526
+ assert manifest["keyframe_count"] == 3
527
+ assert manifest["frame_count"] == 5
528
+ assert manifest["cost_estimate"]["focused"]["images"] == 3
529
+ assert manifest["cost_estimate"]["all_frames"]["images"] == 5
530
+ assert (folder / "analysis.json").exists()
531
+
532
+ @respx.mock
533
+ def test_analyze_ssh_recommends_transcript(self, runner, mock_pws_config, tmp_path):
534
+ """SSH analyze recommends the transcript and selects no keyframes."""
535
+ folder = self._write_dump(tmp_path, "SSH", sizes=[1000, 1000])
536
+ self._mock_session_meta(keystrokes=[
537
+ {"KeystrokeID": 1, "TimeMarker": 1782999904000, "Type": 1, "Data": "id"},
538
+ ])
539
+ with patch("bt_cli.pws.client.base.load_pws_config", return_value=mock_pws_config):
540
+ result = runner.invoke(app, ["pws", "sessions", "analyze", "--dir", str(folder), "-o", "json"])
541
+
542
+ assert result.exit_code == 0, result.output
543
+ manifest = json.loads(result.output[result.output.index("{"):])
544
+ assert manifest["recommended_input"] == "transcript"
545
+ assert manifest["keyframe_count"] == 0
546
+ assert manifest["keystroke_count"] == 1
547
+
491
548
  @respx.mock
492
549
  def test_replay_no_recording(self, runner, mock_pws_config):
493
550
  """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