bt-cli 0.4.65__tar.gz → 0.4.67__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.67}/CLAUDE.md +3 -2
  2. {bt_cli-0.4.65 → bt_cli-0.4.67}/PKG-INFO +1 -1
  3. {bt_cli-0.4.65 → bt_cli-0.4.67}/pyproject.toml +1 -1
  4. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/__init__.py +1 -1
  5. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/cli.py +9 -5
  6. bt_cli-0.4.67/src/bt_cli/data/skills/session-analysis/SKILL.md +64 -0
  7. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pws/commands/sessions.py +266 -0
  8. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/pws/test_commands.py +57 -0
  9. {bt_cli-0.4.65 → bt_cli-0.4.67}/.claude/skills/bt/SKILL.md +0 -0
  10. {bt_cli-0.4.65 → bt_cli-0.4.67}/.claude/skills/entitle/SKILL.md +0 -0
  11. {bt_cli-0.4.65 → bt_cli-0.4.67}/.claude/skills/epml/SKILL.md +0 -0
  12. {bt_cli-0.4.65 → bt_cli-0.4.67}/.claude/skills/epmw/SKILL.md +0 -0
  13. {bt_cli-0.4.65 → bt_cli-0.4.67}/.claude/skills/pra/SKILL.md +0 -0
  14. {bt_cli-0.4.65 → bt_cli-0.4.67}/.claude/skills/pws/SKILL.md +0 -0
  15. {bt_cli-0.4.65 → bt_cli-0.4.67}/.env.example +0 -0
  16. {bt_cli-0.4.65 → bt_cli-0.4.67}/.github/workflows/ci.yml +0 -0
  17. {bt_cli-0.4.65 → bt_cli-0.4.67}/.github/workflows/release.yml +0 -0
  18. {bt_cli-0.4.65 → bt_cli-0.4.67}/.gitignore +0 -0
  19. {bt_cli-0.4.65 → bt_cli-0.4.67}/README.md +0 -0
  20. {bt_cli-0.4.65 → bt_cli-0.4.67}/assets/cli-help.png +0 -0
  21. {bt_cli-0.4.65 → bt_cli-0.4.67}/assets/cli-output.png +0 -0
  22. {bt_cli-0.4.65 → bt_cli-0.4.67}/bt-cli.spec +0 -0
  23. {bt_cli-0.4.65 → bt_cli-0.4.67}/bt_entry.py +0 -0
  24. {bt_cli-0.4.65 → bt_cli-0.4.67}/epml-clients-server-side-filters-plan.md +0 -0
  25. {bt_cli-0.4.65 → bt_cli-0.4.67}/epml-implementation-plan.md +0 -0
  26. {bt_cli-0.4.65 → bt_cli-0.4.67}/pf-implementation-plan.md +0 -0
  27. {bt_cli-0.4.65 → bt_cli-0.4.67}/scripts/bt_entry.py +0 -0
  28. {bt_cli-0.4.65 → bt_cli-0.4.67}/scripts/pf_onboard.py +0 -0
  29. {bt_cli-0.4.65 → bt_cli-0.4.67}/scripts/sync-package-data.sh +0 -0
  30. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/commands/__init__.py +0 -0
  31. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/commands/configure.py +0 -0
  32. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/commands/learn.py +0 -0
  33. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/commands/quick.py +0 -0
  34. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/core/__init__.py +0 -0
  35. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/core/auth.py +0 -0
  36. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/core/client.py +0 -0
  37. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/core/config.py +0 -0
  38. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/core/config_file.py +0 -0
  39. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/core/csv_utils.py +0 -0
  40. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/core/errors.py +0 -0
  41. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/core/output.py +0 -0
  42. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/core/prompts.py +0 -0
  43. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/core/rest_debug.py +0 -0
  44. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/data/CLAUDE.md +0 -0
  45. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/data/__init__.py +0 -0
  46. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/data/skills/bt/SKILL.md +0 -0
  47. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/data/skills/entitle/SKILL.md +0 -0
  48. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/data/skills/epml/SKILL.md +0 -0
  49. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/data/skills/epmw/SKILL.md +0 -0
  50. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/data/skills/pf/SKILL.md +0 -0
  51. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/data/skills/pra/SKILL.md +0 -0
  52. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/data/skills/pws/SKILL.md +0 -0
  53. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/data/skills/secrets/SKILL.md +0 -0
  54. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/entitle/__init__.py +0 -0
  55. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/entitle/client/__init__.py +0 -0
  56. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/entitle/client/base.py +0 -0
  57. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/entitle/commands/__init__.py +0 -0
  58. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/entitle/commands/accounts.py +0 -0
  59. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/entitle/commands/applications.py +0 -0
  60. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/entitle/commands/auth.py +0 -0
  61. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/entitle/commands/bundles.py +0 -0
  62. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/entitle/commands/integrations.py +0 -0
  63. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/entitle/commands/permissions.py +0 -0
  64. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/entitle/commands/policies.py +0 -0
  65. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/entitle/commands/requests.py +0 -0
  66. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/entitle/commands/resources.py +0 -0
  67. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/entitle/commands/roles.py +0 -0
  68. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/entitle/commands/users.py +0 -0
  69. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/entitle/commands/workflows.py +0 -0
  70. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/entitle/models/__init__.py +0 -0
  71. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/entitle/models/bundle.py +0 -0
  72. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/entitle/models/common.py +0 -0
  73. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/entitle/models/integration.py +0 -0
  74. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/entitle/models/permission.py +0 -0
  75. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/entitle/models/policy.py +0 -0
  76. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/entitle/models/resource.py +0 -0
  77. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/entitle/models/role.py +0 -0
  78. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/entitle/models/user.py +0 -0
  79. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/entitle/models/workflow.py +0 -0
  80. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epml/__init__.py +0 -0
  81. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epml/client/__init__.py +0 -0
  82. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epml/client/base.py +0 -0
  83. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epml/commands/__init__.py +0 -0
  84. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epml/commands/audit.py +0 -0
  85. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epml/commands/auth.py +0 -0
  86. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epml/commands/client_pkg.py +0 -0
  87. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epml/commands/clients.py +0 -0
  88. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epml/commands/external_apis.py +0 -0
  89. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epml/commands/hosts.py +0 -0
  90. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epml/commands/iolog.py +0 -0
  91. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epml/commands/license.py +0 -0
  92. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epml/commands/quick.py +0 -0
  93. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epml/commands/rbp_cmdgrps.py +0 -0
  94. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epml/commands/rbp_entitlement.py +0 -0
  95. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epml/commands/rbp_hostgrps.py +0 -0
  96. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epml/commands/rbp_policy.py +0 -0
  97. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epml/commands/rbp_roles.py +0 -0
  98. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epml/commands/rbp_tests.py +0 -0
  99. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epml/commands/rbp_tmdategrps.py +0 -0
  100. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epml/commands/rbp_tx.py +0 -0
  101. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epml/commands/rbp_usergrps.py +0 -0
  102. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epml/commands/settings.py +0 -0
  103. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epml/commands/siems.py +0 -0
  104. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epml/commands/users.py +0 -0
  105. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epml/models/__init__.py +0 -0
  106. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epmw/__init__.py +0 -0
  107. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epmw/client/__init__.py +0 -0
  108. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epmw/client/base.py +0 -0
  109. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epmw/commands/__init__.py +0 -0
  110. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epmw/commands/audits.py +0 -0
  111. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epmw/commands/auth.py +0 -0
  112. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epmw/commands/computers.py +0 -0
  113. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epmw/commands/events.py +0 -0
  114. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epmw/commands/groups.py +0 -0
  115. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epmw/commands/policies.py +0 -0
  116. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epmw/commands/quick.py +0 -0
  117. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epmw/commands/requests.py +0 -0
  118. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epmw/commands/roles.py +0 -0
  119. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epmw/commands/tasks.py +0 -0
  120. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epmw/commands/users.py +0 -0
  121. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/epmw/models/__init__.py +0 -0
  122. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pf/__init__.py +0 -0
  123. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pf/client/__init__.py +0 -0
  124. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pf/client/base.py +0 -0
  125. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pf/commands/__init__.py +0 -0
  126. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pf/commands/auth.py +0 -0
  127. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pf/commands/machines.py +0 -0
  128. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pf/commands/tokens.py +0 -0
  129. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pf/commands/user.py +0 -0
  130. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pf/commands/users.py +0 -0
  131. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pra/__init__.py +0 -0
  132. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pra/client/__init__.py +0 -0
  133. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pra/client/base.py +0 -0
  134. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pra/commands/__init__.py +0 -0
  135. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pra/commands/auth.py +0 -0
  136. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pra/commands/group_policies.py +0 -0
  137. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pra/commands/import_export.py +0 -0
  138. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pra/commands/jump_clients.py +0 -0
  139. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pra/commands/jump_groups.py +0 -0
  140. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pra/commands/jump_items.py +0 -0
  141. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pra/commands/jumpoints.py +0 -0
  142. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pra/commands/policies.py +0 -0
  143. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pra/commands/quick.py +0 -0
  144. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pra/commands/teams.py +0 -0
  145. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pra/commands/users.py +0 -0
  146. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pra/commands/vault.py +0 -0
  147. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pra/models/__init__.py +0 -0
  148. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pra/models/common.py +0 -0
  149. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pra/models/group_policy.py +0 -0
  150. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pra/models/jump_client.py +0 -0
  151. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pra/models/jump_group.py +0 -0
  152. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pra/models/jump_item.py +0 -0
  153. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pra/models/jumpoint.py +0 -0
  154. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pra/models/team.py +0 -0
  155. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pra/models/user.py +0 -0
  156. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pra/models/vault.py +0 -0
  157. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pws/__init__.py +0 -0
  158. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pws/client/__init__.py +0 -0
  159. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pws/client/base.py +0 -0
  160. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pws/client/beyondinsight.py +0 -0
  161. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pws/client/passwordsafe.py +0 -0
  162. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pws/commands/__init__.py +0 -0
  163. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pws/commands/accounts.py +0 -0
  164. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pws/commands/assets.py +0 -0
  165. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pws/commands/attributes.py +0 -0
  166. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pws/commands/audit.py +0 -0
  167. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pws/commands/auth.py +0 -0
  168. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pws/commands/clouds.py +0 -0
  169. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pws/commands/config.py +0 -0
  170. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pws/commands/credentials.py +0 -0
  171. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pws/commands/databases.py +0 -0
  172. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pws/commands/directories.py +0 -0
  173. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pws/commands/functional.py +0 -0
  174. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pws/commands/import_export.py +0 -0
  175. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pws/commands/platforms.py +0 -0
  176. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pws/commands/quick.py +0 -0
  177. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pws/commands/search.py +0 -0
  178. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pws/commands/secrets.py +0 -0
  179. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pws/commands/systems.py +0 -0
  180. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pws/commands/users.py +0 -0
  181. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pws/commands/workgroups.py +0 -0
  182. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pws/config.py +0 -0
  183. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pws/models/__init__.py +0 -0
  184. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pws/models/account.py +0 -0
  185. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pws/models/asset.py +0 -0
  186. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pws/models/common.py +0 -0
  187. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/pws/models/system.py +0 -0
  188. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/secrets/__init__.py +0 -0
  189. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/secrets/client/__init__.py +0 -0
  190. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/secrets/client/base.py +0 -0
  191. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/secrets/commands/__init__.py +0 -0
  192. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/secrets/commands/_hints.py +0 -0
  193. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/secrets/commands/auth.py +0 -0
  194. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/secrets/commands/dynamic.py +0 -0
  195. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/secrets/commands/folders.py +0 -0
  196. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/secrets/commands/integrations.py +0 -0
  197. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/secrets/commands/leases.py +0 -0
  198. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/secrets/commands/static.py +0 -0
  199. {bt_cli-0.4.65 → bt_cli-0.4.67}/src/bt_cli/secrets/models/__init__.py +0 -0
  200. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/__init__.py +0 -0
  201. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/conftest.py +0 -0
  202. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/core/__init__.py +0 -0
  203. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/core/test_auth.py +0 -0
  204. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/core/test_config.py +0 -0
  205. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/core/test_config_file.py +0 -0
  206. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/core/test_errors.py +0 -0
  207. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/core/test_output.py +0 -0
  208. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/core/test_rest_debug.py +0 -0
  209. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/entitle/__init__.py +0 -0
  210. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/entitle/test_client.py +0 -0
  211. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/entitle/test_commands.py +0 -0
  212. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/entitle-smoke-test.sh +0 -0
  213. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/epml/__init__.py +0 -0
  214. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/epml/test_client.py +0 -0
  215. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/epml/test_commands.py +0 -0
  216. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/epmw/__init__.py +0 -0
  217. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/epmw/test_client.py +0 -0
  218. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/epmw/test_commands.py +0 -0
  219. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/epmw-quick-test-plan.md +0 -0
  220. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/fixtures/__init__.py +0 -0
  221. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/fixtures/responses.py +0 -0
  222. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/integration/__init__.py +0 -0
  223. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/integration/conftest.py +0 -0
  224. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/integration/helpers.py +0 -0
  225. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/integration/test_entitle_integration.py +0 -0
  226. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/integration/test_epmw_integration.py +0 -0
  227. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/integration/test_epmw_lifecycle.py +0 -0
  228. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/integration/test_pra_integration.py +0 -0
  229. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/integration/test_pra_lifecycle.py +0 -0
  230. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/integration/test_pws_integration.py +0 -0
  231. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/integration/test_pws_lifecycle.py +0 -0
  232. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/pf/__init__.py +0 -0
  233. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/pf/test_client.py +0 -0
  234. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/pf/test_commands.py +0 -0
  235. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/pra/__init__.py +0 -0
  236. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/pra/test_client.py +0 -0
  237. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/pra/test_commands.py +0 -0
  238. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/pra/test_jump_item_fixes.py +0 -0
  239. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/pra-smoke-test.sh +0 -0
  240. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/pra-test-plan.md +0 -0
  241. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/pws/__init__.py +0 -0
  242. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/pws/test_client.py +0 -0
  243. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/pws/test_quick_sessions.py +0 -0
  244. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/pws-quick-test-plan.md +0 -0
  245. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/pws-smoke-test.sh +0 -0
  246. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/secrets/__init__.py +0 -0
  247. {bt_cli-0.4.65 → bt_cli-0.4.67}/tests/secrets/test_client.py +0 -0
  248. {bt_cli-0.4.65 → bt_cli-0.4.67}/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.67**
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.67
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.67"
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.67"
@@ -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
@@ -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.
@@ -11,6 +11,7 @@ from pathlib import Path
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
@@ -586,6 +587,271 @@ def session_replay(
586
587
  pass
587
588
 
588
589
 
590
+ # Vision cost estimate: input/output USD per 1M tokens (from claude-api pricing).
591
+ COST_MODELS = {
592
+ "opus": ("claude-opus-4-8", 5.0, 25.0),
593
+ "sonnet": ("claude-sonnet-4-6", 3.0, 15.0),
594
+ "haiku": ("claude-haiku-4-5", 1.0, 5.0),
595
+ }
596
+
597
+
598
+ def _png_dims(path: Path) -> tuple:
599
+ """Read (width, height) from a PNG IHDR without any image library."""
600
+ try:
601
+ head = path.read_bytes()[:24]
602
+ if head[:8] == b"\x89PNG\r\n\x1a\n":
603
+ return int.from_bytes(head[16:20], "big"), int.from_bytes(head[20:24], "big")
604
+ except Exception:
605
+ pass
606
+ return (1024, 768)
607
+
608
+
609
+ def _image_tokens(w: int, h: int) -> int:
610
+ """Anthropic vision token estimate for an image (~(w*h)/750)."""
611
+ return (w * h + 749) // 750
612
+
613
+
614
+ def _select_keyframes(folder: Path, frames: list[dict], threshold_pct: float) -> list[dict]:
615
+ """Pick frames whose PNG size differs from the previous kept frame by
616
+ >= threshold_pct. A new window/screen changes the encoded size sharply, so
617
+ this cheaply approximates 'the screen meaningfully changed' without decoding
618
+ pixels. First and last frames are always kept."""
619
+ keyframes: list[dict] = []
620
+ prev_size = None
621
+ for i, f in enumerate(frames):
622
+ try:
623
+ size = (folder / f["file"]).stat().st_size
624
+ except OSError:
625
+ continue
626
+ is_last = i == len(frames) - 1
627
+ if prev_size is None or is_last or abs(size - prev_size) / max(prev_size, 1) * 100 >= threshold_pct:
628
+ keyframes.append(f)
629
+ prev_size = size
630
+ return keyframes
631
+
632
+
633
+ def _epoch_ms(value) -> int:
634
+ """Parse an ISO-8601 timestamp (or raw epoch ms) to epoch milliseconds.
635
+ Naive timestamps are treated as UTC. Returns None if unparseable."""
636
+ if value in (None, "", "-"):
637
+ return None
638
+ try:
639
+ return int(value) # already epoch ms
640
+ except (TypeError, ValueError):
641
+ pass
642
+ try:
643
+ dt = datetime.fromisoformat(str(value).strip().replace("Z", "+00:00"))
644
+ if dt.tzinfo is None:
645
+ dt = dt.replace(tzinfo=timezone.utc)
646
+ return int(dt.timestamp() * 1000)
647
+ except (TypeError, ValueError):
648
+ return None
649
+
650
+
651
+ def _keystroke_bursts(keystrokes: list[dict], start_epoch: int, gap_ms: int = 2500) -> list[dict]:
652
+ """Group typed-input keystrokes (Type 1) into bursts — one phrase the user
653
+ typed — each carrying `offset_ms` (ms from recording start) so it can be
654
+ anchored to a frame. Consecutive inputs within `gap_ms` join one burst.
655
+ Needs `start_epoch` (recording start, epoch ms) to convert the absolute
656
+ keystroke TimeMarkers into recording-relative offsets; returns [] without it
657
+ (e.g. offline `--dir` with no session metadata)."""
658
+ if start_epoch is None:
659
+ return []
660
+ inputs = sorted(
661
+ (k for k in keystrokes if k.get("Type") == 1 and k.get("TimeMarker") is not None),
662
+ key=lambda k: (k.get("TimeMarker", 0), k.get("KeystrokeID", 0)),
663
+ )
664
+ bursts: list[dict] = []
665
+ for k in inputs:
666
+ rel = int(k["TimeMarker"]) - start_epoch
667
+ data = k.get("Data", "")
668
+ if bursts and rel - bursts[-1]["offset_ms"] <= gap_ms:
669
+ bursts[-1]["text"] += data
670
+ bursts[-1]["offset_ms"] = rel
671
+ else:
672
+ bursts.append({"text": data, "offset_ms": rel})
673
+ return bursts
674
+
675
+
676
+ def _frame_after(frames: list[dict], offset_ms: int) -> dict:
677
+ """The frame at or just after `offset_ms` — i.e. the screen state that
678
+ results from an action taken at that time. Falls back to the last frame."""
679
+ after = [f for f in frames if f.get("offset_ms", 0) >= offset_ms]
680
+ if after:
681
+ return min(after, key=lambda f: f.get("offset_ms", 0))
682
+ return frames[-1] if frames else None
683
+
684
+
685
+ def _build_keyframes(
686
+ folder: Path, frames: list[dict], threshold_pct: float, bursts: list[dict]
687
+ ) -> list[dict]:
688
+ """Keyframes = screen-change frames (PNG-size delta) UNION the frame that
689
+ renders each typed burst, sorted by time. Each kept frame is annotated with
690
+ `reasons` (why it was picked) and, when applicable, `typed` (what the user
691
+ typed just before it) so the model reads each frame with intent instead of
692
+ scanning blindly."""
693
+ size_kfs = _select_keyframes(folder, frames, threshold_pct)
694
+ size_files = {f["file"] for f in size_kfs}
695
+ typed_by_file: dict[str, list[str]] = {}
696
+ for b in bursts:
697
+ fr = _frame_after(frames, b["offset_ms"])
698
+ if fr and b["text"].strip():
699
+ typed_by_file.setdefault(fr["file"], []).append(b["text"])
700
+
701
+ selected = {f["file"]: dict(f) for f in size_kfs}
702
+ for f in frames:
703
+ if f["file"] in typed_by_file and f["file"] not in selected:
704
+ selected[f["file"]] = dict(f)
705
+
706
+ keyframes = sorted(selected.values(), key=lambda f: f.get("offset_ms", 0))
707
+ for f in keyframes:
708
+ reasons = []
709
+ if f["file"] in size_files:
710
+ reasons.append("screen-change")
711
+ if f["file"] in typed_by_file:
712
+ reasons.append("after-typing")
713
+ f["typed"] = typed_by_file[f["file"]]
714
+ f["reasons"] = reasons
715
+ return keyframes
716
+
717
+
718
+ @app.command("analyze")
719
+ def session_analyze(
720
+ session_id: int = typer.Argument(None, help="Session ID (defaults to reading ./session-<id>-replay)"),
721
+ folder: str = typer.Option(None, "--dir", help="Replay folder (with index.json + frames). Default: ./session-<id>-replay"),
722
+ threshold: float = typer.Option(3.0, "--threshold", help="Keyframe sensitivity: min %% PNG-size change vs previous kept frame (RDP)"),
723
+ model: str = typer.Option("opus", "--model", help="Model for the cost estimate: opus | sonnet | haiku"),
724
+ output: str = typer.Option("text", "--output", "-o", help="Output format: text or json"),
725
+ ) -> None:
726
+ """Prep a dumped session for LLM analysis: pick keyframes, pull keystrokes, estimate cost.
727
+
728
+ Reads a `replay` folder (frames + index.json) and writes an `analysis.json`
729
+ manifest that a model can consume efficiently:
730
+ - RDP: selects keyframes (frames where the screen changed) so you read a
731
+ handful instead of every frame, and pulls the sparse typed-keystroke
732
+ metadata to focus the read.
733
+ - SSH: the keystroke transcript is the primary signal (cheap text) - frames
734
+ are rarely needed.
735
+ Also prints a token/cost estimate. It does NOT call an LLM itself - feed the
736
+ keyframes/transcript to Claude (see the session-analysis skill).
737
+
738
+ Examples:
739
+ bt pws sessions replay 1491 && bt pws sessions analyze 1491
740
+ bt pws sessions analyze --dir ./session-1491-replay
741
+ bt pws sessions analyze 1491 --model sonnet -o json
742
+ """
743
+ if model not in COST_MODELS:
744
+ print_error(f"Unknown --model '{model}'. Choose: {', '.join(COST_MODELS)}")
745
+ raise typer.Exit(2)
746
+
747
+ base = Path(folder) if folder else (Path(f"session-{session_id}-replay") if session_id else None)
748
+ if base is None:
749
+ print_error("Provide a session id or --dir.")
750
+ raise typer.Exit(2)
751
+ idx_path = base / "index.json"
752
+ if not idx_path.exists():
753
+ print_error(f"No index.json in {base} - run `bt pws sessions replay` first.")
754
+ raise typer.Exit(1)
755
+
756
+ idx = json.loads(idx_path.read_text())
757
+ protocol = idx.get("protocol", "RDP")
758
+ frames = idx.get("frames", [])
759
+ sid = session_id or idx.get("session_id")
760
+
761
+ # Pull keystrokes + session meta (best-effort; folder may be analyzed offline)
762
+ keystrokes: list[dict] = []
763
+ meta: dict = {}
764
+ if sid:
765
+ try:
766
+ with get_client() as client:
767
+ client.authenticate()
768
+ meta = client.get_session(sid)
769
+ keystrokes = client.get_keystrokes(sid)
770
+ except Exception as e:
771
+ if output != "json":
772
+ print_warning(f"Could not pull keystrokes for session {sid} ({e}); continuing with frames only.")
773
+
774
+ decoded_ks = [
775
+ {"time": _fmt_marker(k.get("TimeMarker")), "type": _keystroke_type(k.get("Type")), "data": k.get("Data", "")}
776
+ for k in sorted(keystrokes, key=lambda k: (k.get("TimeMarker", 0), k.get("KeystrokeID", 0)))
777
+ ]
778
+
779
+ w, h = _png_dims(base / frames[0]["file"]) if frames else (1024, 768)
780
+ per_img = _image_tokens(w, h)
781
+ ks_tokens = max(1, len(json.dumps(decoded_ks)) // 4)
782
+
783
+ # Correlate typed keystrokes to the recording timeline so we can target the
784
+ # frames that render each action (needs the recording's start time).
785
+ start_epoch = _epoch_ms(meta.get("StartTime"))
786
+ bursts = _keystroke_bursts(keystrokes, start_epoch)
787
+
788
+ # For SSH the transcript carries the session; frames are optional.
789
+ if protocol.upper() == "SSH":
790
+ keyframes = frames[:0] # recommend transcript-first
791
+ recommended = "transcript"
792
+ else:
793
+ keyframes = _build_keyframes(base, frames, threshold, bursts)
794
+ recommended = "keyframes"
795
+
796
+ model_id, price_in, price_out = COST_MODELS[model]
797
+ overhead, out_tokens = 1500, 1000
798
+
799
+ def _cost(n_imgs: int) -> dict:
800
+ inp = n_imgs * per_img + ks_tokens + overhead
801
+ usd = inp / 1e6 * price_in + out_tokens / 1e6 * price_out
802
+ return {"images": n_imgs, "input_tokens": inp, "output_tokens": out_tokens, "usd": round(usd, 4)}
803
+
804
+ est_focused = _cost(len(keyframes))
805
+ est_all = _cost(len(frames))
806
+
807
+ manifest = {
808
+ "session_id": sid,
809
+ "protocol": protocol,
810
+ "frame_dims": [w, h],
811
+ "frame_count": len(frames),
812
+ "keyframe_count": len(keyframes),
813
+ "keyframes": keyframes,
814
+ "keystroke_count": len(decoded_ks),
815
+ "keystrokes": decoded_ks,
816
+ "typed_bursts": bursts,
817
+ "recommended_input": recommended,
818
+ "cost_estimate": {"model": model_id, "focused": est_focused, "all_frames": est_all},
819
+ }
820
+ (base / "analysis.json").write_text(json.dumps(manifest, indent=2))
821
+
822
+ if output == "json":
823
+ console.print_json(json.dumps(manifest, default=str))
824
+ return
825
+
826
+ acct = meta.get("ManagedAccountName", "?")
827
+ sysn = meta.get("AssetName", "?")
828
+ console.print(f"[bold]Session {sid}[/bold] {protocol} {acct}@{sysn}")
829
+ console.print(f" Frames: {len(frames)} ({w}x{h}) Keystroke events: {len(decoded_ks)}")
830
+ if protocol.upper() == "SSH":
831
+ console.print(" [green]SSH → analyze the transcript[/green] (cheap, full input+output):")
832
+ console.print(f" [cyan]bt pws sessions transcript {sid}[/cyan]")
833
+ else:
834
+ anchored = sum(1 for k in keyframes if "after-typing" in k.get("reasons", []))
835
+ hint = f" ([cyan]{anchored}[/cyan] anchored to typed input)" if anchored else ""
836
+ console.print(f" [green]{len(keyframes)} keyframes[/green] (of {len(frames)}) — read these, not every frame{hint}:")
837
+ for k in keyframes:
838
+ typed_here = " [yellow]⌨ " + rich_escape(" ".join(k["typed"])) + "[/yellow]" if k.get("typed") else ""
839
+ reasons = ",".join(k.get("reasons", [])) or "screen-change"
840
+ console.print(f" {base / k['file']} [dim]@ {k['offset_ms']/1000:.1f}s · {reasons}[/dim]{typed_here}")
841
+ if bursts:
842
+ console.print(" [green]Typed input[/green] (recording-relative — anchors above):")
843
+ for b in bursts[:12]:
844
+ console.print(f" [dim]@ {b['offset_ms']/1000:.1f}s[/dim] {rich_escape(b['text'])}")
845
+ elif protocol.upper() != "SSH" and [k for k in decoded_ks if k["type"] == "input"]:
846
+ console.print(" [dim]Typed input present but no StartTime to anchor it to frames (offline?).[/dim]")
847
+ console.print(
848
+ f"\n [bold]Cost estimate[/bold] ({model_id}): "
849
+ f"focused ≈ [green]${est_focused['usd']:.4f}[/green] ({est_focused['images']} imgs) · "
850
+ f"all frames ≈ ${est_all['usd']:.4f} ({est_all['images']} imgs)"
851
+ )
852
+ console.print(f" [dim]Manifest: {base / 'analysis.json'}[/dim]")
853
+
854
+
589
855
  @app.command("suspend")
590
856
  def suspend_session(
591
857
  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
File without changes
File without changes
File without changes