asher-cli 1.0.1__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.
- asher_cli-1.0.2/.github/workflows/bandit.yml +47 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/CHANGELOG.md +14 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/CLAUDE.md +3 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/PKG-INFO +11 -1
- {asher_cli-1.0.1 → asher_cli-1.0.2}/README.md +10 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/asher/commands/__init__.py +67 -36
- {asher_cli-1.0.1 → asher_cli-1.0.2}/asher/mcp_bridge.py +3 -1
- {asher_cli-1.0.1 → asher_cli-1.0.2}/asher/monitoring/__init__.py +4 -12
- {asher_cli-1.0.1 → asher_cli-1.0.2}/pyproject.toml +19 -2
- {asher_cli-1.0.1 → asher_cli-1.0.2}/uv.lock +170 -170
- {asher_cli-1.0.1 → asher_cli-1.0.2}/watchrun.py +11 -5
- asher_cli-1.0.1/.github/workflows/bandit.yml +0 -52
- {asher_cli-1.0.1 → asher_cli-1.0.2}/.claude/hooks/block-env.ps1 +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/.claude/settings.json +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/.claude/skills/pylitterbot-ref/SKILL.md +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/.claude/skills/release/SKILL.md +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/.claude/skills/textual/SKILL.md +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/.env.example +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/.githooks/pre-push +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/.github/pull_request_template.md +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/.github/workflows/ci.yml +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/.github/workflows/claude-code-review.yml +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/.github/workflows/claude.yml +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/.github/workflows/coverage.yml +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/.github/workflows/release.yml +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/.gitignore +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/.vscode/launch.json +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/.vscode/settings.json +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/.vscode/tasks.json +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/CODEOWNERS +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/LICENSE +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/app.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/asher/__init__.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/asher/__main__.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/asher/activity_labels.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/asher/app.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/asher/auth.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/asher/cats.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/asher/commands/base.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/asher/completion.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/asher/config.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/asher/connection/__init__.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/asher/constants.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/asher/export.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/asher/faults.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/asher/helpers.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/asher/history_view.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/asher/login_flow.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/asher/mcp_config.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/asher/notifications.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/asher/robot_adapters.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/asher/robot_protocol.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/asher/slash-commands/__init__.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/asher/ui/__init__.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/asher/ui/style.tcss +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/cliff.toml +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/docs/ROADMAP.md +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/docs/roadmap-archive/README.md +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/docs/roadmap-archive/cat-panel-badges.md +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/docs/roadmap-archive/config-persistence.md +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/docs/roadmap-archive/desktop-notifications.md +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/docs/roadmap-archive/fault-monitoring.md +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/docs/roadmap-archive/headless-export.md +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/docs/roadmap-archive/history-export.md +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/docs/roadmap-archive/tab-completion.md +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/renovate.json +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/requirements.txt +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/test.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/tests/__init__.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/tests/conftest.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/tests/test_activity_labels.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/tests/test_app_pilot.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/tests/test_auth.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/tests/test_auth_pilot.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/tests/test_cats.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/tests/test_commands_pilot.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/tests/test_completion.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/tests/test_config.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/tests/test_connection.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/tests/test_connection_mixin.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/tests/test_export.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/tests/test_faults.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/tests/test_history_view.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/tests/test_lr5_commands.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/tests/test_mcp_bridge.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/tests/test_mcp_command.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/tests/test_mcp_config.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/tests/test_missing_robot_commands.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/tests/test_monitoring.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/tests/test_new_commands_pilot.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/tests/test_notifications.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/tests/test_robot_adapters.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/tests/test_ui.py +0 -0
- {asher_cli-1.0.1 → asher_cli-1.0.2}/tests/test_version_command.py +0 -0
- {asher_cli-1.0.1 → 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
|
|
@@ -7,12 +7,26 @@ 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.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
|
+
|
|
10
20
|
## [1.0.1] - 2026-08-04
|
|
11
21
|
|
|
12
22
|
### 🚀 Features
|
|
13
23
|
|
|
14
24
|
- *(history)* Copy-to-clipboard + discoverable footer hints
|
|
15
25
|
|
|
26
|
+
### 📚 Documentation
|
|
27
|
+
|
|
28
|
+
- *(changelog)* Cut v1.0.1
|
|
29
|
+
|
|
16
30
|
## [1.0.0] - 2026-08-02
|
|
17
31
|
|
|
18
32
|
### 🚀 Features
|
|
@@ -81,6 +81,7 @@ tests/
|
|
|
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.
|
|
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
|
|
@@ -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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
677
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1317
|
+
argv = ["explorer", "/select,", str(path)]
|
|
1291
1318
|
elif sys.platform == "darwin":
|
|
1292
|
-
|
|
1319
|
+
argv = ["open", "-R", str(path)]
|
|
1293
1320
|
else:
|
|
1294
|
-
|
|
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
|
-
|
|
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:
|
|
@@ -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(
|
|
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
|
|
|
@@ -42,11 +42,9 @@ class MonitoringMixin:
|
|
|
42
42
|
|
|
43
43
|
if self._robot is None:
|
|
44
44
|
return
|
|
45
|
-
|
|
45
|
+
with contextlib.suppress(Exception):
|
|
46
46
|
self._robot.on(EVENT_UPDATE, self._on_robot_update)
|
|
47
47
|
await self._robot.subscribe()
|
|
48
|
-
except Exception:
|
|
49
|
-
pass
|
|
50
48
|
|
|
51
49
|
def _on_robot_update(self) -> None:
|
|
52
50
|
"""Sync callback fired by pylitterbot when robot state changes via WebSocket."""
|
|
@@ -62,7 +60,7 @@ class MonitoringMixin:
|
|
|
62
60
|
"""Cache the timestamp of the most recent cat-detection event from activity history."""
|
|
63
61
|
if self._robot is None:
|
|
64
62
|
return
|
|
65
|
-
|
|
63
|
+
with contextlib.suppress(Exception):
|
|
66
64
|
acts = await self._robot.get_activity_history(limit=50)
|
|
67
65
|
for act in acts:
|
|
68
66
|
if getattr(act, "action", None) == LitterBoxStatus.CAT_DETECTED:
|
|
@@ -70,8 +68,6 @@ class MonitoringMixin:
|
|
|
70
68
|
if ts_dt is not None:
|
|
71
69
|
self._last_cat_seen = ts_dt
|
|
72
70
|
return
|
|
73
|
-
except Exception:
|
|
74
|
-
pass
|
|
75
71
|
|
|
76
72
|
async def _refresh_status(self) -> None:
|
|
77
73
|
r = self._robot
|
|
@@ -85,12 +81,10 @@ class MonitoringMixin:
|
|
|
85
81
|
last_seen = self._last_cat_seen or getattr(r, "last_seen", None)
|
|
86
82
|
|
|
87
83
|
weight_val = "—"
|
|
88
|
-
|
|
84
|
+
with contextlib.suppress(Exception):
|
|
89
85
|
w = getattr(r, "pet_weight", None)
|
|
90
86
|
if w is not None and float(w) > 0:
|
|
91
87
|
weight_val = f"{float(w):.1f} lb"
|
|
92
|
-
except Exception:
|
|
93
|
-
pass
|
|
94
88
|
|
|
95
89
|
active_pet_idx = getattr(self, "_active_pet_idx", 0)
|
|
96
90
|
pet = (
|
|
@@ -327,9 +321,7 @@ class MonitoringMixin:
|
|
|
327
321
|
async def _poll_status_interval(self) -> None:
|
|
328
322
|
if self._robot is None:
|
|
329
323
|
return
|
|
330
|
-
|
|
324
|
+
with contextlib.suppress(Exception):
|
|
331
325
|
await self._robot.refresh()
|
|
332
326
|
await self._update_last_cat_seen()
|
|
333
327
|
await self._refresh_status()
|
|
334
|
-
except Exception:
|
|
335
|
-
pass
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "asher-cli"
|
|
3
|
-
version = "1.0.
|
|
3
|
+
version = "1.0.2"
|
|
4
4
|
description = "Terminal dashboard for Litter Robot (LR3/LR4/LR5) via the Whisker cloud API"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { file = "LICENSE" }
|
|
@@ -58,6 +58,8 @@ lint = "ruff check ."
|
|
|
58
58
|
fmt = "ruff format --check ."
|
|
59
59
|
types = "mypy asher/ --ignore-missing-imports"
|
|
60
60
|
test = "pytest tests/ -v --tb=short"
|
|
61
|
+
security.cmd = "uvx --from bandit[toml] bandit -c pyproject.toml -r ."
|
|
62
|
+
security.help = "run the same Bandit scan as the Bandit workflow"
|
|
61
63
|
fix.sequence = [{cmd = "ruff check --fix ."}, {cmd = "ruff format ."}]
|
|
62
64
|
dev = "textual run --dev asher/__main__.py"
|
|
63
65
|
watch = "python watchrun.py"
|
|
@@ -100,7 +102,7 @@ exclude = ["asher/slash-commands"]
|
|
|
100
102
|
# ── bump-my-version ───────────────────────────────────────────────────────────
|
|
101
103
|
|
|
102
104
|
[tool.bumpversion]
|
|
103
|
-
current_version = "1.0.
|
|
105
|
+
current_version = "1.0.2"
|
|
104
106
|
commit = true
|
|
105
107
|
tag = true
|
|
106
108
|
tag_name = "v{new_version}"
|
|
@@ -111,6 +113,21 @@ filename = "pyproject.toml"
|
|
|
111
113
|
search = 'version = "{current_version}"'
|
|
112
114
|
replace = 'version = "{new_version}"'
|
|
113
115
|
|
|
116
|
+
# ── Bandit ────────────────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
# Read with `bandit -c pyproject.toml` (needs the `toml` extra). The GitHub
|
|
119
|
+
# Bandit workflow passes the same flag so CI and local runs agree.
|
|
120
|
+
# B101 (assert_used) stays enabled for asher/. `tests` is excluded from the scan
|
|
121
|
+
# entirely, so pytest's asserts never reach it — a hit therefore means a real
|
|
122
|
+
# assert in shipped code, where -O would strip it.
|
|
123
|
+
[tool.bandit]
|
|
124
|
+
exclude_dirs = ["tests", ".venv", ".git", "build", "dist"]
|
|
125
|
+
skips = [
|
|
126
|
+
# blacklist — flags the `import subprocess` line itself. Each call site is
|
|
127
|
+
# reviewed individually by B602-B607.
|
|
128
|
+
"B404",
|
|
129
|
+
]
|
|
130
|
+
|
|
114
131
|
# ── Pytest ────────────────────────────────────────────────────────────────────
|
|
115
132
|
|
|
116
133
|
[tool.pytest.ini_options]
|