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.
Files changed (133) hide show
  1. {asher_cli-1.4.1 → asher_cli-1.5.0}/.github/workflows/bandit.yml +1 -1
  2. {asher_cli-1.4.1 → asher_cli-1.5.0}/.github/workflows/release.yml +5 -2
  3. {asher_cli-1.4.1 → asher_cli-1.5.0}/CHANGELOG.md +9 -0
  4. {asher_cli-1.4.1 → asher_cli-1.5.0}/PKG-INFO +1 -1
  5. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/mcp/config.py +18 -9
  6. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/tui/commands/slash.py +13 -8
  7. {asher_cli-1.4.1 → asher_cli-1.5.0}/pyproject.toml +5 -4
  8. {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_mcp_command.py +9 -1
  9. {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_mcp_config.py +36 -6
  10. {asher_cli-1.4.1 → asher_cli-1.5.0}/uv.lock +1 -1
  11. {asher_cli-1.4.1 → asher_cli-1.5.0}/.claude/hooks/block-env.py +0 -0
  12. {asher_cli-1.4.1 → asher_cli-1.5.0}/.claude/settings.json +0 -0
  13. {asher_cli-1.4.1 → asher_cli-1.5.0}/.claude/skills/mcp-bridge/SKILL.md +0 -0
  14. {asher_cli-1.4.1 → asher_cli-1.5.0}/.claude/skills/pylitterbot-ref/SKILL.md +0 -0
  15. {asher_cli-1.4.1 → asher_cli-1.5.0}/.claude/skills/textual/SKILL.md +0 -0
  16. {asher_cli-1.4.1 → asher_cli-1.5.0}/.env.example +0 -0
  17. {asher_cli-1.4.1 → asher_cli-1.5.0}/.githooks/pre-push +0 -0
  18. {asher_cli-1.4.1 → asher_cli-1.5.0}/.github/FUNDING.yml +0 -0
  19. {asher_cli-1.4.1 → asher_cli-1.5.0}/.github/pull_request_template.md +0 -0
  20. {asher_cli-1.4.1 → asher_cli-1.5.0}/.github/workflows/ci.yml +0 -0
  21. {asher_cli-1.4.1 → asher_cli-1.5.0}/.github/workflows/coverage.yml +0 -0
  22. {asher_cli-1.4.1 → asher_cli-1.5.0}/.github/workflows/dependency-automerge.yml +0 -0
  23. {asher_cli-1.4.1 → asher_cli-1.5.0}/.gitignore +0 -0
  24. {asher_cli-1.4.1 → asher_cli-1.5.0}/.vscode/launch.json +0 -0
  25. {asher_cli-1.4.1 → asher_cli-1.5.0}/.vscode/settings.json +0 -0
  26. {asher_cli-1.4.1 → asher_cli-1.5.0}/.vscode/tasks.json +0 -0
  27. {asher_cli-1.4.1 → asher_cli-1.5.0}/CLAUDE.md +0 -0
  28. {asher_cli-1.4.1 → asher_cli-1.5.0}/CODEOWNERS +0 -0
  29. {asher_cli-1.4.1 → asher_cli-1.5.0}/LICENSE +0 -0
  30. {asher_cli-1.4.1 → asher_cli-1.5.0}/README.md +0 -0
  31. {asher_cli-1.4.1 → asher_cli-1.5.0}/app.py +0 -0
  32. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/__init__.py +0 -0
  33. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/__main__.py +0 -0
  34. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/core/__init__.py +0 -0
  35. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/core/activity.py +0 -0
  36. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/core/config.py +0 -0
  37. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/core/constants.py +0 -0
  38. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/core/credentials.py +0 -0
  39. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/core/export.py +0 -0
  40. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/core/faults.py +0 -0
  41. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/core/helpers.py +0 -0
  42. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/core/theme.py +0 -0
  43. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/core/updates.py +0 -0
  44. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/desktop/__init__.py +0 -0
  45. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/desktop/autostart.py +0 -0
  46. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/desktop/daemon.py +0 -0
  47. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/desktop/desktoptheme.py +0 -0
  48. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/desktop/launcher.py +0 -0
  49. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/desktop/notifications.py +0 -0
  50. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/desktop/tray.py +0 -0
  51. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/desktop/watcher.py +0 -0
  52. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/headless.py +0 -0
  53. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/mcp/__init__.py +0 -0
  54. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/mcp/bridge.py +0 -0
  55. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/robot/__init__.py +0 -0
  56. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/robot/adapters.py +0 -0
  57. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/robot/protocol.py +0 -0
  58. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/tui/__init__.py +0 -0
  59. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/tui/app.py +0 -0
  60. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/tui/cats.py +0 -0
  61. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/tui/commands/__init__.py +0 -0
  62. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/tui/commands/base.py +0 -0
  63. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/tui/commands/builtin.py +0 -0
  64. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/tui/commands/lr5.py +0 -0
  65. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/tui/commands/registry.py +0 -0
  66. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/tui/commands/robot.py +0 -0
  67. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/tui/completion.py +0 -0
  68. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/tui/connection.py +0 -0
  69. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/tui/history.py +0 -0
  70. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/tui/loginflow.py +0 -0
  71. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/tui/loginscreen.py +0 -0
  72. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/tui/monitoring.py +0 -0
  73. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/tui/style.tcss +0 -0
  74. {asher_cli-1.4.1 → asher_cli-1.5.0}/asher/tui/ui.py +0 -0
  75. {asher_cli-1.4.1 → asher_cli-1.5.0}/cliff.toml +0 -0
  76. {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/CONTRIBUTING.md +0 -0
  77. {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/ROADMAP.md +0 -0
  78. {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/roadmap-archive/README.md +0 -0
  79. {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/roadmap-archive/cat-panel-badges.md +0 -0
  80. {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/roadmap-archive/config-persistence.md +0 -0
  81. {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/roadmap-archive/credentials.md +0 -0
  82. {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/roadmap-archive/desktop-notifications.md +0 -0
  83. {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/roadmap-archive/e2e-pilot.md +0 -0
  84. {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/roadmap-archive/fault-monitoring.md +0 -0
  85. {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/roadmap-archive/headless-export.md +0 -0
  86. {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/roadmap-archive/history-export.md +0 -0
  87. {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/roadmap-archive/lr5-features.md +0 -0
  88. {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/roadmap-archive/multi-pet.md +0 -0
  89. {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/roadmap-archive/robot-commands.md +0 -0
  90. {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/roadmap-archive/runtime-slash-commands.md +0 -0
  91. {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/roadmap-archive/slash-command-spec.md +0 -0
  92. {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/roadmap-archive/sleep-schedule.md +0 -0
  93. {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/roadmap-archive/tab-completion.md +0 -0
  94. {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/roadmap-archive/ui-ux.md +0 -0
  95. {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/roadmap-archive/version-display.md +0 -0
  96. {asher_cli-1.4.1 → asher_cli-1.5.0}/docs/roadmap-archive/websocket-updates.md +0 -0
  97. {asher_cli-1.4.1 → asher_cli-1.5.0}/renovate.json +0 -0
  98. {asher_cli-1.4.1 → asher_cli-1.5.0}/requirements.txt +0 -0
  99. {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/__init__.py +0 -0
  100. {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/conftest.py +0 -0
  101. {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_activity_labels.py +0 -0
  102. {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_app_pilot.py +0 -0
  103. {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_auth.py +0 -0
  104. {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_auth_pilot.py +0 -0
  105. {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_autostart.py +0 -0
  106. {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_cats.py +0 -0
  107. {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_commands_pilot.py +0 -0
  108. {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_completion.py +0 -0
  109. {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_config.py +0 -0
  110. {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_connection_mixin.py +0 -0
  111. {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_credentials.py +0 -0
  112. {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_daemon.py +0 -0
  113. {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_desktoptheme.py +0 -0
  114. {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_export.py +0 -0
  115. {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_faults.py +0 -0
  116. {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_headless.py +0 -0
  117. {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_history_view.py +0 -0
  118. {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_launcher.py +0 -0
  119. {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_layering.py +0 -0
  120. {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_lr5_commands.py +0 -0
  121. {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_mcp_bridge.py +0 -0
  122. {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_missing_robot_commands.py +0 -0
  123. {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_monitoring.py +0 -0
  124. {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_new_commands_pilot.py +0 -0
  125. {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_notifications.py +0 -0
  126. {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_robot_adapters.py +0 -0
  127. {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_tray.py +0 -0
  128. {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_ui.py +0 -0
  129. {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_updates.py +0 -0
  130. {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_version_command.py +0 -0
  131. {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/test_watcher.py +0 -0
  132. {asher_cli-1.4.1 → asher_cli-1.5.0}/tests/testhelpers.py +0 -0
  133. {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: write # needed to create GitHub Releases
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.4.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: dict = json.loads(path.read_text(encoding="utf-8"))
64
- return data
65
- except (json.JSONDecodeError, OSError):
66
- return {}
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 config_paths():
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
- data = _load(path)
105
- enabled = _SERVER_NAME in data.get("mcpServers", {})
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
- pin = f"pylitterbot[mcp]=={pkg_version('pylitterbot')}"
443
- app._log_info(f"Installing {pin}…")
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
- pin,
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
- app._log_info(f"Try manually: {sys.executable} -m pip install '{pin}'")
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
- touched = set_mcp_enabled(True)
511
- else:
512
- touched = set_mcp_enabled(False)
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.4.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 until pylitterbot ships a 2.x-compatible release.
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.4.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
- from asher.mcp.config import _SERVER_NAME, config_paths, mcp_status, set_mcp_enabled
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
- def test_handles_corrupt_config_file(self, tmp_path):
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("{not valid json", encoding="utf-8")
51
- with patch("asher.mcp.config.config_paths", return_value=[path]):
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
- data = json.loads(path.read_text(encoding="utf-8"))
54
- assert _SERVER_NAME in data["mcpServers"]
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):
@@ -219,7 +219,7 @@ wheels = [
219
219
 
220
220
  [[package]]
221
221
  name = "asher-cli"
222
- version = "1.4.1"
222
+ version = "1.5.0"
223
223
  source = { editable = "." }
224
224
  dependencies = [
225
225
  { name = "keyring" },
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