asher-cli 1.5.0__tar.gz → 1.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. {asher_cli-1.5.0 → asher_cli-1.7.0}/.claude/skills/pylitterbot-ref/SKILL.md +4 -1
  2. {asher_cli-1.5.0 → asher_cli-1.7.0}/.github/workflows/bandit.yml +2 -2
  3. {asher_cli-1.5.0 → asher_cli-1.7.0}/.github/workflows/ci.yml +3 -3
  4. {asher_cli-1.5.0 → asher_cli-1.7.0}/.github/workflows/coverage.yml +1 -1
  5. {asher_cli-1.5.0 → asher_cli-1.7.0}/.github/workflows/release.yml +5 -5
  6. {asher_cli-1.5.0 → asher_cli-1.7.0}/CHANGELOG.md +26 -9
  7. {asher_cli-1.5.0 → asher_cli-1.7.0}/CLAUDE.md +4 -0
  8. {asher_cli-1.5.0 → asher_cli-1.7.0}/PKG-INFO +8 -10
  9. {asher_cli-1.5.0 → asher_cli-1.7.0}/README.md +3 -3
  10. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/core/export.py +4 -4
  11. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/core/faults.py +7 -0
  12. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/core/helpers.py +5 -5
  13. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/headless.py +4 -0
  14. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/mcp/config.py +9 -2
  15. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/robot/adapters.py +33 -43
  16. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/tui/commands/robot.py +4 -1
  17. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/tui/commands/slash.py +2 -2
  18. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/tui/history.py +3 -3
  19. {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/CONTRIBUTING.md +1 -1
  20. {asher_cli-1.5.0 → asher_cli-1.7.0}/pyproject.toml +10 -15
  21. {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/conftest.py +2 -2
  22. {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_activity_labels.py +3 -3
  23. {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_app_pilot.py +2 -2
  24. {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_commands_pilot.py +2 -2
  25. {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_completion.py +2 -2
  26. {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_export.py +5 -5
  27. {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_faults.py +11 -1
  28. {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_headless.py +30 -10
  29. {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_history_view.py +14 -16
  30. {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_lr5_commands.py +2 -2
  31. {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_mcp_command.py +1 -1
  32. {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_missing_robot_commands.py +8 -6
  33. {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_monitoring.py +3 -3
  34. {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_new_commands_pilot.py +3 -3
  35. {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_robot_adapters.py +11 -26
  36. {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/testhelpers.py +11 -11
  37. {asher_cli-1.5.0 → asher_cli-1.7.0}/uv.lock +1118 -1507
  38. {asher_cli-1.5.0 → asher_cli-1.7.0}/.claude/hooks/block-env.py +0 -0
  39. {asher_cli-1.5.0 → asher_cli-1.7.0}/.claude/settings.json +0 -0
  40. {asher_cli-1.5.0 → asher_cli-1.7.0}/.claude/skills/mcp-bridge/SKILL.md +0 -0
  41. {asher_cli-1.5.0 → asher_cli-1.7.0}/.claude/skills/textual/SKILL.md +0 -0
  42. {asher_cli-1.5.0 → asher_cli-1.7.0}/.env.example +0 -0
  43. {asher_cli-1.5.0 → asher_cli-1.7.0}/.githooks/pre-push +0 -0
  44. {asher_cli-1.5.0 → asher_cli-1.7.0}/.github/FUNDING.yml +0 -0
  45. {asher_cli-1.5.0 → asher_cli-1.7.0}/.github/pull_request_template.md +0 -0
  46. {asher_cli-1.5.0 → asher_cli-1.7.0}/.github/workflows/dependency-automerge.yml +0 -0
  47. {asher_cli-1.5.0 → asher_cli-1.7.0}/.gitignore +0 -0
  48. {asher_cli-1.5.0 → asher_cli-1.7.0}/.vscode/launch.json +0 -0
  49. {asher_cli-1.5.0 → asher_cli-1.7.0}/.vscode/settings.json +0 -0
  50. {asher_cli-1.5.0 → asher_cli-1.7.0}/.vscode/tasks.json +0 -0
  51. {asher_cli-1.5.0 → asher_cli-1.7.0}/CODEOWNERS +0 -0
  52. {asher_cli-1.5.0 → asher_cli-1.7.0}/LICENSE +0 -0
  53. {asher_cli-1.5.0 → asher_cli-1.7.0}/app.py +0 -0
  54. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/__init__.py +0 -0
  55. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/__main__.py +0 -0
  56. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/core/__init__.py +0 -0
  57. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/core/activity.py +0 -0
  58. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/core/config.py +0 -0
  59. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/core/constants.py +0 -0
  60. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/core/credentials.py +0 -0
  61. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/core/theme.py +0 -0
  62. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/core/updates.py +0 -0
  63. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/desktop/__init__.py +0 -0
  64. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/desktop/autostart.py +0 -0
  65. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/desktop/daemon.py +0 -0
  66. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/desktop/desktoptheme.py +0 -0
  67. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/desktop/launcher.py +0 -0
  68. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/desktop/notifications.py +0 -0
  69. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/desktop/tray.py +0 -0
  70. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/desktop/watcher.py +0 -0
  71. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/mcp/__init__.py +0 -0
  72. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/mcp/bridge.py +0 -0
  73. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/robot/__init__.py +0 -0
  74. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/robot/protocol.py +0 -0
  75. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/tui/__init__.py +0 -0
  76. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/tui/app.py +0 -0
  77. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/tui/cats.py +0 -0
  78. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/tui/commands/__init__.py +0 -0
  79. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/tui/commands/base.py +0 -0
  80. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/tui/commands/builtin.py +0 -0
  81. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/tui/commands/lr5.py +0 -0
  82. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/tui/commands/registry.py +0 -0
  83. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/tui/completion.py +0 -0
  84. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/tui/connection.py +0 -0
  85. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/tui/loginflow.py +0 -0
  86. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/tui/loginscreen.py +0 -0
  87. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/tui/monitoring.py +0 -0
  88. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/tui/style.tcss +0 -0
  89. {asher_cli-1.5.0 → asher_cli-1.7.0}/asher/tui/ui.py +0 -0
  90. {asher_cli-1.5.0 → asher_cli-1.7.0}/cliff.toml +0 -0
  91. {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/ROADMAP.md +0 -0
  92. {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/roadmap-archive/README.md +0 -0
  93. {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/roadmap-archive/cat-panel-badges.md +0 -0
  94. {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/roadmap-archive/config-persistence.md +0 -0
  95. {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/roadmap-archive/credentials.md +0 -0
  96. {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/roadmap-archive/desktop-notifications.md +0 -0
  97. {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/roadmap-archive/e2e-pilot.md +0 -0
  98. {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/roadmap-archive/fault-monitoring.md +0 -0
  99. {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/roadmap-archive/headless-export.md +0 -0
  100. {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/roadmap-archive/history-export.md +0 -0
  101. {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/roadmap-archive/lr5-features.md +0 -0
  102. {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/roadmap-archive/multi-pet.md +0 -0
  103. {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/roadmap-archive/robot-commands.md +0 -0
  104. {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/roadmap-archive/runtime-slash-commands.md +0 -0
  105. {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/roadmap-archive/slash-command-spec.md +0 -0
  106. {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/roadmap-archive/sleep-schedule.md +0 -0
  107. {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/roadmap-archive/tab-completion.md +0 -0
  108. {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/roadmap-archive/ui-ux.md +0 -0
  109. {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/roadmap-archive/version-display.md +0 -0
  110. {asher_cli-1.5.0 → asher_cli-1.7.0}/docs/roadmap-archive/websocket-updates.md +0 -0
  111. {asher_cli-1.5.0 → asher_cli-1.7.0}/renovate.json +0 -0
  112. {asher_cli-1.5.0 → asher_cli-1.7.0}/requirements.txt +0 -0
  113. {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/__init__.py +0 -0
  114. {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_auth.py +0 -0
  115. {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_auth_pilot.py +0 -0
  116. {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_autostart.py +0 -0
  117. {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_cats.py +0 -0
  118. {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_config.py +0 -0
  119. {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_connection_mixin.py +0 -0
  120. {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_credentials.py +0 -0
  121. {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_daemon.py +0 -0
  122. {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_desktoptheme.py +0 -0
  123. {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_launcher.py +0 -0
  124. {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_layering.py +0 -0
  125. {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_mcp_bridge.py +0 -0
  126. {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_mcp_config.py +0 -0
  127. {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_notifications.py +0 -0
  128. {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_tray.py +0 -0
  129. {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_ui.py +0 -0
  130. {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_updates.py +0 -0
  131. {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_version_command.py +0 -0
  132. {asher_cli-1.5.0 → asher_cli-1.7.0}/tests/test_watcher.py +0 -0
  133. {asher_cli-1.5.0 → asher_cli-1.7.0}/watchrun.py +0 -0
@@ -20,6 +20,7 @@ robot.sleep_mode_enabled # bool — NOT "sleeping"
20
20
  robot.panel_lock_enabled # bool — NOT "panel_lockout"
21
21
  robot.night_light_mode_enabled # bool
22
22
  robot.last_seen # datetime | None
23
+ robot.is_laser_dirty # bool — LR4 since 2025.7.0, LR5 always
23
24
  ```
24
25
 
25
26
  ## Robot methods (all async, await required)
@@ -28,6 +29,8 @@ robot.last_seen # datetime | None
28
29
  await robot.refresh()
29
30
  await robot.start_cleaning()
30
31
  await robot.set_sleep_mode(enabled: bool)
32
+ # LR4 (2025.8.0+) and LR5: per-weekday window, minutes from midnight, DayOfWeek Sun=0
33
+ await robot.set_sleep_mode(True, 1320, wake_time=420, day_of_week=1)
31
34
  await robot.set_panel_lockout(enabled: bool)
32
35
  await robot.set_night_light_brightness(brightness: int) # or:
33
36
  await robot.set_night_light_mode(mode: NightLightMode)
@@ -53,7 +56,7 @@ model_name = type(robot).__name__ # "LitterRobot4", "LitterRobot3", etc.
53
56
 
54
57
  | Feature | LR3 | LR4 | LR5 |
55
58
  |---|---|---|---|
56
- | `set_sleep_mode()` | Simple on/off | Per-weekday schedule | TBD |
59
+ | `set_sleep_mode()` | On/off + start time, fixed 8h | Per-weekday schedule (2025.8.0+); sleep == wake sleeps all day | Per-weekday schedule |
57
60
  | Night light | `set_night_light_brightness(int)` | `set_night_light_mode(NightLightMode)` | TBD |
58
61
 
59
62
  Use `asher/robot/adapters.py` for all model-specific dispatch — do not branch on `type(robot).__name__` inline in command handlers.
@@ -31,7 +31,7 @@ jobs:
31
31
  runs-on: ubuntu-latest
32
32
  steps:
33
33
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
34
- - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
34
+ - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
35
35
 
36
36
  - name: Bandit scan
37
37
  # --exit-zero: findings must not fail the job, or the upload step below
@@ -41,7 +41,7 @@ jobs:
41
41
  -c pyproject.toml -r . -f sarif -o bandit.sarif --exit-zero
42
42
 
43
43
  - name: Upload SARIF to code scanning
44
- uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
44
+ uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
45
45
  with:
46
46
  sarif_file: bandit.sarif
47
47
  category: bandit
@@ -19,7 +19,7 @@ jobs:
19
19
  runs-on: ubuntu-latest
20
20
  steps:
21
21
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
22
- - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
22
+ - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
23
23
  - run: uv sync --no-default-groups --group dev
24
24
  - run: uv run ruff check .
25
25
  - run: uv run ruff format --check .
@@ -30,11 +30,11 @@ jobs:
30
30
  needs: lint
31
31
  strategy:
32
32
  matrix:
33
- python-version: ["3.10", "3.11", "3.12"]
33
+ python-version: ["3.11", "3.12"]
34
34
  os: [ubuntu-latest, windows-latest, macos-latest]
35
35
  runs-on: ${{ matrix.os }}
36
36
  steps:
37
37
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
38
- - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
38
+ - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
39
39
  - run: uv sync --no-default-groups --group dev
40
40
  - run: uv run pytest tests/ -v --tb=short
@@ -18,7 +18,7 @@ jobs:
18
18
  runs-on: ubuntu-latest
19
19
  steps:
20
20
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
21
- - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
21
+ - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
22
22
  - run: uv sync --no-default-groups --group dev
23
23
  - name: Run tests with coverage
24
24
  run: uv run pytest tests/ --cov=asher --cov-report=lcov --cov-report=term-missing
@@ -19,9 +19,9 @@ jobs:
19
19
  - name: Extract version from branch name
20
20
  id: version
21
21
  run: echo "version=${GITHUB_REF_NAME#release/}" >> "$GITHUB_OUTPUT"
22
- - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
22
+ - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
23
23
  - run: uv build
24
- - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
24
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
25
25
  with:
26
26
  name: dist
27
27
  path: dist/
@@ -34,11 +34,11 @@ jobs:
34
34
  permissions:
35
35
  id-token: write # needed for PyPI OIDC trusted publishing
36
36
  steps:
37
- - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
37
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
38
38
  with:
39
39
  name: dist
40
40
  path: dist/
41
- - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
41
+ - uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # release/v1
42
42
 
43
43
  github-release:
44
44
  name: Create GitHub Release
@@ -48,7 +48,7 @@ jobs:
48
48
  contents: write # needed to create GitHub Releases
49
49
  steps:
50
50
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
51
- - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
51
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
52
52
  with:
53
53
  name: dist
54
54
  path: dist/
@@ -7,14 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  Generated by [git-cliff](https://git-cliff.org) from conventional commits.
9
9
 
10
+ ## [1.7.0] - 2026-11-07
11
+
12
+ ### 🚀 Features
13
+
14
+ - *(lr4)* Sleep schedules and panel faults from pylitterbot 2025.8.0
15
+
16
+ ## [1.6.0] - 2026-10-15
17
+
18
+ ### 🚀 Features
19
+
20
+ - *(faults)* Alert when LR4 laser sensors are dirty
21
+
22
+ ### 🔧 Miscellaneous
23
+
24
+ - [**breaking**] Drop Python 3.10 support
25
+
10
26
  ## [1.5.0] - 2026-10-03
11
27
 
12
- ### 🛡️ Security
28
+ ### 🐛 Bug Fixes
29
+
30
+ - *(mcp)* Don't overwrite a claude_desktop_config.json that can't be parsed
31
+ - *(mcp)* Cap mcp<2 when /mcp on installs the extra
13
32
 
14
- - *(mcp)* Cap `mcp<2` when `/mcp on` installs the extra
15
- - *(mcp)* Don't overwrite a `claude_desktop_config.json` that can't be parsed
16
- - *(ci)* Scope release token permissions to the jobs that need them
17
- - *(ci)* Pin bandit to 1.9.4
33
+ ### 👷 CI/CD
34
+
35
+ - *(release)* Scope token permissions to the jobs that need them
36
+ - *(bandit)* Pin bandit to 1.9.4
18
37
 
19
38
  ## [1.4.1] - 2026-10-01
20
39
 
@@ -23,10 +42,6 @@ Generated by [git-cliff](https://git-cliff.org) from conventional commits.
23
42
  - *(export)* Reach the full 30 days on the LR4
24
43
  - *(tui)* Hang wrapped log lines under their message
25
44
 
26
- ### 🛡️ Security
27
-
28
- - *(deps)* Refresh `uv.lock` to patch pyjwt and urllib3 CVEs (#101)
29
-
30
45
  ### ♻️ Refactor
31
46
 
32
47
  - Group the asher package into core, robot, tui, desktop and mcp
@@ -41,6 +56,8 @@ Generated by [git-cliff](https://git-cliff.org) from conventional commits.
41
56
 
42
57
  ### 🔧 Miscellaneous
43
58
 
59
+ - *(release)* Cut v1.4.1
60
+ - *(release)* Cut v1.4.0
44
61
  - Remove claude.yml
45
62
  - Remove claude-code-review.yml
46
63
 
@@ -277,3 +277,7 @@ Regenerate with `changelog-release X.Y.Z`, never plain `changelog` — the workf
277
277
  - Mock external deps with `unittest.mock.AsyncMock` for async robot/account methods
278
278
  - `from pylitterbot import Account` is a local import inside `_connect_worker` — patch it at `pylitterbot.Account`, not `asher.tui.connection.Account`
279
279
  - Coverage: ~76% overall; main gaps are async exception paths and `_connect_worker` auth flow
280
+
281
+ ## Temp files and large downloads
282
+
283
+ `/tmp` is tmpfs on this machine, and so is Claude Code's scratchpad (it lives under `/tmp`), so anything written there is held in RAM. Put venvs, model and runtime downloads, extracted archives and build output on disk under `~/.cache/claude-scratch/<task>/` instead, and keep only small throwaway files in the scratchpad. An in-repo `.venv` or `target/` is already on disk and fine.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: asher-cli
3
- Version: 1.5.0
3
+ Version: 1.7.0
4
4
  Summary: Terminal dashboard for Litter Robot (LR3/LR4/LR5) via the Whisker cloud API
5
5
  Project-URL: Homepage, https://github.com/karanshukla/asher-cli
6
6
  Project-URL: Repository, https://github.com/karanshukla/asher-cli
@@ -33,23 +33,21 @@ Classifier: Development Status :: 4 - Beta
33
33
  Classifier: Environment :: Console
34
34
  Classifier: License :: OSI Approved :: MIT License
35
35
  Classifier: Programming Language :: Python :: 3
36
- Classifier: Programming Language :: Python :: 3.10
37
36
  Classifier: Programming Language :: Python :: 3.11
38
37
  Classifier: Programming Language :: Python :: 3.12
39
38
  Classifier: Topic :: Home Automation
40
- Requires-Python: >=3.10
39
+ Requires-Python: >=3.11
41
40
  Requires-Dist: keyring>=24.0.0
42
41
  Requires-Dist: plyer>=2.1.0
43
- Requires-Dist: pylitterbot==2025.6.6
42
+ Requires-Dist: pylitterbot==2025.8.0
44
43
  Requires-Dist: python-dotenv>=1.0.0
45
44
  Requires-Dist: rich>=13.0.0
46
45
  Requires-Dist: textual>=0.47.0
47
46
  Requires-Dist: tzlocal>=5.4
48
47
  Provides-Extra: mcp
49
- Requires-Dist: mcp<2; extra == 'mcp'
50
- Requires-Dist: pylitterbot[mcp]==2025.6.6; extra == 'mcp'
48
+ Requires-Dist: pylitterbot[mcp]==2025.8.0; extra == 'mcp'
51
49
  Provides-Extra: tray
52
- Requires-Dist: pillow>=10.0; extra == 'tray'
50
+ Requires-Dist: pillow>=13.0; extra == 'tray'
53
51
  Requires-Dist: pygobject>=3.42; (sys_platform == 'linux') and extra == 'tray'
54
52
  Requires-Dist: pystray>=0.19; extra == 'tray'
55
53
  Description-Content-Type: text/markdown
@@ -75,7 +73,7 @@ A Claude Code-style terminal dashboard for monitoring and controlling Litter Rob
75
73
  - Live status bar — unit name, online/offline, drawer fill level, last activity, cat weight
76
74
  - Human-readable robot status — translates raw API states into plain English (`Ready`, `Cleaning`, `Cat Detected`, `Drawer Full`, etc.)
77
75
  - Real-time cycling indicator with elapsed time (`⟳ Cycling M:SS`)
78
- - Fault & safety monitoring — model-scoped in-panel alerts for cat detected, pinch, motor/position/gas faults (LR5: bonnet/laser/drawer); press `d` to dismiss
76
+ - Fault & safety monitoring — model-scoped in-panel alerts for cat detected, pinch, motor/position/gas faults, dirty laser sensors (LR4/LR5), LR5 bonnet/drawer; press `d` to dismiss
79
77
  - Cat panel with mode label + status badges (status chip, lock, night light, sleep, wait time) under the art
80
78
  - Scrollable activity-history pager — `history [count|all] [--type <kind>]` opens a full-screen, paginated view (arrow keys, `Page Up`/`Page Down`, `Home`/`End`); `c` copies the whole history to the clipboard; `q`/`Esc`/`Enter` to close
81
79
  - Commands: `clean`, `status`, `info`, `lock`, `unlock`, `sleep`, `wake`, `night-light on|off|auto|color <hex>`, `night-light-brightness`, `panel-brightness <low|medium|high>` (LR4/LR5), `wait-time`, `power on|off`, `rename`, `insight`, `sleep-schedule [set|disable]`, plus LR5 extras (`privacy`, `volume`, `camera-audio`, `drawer-reset`), `history [count|all] [--type <kind>]`, `export [days|month]`, `help`, `quit`
@@ -152,7 +150,7 @@ The keyring is the only place an installed copy reads credentials from. Working
152
150
  | `rename <new name>` | Rename the unit in the Whisker cloud |
153
151
  | `insight [days\|month]` | Show cycle-usage statistics (default: 30 days) |
154
152
  | `sleep-schedule` | Show the per-day sleep schedule |
155
- | `sleep-schedule set <day\|all> <HH:MM> <HH:MM>` | Set the sleep → wake window (LR3: one window for every day, wake time fixed; LR4: read-only, use the Whisker app) |
153
+ | `sleep-schedule set <day\|all> <HH:MM> <HH:MM>` | Set the sleep → wake window (LR3: one window for every day, wake time fixed; LR4: the same sleep and wake time sleeps all day) |
156
154
  | `sleep-schedule disable` | Turn every scheduled sleep window off |
157
155
  | `privacy on\|off` | Toggle LR5 privacy mode |
158
156
  | `volume <0-100>` | Set LR5 sound volume |
@@ -470,7 +468,7 @@ Scan settings live in `[tool.bandit]` in `pyproject.toml`, so local runs and the
470
468
  Bandit workflow report identical findings. Results from `main` and pull requests
471
469
  are published to the repository's **Security → Code scanning** tab.
472
470
 
473
- CI runs on Python 3.10 / 3.11 / 3.12 across Ubuntu, Windows, and macOS on every push.
471
+ CI runs on Python 3.11 / 3.12 across Ubuntu, Windows, and macOS on every push.
474
472
 
475
473
  ## Changelog
476
474
 
@@ -19,7 +19,7 @@ A Claude Code-style terminal dashboard for monitoring and controlling Litter Rob
19
19
  - Live status bar — unit name, online/offline, drawer fill level, last activity, cat weight
20
20
  - Human-readable robot status — translates raw API states into plain English (`Ready`, `Cleaning`, `Cat Detected`, `Drawer Full`, etc.)
21
21
  - Real-time cycling indicator with elapsed time (`⟳ Cycling M:SS`)
22
- - Fault & safety monitoring — model-scoped in-panel alerts for cat detected, pinch, motor/position/gas faults (LR5: bonnet/laser/drawer); press `d` to dismiss
22
+ - Fault & safety monitoring — model-scoped in-panel alerts for cat detected, pinch, motor/position/gas faults, dirty laser sensors (LR4/LR5), LR5 bonnet/drawer; press `d` to dismiss
23
23
  - Cat panel with mode label + status badges (status chip, lock, night light, sleep, wait time) under the art
24
24
  - Scrollable activity-history pager — `history [count|all] [--type <kind>]` opens a full-screen, paginated view (arrow keys, `Page Up`/`Page Down`, `Home`/`End`); `c` copies the whole history to the clipboard; `q`/`Esc`/`Enter` to close
25
25
  - Commands: `clean`, `status`, `info`, `lock`, `unlock`, `sleep`, `wake`, `night-light on|off|auto|color <hex>`, `night-light-brightness`, `panel-brightness <low|medium|high>` (LR4/LR5), `wait-time`, `power on|off`, `rename`, `insight`, `sleep-schedule [set|disable]`, plus LR5 extras (`privacy`, `volume`, `camera-audio`, `drawer-reset`), `history [count|all] [--type <kind>]`, `export [days|month]`, `help`, `quit`
@@ -96,7 +96,7 @@ The keyring is the only place an installed copy reads credentials from. Working
96
96
  | `rename <new name>` | Rename the unit in the Whisker cloud |
97
97
  | `insight [days\|month]` | Show cycle-usage statistics (default: 30 days) |
98
98
  | `sleep-schedule` | Show the per-day sleep schedule |
99
- | `sleep-schedule set <day\|all> <HH:MM> <HH:MM>` | Set the sleep → wake window (LR3: one window for every day, wake time fixed; LR4: read-only, use the Whisker app) |
99
+ | `sleep-schedule set <day\|all> <HH:MM> <HH:MM>` | Set the sleep → wake window (LR3: one window for every day, wake time fixed; LR4: the same sleep and wake time sleeps all day) |
100
100
  | `sleep-schedule disable` | Turn every scheduled sleep window off |
101
101
  | `privacy on\|off` | Toggle LR5 privacy mode |
102
102
  | `volume <0-100>` | Set LR5 sound volume |
@@ -414,7 +414,7 @@ Scan settings live in `[tool.bandit]` in `pyproject.toml`, so local runs and the
414
414
  Bandit workflow report identical findings. Results from `main` and pull requests
415
415
  are published to the repository's **Security → Code scanning** tab.
416
416
 
417
- CI runs on Python 3.10 / 3.11 / 3.12 across Ubuntu, Windows, and macOS on every push.
417
+ CI runs on Python 3.11 / 3.12 across Ubuntu, Windows, and macOS on every push.
418
418
 
419
419
  ## Changelog
420
420
 
@@ -10,7 +10,7 @@ identical regardless of how it was invoked.
10
10
  from __future__ import annotations
11
11
 
12
12
  import csv
13
- from datetime import datetime, timedelta, timezone, tzinfo
13
+ from datetime import UTC, datetime, timedelta, tzinfo
14
14
  from pathlib import Path
15
15
  from typing import TYPE_CHECKING, Any
16
16
 
@@ -69,7 +69,7 @@ def _filter_recent(acts: list[Any], cutoff: datetime) -> list[tuple[Any, datetim
69
69
  if ts_dt is None:
70
70
  continue
71
71
  if ts_dt.tzinfo is None:
72
- ts_dt = ts_dt.replace(tzinfo=timezone.utc)
72
+ ts_dt = ts_dt.replace(tzinfo=UTC)
73
73
  if ts_dt >= cutoff:
74
74
  filtered.append((act, ts_dt))
75
75
  filtered.sort(key=lambda x: x[1])
@@ -106,7 +106,7 @@ async def build_history_csv(robot: RobotProtocol, pets: list[Any], days: int, de
106
106
  ``ACTION_LABELS`` / ``activity_raw_text`` so events render identically to
107
107
  the ``history`` command output.
108
108
  """
109
- cutoff = datetime.now(tz=timezone.utc) - timedelta(days=days)
109
+ cutoff = datetime.now(tz=UTC) - timedelta(days=days)
110
110
  # Only the LR4's get_activity_history() takes a start time.
111
111
  fetch: Any = robot.get_activity_history
112
112
  try:
@@ -127,7 +127,7 @@ async def build_history_csv(robot: RobotProtocol, pets: list[Any], days: int, de
127
127
 
128
128
  local_tz: tzinfo = get_localzone()
129
129
  except Exception: # noqa: BLE001 — tz discovery is best-effort; fall back to UTC
130
- local_tz = timezone.utc
130
+ local_tz = UTC
131
131
 
132
132
  try:
133
133
  with dest.open("w", newline="", encoding="utf-8") as f:
@@ -48,6 +48,10 @@ class Fault:
48
48
  _SAFETY_STATUSES: dict[LitterBoxStatus, tuple[str, str]] = {
49
49
  LitterBoxStatus.CAT_DETECTED: ("CAT DETECTED — cycle halted", SEVERITY_ERROR),
50
50
  LitterBoxStatus.CAT_SENSOR_INTERRUPTED: ("CAT SENSOR INTERRUPTED", SEVERITY_ERROR),
51
+ LitterBoxStatus.CAT_SENSOR_FAULT: (
52
+ "CAT SENSOR FAULT — check for a blocked sensor",
53
+ SEVERITY_ERROR,
54
+ ),
51
55
  LitterBoxStatus.PINCH_DETECT: ("PINCH DETECT — possible obstruction", SEVERITY_ERROR),
52
56
  LitterBoxStatus.STARTUP_PINCH_DETECT: ("PINCH DETECT — possible obstruction", SEVERITY_ERROR),
53
57
  LitterBoxStatus.OVER_TORQUE_FAULT: ("OVER-TORQUE — globe blocked or jammed", SEVERITY_WARN),
@@ -104,6 +108,9 @@ _MODEL_ENUM_FAULTS: dict[str, tuple[_EnumCheck, ...]] = {
104
108
 
105
109
  _MODEL_BOOL_FAULTS: dict[str, tuple[_BoolCheck, ...]] = {
106
110
  # LR3 has no component-fault attributes exposed by pylitterbot.
111
+ "LitterRobot4": (
112
+ _BoolCheck("is_laser_dirty", "LASER SENSOR DIRTY — clean globe", SEVERITY_WARN),
113
+ ),
107
114
  "LitterRobot5": (
108
115
  _BoolCheck("is_bonnet_removed", "BONNET OPEN", SEVERITY_WARN),
109
116
  _BoolCheck("is_laser_dirty", "LASER SENSOR DIRTY — clean globe", SEVERITY_WARN),
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import os
6
6
  import re
7
- from datetime import datetime, time, timezone
7
+ from datetime import UTC, datetime, time
8
8
 
9
9
  from rich.text import Text
10
10
 
@@ -42,8 +42,8 @@ def fmt_ago(dt: datetime | None) -> str:
42
42
  if dt is None:
43
43
  return "never"
44
44
  if dt.tzinfo is None:
45
- dt = dt.replace(tzinfo=timezone.utc)
46
- s = int((datetime.now(timezone.utc) - dt).total_seconds())
45
+ dt = dt.replace(tzinfo=UTC)
46
+ s = int((datetime.now(UTC) - dt).total_seconds())
47
47
  if s < 60:
48
48
  return f"{s}s ago"
49
49
  if s < 3600:
@@ -58,8 +58,8 @@ def fmt_until(dt: datetime | None) -> str:
58
58
  if dt is None:
59
59
  return "—"
60
60
  if dt.tzinfo is None:
61
- dt = dt.replace(tzinfo=timezone.utc)
62
- seconds = (dt - datetime.now(timezone.utc)).total_seconds()
61
+ dt = dt.replace(tzinfo=UTC)
62
+ seconds = (dt - datetime.now(UTC)).total_seconds()
63
63
  stamp = dt.date().isoformat()
64
64
  # Counted away from now in whole days, so 3 days and an hour ago reads as
65
65
  # "3d" in either direction — timedelta.days alone floors that to 4.
@@ -381,6 +381,8 @@ async def _sleep_schedule(session: Session, args: list[str]) -> Result:
381
381
  "enabled": bool(getattr(day, "is_enabled", False)),
382
382
  "sleep_time": _sleep_time(getattr(day, "sleep_time", None)),
383
383
  "wake_time": _sleep_time(getattr(day, "wake_time", None)),
384
+ # LR4 only: matching sleep and wake times mean asleep all day.
385
+ "full_day": bool(getattr(day, "is_full_day", False)),
384
386
  }
385
387
  )
386
388
  days.sort(key=lambda entry: DAY_NAMES.index(entry["day"]))
@@ -392,6 +394,8 @@ async def _sleep_schedule(session: Session, args: list[str]) -> Result:
392
394
  if entry["enabled"]
393
395
  else "off"
394
396
  )
397
+ if entry["enabled"] and entry["full_day"]:
398
+ window += " (all day)"
395
399
  lines.append(f" {entry['day']} {window}")
396
400
  return Result(data={"enabled": enabled, "days": days}, lines=lines)
397
401
 
@@ -16,8 +16,15 @@ class McpConfigError(Exception):
16
16
 
17
17
 
18
18
  def mcp_extra_installed() -> bool:
19
- """Whether pylitterbot's mcp extra (the third-party `mcp` SDK) is importable."""
20
- return importlib.util.find_spec("mcp") is not None
19
+ """Whether pylitterbot's mcp extra (the third-party `mcp` SDK, 2.x) is importable.
20
+
21
+ Probes the module pylitterbot's server imports rather than `mcp` itself, so an
22
+ mcp 1.x left over from an older `/mcp on` counts as missing and gets upgraded.
23
+ """
24
+ try:
25
+ return importlib.util.find_spec("mcp.server.mcpserver") is not None
26
+ except ModuleNotFoundError:
27
+ return False
21
28
 
22
29
 
23
30
  def config_paths() -> list[Path]:
@@ -18,13 +18,6 @@ if TYPE_CHECKING:
18
18
  # litterrobot3.SLEEP_DURATION_HOURS), so only the start time is settable.
19
19
  LR3_SLEEP_HOURS = 8
20
20
 
21
- # The LR4 exposes no schedule setter at all — the app is the only way to change
22
- # one, and saying so beats a cloud rejection the user can't act on.
23
- LR4_SCHEDULE_NOTE = (
24
- "LR4 sleep schedules can only be changed in the Whisker app — "
25
- "'sleep-schedule' shows the one it's running"
26
- )
27
-
28
21
  # Only the LR5's activity endpoint takes a type parameter; the LR3/LR4 history
29
22
  # call returns whatever the cloud feels like sending.
30
23
  HISTORY_FILTER_NOTE = "Filtering history by type is only available on the LR5"
@@ -77,7 +70,7 @@ class RobotAdapter(ABC):
77
70
 
78
71
  Disabling is the same cloud call as waking the robot on every model that
79
72
  has one, so this rides on ``set_sleep`` rather than repeating the
80
- per-model dispatch — including the LR4's "use the app" refusal.
73
+ per-model dispatch.
81
74
  """
82
75
  ok, msg = await self.set_sleep(False)
83
76
  return (True, "Sleep schedule disabled for every day") if ok else (False, msg)
@@ -165,15 +158,41 @@ class LR3Adapter(RobotAdapter):
165
158
  )
166
159
 
167
160
 
168
- class LR4Adapter(RobotAdapter):
161
+ class _WeekdaySleepMixin:
162
+ """Sleep commands for the LR4 and LR5, which share pylitterbot's per-weekday
163
+ ``set_sleep_mode(value, sleep, wake_time=, day_of_week=)``."""
164
+
165
+ robot: RobotProtocol
166
+
169
167
  async def set_sleep(self, enable: bool) -> tuple[bool, str]:
170
- return False, LR4_SCHEDULE_NOTE
168
+ try:
169
+ ok = await self.robot.set_sleep_mode(enable)
170
+ except Exception as exc:
171
+ return False, f"Sleep mode change failed: {exc}"
172
+ if not ok:
173
+ return False, "Sleep mode command rejected"
174
+ return True, "Sleep mode enabled" if enable else "Robot woken up"
171
175
 
172
176
  async def set_sleep_window(
173
177
  self, weekday: int | None, sleep: time, wake: time
174
178
  ) -> tuple[bool, str]:
175
- return False, LR4_SCHEDULE_NOTE
179
+ robot: Any = self.robot
180
+ try:
181
+ ok = await robot.set_sleep_mode(
182
+ True,
183
+ sleep.hour * 60 + sleep.minute,
184
+ wake_time=wake.hour * 60 + wake.minute,
185
+ day_of_week=_to_day_of_week(weekday),
186
+ )
187
+ except Exception as exc:
188
+ return False, f"Sleep schedule change failed: {exc}"
189
+ if not ok:
190
+ return False, "Sleep schedule command rejected"
191
+ when = "every day" if weekday is None else DAY_NAMES[weekday]
192
+ return True, f"Sleeping {sleep:%H:%M} → {wake:%H:%M} on {when}"
193
+
176
194
 
195
+ class LR4Adapter(_WeekdaySleepMixin, RobotAdapter):
177
196
  async def set_night_light(self, mode: str) -> tuple[bool, str]:
178
197
  from pylitterbot.enums import NightLightMode # noqa: PLC0415
179
198
 
@@ -224,18 +243,9 @@ class LR4Adapter(RobotAdapter):
224
243
  return True, f"Panel brightness set to {level}"
225
244
 
226
245
 
227
- class LR5Adapter(RobotAdapter):
246
+ class LR5Adapter(_WeekdaySleepMixin, RobotAdapter):
228
247
  supports_history_filter = True
229
248
 
230
- async def set_sleep(self, enable: bool) -> tuple[bool, str]:
231
- try:
232
- ok = await self.robot.set_sleep_mode(enable)
233
- except Exception as exc:
234
- return False, f"Sleep mode change failed: {exc}"
235
- if not ok:
236
- return False, "Sleep mode command rejected"
237
- return True, "Sleep mode enabled" if enable else "Robot woken up"
238
-
239
249
  async def set_night_light(self, mode: str) -> tuple[bool, str]:
240
250
  from pylitterbot.enums import NightLightMode # noqa: PLC0415
241
251
 
@@ -335,24 +345,6 @@ class LR5Adapter(RobotAdapter):
335
345
  return False, "Night light colour command rejected"
336
346
  return True, f"Night light colour set to {colour}"
337
347
 
338
- async def set_sleep_window(
339
- self, weekday: int | None, sleep: time, wake: time
340
- ) -> tuple[bool, str]:
341
- robot: Any = self.robot
342
- try:
343
- ok = await robot.set_sleep_mode(
344
- True,
345
- sleep.hour * 60 + sleep.minute,
346
- wake_time=wake.hour * 60 + wake.minute,
347
- day_of_week=_to_day_of_week(weekday),
348
- )
349
- except Exception as exc:
350
- return False, f"Sleep schedule change failed: {exc}"
351
- if not ok:
352
- return False, "Sleep schedule command rejected"
353
- when = "every day" if weekday is None else DAY_NAMES[weekday]
354
- return True, f"Sleeping {sleep:%H:%M} → {wake:%H:%M} on {when}"
355
-
356
348
  async def get_history(
357
349
  self, limit: int, activity_type: str | None = None
358
350
  ) -> tuple[list[Activity] | None, str]:
@@ -380,10 +372,8 @@ class LR5Adapter(RobotAdapter):
380
372
  def _to_day_of_week(weekday: int | None) -> int | None:
381
373
  """Convert a Python weekday (Mon=0…Sun=6) to pylitterbot's DayOfWeek (Sun=0…Sat=6).
382
374
 
383
- ``set_sleep_mode``'s own docstring claims 0=Monday, but it matches the value
384
- against the ``dayOfWeek`` field of the schedule the cloud sent back, and
385
- those follow the ``DayOfWeek`` enum — so the enum's numbering is the one
386
- that actually selects a day.
375
+ ``set_sleep_mode`` matches the value against the ``dayOfWeek`` field of the
376
+ schedule the cloud sent back, and those follow the ``DayOfWeek`` enum.
387
377
  """
388
378
  return None if weekday is None else (weekday + 1) % 7
389
379
 
@@ -121,7 +121,7 @@ class CleanCommand(Command):
121
121
  timed_out = False
122
122
  try:
123
123
  await asyncio.wait_for(done.wait(), timeout=300)
124
- except asyncio.TimeoutError:
124
+ except TimeoutError:
125
125
  timed_out = True
126
126
  finally:
127
127
  unsubscribe()
@@ -754,6 +754,9 @@ class SleepScheduleCommand(Command):
754
754
  t.append(f" {name} ", style=theme.MUTED)
755
755
  if day_enabled:
756
756
  window_str = f"{_fmt_sleep_time(sleep_t)} → {_fmt_sleep_time(wake_t)}"
757
+ # LR4 only: matching sleep and wake times mean asleep all day.
758
+ if getattr(day, "is_full_day", False):
759
+ window_str += " (all day)"
757
760
  t.append(window_str, style=theme.FOREGROUND)
758
761
  if idx in active_day_indices:
759
762
  t.append(" ● now", style=f"bold {theme.WARN}")
@@ -439,8 +439,8 @@ async def _ensure_mcp_extra(app: AsherApp) -> bool:
439
439
  if mcp_extra_installed():
440
440
  return True
441
441
 
442
- # Same requirements as the `mcp` extra in pyproject.toml, cap included.
443
- requirements = [f"pylitterbot[mcp]=={pkg_version('pylitterbot')}", "mcp<2"]
442
+ # Same requirement as the `mcp` extra in pyproject.toml.
443
+ requirements = [f"pylitterbot[mcp]=={pkg_version('pylitterbot')}"]
444
444
  app._log_info(f"Installing {' '.join(requirements)}…")
445
445
  proc = await asyncio.create_subprocess_exec(
446
446
  sys.executable,
@@ -22,7 +22,7 @@ vertically regardless of an event's age — every event shows its date.
22
22
  from __future__ import annotations
23
23
 
24
24
  from collections import Counter
25
- from datetime import datetime, timezone
25
+ from datetime import UTC, datetime
26
26
  from typing import TYPE_CHECKING, Any
27
27
 
28
28
  from rich.text import Text
@@ -61,7 +61,7 @@ def _localize(ts: object) -> datetime | None:
61
61
  if not isinstance(ts, datetime):
62
62
  return None
63
63
  if ts.tzinfo is None:
64
- ts = ts.replace(tzinfo=timezone.utc)
64
+ ts = ts.replace(tzinfo=UTC)
65
65
  return ts.astimezone(get_localzone())
66
66
 
67
67
 
@@ -69,7 +69,7 @@ def _ts_str(local_dt: datetime | None) -> str:
69
69
  """Fixed-width timestamp: ``MM/DD HH:MM`` this year, ``YYYY-MM-DD`` older."""
70
70
  if local_dt is None:
71
71
  return "?"
72
- if local_dt.year == datetime.now(tz=timezone.utc).year:
72
+ if local_dt.year == datetime.now(tz=UTC).year:
73
73
  return local_dt.strftime("%m/%d %H:%M")
74
74
  return local_dt.strftime("%Y-%m-%d")
75
75
 
@@ -51,7 +51,7 @@ uv run pytest --cov=asher --cov-report=term-missing
51
51
  Per-file descriptions are in `CLAUDE.md` § Package structure. Pilot gotchas are
52
52
  in `CLAUDE.md` § Testing notes.
53
53
 
54
- CI matrix: Python 3.10 / 3.11 / 3.12 × Ubuntu / Windows / macOS.
54
+ CI matrix: Python 3.11 / 3.12 × Ubuntu / Windows / macOS.
55
55
 
56
56
  ### Coverage
57
57