asher-cli 1.4.1__tar.gz → 1.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {asher_cli-1.4.1 → asher_cli-1.5.0}/.github/workflows/bandit.yml +1 -1
- {asher_cli-1.4.1 → asher_cli-1.5.0}/.github/workflows/release.yml +5 -2
- {asher_cli-1.4.1 → asher_cli-1.5.0}/CHANGELOG.md +9 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/PKG-INFO +1 -1
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/mcp/config.py +18 -9
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/tui/commands/slash.py +13 -8
- {asher_cli-1.4.1 → asher_cli-1.5.0}/pyproject.toml +5 -4
- {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_mcp_command.py +9 -1
- {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_mcp_config.py +36 -6
- {asher_cli-1.4.1 → asher_cli-1.5.0}/uv.lock +1 -1
- {asher_cli-1.4.1 → asher_cli-1.5.0}/.claude/hooks/block-env.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/.claude/settings.json +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/.claude/skills/mcp-bridge/SKILL.md +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/.claude/skills/pylitterbot-ref/SKILL.md +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/.claude/skills/textual/SKILL.md +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/.env.example +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/.githooks/pre-push +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/.github/FUNDING.yml +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/.github/pull_request_template.md +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/.github/workflows/ci.yml +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/.github/workflows/coverage.yml +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/.github/workflows/dependency-automerge.yml +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/.gitignore +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/.vscode/launch.json +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/.vscode/settings.json +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/.vscode/tasks.json +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/CLAUDE.md +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/CODEOWNERS +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/LICENSE +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/README.md +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/app.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/__init__.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/__main__.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/core/__init__.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/core/activity.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/core/config.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/core/constants.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/core/credentials.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/core/export.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/core/faults.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/core/helpers.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/core/theme.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/core/updates.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/desktop/__init__.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/desktop/autostart.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/desktop/daemon.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/desktop/desktoptheme.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/desktop/launcher.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/desktop/notifications.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/desktop/tray.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/desktop/watcher.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/headless.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/mcp/__init__.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/mcp/bridge.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/robot/__init__.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/robot/adapters.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/robot/protocol.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/tui/__init__.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/tui/app.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/tui/cats.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/tui/commands/__init__.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/tui/commands/base.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/tui/commands/builtin.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/tui/commands/lr5.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/tui/commands/registry.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/tui/commands/robot.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/tui/completion.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/tui/connection.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/tui/history.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/tui/loginflow.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/tui/loginscreen.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/tui/monitoring.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/tui/style.tcss +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/tui/ui.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/cliff.toml +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/CONTRIBUTING.md +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/ROADMAP.md +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/roadmap-archive/README.md +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/roadmap-archive/cat-panel-badges.md +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/roadmap-archive/config-persistence.md +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/roadmap-archive/credentials.md +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/roadmap-archive/desktop-notifications.md +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/roadmap-archive/e2e-pilot.md +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/roadmap-archive/fault-monitoring.md +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/roadmap-archive/headless-export.md +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/roadmap-archive/history-export.md +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/roadmap-archive/lr5-features.md +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/roadmap-archive/multi-pet.md +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/roadmap-archive/robot-commands.md +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/roadmap-archive/runtime-slash-commands.md +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/roadmap-archive/slash-command-spec.md +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/roadmap-archive/sleep-schedule.md +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/roadmap-archive/tab-completion.md +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/roadmap-archive/ui-ux.md +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/roadmap-archive/version-display.md +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/roadmap-archive/websocket-updates.md +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/renovate.json +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/requirements.txt +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/__init__.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/conftest.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_activity_labels.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_app_pilot.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_auth.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_auth_pilot.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_autostart.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_cats.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_commands_pilot.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_completion.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_config.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_connection_mixin.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_credentials.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_daemon.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_desktoptheme.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_export.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_faults.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_headless.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_history_view.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_launcher.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_layering.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_lr5_commands.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_mcp_bridge.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_missing_robot_commands.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_monitoring.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_new_commands_pilot.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_notifications.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_robot_adapters.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_tray.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_ui.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_updates.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_version_command.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_watcher.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/testhelpers.py +0 -0
- {asher_cli-1.4.1 → asher_cli-1.5.0}/watchrun.py +0 -0
|
@@ -37,7 +37,7 @@ jobs:
|
|
|
37
37
|
# --exit-zero: findings must not fail the job, or the upload step below
|
|
38
38
|
# is skipped and code scanning keeps showing the previous run's alerts.
|
|
39
39
|
run: >-
|
|
40
|
-
uvx --from 'bandit[toml,sarif]' bandit
|
|
40
|
+
uvx --from 'bandit[toml,sarif]==1.9.4' bandit
|
|
41
41
|
-c pyproject.toml -r . -f sarif -o bandit.sarif --exit-zero
|
|
42
42
|
|
|
43
43
|
- name: Upload SARIF to code scanning
|
|
@@ -6,8 +6,7 @@ on:
|
|
|
6
6
|
- "release/*"
|
|
7
7
|
|
|
8
8
|
permissions:
|
|
9
|
-
contents:
|
|
10
|
-
id-token: write # needed for PyPI OIDC trusted publishing
|
|
9
|
+
contents: read
|
|
11
10
|
|
|
12
11
|
jobs:
|
|
13
12
|
build:
|
|
@@ -32,6 +31,8 @@ jobs:
|
|
|
32
31
|
needs: build
|
|
33
32
|
runs-on: ubuntu-latest
|
|
34
33
|
environment: pypi
|
|
34
|
+
permissions:
|
|
35
|
+
id-token: write # needed for PyPI OIDC trusted publishing
|
|
35
36
|
steps:
|
|
36
37
|
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
|
37
38
|
with:
|
|
@@ -43,6 +44,8 @@ jobs:
|
|
|
43
44
|
name: Create GitHub Release
|
|
44
45
|
needs: [build, publish]
|
|
45
46
|
runs-on: ubuntu-latest
|
|
47
|
+
permissions:
|
|
48
|
+
contents: write # needed to create GitHub Releases
|
|
46
49
|
steps:
|
|
47
50
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
48
51
|
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
|
@@ -7,6 +7,15 @@ 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.5.0] - 2026-10-03
|
|
11
|
+
|
|
12
|
+
### 🛡️ Security
|
|
13
|
+
|
|
14
|
+
- *(mcp)* Cap `mcp<2` when `/mcp on` installs the extra
|
|
15
|
+
- *(mcp)* Don't overwrite a `claude_desktop_config.json` that can't be parsed
|
|
16
|
+
- *(ci)* Scope release token permissions to the jobs that need them
|
|
17
|
+
- *(ci)* Pin bandit to 1.9.4
|
|
18
|
+
|
|
10
19
|
## [1.4.1] - 2026-10-01
|
|
11
20
|
|
|
12
21
|
### 🐛 Bug Fixes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: asher-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.0
|
|
4
4
|
Summary: Terminal dashboard for Litter Robot (LR3/LR4/LR5) via the Whisker cloud API
|
|
5
5
|
Project-URL: Homepage, https://github.com/karanshukla/asher-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/karanshukla/asher-cli
|
|
@@ -11,6 +11,10 @@ from pathlib import Path
|
|
|
11
11
|
_SERVER_NAME = "Asher CLI MCP Bridge"
|
|
12
12
|
|
|
13
13
|
|
|
14
|
+
class McpConfigError(Exception):
|
|
15
|
+
"""A Claude Desktop config file exists but can't be read as a JSON object."""
|
|
16
|
+
|
|
17
|
+
|
|
14
18
|
def mcp_extra_installed() -> bool:
|
|
15
19
|
"""Whether pylitterbot's mcp extra (the third-party `mcp` SDK) is importable."""
|
|
16
20
|
return importlib.util.find_spec("mcp") is not None
|
|
@@ -60,10 +64,12 @@ def _load(path: Path) -> dict:
|
|
|
60
64
|
if not path.exists():
|
|
61
65
|
return {}
|
|
62
66
|
try:
|
|
63
|
-
data
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
68
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
69
|
+
raise McpConfigError(f"Can't read {path}: {e}") from e
|
|
70
|
+
if not isinstance(data, dict):
|
|
71
|
+
raise McpConfigError(f"Can't read {path}: not a JSON object")
|
|
72
|
+
return data
|
|
67
73
|
|
|
68
74
|
|
|
69
75
|
def _save(path: Path, data: dict) -> None:
|
|
@@ -74,12 +80,13 @@ def _save(path: Path, data: dict) -> None:
|
|
|
74
80
|
def set_mcp_enabled(enabled: bool) -> list[Path]:
|
|
75
81
|
"""Add or remove the MCP server entry in every known config location.
|
|
76
82
|
|
|
77
|
-
Returns the list of config paths actually written.
|
|
83
|
+
Returns the list of config paths actually written. Raises McpConfigError,
|
|
84
|
+
before writing anything, if any existing config can't be read.
|
|
78
85
|
"""
|
|
79
86
|
touched: list[Path] = []
|
|
87
|
+
configs = [(path, _load(path)) for path in config_paths()]
|
|
80
88
|
|
|
81
|
-
for path in
|
|
82
|
-
data = _load(path)
|
|
89
|
+
for path, data in configs:
|
|
83
90
|
servers = data.setdefault("mcpServers", {})
|
|
84
91
|
|
|
85
92
|
if enabled:
|
|
@@ -101,7 +108,9 @@ def mcp_status() -> list[tuple[Path, bool]]:
|
|
|
101
108
|
"""Return (path, enabled) for every known Claude Desktop config location."""
|
|
102
109
|
result = []
|
|
103
110
|
for path in config_paths():
|
|
104
|
-
|
|
105
|
-
|
|
111
|
+
try:
|
|
112
|
+
enabled = _SERVER_NAME in _load(path).get("mcpServers", {})
|
|
113
|
+
except McpConfigError:
|
|
114
|
+
enabled = False
|
|
106
115
|
result.append((path, enabled))
|
|
107
116
|
return result
|
|
@@ -439,14 +439,15 @@ async def _ensure_mcp_extra(app: AsherApp) -> bool:
|
|
|
439
439
|
if mcp_extra_installed():
|
|
440
440
|
return True
|
|
441
441
|
|
|
442
|
-
|
|
443
|
-
|
|
442
|
+
# Same requirements as the `mcp` extra in pyproject.toml, cap included.
|
|
443
|
+
requirements = [f"pylitterbot[mcp]=={pkg_version('pylitterbot')}", "mcp<2"]
|
|
444
|
+
app._log_info(f"Installing {' '.join(requirements)}…")
|
|
444
445
|
proc = await asyncio.create_subprocess_exec(
|
|
445
446
|
sys.executable,
|
|
446
447
|
"-m",
|
|
447
448
|
"pip",
|
|
448
449
|
"install",
|
|
449
|
-
|
|
450
|
+
*requirements,
|
|
450
451
|
stdout=asyncio.subprocess.PIPE,
|
|
451
452
|
stderr=asyncio.subprocess.STDOUT,
|
|
452
453
|
)
|
|
@@ -458,7 +459,8 @@ async def _ensure_mcp_extra(app: AsherApp) -> bool:
|
|
|
458
459
|
app._log_err("Failed to install pylitterbot[mcp]:")
|
|
459
460
|
for line in output.splitlines()[-10:]:
|
|
460
461
|
app._log_err(f" {line}")
|
|
461
|
-
|
|
462
|
+
quoted = " ".join(f"'{r}'" for r in requirements)
|
|
463
|
+
app._log_info(f"Try manually: {sys.executable} -m pip install {quoted}")
|
|
462
464
|
return False
|
|
463
465
|
|
|
464
466
|
|
|
@@ -467,7 +469,7 @@ class McpCommand(SlashCommand):
|
|
|
467
469
|
description = "on|off|status Litter-Robot MCP server for Claude Desktop"
|
|
468
470
|
|
|
469
471
|
async def run(self, app: AsherApp, args: list[str]) -> None:
|
|
470
|
-
from ...mcp.config import mcp_status, set_mcp_enabled # noqa: PLC0415
|
|
472
|
+
from ...mcp.config import McpConfigError, mcp_status, set_mcp_enabled # noqa: PLC0415
|
|
471
473
|
|
|
472
474
|
sub = args[0].lower() if args else "status"
|
|
473
475
|
if sub not in ("on", "off", "status"):
|
|
@@ -507,9 +509,12 @@ class McpCommand(SlashCommand):
|
|
|
507
509
|
return
|
|
508
510
|
if not await _ensure_mcp_extra(app):
|
|
509
511
|
return
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
touched = set_mcp_enabled(
|
|
512
|
+
|
|
513
|
+
try:
|
|
514
|
+
touched = set_mcp_enabled(sub == "on")
|
|
515
|
+
except McpConfigError as e:
|
|
516
|
+
app._log_err(f"{e}. Nothing was changed.")
|
|
517
|
+
return
|
|
513
518
|
|
|
514
519
|
verb = "enabled" if sub == "on" else "disabled"
|
|
515
520
|
if touched:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "asher-cli"
|
|
3
|
-
version = "1.
|
|
3
|
+
version = "1.5.0"
|
|
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" }
|
|
@@ -29,7 +29,8 @@ dependencies = [
|
|
|
29
29
|
[project.optional-dependencies]
|
|
30
30
|
# pylitterbot asks for mcp[cli]>=1.0.0 with no upper bound, but its server does
|
|
31
31
|
# `from mcp.server.fastmcp import FastMCP`, and mcp 2.0 deleted that module.
|
|
32
|
-
# Cap it here
|
|
32
|
+
# Cap it here, and in _ensure_mcp_extra (tui/commands/slash.py), until
|
|
33
|
+
# pylitterbot ships a 2.x-compatible release.
|
|
33
34
|
mcp = ["pylitterbot[mcp]==2025.6.6", "mcp<2"]
|
|
34
35
|
# PyGObject is what puts pystray on its AppIndicator backend. Without it pystray
|
|
35
36
|
# silently falls back to a legacy XEmbed icon, which KDE Plasma and GNOME no
|
|
@@ -81,7 +82,7 @@ lint = "ruff check ."
|
|
|
81
82
|
fmt = "ruff format --check ."
|
|
82
83
|
types = "mypy asher/ --ignore-missing-imports"
|
|
83
84
|
test = "pytest tests/ -v --tb=short"
|
|
84
|
-
security.cmd = "uvx --from bandit[toml] bandit -c pyproject.toml -r ."
|
|
85
|
+
security.cmd = "uvx --from bandit[toml]==1.9.4 bandit -c pyproject.toml -r ."
|
|
85
86
|
security.help = "run the same Bandit scan as the Bandit workflow"
|
|
86
87
|
fix.sequence = [{cmd = "ruff check --fix ."}, {cmd = "ruff format ."}]
|
|
87
88
|
dev = "textual run --dev asher/__main__.py"
|
|
@@ -131,7 +132,7 @@ ignore_missing_imports = true # pylitterbot and textual have incomplete stubs
|
|
|
131
132
|
# ── bump-my-version ───────────────────────────────────────────────────────────
|
|
132
133
|
|
|
133
134
|
[tool.bumpversion]
|
|
134
|
-
current_version = "1.
|
|
135
|
+
current_version = "1.5.0"
|
|
135
136
|
commit = true
|
|
136
137
|
tag = true
|
|
137
138
|
tag_name = "v{new_version}"
|
|
@@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch
|
|
|
7
7
|
|
|
8
8
|
import pytest
|
|
9
9
|
|
|
10
|
+
from asher.mcp.config import McpConfigError
|
|
10
11
|
from asher.tui.commands.slash import McpCommand
|
|
11
12
|
|
|
12
13
|
|
|
@@ -81,10 +82,11 @@ class TestMcpCommand:
|
|
|
81
82
|
with (
|
|
82
83
|
patch("asher.core.credentials.load", return_value=("a@b.com", "pw")),
|
|
83
84
|
patch("asher.mcp.config.mcp_extra_installed", return_value=False),
|
|
84
|
-
patch("asyncio.create_subprocess_exec", return_value=proc),
|
|
85
|
+
patch("asyncio.create_subprocess_exec", return_value=proc) as mock_exec,
|
|
85
86
|
patch("asher.mcp.config.set_mcp_enabled", return_value=[Path("x")]) as mock_set,
|
|
86
87
|
):
|
|
87
88
|
await McpCommand().run(app, ["on"])
|
|
89
|
+
assert "mcp<2" in mock_exec.call_args.args
|
|
88
90
|
mock_set.assert_called_once_with(True)
|
|
89
91
|
|
|
90
92
|
async def test_on_skips_enabling_when_install_fails(self, app):
|
|
@@ -107,6 +109,12 @@ class TestMcpCommand:
|
|
|
107
109
|
mock_set.assert_called_once_with(False)
|
|
108
110
|
app._log_ok.assert_called_once()
|
|
109
111
|
|
|
112
|
+
async def test_off_reports_an_unreadable_config_instead_of_writing(self, app):
|
|
113
|
+
with patch("asher.mcp.config.set_mcp_enabled", side_effect=McpConfigError("Can't read x")):
|
|
114
|
+
await McpCommand().run(app, ["off"])
|
|
115
|
+
app._log_err.assert_called_once()
|
|
116
|
+
app._log_ok.assert_not_called()
|
|
117
|
+
|
|
110
118
|
async def test_off_when_already_disabled_logs_info_not_ok(self, app):
|
|
111
119
|
with patch("asher.mcp.config.set_mcp_enabled", return_value=[]):
|
|
112
120
|
await McpCommand().run(app, ["off"])
|
|
@@ -5,7 +5,15 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
from unittest.mock import patch
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from asher.mcp.config import (
|
|
11
|
+
_SERVER_NAME,
|
|
12
|
+
McpConfigError,
|
|
13
|
+
config_paths,
|
|
14
|
+
mcp_status,
|
|
15
|
+
set_mcp_enabled,
|
|
16
|
+
)
|
|
9
17
|
|
|
10
18
|
|
|
11
19
|
class TestSetMcpEnabled:
|
|
@@ -45,13 +53,28 @@ class TestSetMcpEnabled:
|
|
|
45
53
|
assert touched == []
|
|
46
54
|
assert not path.exists()
|
|
47
55
|
|
|
48
|
-
|
|
56
|
+
@pytest.mark.parametrize("content", ["{not valid json", "[]"])
|
|
57
|
+
def test_refuses_to_overwrite_an_unreadable_config(self, tmp_path, content):
|
|
49
58
|
path = tmp_path / "claude_desktop_config.json"
|
|
50
|
-
path.write_text(
|
|
51
|
-
with
|
|
59
|
+
path.write_text(content, encoding="utf-8")
|
|
60
|
+
with (
|
|
61
|
+
patch("asher.mcp.config.config_paths", return_value=[path]),
|
|
62
|
+
pytest.raises(McpConfigError),
|
|
63
|
+
):
|
|
52
64
|
set_mcp_enabled(True)
|
|
53
|
-
|
|
54
|
-
|
|
65
|
+
assert path.read_text(encoding="utf-8") == content
|
|
66
|
+
|
|
67
|
+
def test_writes_no_location_when_any_is_unreadable(self, tmp_path):
|
|
68
|
+
good = tmp_path / "good" / "claude_desktop_config.json"
|
|
69
|
+
bad = tmp_path / "bad" / "claude_desktop_config.json"
|
|
70
|
+
bad.parent.mkdir()
|
|
71
|
+
bad.write_text("{not valid json", encoding="utf-8")
|
|
72
|
+
with (
|
|
73
|
+
patch("asher.mcp.config.config_paths", return_value=[good, bad]),
|
|
74
|
+
pytest.raises(McpConfigError),
|
|
75
|
+
):
|
|
76
|
+
set_mcp_enabled(True)
|
|
77
|
+
assert not good.exists()
|
|
55
78
|
|
|
56
79
|
def test_enables_across_multiple_locations(self, tmp_path):
|
|
57
80
|
standard = tmp_path / "standard" / "claude_desktop_config.json"
|
|
@@ -80,6 +103,13 @@ class TestMcpStatus:
|
|
|
80
103
|
result = mcp_status()
|
|
81
104
|
assert result == [(path, True)]
|
|
82
105
|
|
|
106
|
+
def test_reports_disabled_for_an_unreadable_config(self, tmp_path):
|
|
107
|
+
path = tmp_path / "claude_desktop_config.json"
|
|
108
|
+
path.write_text("{not valid json", encoding="utf-8")
|
|
109
|
+
with patch("asher.mcp.config.config_paths", return_value=[path]):
|
|
110
|
+
result = mcp_status()
|
|
111
|
+
assert result == [(path, False)]
|
|
112
|
+
|
|
83
113
|
|
|
84
114
|
class TestConfigPaths:
|
|
85
115
|
def test_windows_includes_msix_package_when_present(self, tmp_path, monkeypatch):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|