asher-cli 1.0.0__tar.gz → 1.0.2__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 (94) hide show
  1. asher_cli-1.0.2/.github/workflows/bandit.yml +47 -0
  2. {asher_cli-1.0.0 → asher_cli-1.0.2}/.github/workflows/coverage.yml +1 -1
  3. {asher_cli-1.0.0 → asher_cli-1.0.2}/CHANGELOG.md +22 -1
  4. {asher_cli-1.0.0 → asher_cli-1.0.2}/CLAUDE.md +5 -2
  5. {asher_cli-1.0.0 → asher_cli-1.0.2}/PKG-INFO +12 -2
  6. {asher_cli-1.0.0 → asher_cli-1.0.2}/README.md +11 -1
  7. {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/commands/__init__.py +72 -36
  8. {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/history_view.py +62 -5
  9. {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/mcp_bridge.py +3 -1
  10. {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/monitoring/__init__.py +4 -12
  11. {asher_cli-1.0.0 → asher_cli-1.0.2}/pyproject.toml +19 -2
  12. {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_history_view.py +117 -1
  13. {asher_cli-1.0.0 → asher_cli-1.0.2}/uv.lock +170 -170
  14. {asher_cli-1.0.0 → asher_cli-1.0.2}/watchrun.py +11 -5
  15. {asher_cli-1.0.0 → asher_cli-1.0.2}/.claude/hooks/block-env.ps1 +0 -0
  16. {asher_cli-1.0.0 → asher_cli-1.0.2}/.claude/settings.json +0 -0
  17. {asher_cli-1.0.0 → asher_cli-1.0.2}/.claude/skills/pylitterbot-ref/SKILL.md +0 -0
  18. {asher_cli-1.0.0 → asher_cli-1.0.2}/.claude/skills/release/SKILL.md +0 -0
  19. {asher_cli-1.0.0 → asher_cli-1.0.2}/.claude/skills/textual/SKILL.md +0 -0
  20. {asher_cli-1.0.0 → asher_cli-1.0.2}/.env.example +0 -0
  21. {asher_cli-1.0.0 → asher_cli-1.0.2}/.githooks/pre-push +0 -0
  22. {asher_cli-1.0.0 → asher_cli-1.0.2}/.github/pull_request_template.md +0 -0
  23. {asher_cli-1.0.0 → asher_cli-1.0.2}/.github/workflows/ci.yml +0 -0
  24. {asher_cli-1.0.0 → asher_cli-1.0.2}/.github/workflows/claude-code-review.yml +0 -0
  25. {asher_cli-1.0.0 → asher_cli-1.0.2}/.github/workflows/claude.yml +0 -0
  26. {asher_cli-1.0.0 → asher_cli-1.0.2}/.github/workflows/release.yml +0 -0
  27. {asher_cli-1.0.0 → asher_cli-1.0.2}/.gitignore +0 -0
  28. {asher_cli-1.0.0 → asher_cli-1.0.2}/.vscode/launch.json +0 -0
  29. {asher_cli-1.0.0 → asher_cli-1.0.2}/.vscode/settings.json +0 -0
  30. {asher_cli-1.0.0 → asher_cli-1.0.2}/.vscode/tasks.json +0 -0
  31. {asher_cli-1.0.0 → asher_cli-1.0.2}/CODEOWNERS +0 -0
  32. {asher_cli-1.0.0 → asher_cli-1.0.2}/LICENSE +0 -0
  33. {asher_cli-1.0.0 → asher_cli-1.0.2}/app.py +0 -0
  34. {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/__init__.py +0 -0
  35. {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/__main__.py +0 -0
  36. {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/activity_labels.py +0 -0
  37. {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/app.py +0 -0
  38. {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/auth.py +0 -0
  39. {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/cats.py +0 -0
  40. {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/commands/base.py +0 -0
  41. {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/completion.py +0 -0
  42. {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/config.py +0 -0
  43. {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/connection/__init__.py +0 -0
  44. {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/constants.py +0 -0
  45. {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/export.py +0 -0
  46. {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/faults.py +0 -0
  47. {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/helpers.py +0 -0
  48. {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/login_flow.py +0 -0
  49. {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/mcp_config.py +0 -0
  50. {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/notifications.py +0 -0
  51. {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/robot_adapters.py +0 -0
  52. {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/robot_protocol.py +0 -0
  53. {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/slash-commands/__init__.py +0 -0
  54. {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/ui/__init__.py +0 -0
  55. {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/ui/style.tcss +0 -0
  56. {asher_cli-1.0.0 → asher_cli-1.0.2}/cliff.toml +0 -0
  57. {asher_cli-1.0.0 → asher_cli-1.0.2}/docs/ROADMAP.md +0 -0
  58. {asher_cli-1.0.0 → asher_cli-1.0.2}/docs/roadmap-archive/README.md +0 -0
  59. {asher_cli-1.0.0 → asher_cli-1.0.2}/docs/roadmap-archive/cat-panel-badges.md +0 -0
  60. {asher_cli-1.0.0 → asher_cli-1.0.2}/docs/roadmap-archive/config-persistence.md +0 -0
  61. {asher_cli-1.0.0 → asher_cli-1.0.2}/docs/roadmap-archive/desktop-notifications.md +0 -0
  62. {asher_cli-1.0.0 → asher_cli-1.0.2}/docs/roadmap-archive/fault-monitoring.md +0 -0
  63. {asher_cli-1.0.0 → asher_cli-1.0.2}/docs/roadmap-archive/headless-export.md +0 -0
  64. {asher_cli-1.0.0 → asher_cli-1.0.2}/docs/roadmap-archive/history-export.md +0 -0
  65. {asher_cli-1.0.0 → asher_cli-1.0.2}/docs/roadmap-archive/tab-completion.md +0 -0
  66. {asher_cli-1.0.0 → asher_cli-1.0.2}/renovate.json +0 -0
  67. {asher_cli-1.0.0 → asher_cli-1.0.2}/requirements.txt +0 -0
  68. {asher_cli-1.0.0 → asher_cli-1.0.2}/test.py +0 -0
  69. {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/__init__.py +0 -0
  70. {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/conftest.py +0 -0
  71. {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_activity_labels.py +0 -0
  72. {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_app_pilot.py +0 -0
  73. {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_auth.py +0 -0
  74. {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_auth_pilot.py +0 -0
  75. {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_cats.py +0 -0
  76. {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_commands_pilot.py +0 -0
  77. {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_completion.py +0 -0
  78. {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_config.py +0 -0
  79. {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_connection.py +0 -0
  80. {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_connection_mixin.py +0 -0
  81. {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_export.py +0 -0
  82. {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_faults.py +0 -0
  83. {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_lr5_commands.py +0 -0
  84. {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_mcp_bridge.py +0 -0
  85. {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_mcp_command.py +0 -0
  86. {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_mcp_config.py +0 -0
  87. {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_missing_robot_commands.py +0 -0
  88. {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_monitoring.py +0 -0
  89. {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_new_commands_pilot.py +0 -0
  90. {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_notifications.py +0 -0
  91. {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_robot_adapters.py +0 -0
  92. {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_ui.py +0 -0
  93. {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_version_command.py +0 -0
  94. {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/testhelpers.py +0 -0
@@ -0,0 +1,47 @@
1
+ # Bandit is a security linter for Python, by PyCQA (Apache v2.0).
2
+ # https://pypi.org/project/bandit/
3
+ #
4
+ # Results land under the repository's Security → Code scanning tab.
5
+ #
6
+ # Scan configuration — which paths are excluded, which checks are skipped and
7
+ # why — lives in [tool.bandit] in pyproject.toml, so `uv run poe security`
8
+ # reproduces this job's findings locally.
9
+
10
+ name: Bandit
11
+
12
+ on:
13
+ push:
14
+ branches: ["main", "release/*"]
15
+ pull_request:
16
+ # The branches below must be a subset of the branches above
17
+ branches: ["main"]
18
+ schedule:
19
+ - cron: '32 0 * * 1'
20
+
21
+ permissions:
22
+ contents: read
23
+
24
+ jobs:
25
+ bandit:
26
+ permissions:
27
+ contents: read # for actions/checkout to fetch code
28
+ security-events: write # for github/codeql-action/upload-sarif to upload SARIF results
29
+ actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status
30
+
31
+ runs-on: ubuntu-latest
32
+ steps:
33
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
34
+ - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
35
+
36
+ - name: Bandit scan
37
+ # --exit-zero: findings must not fail the job, or the upload step below
38
+ # is skipped and code scanning keeps showing the previous run's alerts.
39
+ run: >-
40
+ uvx --from 'bandit[toml,sarif]' bandit
41
+ -c pyproject.toml -r . -f sarif -o bandit.sarif --exit-zero
42
+
43
+ - name: Upload SARIF to code scanning
44
+ uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
45
+ with:
46
+ sarif_file: bandit.sarif
47
+ category: bandit
@@ -18,7 +18,7 @@ jobs:
18
18
  - name: Run tests with coverage
19
19
  run: uv run pytest tests/ --cov=asher --cov-report=lcov --cov-report=term-missing
20
20
  - name: Upload to Coveralls
21
- uses: coverallsapp/github-action@5cbfd81b66ca5d10c19b062c04de0199c215fb6e # v2
21
+ uses: coverallsapp/github-action@8d6379e14d29928660c4ba802d8e85393440b329 # v2
22
22
  with:
23
23
  github-token: ${{ secrets.GITHUB_TOKEN }}
24
24
  path-to-lcov: coverage.lcov
@@ -7,7 +7,27 @@ 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
- ## [Unreleased]
10
+ ## [1.0.2] - 2026-08-05
11
+
12
+ ### 🐛 Bug Fixes
13
+
14
+ - Silence bandit noise and resolve dependency advisories
15
+
16
+ ### 🔧 Miscellaneous
17
+
18
+ - Enable Bandit B101 for asher/ and bump version to 1.0.2
19
+
20
+ ## [1.0.1] - 2026-08-04
21
+
22
+ ### 🚀 Features
23
+
24
+ - *(history)* Copy-to-clipboard + discoverable footer hints
25
+
26
+ ### 📚 Documentation
27
+
28
+ - *(changelog)* Cut v1.0.1
29
+
30
+ ## [1.0.0] - 2026-08-02
11
31
 
12
32
  ### 🚀 Features
13
33
 
@@ -18,6 +38,7 @@ Generated by [git-cliff](https://git-cliff.org) from conventional commits.
18
38
 
19
39
  ### 📚 Documentation
20
40
 
41
+ - *(changelog)* Regenerate for next release, trim duplicate README list
21
42
  - *(roadmap)* Archive completed sections to docs/roadmap-archive/
22
43
 
23
44
  ### 🔧 Miscellaneous
@@ -46,7 +46,7 @@ asher/
46
46
  mcp_config.py Claude Desktop config read/write for the /mcp slash command
47
47
  mcp_bridge.py asher-mcp-launch console script — keyring-backed pylitterbot MCP launcher
48
48
  faults.py check_faults(robot) — model-scoped safety/component fault detection (status enum + per-model attr allowlist incl. LR4 USB power fault; hopper never a fault)
49
- history_view.py HistoryScreen (ModalScreen) + format_history_rows() — scrollable activity-history pager pushed by the `history` command
49
+ history_view.py HistoryScreen (ModalScreen) + format_history_rows()/format_history_text() — scrollable activity-history pager pushed by the `history` command; `c` copies the full history (plain text) to the clipboard via action_copy_all()
50
50
  export.py shared activity-history CSV core + headless export path: build_history_csv(), resolve_dest(), resolve_robot(), _run_headless_export(), ExportError — no Textual imports; both the TUI `export` command and `asher --export` call build_history_csv()
51
51
  completion.py pure helpers for command completion: slash popup (slash_matches, enter_completes, render_completion) + inline ghost text (CommandSuggester) — fed by _registry, no Textual imports except the Suggester base class
52
52
  __main__.py main() entry point
@@ -75,12 +75,13 @@ tests/
75
75
  test_mcp_bridge.py mcp_bridge launcher credential/subprocess handling
76
76
  test_mcp_command.py /mcp slash command dispatch
77
77
  test_faults.py check_faults() — safety statuses, attribute faults, graceful degradation
78
- test_history_view.py format_history_rows() + HistoryScreen structure + Pilot push/dismiss
78
+ test_history_view.py format_history_rows()/format_history_text() + HistoryScreen structure, copy-all + Pilot push/dismiss
79
79
  test_export.py build_history_csv/resolve_dest/resolve_robot/parse_days (pure) + headless _run_headless_export (no Pilot, mocks _connect_headless)
80
80
  test_completion.py slash_matches/enter_completes/render_completion (pure) + Pilot overlay visibility/navigation/accept
81
81
 
82
82
  .github/workflows/
83
83
  ci.yml ruff + mypy + pytest on every push/PR
84
+ bandit.yml bandit security scan → SARIF → code scanning (config: [tool.bandit] in pyproject.toml)
84
85
  ```
85
86
 
86
87
  ## Credentials
@@ -196,6 +197,7 @@ Don't add comments above functions or inline unless the WHY is genuinely non-obv
196
197
  - All command execution runs in `@work` async workers to keep the UI responsive
197
198
  - Cat modes: `idle`, `happy`, `cleaning` (animated), `sleeping`, `error`, `full`
198
199
  - `VERSION` is read from `importlib.metadata.version("asher-cli")` — falls back to `"dev"` when running from source
200
+ - **No `assert` in `asher/`** — Bandit enforces this (B101 is enabled; only `tests/` is exempt, via `exclude_dirs`). For a `requires_robot` command, narrow with `if app._robot is None: return` rather than an assert: `_dispatch_command` already rejects the disconnected case, and unlike `assert` the guard survives `python -O`
199
201
  - The primary login path is the inline flow in `login_flow.py` (`LoginFlow` state machine: `IDLE` → `AWAITING_EMAIL` → `AWAITING_PASSWORD`). `LoginScreen` (`auth.py`) still exists as a modal but is not used in the current main flow.
200
202
  - `LoginScreen` uses `event.stop()` on `Input.Submitted` and `Button.Pressed` to prevent bubbling to the App's `on_input_submitted` (relevant if re-activating the modal path)
201
203
 
@@ -226,6 +228,7 @@ uv run poe watch # run with Python auto-restart on file change (watchfil
226
228
  uv run poe test # run test suite
227
229
  uv run poe check # ruff + mypy + pytest (same as CI)
228
230
  uv run poe fix # auto-fix ruff issues
231
+ uv run poe security # bandit scan (same config the Bandit workflow uses)
229
232
  ```
230
233
 
231
234
  Pre-push hook (`.githooks/pre-push`) runs: ruff check → ruff format --check → mypy. Tests are not in the hook — run them manually.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: asher-cli
3
- Version: 1.0.0
3
+ Version: 1.0.2
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
@@ -70,7 +70,7 @@ A Claude Code-style terminal dashboard for monitoring and controlling Litter Rob
70
70
  - Real-time cycling indicator with elapsed time (`⟳ Cycling M:SS`)
71
71
  - Fault & safety monitoring — model-scoped in-panel alerts for cat detected, pinch, motor/position/gas faults (LR5: bonnet/laser/drawer); press `d` to dismiss
72
72
  - Cat panel with mode label + status badges (status chip, lock, night light, sleep, wait time) under the art
73
- - Scrollable activity-history pager — `history [count|all]` opens a full-screen, paginated view (arrow keys, `Page Up`/`Page Down`, `Home`/`End`); `q`/`Esc`/`Enter` to close
73
+ - Scrollable activity-history pager — `history [count|all]` 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
74
74
  - Commands: `clean`, `status`, `info`, `lock`, `unlock`, `sleep`, `wake`, `night-light on|off|auto`, `night-light-brightness`, `panel-brightness <low|medium|high>` (LR4/LR5), `wait-time`, `power on|off`, `rename`, `insight`, `sleep-schedule`, plus LR5 extras (`privacy`, `volume`, `camera-audio`, `drawer-reset`), `history [count|all]`, `export [days|month]`, `help`, `quit`
75
75
  - Slash commands for app management: `/login`, `/logout`, `/robots`, `/robot <index|name>`, `/pets`, `/pet <index|name>`, `/cat on|off|colour <hex>`, `/refresh [seconds|off]`, `/config`, `/notify on|off|sound on|off|test`, `/version`, `/mcp on|off|status`, `/exit`
76
76
  - Slash-command tab completion — type `/` and a Claude Code-style overlay lists matching commands; `↑`/`↓` to move, `Tab` or `Enter` to accept, `Esc` to dismiss
@@ -339,6 +339,16 @@ uv run poe types # mypy
339
339
  uv run poe check # run all of the above + tests (same as CI)
340
340
  ```
341
341
 
342
+ ### 6. Security scan
343
+
344
+ ```bash
345
+ uv run poe security # bandit, same config the Bandit workflow uses
346
+ ```
347
+
348
+ Scan settings live in `[tool.bandit]` in `pyproject.toml`, so local runs and the
349
+ Bandit workflow report identical findings. Results from `main` and pull requests
350
+ are published to the repository's **Security → Code scanning** tab.
351
+
342
352
  CI runs on Python 3.10 / 3.11 / 3.12 across Ubuntu, Windows, and macOS on every push.
343
353
 
344
354
  ## Changelog
@@ -19,7 +19,7 @@ A Claude Code-style terminal dashboard for monitoring and controlling Litter Rob
19
19
  - Real-time cycling indicator with elapsed time (`⟳ Cycling M:SS`)
20
20
  - Fault & safety monitoring — model-scoped in-panel alerts for cat detected, pinch, motor/position/gas faults (LR5: bonnet/laser/drawer); press `d` to dismiss
21
21
  - Cat panel with mode label + status badges (status chip, lock, night light, sleep, wait time) under the art
22
- - Scrollable activity-history pager — `history [count|all]` opens a full-screen, paginated view (arrow keys, `Page Up`/`Page Down`, `Home`/`End`); `q`/`Esc`/`Enter` to close
22
+ - Scrollable activity-history pager — `history [count|all]` 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
23
23
  - Commands: `clean`, `status`, `info`, `lock`, `unlock`, `sleep`, `wake`, `night-light on|off|auto`, `night-light-brightness`, `panel-brightness <low|medium|high>` (LR4/LR5), `wait-time`, `power on|off`, `rename`, `insight`, `sleep-schedule`, plus LR5 extras (`privacy`, `volume`, `camera-audio`, `drawer-reset`), `history [count|all]`, `export [days|month]`, `help`, `quit`
24
24
  - Slash commands for app management: `/login`, `/logout`, `/robots`, `/robot <index|name>`, `/pets`, `/pet <index|name>`, `/cat on|off|colour <hex>`, `/refresh [seconds|off]`, `/config`, `/notify on|off|sound on|off|test`, `/version`, `/mcp on|off|status`, `/exit`
25
25
  - Slash-command tab completion — type `/` and a Claude Code-style overlay lists matching commands; `↑`/`↓` to move, `Tab` or `Enter` to accept, `Esc` to dismiss
@@ -288,6 +288,16 @@ uv run poe types # mypy
288
288
  uv run poe check # run all of the above + tests (same as CI)
289
289
  ```
290
290
 
291
+ ### 6. Security scan
292
+
293
+ ```bash
294
+ uv run poe security # bandit, same config the Bandit workflow uses
295
+ ```
296
+
297
+ Scan settings live in `[tool.bandit]` in `pyproject.toml`, so local runs and the
298
+ Bandit workflow report identical findings. Results from `main` and pull requests
299
+ are published to the repository's **Security → Code scanning** tab.
300
+
291
301
  CI runs on Python 3.10 / 3.11 / 3.12 across Ubuntu, Windows, and macOS on every push.
292
302
 
293
303
  ## Changelog
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  import contextlib
7
7
  import os
8
+ import shutil
8
9
  import subprocess
9
10
  import sys
10
11
  from datetime import time
@@ -121,7 +122,8 @@ class CleanCommand(Command):
121
122
  requires_robot = True
122
123
 
123
124
  async def run(self, app: AsherApp, args: list[str]) -> None:
124
- assert app._robot is not None
125
+ if app._robot is None:
126
+ return
125
127
  app._set_cat("cleaning", "cleaning…")
126
128
 
127
129
  done: asyncio.Event = asyncio.Event()
@@ -169,7 +171,8 @@ class StatusCommand(Command):
169
171
  requires_robot = True
170
172
 
171
173
  async def run(self, app: AsherApp, args: list[str]) -> None:
172
- assert app._robot is not None
174
+ if app._robot is None:
175
+ return
173
176
  try:
174
177
  await app._robot.refresh()
175
178
  await app._refresh_status()
@@ -178,12 +181,10 @@ class StatusCommand(Command):
178
181
  return
179
182
  r = app._robot
180
183
  weight = "—"
181
- try:
184
+ with contextlib.suppress(Exception):
182
185
  w = getattr(r, "pet_weight", None)
183
186
  if w is not None and float(w) > 0:
184
187
  weight = f"{float(w):.1f} lb"
185
- except Exception:
186
- pass
187
188
  last_seen = getattr(app, "_last_cat_seen", None) or getattr(r, "last_seen", None)
188
189
  rows = [
189
190
  ("Online", "yes" if getattr(r, "is_online", False) else "no"),
@@ -206,7 +207,8 @@ class InfoCommand(Command):
206
207
  requires_robot = True
207
208
 
208
209
  async def run(self, app: AsherApp, args: list[str]) -> None:
209
- assert app._robot is not None
210
+ if app._robot is None:
211
+ return
210
212
  try:
211
213
  await app._robot.refresh()
212
214
  except Exception as exc:
@@ -267,7 +269,8 @@ class LockCommand(Command):
267
269
  return "lock / unlock"
268
270
 
269
271
  async def run(self, app: AsherApp, args: list[str]) -> None:
270
- assert app._adapter is not None
272
+ if app._adapter is None:
273
+ return
271
274
  ok, msg = await app._adapter.set_panel_lockout(True)
272
275
  if ok:
273
276
  app.query_one("#lock-lbl", Static).update(Text("⊘ Locked", style="bold #d29922"))
@@ -286,7 +289,8 @@ class UnlockCommand(Command):
286
289
  return "lock / unlock"
287
290
 
288
291
  async def run(self, app: AsherApp, args: list[str]) -> None:
289
- assert app._adapter is not None
292
+ if app._adapter is None:
293
+ return
290
294
  ok, msg = await app._adapter.set_panel_lockout(False)
291
295
  if ok:
292
296
  app.query_one("#lock-lbl", Static).update(Text("□ Unlocked", style="#484f58"))
@@ -305,7 +309,8 @@ class SleepCommand(Command):
305
309
  return "sleep / wake"
306
310
 
307
311
  async def run(self, app: AsherApp, args: list[str]) -> None:
308
- assert app._adapter is not None
312
+ if app._adapter is None:
313
+ return
309
314
  ok, msg = await app._adapter.set_sleep(True)
310
315
  if ok:
311
316
  app._log_ok(msg)
@@ -327,7 +332,8 @@ class WakeCommand(Command):
327
332
  return "sleep / wake"
328
333
 
329
334
  async def run(self, app: AsherApp, args: list[str]) -> None:
330
- assert app._adapter is not None
335
+ if app._adapter is None:
336
+ return
331
337
  ok, msg = await app._adapter.set_sleep(False)
332
338
  if ok:
333
339
  app._log_ok(msg)
@@ -350,7 +356,8 @@ class NightLightCommand(Command):
350
356
  return "night-light on|off|auto"
351
357
 
352
358
  async def run(self, app: AsherApp, args: list[str]) -> None:
353
- assert app._adapter is not None
359
+ if app._adapter is None:
360
+ return
354
361
  arg = args[0].lower() if args else ""
355
362
  if arg not in ("on", "off", "auto"):
356
363
  app._log_warn("Usage: night-light on|off|auto")
@@ -380,7 +387,8 @@ class NightLightBrightnessCommand(Command):
380
387
  return "night-light-brightness"
381
388
 
382
389
  async def run(self, app: AsherApp, args: list[str]) -> None:
383
- assert app._adapter is not None
390
+ if app._adapter is None:
391
+ return
384
392
  if not args or not args[0].isdigit():
385
393
  app._log_warn("Usage: night-light-brightness <level>")
386
394
  return
@@ -416,7 +424,8 @@ class PanelBrightnessCommand(Command):
416
424
  requires_robot = True
417
425
 
418
426
  async def run(self, app: AsherApp, args: list[str]) -> None:
419
- assert app._adapter is not None
427
+ if app._adapter is None:
428
+ return
420
429
  if not args:
421
430
  current = getattr(app._robot, "panel_brightness", None)
422
431
  app._log_info(f"Usage: panel-brightness <low|medium|high> (current: {current or '—'})")
@@ -435,7 +444,8 @@ class HistoryCommand(Command):
435
444
  requires_robot = True
436
445
 
437
446
  async def run(self, app: AsherApp, args: list[str]) -> None:
438
- assert app._robot is not None
447
+ if app._robot is None:
448
+ return
439
449
  raw = args[0].lower() if args else ""
440
450
  if raw in ("all", "max"):
441
451
  limit = 500
@@ -467,7 +477,8 @@ class WaitTimeCommand(Command):
467
477
  return "wait-time <minutes>"
468
478
 
469
479
  async def run(self, app: AsherApp, args: list[str]) -> None:
470
- assert app._robot is not None
480
+ if app._robot is None:
481
+ return
471
482
  valid = sorted(getattr(app._robot, "VALID_WAIT_TIMES", []))
472
483
  if not args or not args[0].isdigit():
473
484
  current = getattr(app._robot, "clean_cycle_wait_time_minutes", "?")
@@ -508,7 +519,8 @@ class PowerCommand(Command):
508
519
  return "power on|off"
509
520
 
510
521
  async def run(self, app: AsherApp, args: list[str]) -> None:
511
- assert app._robot is not None
522
+ if app._robot is None:
523
+ return
512
524
  arg = args[0].lower() if args else ""
513
525
  if arg not in ("on", "off"):
514
526
  app._log_warn("Usage: power on|off")
@@ -536,7 +548,8 @@ class RenameCommand(Command):
536
548
  return "rename <name>"
537
549
 
538
550
  async def run(self, app: AsherApp, args: list[str]) -> None:
539
- assert app._robot is not None
551
+ if app._robot is None:
552
+ return
540
553
  if not args:
541
554
  app._log_warn(f"Usage: rename <new name> (current: {app._robot.name})")
542
555
  return
@@ -563,7 +576,8 @@ class InsightCommand(Command):
563
576
  requires_robot = True
564
577
 
565
578
  async def run(self, app: AsherApp, args: list[str]) -> None:
566
- assert app._robot is not None
579
+ if app._robot is None:
580
+ return
567
581
  raw = args[0].lower() if args else "30"
568
582
  if raw == "month":
569
583
  days = 30
@@ -618,6 +632,19 @@ def _dow_to_weekday(day: int) -> int:
618
632
  return (day - 1) % 7
619
633
 
620
634
 
635
+ def _parse_sleep_day(day: Any) -> tuple[int, bool, object, object] | None:
636
+ """Read one schedule day, or None when the API returns a shape we can't read."""
637
+ try:
638
+ return (
639
+ _dow_to_weekday(int(getattr(day, "day", 0))),
640
+ bool(getattr(day, "is_enabled", False)),
641
+ getattr(day, "sleep_time", None),
642
+ getattr(day, "wake_time", None),
643
+ )
644
+ except Exception:
645
+ return None
646
+
647
+
621
648
  class SleepScheduleCommand(Command):
622
649
  name = "sleep-schedule"
623
650
  aliases = ("sleepschedule",)
@@ -629,7 +656,8 @@ class SleepScheduleCommand(Command):
629
656
  return "sleep-schedule"
630
657
 
631
658
  async def run(self, app: AsherApp, args: list[str]) -> None:
632
- assert app._robot is not None
659
+ if app._robot is None:
660
+ return
633
661
  try:
634
662
  schedule = getattr(app._robot, "sleep_schedule", None)
635
663
  except Exception as exc:
@@ -663,23 +691,18 @@ class SleepScheduleCommand(Command):
663
691
  log = app.query_one("#log", RichLog)
664
692
  active_day_indices = set()
665
693
  if window is not None:
666
- try:
694
+ with contextlib.suppress(Exception):
667
695
  start_dt, end_dt = window
668
696
  # Sleep windows can wrap past midnight, so flag both the start
669
697
  # and end day as "active now" for the user-facing marker.
670
698
  active_day_indices.add(start_dt.weekday())
671
699
  active_day_indices.add(end_dt.weekday())
672
- except Exception:
673
- pass
674
700
 
675
701
  for day in days:
676
- try:
677
- idx = _dow_to_weekday(int(getattr(day, "day", 0)))
678
- day_enabled = bool(getattr(day, "is_enabled", False))
679
- sleep_t = getattr(day, "sleep_time", None)
680
- wake_t = getattr(day, "wake_time", None)
681
- except Exception:
702
+ parsed = _parse_sleep_day(day)
703
+ if parsed is None:
682
704
  continue
705
+ idx, day_enabled, sleep_t, wake_t = parsed
683
706
 
684
707
  name = _DAY_NAMES[idx]
685
708
  t = Text()
@@ -712,7 +735,8 @@ class PrivacyCommand(Command):
712
735
  return "privacy on|off"
713
736
 
714
737
  async def run(self, app: AsherApp, args: list[str]) -> None:
715
- assert app._adapter is not None
738
+ if app._adapter is None:
739
+ return
716
740
  arg = args[0].lower() if args else ""
717
741
  if arg not in ("on", "off"):
718
742
  app._log_warn("Usage: privacy on|off")
@@ -735,7 +759,8 @@ class VolumeCommand(Command):
735
759
  return "volume <0-100>"
736
760
 
737
761
  async def run(self, app: AsherApp, args: list[str]) -> None:
738
- assert app._adapter is not None
762
+ if app._adapter is None:
763
+ return
739
764
  if not args or not args[0].lstrip("-").isdigit():
740
765
  current = getattr(app._robot, "sound_volume", None)
741
766
  extra = f" (current: {current})" if current is not None else ""
@@ -760,7 +785,8 @@ class CameraAudioCommand(Command):
760
785
  return "camera-audio on|off"
761
786
 
762
787
  async def run(self, app: AsherApp, args: list[str]) -> None:
763
- assert app._adapter is not None
788
+ if app._adapter is None:
789
+ return
764
790
  arg = args[0].lower() if args else ""
765
791
  if arg not in ("on", "off"):
766
792
  app._log_warn("Usage: camera-audio on|off")
@@ -784,7 +810,8 @@ class DrawerResetCommand(Command):
784
810
  return "drawer-reset"
785
811
 
786
812
  async def run(self, app: AsherApp, args: list[str]) -> None:
787
- assert app._adapter is not None
813
+ if app._adapter is None:
814
+ return
788
815
  ok, msg = await app._adapter.reset_waste_drawer()
789
816
  if ok:
790
817
  app._log_ok(msg)
@@ -1287,15 +1314,19 @@ class VersionCommand(SlashCommand):
1287
1314
 
1288
1315
  def _open_folder(path: Path) -> None:
1289
1316
  if sys.platform == "win32":
1290
- subprocess.Popen(["explorer", "/select,", str(path)])
1317
+ argv = ["explorer", "/select,", str(path)]
1291
1318
  elif sys.platform == "darwin":
1292
- subprocess.Popen(["open", "-R", str(path)])
1319
+ argv = ["open", "-R", str(path)]
1293
1320
  else:
1294
- subprocess.Popen(["xdg-open", str(path.parent)])
1321
+ argv = ["xdg-open", str(path.parent)]
1322
+
1323
+ argv[0] = shutil.which(argv[0]) or argv[0]
1324
+ subprocess.Popen(argv) # nosec B603 # fixed argv, no shell, path is not user-controlled
1295
1325
 
1296
1326
 
1297
1327
  async def _run_export(app: AsherApp, days: int) -> None:
1298
- assert app._robot is not None
1328
+ if app._robot is None:
1329
+ return
1299
1330
  dest = resolve_dest(app._robot, None)
1300
1331
  app._log_info(f"Fetching history (last {days} days)…")
1301
1332
  try:
@@ -1522,6 +1553,11 @@ class CommandsMixin:
1522
1553
  return False
1523
1554
 
1524
1555
  def on_key(self, event) -> None: # type: ignore[override]
1556
+ # A pushed modal (history pager, login modal, …) owns its keys. The base
1557
+ # screen's `focused` still points at #cmd-input under a modal, so
1558
+ # has_focus alone would let us hijack arrows/special keys from the overlay.
1559
+ if len(self.screen_stack) > 1: # type: ignore[attr-defined]
1560
+ return
1525
1561
  cmd_input = self.query_one("#cmd-input", Input) # type: ignore[attr-defined]
1526
1562
  if not cmd_input.has_focus:
1527
1563
  return
@@ -13,7 +13,7 @@ Layout (top to bottom):
13
13
  * summary line — a compact event-type breakdown (cleans, cat visits, …)
14
14
  * column header — pinned ``TIME`` / ``EVENT`` row aligned with the data
15
15
  * scroll area — one styled row per event, newest first, fixed-width timestamps
16
- * footer — navigation + close hints
16
+ * footer — navigation, copy, and close hints (``c`` copies the whole history to the clipboard)
17
17
 
18
18
  Timestamps are rendered at a fixed width so the event column lines up
19
19
  vertically regardless of an event's age — every event shows its date.
@@ -47,6 +47,13 @@ _TS_WIDTH = 11
47
47
  # raw strings still fall into "other" rather than mislabeling.
48
48
  _ALERT_KEYWORDS = ("offline", "fault", "pinch", "power", "motor", "timing")
49
49
 
50
+ # Footer hint line shown at the bottom of the pager and restored after the
51
+ # post-copy confirmation flash.
52
+ _FOOTER_HINT = (
53
+ " \u2191/\u2193 scroll PgUp/PgDn page Home/End jump "
54
+ "c copy q \u00b7 Esc \u00b7 Enter close"
55
+ )
56
+
50
57
 
51
58
  def _localize(ts: object) -> datetime | None:
52
59
  """Coerce an activity timestamp to a local-tz-aware datetime, or ``None``."""
@@ -85,6 +92,32 @@ def format_history_rows(acts: list[Activity], pets: list[Any] | None = None) ->
85
92
  return rows
86
93
 
87
94
 
95
+ def format_history_text(
96
+ acts: list[Activity], pets: list[Any] | None = None, robot_name: str = ""
97
+ ) -> str:
98
+ """Plain-text rendering of the history, for clipboard copying.
99
+
100
+ Self-contained: title line, event-type summary, column header, and one row
101
+ per event (newest first) — mirrors what the on-screen pager shows so a pasted
102
+ copy reads the same as the view.
103
+ """
104
+ count = len(acts)
105
+ lines = [
106
+ f"Activity history \u2014 {robot_name} {_plural(count, 'event')} {_date_range(acts)}"
107
+ ]
108
+ if not count:
109
+ lines.append("No activity history available.")
110
+ return "\n".join(lines)
111
+ lines.append(_summarize(acts, pets))
112
+ lines.append("")
113
+ lines.append(f" {'TIME'.ljust(_TS_WIDTH)} EVENT")
114
+ for act in acts:
115
+ ts_str = _ts_str(_localize(getattr(act, "timestamp", None)))
116
+ label, _ = format_activity(act, pets)
117
+ lines.append(f" {ts_str.ljust(_TS_WIDTH)} {label}")
118
+ return "\n".join(lines)
119
+
120
+
88
121
  def _category(label: str) -> str:
89
122
  """Bucket a translated label into a short summary name."""
90
123
  low = label.lower()
@@ -147,7 +180,7 @@ class HistoryScreen(ModalScreen[None]):
147
180
 
148
181
  #history-meta {
149
182
  dock: top;
150
- height: 1;
183
+ height: 2;
151
184
  background: #161b22;
152
185
  border-bottom: solid #30363d;
153
186
  padding: 0 2;
@@ -165,11 +198,11 @@ class HistoryScreen(ModalScreen[None]):
165
198
 
166
199
  #history-footer {
167
200
  dock: bottom;
168
- height: 1;
201
+ height: 2;
169
202
  background: #161b22;
170
203
  border-top: solid #30363d;
171
204
  padding: 0 2;
172
- color: #484f58;
205
+ color: #c9d1d9;
173
206
  }
174
207
 
175
208
  #history-scroll {
@@ -188,6 +221,7 @@ class HistoryScreen(ModalScreen[None]):
188
221
 
189
222
  BINDINGS = [
190
223
  Binding("escape,q,enter", "dismiss", "Close", show=False, priority=True),
224
+ Binding("c", "copy_all", "Copy all", show=False, priority=True),
191
225
  ]
192
226
 
193
227
  def __init__(self, acts: list[Activity], pets: list[Any] | None, robot_name: str) -> None:
@@ -195,6 +229,7 @@ class HistoryScreen(ModalScreen[None]):
195
229
  self._acts = acts
196
230
  self._pets = pets
197
231
  self._robot_name = robot_name
232
+ self._restore_timer: Any = None
198
233
 
199
234
  def compose(self) -> ComposeResult:
200
235
  count = len(self._acts)
@@ -209,13 +244,35 @@ class HistoryScreen(ModalScreen[None]):
209
244
  else:
210
245
  yield Static("No activity history available for this robot.", id="history-empty")
211
246
  yield Static(
212
- " ↑/↓ scroll PgUp/PgDn page Home/End jump q · Esc · Enter close",
247
+ _FOOTER_HINT,
213
248
  id="history-footer",
214
249
  )
215
250
 
216
251
  def on_mount(self) -> None:
217
252
  self.query_one("#history-scroll", ScrollableContainer).focus()
218
253
 
254
+ def action_copy_all(self) -> None:
255
+ """Copy the full history (plain text) to the OS clipboard.
256
+
257
+ Flashes the footer with a confirmation, then restores the hint line.
258
+ ``copy_to_clipboard`` uses OSC52 under the hood and silently no-ops on
259
+ terminals that don't support it, so we optimistically report success.
260
+ """
261
+ text = format_history_text(self._acts, self._pets, self._robot_name)
262
+ try:
263
+ self.app.copy_to_clipboard(text)
264
+ except Exception:
265
+ message = " Clipboard unavailable in this terminal"
266
+ else:
267
+ message = f" ✓ Copied {_plural(len(self._acts), 'event')} to clipboard"
268
+ self.query_one("#history-footer", Static).update(message)
269
+ if self._restore_timer is not None:
270
+ self._restore_timer.stop()
271
+ self._restore_timer = self.set_timer(1.5, self._restore_footer)
272
+
273
+ def _restore_footer(self) -> None:
274
+ self.query_one("#history-footer", Static).update(_FOOTER_HINT)
275
+
219
276
  def _title_line(self, count: int) -> str:
220
277
  return f" Activity history — {self._robot_name} {_plural(count, 'event')} {_date_range(self._acts)}"
221
278
 
@@ -33,7 +33,9 @@ def main() -> None:
33
33
  env["LITTER_ROBOT_USERNAME"] = email
34
34
  env["LITTER_ROBOT_PASSWORD"] = password
35
35
 
36
- result = subprocess.run([sys.executable, "-m", "pylitterbot.mcp"], env=env, check=False)
36
+ result = subprocess.run( # nosec B603 # fixed argv on this interpreter, no shell
37
+ [sys.executable, "-m", "pylitterbot.mcp"], env=env, check=False
38
+ )
37
39
  sys.exit(result.returncode)
38
40
 
39
41