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.
- asher_cli-1.0.2/.github/workflows/bandit.yml +47 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/.github/workflows/coverage.yml +1 -1
- {asher_cli-1.0.0 → asher_cli-1.0.2}/CHANGELOG.md +22 -1
- {asher_cli-1.0.0 → asher_cli-1.0.2}/CLAUDE.md +5 -2
- {asher_cli-1.0.0 → asher_cli-1.0.2}/PKG-INFO +12 -2
- {asher_cli-1.0.0 → asher_cli-1.0.2}/README.md +11 -1
- {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/commands/__init__.py +72 -36
- {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/history_view.py +62 -5
- {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/mcp_bridge.py +3 -1
- {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/monitoring/__init__.py +4 -12
- {asher_cli-1.0.0 → asher_cli-1.0.2}/pyproject.toml +19 -2
- {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_history_view.py +117 -1
- {asher_cli-1.0.0 → asher_cli-1.0.2}/uv.lock +170 -170
- {asher_cli-1.0.0 → asher_cli-1.0.2}/watchrun.py +11 -5
- {asher_cli-1.0.0 → asher_cli-1.0.2}/.claude/hooks/block-env.ps1 +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/.claude/settings.json +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/.claude/skills/pylitterbot-ref/SKILL.md +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/.claude/skills/release/SKILL.md +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/.claude/skills/textual/SKILL.md +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/.env.example +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/.githooks/pre-push +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/.github/pull_request_template.md +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/.github/workflows/ci.yml +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/.github/workflows/claude-code-review.yml +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/.github/workflows/claude.yml +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/.github/workflows/release.yml +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/.gitignore +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/.vscode/launch.json +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/.vscode/settings.json +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/.vscode/tasks.json +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/CODEOWNERS +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/LICENSE +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/app.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/__init__.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/__main__.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/activity_labels.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/app.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/auth.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/cats.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/commands/base.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/completion.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/config.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/connection/__init__.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/constants.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/export.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/faults.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/helpers.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/login_flow.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/mcp_config.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/notifications.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/robot_adapters.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/robot_protocol.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/slash-commands/__init__.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/ui/__init__.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/asher/ui/style.tcss +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/cliff.toml +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/docs/ROADMAP.md +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/docs/roadmap-archive/README.md +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/docs/roadmap-archive/cat-panel-badges.md +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/docs/roadmap-archive/config-persistence.md +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/docs/roadmap-archive/desktop-notifications.md +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/docs/roadmap-archive/fault-monitoring.md +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/docs/roadmap-archive/headless-export.md +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/docs/roadmap-archive/history-export.md +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/docs/roadmap-archive/tab-completion.md +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/renovate.json +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/requirements.txt +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/test.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/__init__.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/conftest.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_activity_labels.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_app_pilot.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_auth.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_auth_pilot.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_cats.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_commands_pilot.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_completion.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_config.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_connection.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_connection_mixin.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_export.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_faults.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_lr5_commands.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_mcp_bridge.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_mcp_command.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_mcp_config.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_missing_robot_commands.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_monitoring.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_new_commands_pilot.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_notifications.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_robot_adapters.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_ui.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.2}/tests/test_version_command.py +0 -0
- {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@
|
|
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
|
-
## [
|
|
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.
|
|
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
|
-
|
|
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:
|
|
@@ -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
|
|
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:
|
|
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:
|
|
201
|
+
height: 2;
|
|
169
202
|
background: #161b22;
|
|
170
203
|
border-top: solid #30363d;
|
|
171
204
|
padding: 0 2;
|
|
172
|
-
color: #
|
|
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
|
-
|
|
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(
|
|
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
|
|