asher-cli 0.0.4__tar.gz → 0.0.5__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-0.0.4 → asher_cli-0.0.5}/CLAUDE.md +16 -1
- {asher_cli-0.0.4 → asher_cli-0.0.5}/PKG-INFO +7 -3
- {asher_cli-0.0.4 → asher_cli-0.0.5}/README.md +4 -2
- {asher_cli-0.0.4 → asher_cli-0.0.5}/ROADMAP.md +106 -1
- {asher_cli-0.0.4 → asher_cli-0.0.5}/asher/commands/__init__.py +98 -0
- asher_cli-0.0.5/asher/mcp_bridge.py +41 -0
- asher_cli-0.0.5/asher/mcp_config.py +107 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/pyproject.toml +6 -2
- asher_cli-0.0.5/tests/test_mcp_bridge.py +43 -0
- asher_cli-0.0.5/tests/test_mcp_command.py +117 -0
- asher_cli-0.0.5/tests/test_mcp_config.py +130 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/uv.lock +469 -6
- {asher_cli-0.0.4 → asher_cli-0.0.5}/.claude/hooks/block-env.ps1 +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/.claude/settings.json +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/.claude/skills/pylitterbot-ref/SKILL.md +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/.claude/skills/release/SKILL.md +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/.claude/skills/textual/SKILL.md +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/.env.example +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/.githooks/pre-push +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/.github/workflows/ci.yml +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/.github/workflows/claude-code-review.yml +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/.github/workflows/claude.yml +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/.github/workflows/coverage.yml +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/.github/workflows/release.yml +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/.gitignore +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/.vscode/launch.json +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/.vscode/settings.json +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/.vscode/tasks.json +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/LICENSE +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/app.py +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/asher/__init__.py +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/asher/__main__.py +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/asher/app.py +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/asher/auth.py +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/asher/cats.py +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/asher/commands/base.py +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/asher/connection/__init__.py +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/asher/constants.py +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/asher/helpers.py +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/asher/login_flow.py +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/asher/monitoring/__init__.py +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/asher/robot_adapters.py +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/asher/robot_protocol.py +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/asher/slash-commands/__init__.py +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/asher/ui/__init__.py +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/asher/ui/style.tcss +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/renovate.json +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/requirements.txt +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/test.py +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/tests/__init__.py +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/tests/conftest.py +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/tests/test_app_pilot.py +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/tests/test_auth.py +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/tests/test_auth_pilot.py +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/tests/test_cats.py +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/tests/test_commands_pilot.py +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/tests/test_connection.py +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/tests/test_connection_mixin.py +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/tests/test_monitoring.py +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/tests/test_new_commands_pilot.py +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/tests/test_robot_adapters.py +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/tests/test_ui.py +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/tests/testhelpers.py +0 -0
- {asher_cli-0.0.4 → asher_cli-0.0.5}/watchrun.py +0 -0
|
@@ -41,6 +41,8 @@ asher/
|
|
|
41
41
|
login_flow.py LoginFlow state machine — inline email/password prompt in command bar
|
|
42
42
|
robot_protocol.py RobotProtocol structural Protocol for pylitterbot robot objects
|
|
43
43
|
robot_adapters.py RobotAdapter ABC + LR3/LR4/LR5 subclasses + make_adapter() factory
|
|
44
|
+
mcp_config.py Claude Desktop config read/write for the /mcp slash command
|
|
45
|
+
mcp_bridge.py asher-mcp-launch console script — keyring-backed pylitterbot MCP launcher
|
|
44
46
|
__main__.py main() entry point
|
|
45
47
|
commands/
|
|
46
48
|
base.py Command ABC, SlashCommand, CommandRegistry
|
|
@@ -61,6 +63,9 @@ tests/
|
|
|
61
63
|
test_connection_mixin.py ConnectionMixin structure
|
|
62
64
|
test_monitoring.py MonitoringMixin async methods
|
|
63
65
|
test_ui.py UIMixin constants, CSS, helper existence
|
|
66
|
+
test_mcp_config.py Claude Desktop config read/write
|
|
67
|
+
test_mcp_bridge.py mcp_bridge launcher credential/subprocess handling
|
|
68
|
+
test_mcp_command.py /mcp slash command dispatch
|
|
64
69
|
|
|
65
70
|
.github/workflows/
|
|
66
71
|
ci.yml ruff + mypy + pytest on every push/PR
|
|
@@ -83,13 +88,23 @@ LITTER_ROBOT_PASSWORD=...
|
|
|
83
88
|
Keyring service name: `asher-cli`, keys `email` and `password`.
|
|
84
89
|
Helper functions in `asher/connection/__init__.py`: `_keyring_load()`, `_keyring_save()`, `_keyring_delete()`.
|
|
85
90
|
|
|
91
|
+
## MCP bridge (`/mcp`)
|
|
92
|
+
|
|
93
|
+
pylitterbot ships an optional MCP server (`pip install pylitterbot[mcp]`, run via `python -m pylitterbot.mcp`) that lets an MCP client like Claude Desktop monitor/control the robot directly. Its own docs configure it with plaintext credentials in the client's JSON config — asher-cli avoids that:
|
|
94
|
+
|
|
95
|
+
- `/mcp on|off|status` (in `asher/commands/__init__.py`, logic in `asher/mcp_config.py`) adds/removes an entry (named by `mcp_config._SERVER_NAME`) in every `claude_desktop_config.json` this OS's Claude Desktop might read (`mcp_config.config_paths()` — on Windows this includes both the standard installer path and any MSIX/Microsoft Store virtualized path). The entry's `command` is `sys.executable -m asher.mcp_bridge` — never the credentials themselves.
|
|
96
|
+
- `/mcp on` also auto-installs pylitterbot's `mcp` extra via `sys.executable -m pip install "pylitterbot[mcp]==<installed version>"` if the `mcp` package isn't importable yet.
|
|
97
|
+
- `asher/mcp_bridge.py` (console script `asher-mcp-launch`) is what Claude Desktop actually spawns. It reads email/password from the OS keyring at process start, sets them as `LITTER_ROBOT_USERNAME`/`LITTER_ROBOT_PASSWORD` (pylitterbot's expected names — note these differ from asher-cli's own `.env` var `LITTER_ROBOT_USER`) in that process's environment only, then execs `python -m pylitterbot.mcp`. No credentials ever touch the on-disk MCP config.
|
|
98
|
+
- `/mcp on` requires keyring credentials. If none are found but `.env` fallback credentials are set, it copies them into the keyring automatically (since the bridge process can't reliably discover a project-relative `.env` — Claude Desktop controls its working directory, not asher-cli).
|
|
99
|
+
- Requires the `mcp` extra: `uv sync --extra mcp` / `pip install asher-cli[mcp]`. Restart Claude Desktop after toggling for the change to take effect.
|
|
100
|
+
|
|
86
101
|
## Command convention
|
|
87
102
|
|
|
88
103
|
**Normal commands** (no prefix) — robot actions only:
|
|
89
104
|
`clean`, `status`, `lock`, `unlock`, `sleep`, `wake`, `night-light on|off|auto`, `night-light-brightness <level>`, `history`, `clear`, `help`
|
|
90
105
|
|
|
91
106
|
**Slash commands** (`/` prefix) — app management only:
|
|
92
|
-
`/login`, `/logout`, `/exit`, `/robots`, `/robot <index|name
|
|
107
|
+
`/login`, `/logout`, `/exit`, `/robots`, `/robot <index|name>`, `/pets`, `/pet <index|name>`, `/cat on|off|color <hex>`, `/refresh <seconds|off>`, `/config`, `/mcp on|off|status`
|
|
93
108
|
|
|
94
109
|
**Special cases** (accepted both with and without `/`):
|
|
95
110
|
`exit`, `quit`, `q` — exit the app
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: asher-cli
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.5
|
|
4
4
|
Summary: Terminal dashboard for Litter Robot (LR3/LR4/LR5) via the Whisker cloud API
|
|
5
5
|
License: MIT License
|
|
6
6
|
|
|
@@ -40,6 +40,8 @@ Requires-Dist: python-dotenv>=1.0.0
|
|
|
40
40
|
Requires-Dist: rich>=13.0.0
|
|
41
41
|
Requires-Dist: textual>=0.47.0
|
|
42
42
|
Requires-Dist: tzlocal>=5.4
|
|
43
|
+
Provides-Extra: mcp
|
|
44
|
+
Requires-Dist: pylitterbot[mcp]==2025.5.0; extra == 'mcp'
|
|
43
45
|
Description-Content-Type: text/markdown
|
|
44
46
|
|
|
45
47
|
# Asher CLI
|
|
@@ -154,12 +156,14 @@ LITTER_ROBOT_PASSWORD=yourpassword
|
|
|
154
156
|
## Releasing
|
|
155
157
|
|
|
156
158
|
```bash
|
|
157
|
-
# bump version, commit, and tag in one step
|
|
159
|
+
# bump version, commit, and tag in one step, then push with tags
|
|
158
160
|
uv run bump-my-version bump patch # 0.0.1 → 0.0.2
|
|
159
161
|
uv run bump-my-version bump minor # 0.0.2 → 0.1.0
|
|
160
162
|
uv run bump-my-version bump major # 0.1.0 → 1.0.0
|
|
161
163
|
|
|
162
|
-
|
|
164
|
+
git push && git push --tags
|
|
165
|
+
|
|
166
|
+
# then push the release branch to trigger PyPI publish with the new semver
|
|
163
167
|
git checkout -b release/0.0.2
|
|
164
168
|
git push origin release/0.0.2
|
|
165
169
|
```
|
|
@@ -110,12 +110,14 @@ LITTER_ROBOT_PASSWORD=yourpassword
|
|
|
110
110
|
## Releasing
|
|
111
111
|
|
|
112
112
|
```bash
|
|
113
|
-
# bump version, commit, and tag in one step
|
|
113
|
+
# bump version, commit, and tag in one step, then push with tags
|
|
114
114
|
uv run bump-my-version bump patch # 0.0.1 → 0.0.2
|
|
115
115
|
uv run bump-my-version bump minor # 0.0.2 → 0.1.0
|
|
116
116
|
uv run bump-my-version bump major # 0.1.0 → 1.0.0
|
|
117
117
|
|
|
118
|
-
|
|
118
|
+
git push && git push --tags
|
|
119
|
+
|
|
120
|
+
# then push the release branch to trigger PyPI publish with the new semver
|
|
119
121
|
git checkout -b release/0.0.2
|
|
120
122
|
git push origin release/0.0.2
|
|
121
123
|
```
|
|
@@ -15,7 +15,8 @@ Current state, missing functionality, and suggested additions — grounded in wh
|
|
|
15
15
|
| Status bar second row — drawer %, litter %, cat weight (with pet name), last visit | ✅ |
|
|
16
16
|
| Pet name from Whisker account profile | ✅ |
|
|
17
17
|
| Commands: clean, status, lock, unlock, sleep, wake, night-light on/off/auto, night-light-brightness, history, export [days\|month], help, clear, quit | ✅ |
|
|
18
|
-
| Slash commands: `/login`, `/logout`, `/exit`, `/help`, `/robots`, `/robot <index\|name>`, `/pets`, `/pet <index\|name>`, `/cat on\|off\|color <hex>`, `/refresh [seconds\|off]`, `/config` | ✅ |
|
|
18
|
+
| Slash commands: `/login`, `/logout`, `/exit`, `/help`, `/robots`, `/robot <index\|name>`, `/pets`, `/pet <index\|name>`, `/cat on\|off\|color <hex>`, `/refresh [seconds\|off]`, `/config`, `/mcp on\|off\|status` | ✅ |
|
|
19
|
+
| MCP bridge — keyring-backed `pylitterbot[mcp]` launcher, auto-installs the extra, writes/removes the Claude Desktop config entry (incl. Windows MSIX path) | ✅ |
|
|
19
20
|
| Inline login flow (email → password in command bar, no restart) | ✅ |
|
|
20
21
|
| `LoginScreen` modal (`auth.py`) — available for future use | ✅ |
|
|
21
22
|
| Activity history (`get_activity_history`) | ✅ |
|
|
@@ -2065,6 +2066,109 @@ Implemented via `robot_model(r)` in `asher/helpers.py`, called from `_refresh_st
|
|
|
2065
2066
|
|
|
2066
2067
|
---
|
|
2067
2068
|
|
|
2069
|
+
## 25. Headless CLI export — automate history without the TUI or MCP
|
|
2070
|
+
|
|
2071
|
+
`export [days|month]` (§2) already writes activity history to CSV, but only
|
|
2072
|
+
from *inside* the running interactive TUI — a human has to launch `asher`,
|
|
2073
|
+
wait for it to connect, and type the command. That's unusable from cron,
|
|
2074
|
+
Windows Task Scheduler, or a systemd timer. The MCP bridge (`/mcp`, shipped)
|
|
2075
|
+
solves automation for an AI assistant talking to the robot, but it doesn't
|
|
2076
|
+
help someone who just wants `asher --export 7` in a nightly script with no
|
|
2077
|
+
Claude Desktop involved at all.
|
|
2078
|
+
|
|
2079
|
+
### Command syntax
|
|
2080
|
+
|
|
2081
|
+
```
|
|
2082
|
+
asher --export 7 export last 7 days to the default path
|
|
2083
|
+
asher --export 7 --output ~/hist.csv explicit output path
|
|
2084
|
+
asher --export month --robot "Asher 2" export 30 days for a specific robot
|
|
2085
|
+
```
|
|
2086
|
+
|
|
2087
|
+
No flags → today's behavior unchanged: launches the interactive TUI. Any
|
|
2088
|
+
recognized flag → run headlessly and exit; the Textual `App` is never
|
|
2089
|
+
constructed, so this works over SSH, in a container, or from Task Scheduler
|
|
2090
|
+
with no terminal attached.
|
|
2091
|
+
|
|
2092
|
+
### Entry point changes
|
|
2093
|
+
|
|
2094
|
+
`asher/__main__.py` parses `sys.argv` with `argparse` *before* deciding
|
|
2095
|
+
whether to build `AsherApp`:
|
|
2096
|
+
|
|
2097
|
+
```python
|
|
2098
|
+
def main() -> None:
|
|
2099
|
+
args = _parse_args()
|
|
2100
|
+
if args.export is not None:
|
|
2101
|
+
sys.exit(asyncio.run(_run_headless_export(args)))
|
|
2102
|
+
AsherApp().run()
|
|
2103
|
+
```
|
|
2104
|
+
|
|
2105
|
+
### Decouple `_run_export` from Textual
|
|
2106
|
+
|
|
2107
|
+
`_run_export(app, days)` in `asher/commands/__init__.py` currently logs via
|
|
2108
|
+
`app._log_info` / `_log_err` / `_log_ok` (RichLog writes) and always opens
|
|
2109
|
+
the output folder in the OS file explorer — neither makes sense headlessly
|
|
2110
|
+
(no widget tree, no desktop session on a server). Split the CSV-writing core
|
|
2111
|
+
out into a plain function both paths share:
|
|
2112
|
+
|
|
2113
|
+
```python
|
|
2114
|
+
async def build_history_csv(
|
|
2115
|
+
robot: RobotProtocol, pets: list, days: int, dest: Path,
|
|
2116
|
+
) -> None:
|
|
2117
|
+
"""Pure logic: fetch, filter, write. No Textual, no I/O side effects beyond dest."""
|
|
2118
|
+
...
|
|
2119
|
+
|
|
2120
|
+
async def _run_export(app: AsherApp, days: int) -> None:
|
|
2121
|
+
# existing TUI path: resolve dest via app, call build_history_csv,
|
|
2122
|
+
# log via app._log_*, then _open_folder(dest)
|
|
2123
|
+
|
|
2124
|
+
async def _run_headless_export(args: argparse.Namespace) -> int:
|
|
2125
|
+
# connect via keyring -> .env (same priority as _connect_worker), no
|
|
2126
|
+
# interactive login possible - print a clear error and exit 1 if missing
|
|
2127
|
+
# resolve robot by --robot or keyring preferred_robot or robots[0]
|
|
2128
|
+
# call build_history_csv, print plain text to stdout/stderr, no folder-open
|
|
2129
|
+
```
|
|
2130
|
+
|
|
2131
|
+
### Credentials — same priority, no interactive fallback
|
|
2132
|
+
|
|
2133
|
+
Headless mode can't prompt for a password. Priority stays keyring → `.env`,
|
|
2134
|
+
but if neither has credentials, print an actionable error and exit non-zero
|
|
2135
|
+
rather than starting the inline login flow (there's no command bar to type
|
|
2136
|
+
into). This mirrors the constraint already documented for the MCP bridge:
|
|
2137
|
+
a scheduled task's environment can't be assumed to match the project's
|
|
2138
|
+
working directory, so `.env` discovery should not rely on `find_dotenv()`'s
|
|
2139
|
+
upward directory search — same caveat as `asher/mcp_bridge.py`.
|
|
2140
|
+
|
|
2141
|
+
### Exit codes (for shell scripting)
|
|
2142
|
+
|
|
2143
|
+
| Code | Meaning |
|
|
2144
|
+
|---|---|
|
|
2145
|
+
| `0` | Export succeeded |
|
|
2146
|
+
| `1` | No credentials found (keyring or `.env`) |
|
|
2147
|
+
| `2` | Connection or API failure |
|
|
2148
|
+
| `3` | Failed to write the CSV (permissions, disk full) |
|
|
2149
|
+
| `4` | `--robot` selector matched no robot on the account |
|
|
2150
|
+
|
|
2151
|
+
### Example automation
|
|
2152
|
+
|
|
2153
|
+
```bash
|
|
2154
|
+
# crontab -e — nightly export at 03:00
|
|
2155
|
+
0 3 * * * /usr/bin/env asher --export 7 --output /home/me/litter-history.csv >> /var/log/asher-export.log 2>&1
|
|
2156
|
+
```
|
|
2157
|
+
|
|
2158
|
+
```powershell
|
|
2159
|
+
# Windows Task Scheduler action
|
|
2160
|
+
asher.exe --export 7 --output C:\Users\me\litter-history.csv
|
|
2161
|
+
```
|
|
2162
|
+
|
|
2163
|
+
### Testing
|
|
2164
|
+
|
|
2165
|
+
No Textual `Pilot` needed — `build_history_csv` and `_run_headless_export`
|
|
2166
|
+
are plain async functions, testable the same way as `mcp_bridge.main()`
|
|
2167
|
+
(§ MCP bridge): mock `Account.connect`, mock `robot.get_activity_history`,
|
|
2168
|
+
assert on the written CSV content and the returned exit code.
|
|
2169
|
+
|
|
2170
|
+
---
|
|
2171
|
+
|
|
2068
2172
|
## Priority suggestion
|
|
2069
2173
|
|
|
2070
2174
|
Ranked by user-visible impact vs. implementation effort:
|
|
@@ -2103,6 +2207,7 @@ Ranked by user-visible impact vs. implementation effort:
|
|
|
2103
2207
|
6. **`/version` slash command** (§24) — print Python/package versions to log; model badge in status bar is already done
|
|
2104
2208
|
7. **`wait-time`, `power`, `rename`, `insight` commands** (§3) — each is a two-line wiring job
|
|
2105
2209
|
8. **Sleep schedule viewer** (§8) — read-only first, config wizard later
|
|
2210
|
+
9. **Headless CLI export** (§25) — `asher --export 7` for cron/Task Scheduler automation, no TUI or Claude Desktop needed
|
|
2106
2211
|
|
|
2107
2212
|
### Release pipeline
|
|
2108
2213
|
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import contextlib
|
|
7
7
|
import csv
|
|
8
|
+
import os
|
|
8
9
|
import subprocess
|
|
9
10
|
import sys
|
|
10
11
|
from datetime import datetime, timedelta, timezone
|
|
@@ -628,6 +629,102 @@ class RobotCommand(SlashCommand):
|
|
|
628
629
|
_keyring_save_robot(serial)
|
|
629
630
|
|
|
630
631
|
|
|
632
|
+
async def _ensure_mcp_extra(app: AsherApp) -> bool:
|
|
633
|
+
"""Install pylitterbot's mcp extra if it isn't already available. Returns success."""
|
|
634
|
+
from importlib.metadata import version as pkg_version # noqa: PLC0415
|
|
635
|
+
|
|
636
|
+
from ..mcp_config import mcp_extra_installed # noqa: PLC0415
|
|
637
|
+
|
|
638
|
+
if mcp_extra_installed():
|
|
639
|
+
return True
|
|
640
|
+
|
|
641
|
+
pin = f"pylitterbot[mcp]=={pkg_version('pylitterbot')}"
|
|
642
|
+
app._log_info(f"Installing {pin}…")
|
|
643
|
+
proc = await asyncio.create_subprocess_exec(
|
|
644
|
+
sys.executable,
|
|
645
|
+
"-m",
|
|
646
|
+
"pip",
|
|
647
|
+
"install",
|
|
648
|
+
pin,
|
|
649
|
+
stdout=asyncio.subprocess.PIPE,
|
|
650
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
651
|
+
)
|
|
652
|
+
output = (await proc.communicate())[0].decode(errors="replace")
|
|
653
|
+
if proc.returncode == 0:
|
|
654
|
+
app._log_ok("Installed pylitterbot[mcp].")
|
|
655
|
+
return True
|
|
656
|
+
|
|
657
|
+
app._log_err("Failed to install pylitterbot[mcp]:")
|
|
658
|
+
for line in output.splitlines()[-10:]:
|
|
659
|
+
app._log_err(f" {line}")
|
|
660
|
+
app._log_info(f"Try manually: {sys.executable} -m pip install '{pin}'")
|
|
661
|
+
return False
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
class McpCommand(SlashCommand):
|
|
665
|
+
name = "mcp"
|
|
666
|
+
description = "on|off|status Litter-Robot MCP server for Claude Desktop"
|
|
667
|
+
|
|
668
|
+
async def run(self, app: AsherApp, args: list[str]) -> None:
|
|
669
|
+
from ..mcp_config import mcp_status, set_mcp_enabled # noqa: PLC0415
|
|
670
|
+
|
|
671
|
+
sub = args[0].lower() if args else "status"
|
|
672
|
+
if sub not in ("on", "off", "status"):
|
|
673
|
+
app._log_warn("Usage: /mcp on|off|status")
|
|
674
|
+
return
|
|
675
|
+
|
|
676
|
+
if sub == "status":
|
|
677
|
+
from ..connection import _keyring_load # noqa: PLC0415
|
|
678
|
+
|
|
679
|
+
email, password = _keyring_load()
|
|
680
|
+
has_keyring_creds = bool(email and password)
|
|
681
|
+
has_env_creds = bool(
|
|
682
|
+
os.getenv("LITTER_ROBOT_USER") and os.getenv("LITTER_ROBOT_PASSWORD")
|
|
683
|
+
)
|
|
684
|
+
if has_keyring_creds:
|
|
685
|
+
app._log_info("Credentials: present in keyring")
|
|
686
|
+
elif has_env_creds:
|
|
687
|
+
app._log_info("Credentials: present in .env (will be copied to keyring on /mcp on)")
|
|
688
|
+
else:
|
|
689
|
+
app._log_info("Credentials: missing - use /login first")
|
|
690
|
+
for path, enabled in mcp_status():
|
|
691
|
+
state = "enabled " if enabled else "disabled"
|
|
692
|
+
found = "found" if path.exists() else "not found"
|
|
693
|
+
app._log_info(f" [{state}, {found}] {path}")
|
|
694
|
+
return
|
|
695
|
+
|
|
696
|
+
if sub == "on":
|
|
697
|
+
from ..connection import _keyring_load, _keyring_save # noqa: PLC0415
|
|
698
|
+
|
|
699
|
+
email, password = _keyring_load()
|
|
700
|
+
if not email or not password:
|
|
701
|
+
env_email = os.getenv("LITTER_ROBOT_USER") or ""
|
|
702
|
+
env_password = os.getenv("LITTER_ROBOT_PASSWORD") or ""
|
|
703
|
+
if env_email and env_password and _keyring_save(env_email, env_password):
|
|
704
|
+
app._log_info("Copied .env credentials into the OS keyring for MCP use.")
|
|
705
|
+
email, password = env_email, env_password
|
|
706
|
+
|
|
707
|
+
if not email or not password:
|
|
708
|
+
app._log_err(
|
|
709
|
+
"No credentials in keyring or .env - use /login first, "
|
|
710
|
+
"or set LITTER_ROBOT_USER/LITTER_ROBOT_PASSWORD."
|
|
711
|
+
)
|
|
712
|
+
return
|
|
713
|
+
if not await _ensure_mcp_extra(app):
|
|
714
|
+
return
|
|
715
|
+
touched = set_mcp_enabled(True)
|
|
716
|
+
else:
|
|
717
|
+
touched = set_mcp_enabled(False)
|
|
718
|
+
|
|
719
|
+
verb = "enabled" if sub == "on" else "disabled"
|
|
720
|
+
if touched:
|
|
721
|
+
for path in touched:
|
|
722
|
+
app._log_ok(f"MCP server '{verb}' in {path}")
|
|
723
|
+
app._log_info("Restart Claude Desktop to apply this change.")
|
|
724
|
+
else:
|
|
725
|
+
app._log_info(f"MCP server was already {verb}")
|
|
726
|
+
|
|
727
|
+
|
|
631
728
|
_ACTION_LABELS: dict[str, tuple[str, str]] = {
|
|
632
729
|
"ready": ("Ready", "#484f58"),
|
|
633
730
|
"litter robot is ready.": ("Ready", "#484f58"),
|
|
@@ -778,6 +875,7 @@ _registry.register(PetCommand())
|
|
|
778
875
|
_registry.register(CatCommand())
|
|
779
876
|
_registry.register(RefreshCommand())
|
|
780
877
|
_registry.register(ConfigCommand())
|
|
878
|
+
_registry.register(McpCommand())
|
|
781
879
|
|
|
782
880
|
|
|
783
881
|
# ── mixin ───────────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Launcher for pylitterbot's MCP server, run by an MCP client (e.g. Claude Desktop).
|
|
2
|
+
|
|
3
|
+
Reads credentials from the OS keyring — the same store asher-cli itself uses — and
|
|
4
|
+
passes them to pylitterbot's MCP server as environment variables held only in this
|
|
5
|
+
process's memory. The MCP client's on-disk config never contains a plaintext
|
|
6
|
+
username or password; it just points here.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
_SERVICE = "asher-cli"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main() -> None:
|
|
19
|
+
import keyring
|
|
20
|
+
|
|
21
|
+
email = keyring.get_password(_SERVICE, "email") or ""
|
|
22
|
+
password = keyring.get_password(_SERVICE, "password") or ""
|
|
23
|
+
|
|
24
|
+
if not email or not password:
|
|
25
|
+
print(
|
|
26
|
+
"asher-mcp-launch: no credentials found in the OS keyring. "
|
|
27
|
+
"Run asher-cli and sign in with /login at least once, then retry.",
|
|
28
|
+
file=sys.stderr,
|
|
29
|
+
)
|
|
30
|
+
sys.exit(1)
|
|
31
|
+
|
|
32
|
+
env = os.environ.copy()
|
|
33
|
+
env["LITTER_ROBOT_USERNAME"] = email
|
|
34
|
+
env["LITTER_ROBOT_PASSWORD"] = password
|
|
35
|
+
|
|
36
|
+
result = subprocess.run([sys.executable, "-m", "pylitterbot.mcp"], env=env, check=False)
|
|
37
|
+
sys.exit(result.returncode)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
if __name__ == "__main__":
|
|
41
|
+
main()
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Manage the Litter-Robot MCP server entry in Claude Desktop's config file(s)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
_SERVER_NAME = "Asher CLI MCP Bridge"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def mcp_extra_installed() -> bool:
|
|
15
|
+
"""Whether pylitterbot's mcp extra (the third-party `mcp` SDK) is importable."""
|
|
16
|
+
return importlib.util.find_spec("mcp") is not None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def config_paths() -> list[Path]:
|
|
20
|
+
"""Return every claude_desktop_config.json path this OS's Claude Desktop might read.
|
|
21
|
+
|
|
22
|
+
On Windows, a standard installer and an MSIX/Microsoft Store install use different,
|
|
23
|
+
unrelated directories (the Store version is redirected by Windows to a virtualized
|
|
24
|
+
per-package path) - write to both so the entry is picked up regardless of install type.
|
|
25
|
+
|
|
26
|
+
On Linux, Anthropic's official apt package (beta as of mid-2026) is expected to use
|
|
27
|
+
~/.config/Claude/, matching macOS/Windows naming, but older community-built wrappers
|
|
28
|
+
used ~/.config/claude-desktop/ - include that one too if it's already present.
|
|
29
|
+
"""
|
|
30
|
+
if sys.platform == "win32":
|
|
31
|
+
paths = []
|
|
32
|
+
appdata = Path(os.environ.get("APPDATA", str(Path.home() / "AppData" / "Roaming")))
|
|
33
|
+
paths.append(appdata / "Claude" / "claude_desktop_config.json")
|
|
34
|
+
|
|
35
|
+
local_appdata = Path(os.environ.get("LOCALAPPDATA", str(Path.home() / "AppData" / "Local")))
|
|
36
|
+
packages_dir = local_appdata / "Packages"
|
|
37
|
+
if packages_dir.is_dir():
|
|
38
|
+
for entry in sorted(packages_dir.glob("Claude_*")):
|
|
39
|
+
paths.append(
|
|
40
|
+
entry / "LocalCache" / "Roaming" / "Claude" / "claude_desktop_config.json"
|
|
41
|
+
)
|
|
42
|
+
return paths
|
|
43
|
+
if sys.platform == "darwin":
|
|
44
|
+
return [
|
|
45
|
+
Path.home()
|
|
46
|
+
/ "Library"
|
|
47
|
+
/ "Application Support"
|
|
48
|
+
/ "Claude"
|
|
49
|
+
/ "claude_desktop_config.json"
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
paths = [Path.home() / ".config" / "Claude" / "claude_desktop_config.json"]
|
|
53
|
+
alt_dir = Path.home() / ".config" / "claude-desktop"
|
|
54
|
+
if alt_dir.is_dir():
|
|
55
|
+
paths.append(alt_dir / "claude_desktop_config.json")
|
|
56
|
+
return paths
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _load(path: Path) -> dict:
|
|
60
|
+
if not path.exists():
|
|
61
|
+
return {}
|
|
62
|
+
try:
|
|
63
|
+
data: dict = json.loads(path.read_text(encoding="utf-8"))
|
|
64
|
+
return data
|
|
65
|
+
except (json.JSONDecodeError, OSError):
|
|
66
|
+
return {}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _save(path: Path, data: dict) -> None:
|
|
70
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def set_mcp_enabled(enabled: bool) -> list[Path]:
|
|
75
|
+
"""Add or remove the MCP server entry in every known config location.
|
|
76
|
+
|
|
77
|
+
Returns the list of config paths actually written.
|
|
78
|
+
"""
|
|
79
|
+
touched: list[Path] = []
|
|
80
|
+
|
|
81
|
+
for path in config_paths():
|
|
82
|
+
data = _load(path)
|
|
83
|
+
servers = data.setdefault("mcpServers", {})
|
|
84
|
+
|
|
85
|
+
if enabled:
|
|
86
|
+
servers[_SERVER_NAME] = {
|
|
87
|
+
"command": sys.executable,
|
|
88
|
+
"args": ["-m", "asher.mcp_bridge"],
|
|
89
|
+
}
|
|
90
|
+
_save(path, data)
|
|
91
|
+
touched.append(path)
|
|
92
|
+
elif _SERVER_NAME in servers:
|
|
93
|
+
del servers[_SERVER_NAME]
|
|
94
|
+
_save(path, data)
|
|
95
|
+
touched.append(path)
|
|
96
|
+
|
|
97
|
+
return touched
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def mcp_status() -> list[tuple[Path, bool]]:
|
|
101
|
+
"""Return (path, enabled) for every known Claude Desktop config location."""
|
|
102
|
+
result = []
|
|
103
|
+
for path in config_paths():
|
|
104
|
+
data = _load(path)
|
|
105
|
+
enabled = _SERVER_NAME in data.get("mcpServers", {})
|
|
106
|
+
result.append((path, enabled))
|
|
107
|
+
return result
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "asher-cli"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.5"
|
|
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" }
|
|
@@ -25,8 +25,12 @@ dependencies = [
|
|
|
25
25
|
"tzlocal>=5.4",
|
|
26
26
|
]
|
|
27
27
|
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
mcp = ["pylitterbot[mcp]==2025.5.0"]
|
|
30
|
+
|
|
28
31
|
[project.scripts]
|
|
29
32
|
asher = "asher.__main__:main"
|
|
33
|
+
asher-mcp-launch = "asher.mcp_bridge:main"
|
|
30
34
|
|
|
31
35
|
[dependency-groups]
|
|
32
36
|
dev = [
|
|
@@ -87,7 +91,7 @@ exclude = ["asher/slash-commands"]
|
|
|
87
91
|
# ── bump-my-version ───────────────────────────────────────────────────────────
|
|
88
92
|
|
|
89
93
|
[tool.bumpversion]
|
|
90
|
-
current_version = "0.0.
|
|
94
|
+
current_version = "0.0.5"
|
|
91
95
|
commit = true
|
|
92
96
|
tag = true
|
|
93
97
|
tag_name = "v{new_version}"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Tests for asher.mcp_bridge module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from unittest.mock import MagicMock, patch
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from asher.mcp_bridge import main
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestMain:
|
|
13
|
+
def test_exits_nonzero_when_no_credentials(self):
|
|
14
|
+
with (
|
|
15
|
+
patch("keyring.get_password", return_value=None),
|
|
16
|
+
pytest.raises(SystemExit) as exc,
|
|
17
|
+
):
|
|
18
|
+
main()
|
|
19
|
+
assert exc.value.code == 1
|
|
20
|
+
|
|
21
|
+
def test_launches_pylitterbot_mcp_with_env_credentials(self):
|
|
22
|
+
mock_result = MagicMock(returncode=0)
|
|
23
|
+
with (
|
|
24
|
+
patch("keyring.get_password", side_effect=["test@example.com", "secret123"]),
|
|
25
|
+
patch("asher.mcp_bridge.subprocess.run", return_value=mock_result) as mock_run,
|
|
26
|
+
pytest.raises(SystemExit) as exc,
|
|
27
|
+
):
|
|
28
|
+
main()
|
|
29
|
+
assert exc.value.code == 0
|
|
30
|
+
args, kwargs = mock_run.call_args
|
|
31
|
+
assert args[0][1:] == ["-m", "pylitterbot.mcp"]
|
|
32
|
+
assert kwargs["env"]["LITTER_ROBOT_USERNAME"] == "test@example.com"
|
|
33
|
+
assert kwargs["env"]["LITTER_ROBOT_PASSWORD"] == "secret123"
|
|
34
|
+
|
|
35
|
+
def test_propagates_child_exit_code(self):
|
|
36
|
+
mock_result = MagicMock(returncode=3)
|
|
37
|
+
with (
|
|
38
|
+
patch("keyring.get_password", side_effect=["a@b.com", "pw"]),
|
|
39
|
+
patch("asher.mcp_bridge.subprocess.run", return_value=mock_result),
|
|
40
|
+
pytest.raises(SystemExit) as exc,
|
|
41
|
+
):
|
|
42
|
+
main()
|
|
43
|
+
assert exc.value.code == 3
|