bt-cli 0.4.64__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.
- {bt_cli-0.4.64 → bt_cli-0.4.67}/CLAUDE.md +3 -2
- {bt_cli-0.4.64 → bt_cli-0.4.67}/PKG-INFO +1 -1
- {bt_cli-0.4.64 → bt_cli-0.4.67}/pyproject.toml +1 -1
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/__init__.py +1 -1
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/cli.py +9 -5
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/data/skills/pws/SKILL.md +20 -11
- bt_cli-0.4.67/src/bt_cli/data/skills/session-analysis/SKILL.md +64 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pws/commands/sessions.py +321 -17
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/pws/test_commands.py +99 -2
- {bt_cli-0.4.64 → bt_cli-0.4.67}/.claude/skills/bt/SKILL.md +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/.claude/skills/entitle/SKILL.md +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/.claude/skills/epml/SKILL.md +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/.claude/skills/epmw/SKILL.md +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/.claude/skills/pra/SKILL.md +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/.claude/skills/pws/SKILL.md +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/.env.example +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/.github/workflows/ci.yml +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/.github/workflows/release.yml +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/.gitignore +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/README.md +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/assets/cli-help.png +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/assets/cli-output.png +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/bt-cli.spec +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/bt_entry.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/epml-clients-server-side-filters-plan.md +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/epml-implementation-plan.md +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/pf-implementation-plan.md +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/scripts/bt_entry.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/scripts/pf_onboard.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/scripts/sync-package-data.sh +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/commands/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/commands/configure.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/commands/learn.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/commands/quick.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/core/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/core/auth.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/core/client.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/core/config.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/core/config_file.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/core/csv_utils.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/core/errors.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/core/output.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/core/prompts.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/core/rest_debug.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/data/CLAUDE.md +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/data/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/data/skills/bt/SKILL.md +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/data/skills/entitle/SKILL.md +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/data/skills/epml/SKILL.md +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/data/skills/epmw/SKILL.md +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/data/skills/pf/SKILL.md +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/data/skills/pra/SKILL.md +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/data/skills/secrets/SKILL.md +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/entitle/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/entitle/client/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/entitle/client/base.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/entitle/commands/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/entitle/commands/accounts.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/entitle/commands/applications.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/entitle/commands/auth.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/entitle/commands/bundles.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/entitle/commands/integrations.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/entitle/commands/permissions.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/entitle/commands/policies.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/entitle/commands/requests.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/entitle/commands/resources.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/entitle/commands/roles.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/entitle/commands/users.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/entitle/commands/workflows.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/entitle/models/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/entitle/models/bundle.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/entitle/models/common.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/entitle/models/integration.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/entitle/models/permission.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/entitle/models/policy.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/entitle/models/resource.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/entitle/models/role.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/entitle/models/user.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/entitle/models/workflow.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epml/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epml/client/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epml/client/base.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epml/commands/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epml/commands/audit.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epml/commands/auth.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epml/commands/client_pkg.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epml/commands/clients.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epml/commands/external_apis.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epml/commands/hosts.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epml/commands/iolog.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epml/commands/license.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epml/commands/quick.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epml/commands/rbp_cmdgrps.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epml/commands/rbp_entitlement.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epml/commands/rbp_hostgrps.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epml/commands/rbp_policy.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epml/commands/rbp_roles.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epml/commands/rbp_tests.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epml/commands/rbp_tmdategrps.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epml/commands/rbp_tx.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epml/commands/rbp_usergrps.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epml/commands/settings.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epml/commands/siems.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epml/commands/users.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epml/models/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epmw/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epmw/client/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epmw/client/base.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epmw/commands/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epmw/commands/audits.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epmw/commands/auth.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epmw/commands/computers.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epmw/commands/events.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epmw/commands/groups.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epmw/commands/policies.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epmw/commands/quick.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epmw/commands/requests.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epmw/commands/roles.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epmw/commands/tasks.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epmw/commands/users.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/epmw/models/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pf/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pf/client/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pf/client/base.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pf/commands/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pf/commands/auth.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pf/commands/machines.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pf/commands/tokens.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pf/commands/user.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pf/commands/users.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pra/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pra/client/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pra/client/base.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pra/commands/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pra/commands/auth.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pra/commands/group_policies.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pra/commands/import_export.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pra/commands/jump_clients.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pra/commands/jump_groups.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pra/commands/jump_items.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pra/commands/jumpoints.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pra/commands/policies.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pra/commands/quick.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pra/commands/teams.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pra/commands/users.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pra/commands/vault.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pra/models/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pra/models/common.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pra/models/group_policy.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pra/models/jump_client.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pra/models/jump_group.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pra/models/jump_item.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pra/models/jumpoint.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pra/models/team.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pra/models/user.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pra/models/vault.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pws/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pws/client/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pws/client/base.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pws/client/beyondinsight.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pws/client/passwordsafe.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pws/commands/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pws/commands/accounts.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pws/commands/assets.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pws/commands/attributes.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pws/commands/audit.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pws/commands/auth.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pws/commands/clouds.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pws/commands/config.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pws/commands/credentials.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pws/commands/databases.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pws/commands/directories.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pws/commands/functional.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pws/commands/import_export.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pws/commands/platforms.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pws/commands/quick.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pws/commands/search.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pws/commands/secrets.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pws/commands/systems.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pws/commands/users.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pws/commands/workgroups.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pws/config.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pws/models/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pws/models/account.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pws/models/asset.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pws/models/common.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/pws/models/system.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/secrets/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/secrets/client/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/secrets/client/base.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/secrets/commands/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/secrets/commands/_hints.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/secrets/commands/auth.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/secrets/commands/dynamic.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/secrets/commands/folders.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/secrets/commands/integrations.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/secrets/commands/leases.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/secrets/commands/static.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/src/bt_cli/secrets/models/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/conftest.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/core/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/core/test_auth.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/core/test_config.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/core/test_config_file.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/core/test_errors.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/core/test_output.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/core/test_rest_debug.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/entitle/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/entitle/test_client.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/entitle/test_commands.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/entitle-smoke-test.sh +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/epml/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/epml/test_client.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/epml/test_commands.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/epmw/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/epmw/test_client.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/epmw/test_commands.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/epmw-quick-test-plan.md +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/fixtures/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/fixtures/responses.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/integration/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/integration/conftest.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/integration/helpers.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/integration/test_entitle_integration.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/integration/test_epmw_integration.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/integration/test_epmw_lifecycle.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/integration/test_pra_integration.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/integration/test_pra_lifecycle.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/integration/test_pws_integration.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/integration/test_pws_lifecycle.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/pf/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/pf/test_client.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/pf/test_commands.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/pra/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/pra/test_client.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/pra/test_commands.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/pra/test_jump_item_fixes.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/pra-smoke-test.sh +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/pra-test-plan.md +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/pws/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/pws/test_client.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/pws/test_quick_sessions.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/pws-quick-test-plan.md +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/pws-smoke-test.sh +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/secrets/__init__.py +0 -0
- {bt_cli-0.4.64 → bt_cli-0.4.67}/tests/secrets/test_client.py +0 -0
- {bt_cli-0.4.64 → 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.
|
|
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
|
|
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
|
|
@@ -215,22 +215,31 @@ recording. For session *metadata* evidence, use `bt pws audit export`.
|
|
|
215
215
|
|
|
216
216
|
```bash
|
|
217
217
|
# Download the VISUAL recording as PNG frames (RDP screen / SSH terminal)
|
|
218
|
-
bt pws sessions replay <session_id> #
|
|
219
|
-
bt pws sessions replay <session_id> --
|
|
218
|
+
bt pws sessions replay <session_id> # ~60 frames across the session, deduped
|
|
219
|
+
bt pws sessions replay <session_id> --frames 30 # ~30 evenly-spaced frames
|
|
220
220
|
bt pws sessions replay <session_id> --fps 1 --to 120 # 1 fps for the first 2 minutes
|
|
221
|
-
bt pws sessions replay <session_id> --
|
|
221
|
+
bt pws sessions replay <session_id> --all-frames --no-dedup # every recorded frame, verbatim
|
|
222
|
+
bt pws sessions replay <session_id> --mp4 # also stitch an MP4 (needs ffmpeg)
|
|
222
223
|
```
|
|
223
224
|
|
|
224
225
|
`replay` drives the headless renderer at `/pbsm/replay` (there is **no**
|
|
225
226
|
video-export API): create replay → seek by offset → pull one PNG per frame →
|
|
226
|
-
delete. By default it
|
|
227
|
-
`
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
227
|
+
delete. **By default it captures ~60 evenly-spaced frames across the whole
|
|
228
|
+
session and skips identical frames** (`--dedup`), so you see the full timeline
|
|
229
|
+
rather than just the busy start. Sampling modes are mutually exclusive:
|
|
230
|
+
|
|
231
|
+
- `--frames N` (default, N=60) — N frames spread evenly across the session
|
|
232
|
+
- `--fps F` / `--interval MS` — fixed-rate sampling
|
|
233
|
+
- `--all-frames` — event-driven, one frame per recorded change (forensic; can be
|
|
234
|
+
huge — RDP records many deltas while the screen paints, which is why this
|
|
235
|
+
isn't the default)
|
|
236
|
+
|
|
237
|
+
**One network round-trip per frame**, so bound long captures with `--frames`,
|
|
238
|
+
`--from/--to`, or `--max-frames` (default 3600). `--no-dedup` keeps every
|
|
239
|
+
sampled frame. `--mp4` stitches with ffmpeg (H.264); if ffmpeg is missing it
|
|
240
|
+
prints the exact command + install instructions and leaves the frames. Works for
|
|
241
|
+
RDP (screen) and SSH (rendered terminal). Gotcha: the renderer needs ~0.5s
|
|
242
|
+
warm-up (first seek 404s), handled automatically.
|
|
234
243
|
|
|
235
244
|
**Gotchas:**
|
|
236
245
|
- `/Sessions` returns session **history**, not just active sessions, and **ignores `limit`/`offset`** (returns the full set every call - the client does a single GET, the CLI caps display client-side). Use `--active` for the live ones.
|
|
@@ -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.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""CLI commands for managing active Password Safe sessions."""
|
|
2
2
|
|
|
3
|
+
import hashlib
|
|
3
4
|
import json
|
|
4
5
|
import shutil
|
|
5
6
|
import subprocess
|
|
@@ -10,6 +11,7 @@ from pathlib import Path
|
|
|
10
11
|
import httpx
|
|
11
12
|
import typer
|
|
12
13
|
from rich.console import Console
|
|
14
|
+
from rich.markup import escape as rich_escape
|
|
13
15
|
from rich.table import Table
|
|
14
16
|
|
|
15
17
|
from ...core.output import print_api_error, print_error, print_warning
|
|
@@ -365,8 +367,11 @@ def _warmup_seek(client, replay_id: str, offset_ms: int, quiet: bool, attempts:
|
|
|
365
367
|
def session_replay(
|
|
366
368
|
session_id: int = typer.Argument(..., help="Session ID (from `sessions list`)"),
|
|
367
369
|
out: str = typer.Option(None, "--out", help="Output directory (default: ./session-<id>-replay)"),
|
|
368
|
-
|
|
369
|
-
|
|
370
|
+
frames: int = typer.Option(None, "--frames", help="Target number of evenly-spaced frames across the session (default: 60)"),
|
|
371
|
+
fps: float = typer.Option(None, "--fps", help="Fixed sampling rate in frames/sec (alternative to --frames)"),
|
|
372
|
+
interval: int = typer.Option(None, "--interval", help="Fixed sampling interval in ms (alternative to --frames)"),
|
|
373
|
+
all_frames: bool = typer.Option(False, "--all-frames", help="Capture every recorded frame change (event-driven; forensic, can be very large)"),
|
|
374
|
+
dedup: bool = typer.Option(True, "--dedup/--no-dedup", help="Skip consecutive identical frames (on by default)"),
|
|
370
375
|
scale: int = typer.Option(1, "--scale", help="PNG scale factor (1 = full size)"),
|
|
371
376
|
start: float = typer.Option(0, "--from", help="Start offset in seconds"),
|
|
372
377
|
end: float = typer.Option(None, "--to", help="End offset in seconds (default: end of session)"),
|
|
@@ -381,21 +386,34 @@ def session_replay(
|
|
|
381
386
|
Password Safe has no video-export API, so this drives the headless replay
|
|
382
387
|
renderer: it seeks through the recording and pulls one PNG per frame, then
|
|
383
388
|
(with --mp4) stitches them with ffmpeg. Works for RDP (screen) and SSH
|
|
384
|
-
(rendered terminal).
|
|
385
|
-
|
|
389
|
+
(rendered terminal).
|
|
390
|
+
|
|
391
|
+
By default it captures ~60 evenly-spaced frames across the whole session
|
|
392
|
+
(so you see the full timeline, not just the busy start) and skips identical
|
|
393
|
+
frames (--dedup). Use --frames/--fps/--interval to change the sampling rate,
|
|
394
|
+
or --all-frames for the forensic every-recorded-change mode (can be huge -
|
|
395
|
+
RDP records many deltas while the screen paints). One network round-trip per
|
|
396
|
+
frame, so bound long captures with --frames, --from/--to, or --max-frames.
|
|
386
397
|
|
|
387
398
|
Examples:
|
|
388
|
-
bt pws sessions replay 1460 #
|
|
389
|
-
bt pws sessions replay 1460 --
|
|
399
|
+
bt pws sessions replay 1460 # ~60 frames across the session, deduped
|
|
400
|
+
bt pws sessions replay 1460 --frames 30 # ~30 evenly-spaced frames
|
|
390
401
|
bt pws sessions replay 1460 --fps 1 --to 120 # 1 fps for the first 2 minutes
|
|
391
|
-
bt pws sessions replay 1460 --
|
|
402
|
+
bt pws sessions replay 1460 --all-frames --no-dedup # every recorded frame, verbatim
|
|
403
|
+
bt pws sessions replay 1460 --mp4 # also build an MP4
|
|
392
404
|
"""
|
|
393
405
|
quiet = output == "json"
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
406
|
+
# Resolve sampling mode (mutually exclusive). Default = even-coverage frames.
|
|
407
|
+
n_modes = sum(x is not None for x in (frames, fps, interval)) + int(all_frames)
|
|
408
|
+
if n_modes > 1:
|
|
409
|
+
print_error("Choose one of --frames / --fps / --interval / --all-frames.")
|
|
397
410
|
raise typer.Exit(2)
|
|
398
|
-
|
|
411
|
+
|
|
412
|
+
fixed_interval = None # set for fps/interval; computed later for frames mode
|
|
413
|
+
frames_target = None # set for even-coverage frames mode
|
|
414
|
+
if all_frames:
|
|
415
|
+
pass # event-driven: fixed_interval stays None
|
|
416
|
+
elif fps is not None:
|
|
399
417
|
if fps <= 0:
|
|
400
418
|
print_error("--fps must be positive.")
|
|
401
419
|
raise typer.Exit(2)
|
|
@@ -405,6 +423,11 @@ def session_replay(
|
|
|
405
423
|
print_error("--interval must be positive.")
|
|
406
424
|
raise typer.Exit(2)
|
|
407
425
|
fixed_interval = interval
|
|
426
|
+
else:
|
|
427
|
+
frames_target = frames if frames is not None else 60
|
|
428
|
+
if frames_target <= 0:
|
|
429
|
+
print_error("--frames must be positive.")
|
|
430
|
+
raise typer.Exit(2)
|
|
408
431
|
|
|
409
432
|
out_dir = Path(out) if out else Path(f"session-{session_id}-replay")
|
|
410
433
|
replay_id = None
|
|
@@ -435,7 +458,15 @@ def session_replay(
|
|
|
435
458
|
total_ms = state.get("duration") or int(duration_s * 1000)
|
|
436
459
|
end_ms = int(end * 1000) if end is not None else total_ms
|
|
437
460
|
|
|
461
|
+
# Even-coverage mode: spread the target frame count across the span
|
|
462
|
+
# (endpoints included), then capture like a fixed interval.
|
|
463
|
+
if frames_target is not None:
|
|
464
|
+
span = max(0, end_ms - start_ms)
|
|
465
|
+
fixed_interval = max(1, span // frames_target) if frames_target > 1 else span + 1
|
|
466
|
+
|
|
438
467
|
frames: list[dict] = []
|
|
468
|
+
deduped = 0
|
|
469
|
+
last_hash = None
|
|
439
470
|
offset = start_ms
|
|
440
471
|
settle = settle_ms / 1000.0
|
|
441
472
|
while True:
|
|
@@ -450,13 +481,19 @@ def session_replay(
|
|
|
450
481
|
if fixed_interval is None:
|
|
451
482
|
break
|
|
452
483
|
else:
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
484
|
+
digest = hashlib.sha1(png).digest()
|
|
485
|
+
if dedup and digest == last_hash:
|
|
486
|
+
deduped += 1 # identical to the previous kept frame - skip
|
|
487
|
+
else:
|
|
488
|
+
last_hash = digest
|
|
489
|
+
idx = len(frames)
|
|
490
|
+
fname = f"frame_{idx:04d}.png"
|
|
491
|
+
(out_dir / fname).write_bytes(png)
|
|
492
|
+
frames.append({"file": fname, "offset_ms": offset})
|
|
457
493
|
if not quiet:
|
|
458
494
|
pct = min(100, int(offset / total_ms * 100)) if total_ms else 0
|
|
459
|
-
|
|
495
|
+
dd = f" (-{deduped} dup)" if deduped else ""
|
|
496
|
+
console.print(f"\r[cyan] {len(frames)} frames {offset/1000:.1f}s ({pct}%){dd}[/cyan]", end="")
|
|
460
497
|
|
|
461
498
|
# Advance
|
|
462
499
|
if fixed_interval is not None:
|
|
@@ -515,13 +552,15 @@ def session_replay(
|
|
|
515
552
|
"protocol": protocol,
|
|
516
553
|
"output_dir": str(out_dir.resolve()),
|
|
517
554
|
"frame_count": len(frames),
|
|
555
|
+
"deduped": deduped,
|
|
518
556
|
"mp4": str(mp4_path.resolve()) if mp4_path else None,
|
|
519
557
|
}, default=str))
|
|
520
558
|
else:
|
|
521
559
|
if not frames:
|
|
522
560
|
console.print(f"[yellow]No frames captured for session {session_id}.[/yellow]")
|
|
523
561
|
else:
|
|
524
|
-
|
|
562
|
+
dd = f" ({deduped} duplicate(s) skipped)" if deduped else ""
|
|
563
|
+
console.print(f"[green]Captured {len(frames)} frame(s).{dd}[/green]")
|
|
525
564
|
console.print(f" Frames: [cyan]{out_dir.resolve()}[/cyan]")
|
|
526
565
|
if mp4_path:
|
|
527
566
|
console.print(f" MP4: [cyan]{mp4_path.resolve()}[/cyan]")
|
|
@@ -548,6 +587,271 @@ def session_replay(
|
|
|
548
587
|
pass
|
|
549
588
|
|
|
550
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
|
+
|
|
551
855
|
@app.command("suspend")
|
|
552
856
|
def suspend_session(
|
|
553
857
|
session_id: int = typer.Argument(..., help="Session ID to lock/suspend"),
|