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.
- {bt_cli-0.4.65 → bt_cli-0.4.68}/CLAUDE.md +3 -2
- {bt_cli-0.4.65 → bt_cli-0.4.68}/PKG-INFO +1 -1
- {bt_cli-0.4.65 → bt_cli-0.4.68}/pyproject.toml +1 -1
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/__init__.py +1 -1
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/cli.py +9 -5
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/data/skills/pws/SKILL.md +26 -0
- bt_cli-0.4.68/src/bt_cli/data/skills/session-analysis/SKILL.md +64 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/sessions.py +314 -1
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/pws/test_commands.py +57 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/.claude/skills/bt/SKILL.md +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/.claude/skills/entitle/SKILL.md +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/.claude/skills/epml/SKILL.md +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/.claude/skills/epmw/SKILL.md +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/.claude/skills/pra/SKILL.md +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/.claude/skills/pws/SKILL.md +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/.env.example +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/.github/workflows/ci.yml +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/.github/workflows/release.yml +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/.gitignore +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/README.md +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/assets/cli-help.png +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/assets/cli-output.png +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/bt-cli.spec +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/bt_entry.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/epml-clients-server-side-filters-plan.md +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/epml-implementation-plan.md +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/pf-implementation-plan.md +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/scripts/bt_entry.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/scripts/pf_onboard.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/scripts/sync-package-data.sh +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/commands/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/commands/configure.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/commands/learn.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/commands/quick.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/core/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/core/auth.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/core/client.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/core/config.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/core/config_file.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/core/csv_utils.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/core/errors.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/core/output.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/core/prompts.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/core/rest_debug.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/data/CLAUDE.md +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/data/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/data/skills/bt/SKILL.md +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/data/skills/entitle/SKILL.md +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/data/skills/epml/SKILL.md +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/data/skills/epmw/SKILL.md +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/data/skills/pf/SKILL.md +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/data/skills/pra/SKILL.md +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/data/skills/secrets/SKILL.md +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/client/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/client/base.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/commands/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/commands/accounts.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/commands/applications.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/commands/auth.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/commands/bundles.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/commands/integrations.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/commands/permissions.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/commands/policies.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/commands/requests.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/commands/resources.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/commands/roles.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/commands/users.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/commands/workflows.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/models/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/models/bundle.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/models/common.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/models/integration.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/models/permission.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/models/policy.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/models/resource.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/models/role.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/models/user.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/entitle/models/workflow.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/client/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/client/base.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/audit.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/auth.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/client_pkg.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/clients.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/external_apis.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/hosts.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/iolog.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/license.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/quick.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/rbp_cmdgrps.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/rbp_entitlement.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/rbp_hostgrps.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/rbp_policy.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/rbp_roles.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/rbp_tests.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/rbp_tmdategrps.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/rbp_tx.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/rbp_usergrps.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/settings.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/siems.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/commands/users.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epml/models/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epmw/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epmw/client/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epmw/client/base.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epmw/commands/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epmw/commands/audits.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epmw/commands/auth.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epmw/commands/computers.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epmw/commands/events.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epmw/commands/groups.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epmw/commands/policies.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epmw/commands/quick.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epmw/commands/requests.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epmw/commands/roles.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epmw/commands/tasks.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epmw/commands/users.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/epmw/models/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pf/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pf/client/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pf/client/base.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pf/commands/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pf/commands/auth.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pf/commands/machines.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pf/commands/tokens.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pf/commands/user.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pf/commands/users.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/client/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/client/base.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/commands/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/commands/auth.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/commands/group_policies.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/commands/import_export.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/commands/jump_clients.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/commands/jump_groups.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/commands/jump_items.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/commands/jumpoints.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/commands/policies.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/commands/quick.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/commands/teams.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/commands/users.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/commands/vault.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/models/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/models/common.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/models/group_policy.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/models/jump_client.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/models/jump_group.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/models/jump_item.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/models/jumpoint.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/models/team.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/models/user.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pra/models/vault.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/client/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/client/base.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/client/beyondinsight.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/client/passwordsafe.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/accounts.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/assets.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/attributes.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/audit.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/auth.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/clouds.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/config.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/credentials.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/databases.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/directories.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/functional.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/import_export.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/platforms.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/quick.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/search.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/secrets.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/systems.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/users.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/commands/workgroups.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/config.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/models/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/models/account.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/models/asset.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/models/common.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/pws/models/system.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/secrets/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/secrets/client/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/secrets/client/base.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/secrets/commands/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/secrets/commands/_hints.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/secrets/commands/auth.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/secrets/commands/dynamic.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/secrets/commands/folders.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/secrets/commands/integrations.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/secrets/commands/leases.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/secrets/commands/static.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/src/bt_cli/secrets/models/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/conftest.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/core/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/core/test_auth.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/core/test_config.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/core/test_config_file.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/core/test_errors.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/core/test_output.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/core/test_rest_debug.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/entitle/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/entitle/test_client.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/entitle/test_commands.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/entitle-smoke-test.sh +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/epml/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/epml/test_client.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/epml/test_commands.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/epmw/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/epmw/test_client.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/epmw/test_commands.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/epmw-quick-test-plan.md +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/fixtures/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/fixtures/responses.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/integration/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/integration/conftest.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/integration/helpers.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/integration/test_entitle_integration.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/integration/test_epmw_integration.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/integration/test_epmw_lifecycle.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/integration/test_pra_integration.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/integration/test_pra_lifecycle.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/integration/test_pws_integration.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/integration/test_pws_lifecycle.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/pf/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/pf/test_client.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/pf/test_commands.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/pra/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/pra/test_client.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/pra/test_commands.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/pra/test_jump_item_fixes.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/pra-smoke-test.sh +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/pra-test-plan.md +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/pws/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/pws/test_client.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/pws/test_quick_sessions.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/pws-quick-test-plan.md +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/pws-smoke-test.sh +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/secrets/__init__.py +0 -0
- {bt_cli-0.4.65 → bt_cli-0.4.68}/tests/secrets/test_client.py +0 -0
- {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.
|
|
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)
|
|
@@ -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
|
|
672
|
-
/pws
|
|
673
|
-
/pra
|
|
674
|
-
/entitle
|
|
675
|
-
/epmw
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|