asher-cli 1.0.2__tar.gz → 1.2.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 (119) hide show
  1. asher_cli-1.2.0/.claude/hooks/block-env.py +14 -0
  2. {asher_cli-1.0.2 → asher_cli-1.2.0}/.claude/settings.json +1 -1
  3. asher_cli-1.2.0/.claude/skills/mcp-bridge/SKILL.md +15 -0
  4. asher_cli-1.2.0/.github/FUNDING.yml +15 -0
  5. {asher_cli-1.0.2 → asher_cli-1.2.0}/.github/workflows/ci.yml +8 -2
  6. {asher_cli-1.0.2 → asher_cli-1.2.0}/.github/workflows/claude-code-review.yml +1 -1
  7. {asher_cli-1.0.2 → asher_cli-1.2.0}/.github/workflows/claude.yml +1 -1
  8. {asher_cli-1.0.2 → asher_cli-1.2.0}/.github/workflows/coverage.yml +6 -1
  9. {asher_cli-1.0.2 → asher_cli-1.2.0}/.github/workflows/release.yml +16 -13
  10. {asher_cli-1.0.2 → asher_cli-1.2.0}/CHANGELOG.md +57 -6
  11. {asher_cli-1.0.2 → asher_cli-1.2.0}/CLAUDE.md +74 -58
  12. {asher_cli-1.0.2 → asher_cli-1.2.0}/PKG-INFO +142 -28
  13. {asher_cli-1.0.2 → asher_cli-1.2.0}/README.md +133 -24
  14. asher_cli-1.2.0/asher/__main__.py +179 -0
  15. {asher_cli-1.0.2 → asher_cli-1.2.0}/asher/activity_labels.py +22 -20
  16. {asher_cli-1.0.2 → asher_cli-1.2.0}/asher/app.py +10 -0
  17. {asher_cli-1.0.2 → asher_cli-1.2.0}/asher/auth.py +13 -11
  18. asher_cli-1.2.0/asher/autostart.py +370 -0
  19. {asher_cli-1.0.2 → asher_cli-1.2.0}/asher/commands/__init__.py +107 -52
  20. {asher_cli-1.0.2 → asher_cli-1.2.0}/asher/completion.py +8 -4
  21. {asher_cli-1.0.2 → asher_cli-1.2.0}/asher/config.py +10 -0
  22. {asher_cli-1.0.2 → asher_cli-1.2.0}/asher/connection/__init__.py +48 -15
  23. asher_cli-1.2.0/asher/constants.py +24 -0
  24. asher_cli-1.2.0/asher/daemon.py +429 -0
  25. asher_cli-1.2.0/asher/desktoptheme.py +192 -0
  26. {asher_cli-1.0.2 → asher_cli-1.2.0}/asher/export.py +17 -42
  27. asher_cli-1.2.0/asher/headless.py +626 -0
  28. asher_cli-1.2.0/asher/helpers.py +71 -0
  29. {asher_cli-1.0.2 → asher_cli-1.2.0}/asher/history_view.py +19 -18
  30. asher_cli-1.2.0/asher/launcher.py +133 -0
  31. {asher_cli-1.0.2 → asher_cli-1.2.0}/asher/monitoring/__init__.py +49 -34
  32. asher_cli-1.2.0/asher/notifications.py +120 -0
  33. {asher_cli-1.0.2 → asher_cli-1.2.0}/asher/slash-commands/__init__.py +2 -0
  34. asher_cli-1.2.0/asher/theme.py +138 -0
  35. asher_cli-1.2.0/asher/tray.py +264 -0
  36. {asher_cli-1.0.2 → asher_cli-1.2.0}/asher/ui/__init__.py +71 -59
  37. {asher_cli-1.0.2 → asher_cli-1.2.0}/asher/ui/style.tcss +26 -26
  38. asher_cli-1.2.0/asher/updates.py +181 -0
  39. asher_cli-1.2.0/asher/watcher.py +393 -0
  40. {asher_cli-1.0.2 → asher_cli-1.2.0}/cliff.toml +3 -0
  41. {asher_cli-1.0.2 → asher_cli-1.2.0}/docs/ROADMAP.md +3 -1
  42. {asher_cli-1.0.2 → asher_cli-1.2.0}/pyproject.toml +34 -4
  43. {asher_cli-1.0.2 → asher_cli-1.2.0}/tests/test_activity_labels.py +6 -5
  44. {asher_cli-1.0.2 → asher_cli-1.2.0}/tests/test_app_pilot.py +3 -0
  45. {asher_cli-1.0.2 → asher_cli-1.2.0}/tests/test_auth.py +15 -4
  46. asher_cli-1.2.0/tests/test_autostart.py +341 -0
  47. {asher_cli-1.0.2 → asher_cli-1.2.0}/tests/test_commands_pilot.py +3 -0
  48. {asher_cli-1.0.2 → asher_cli-1.2.0}/tests/test_config.py +4 -4
  49. {asher_cli-1.0.2 → asher_cli-1.2.0}/tests/test_connection.py +77 -0
  50. asher_cli-1.2.0/tests/test_daemon.py +445 -0
  51. asher_cli-1.2.0/tests/test_desktoptheme.py +228 -0
  52. asher_cli-1.2.0/tests/test_headless.py +529 -0
  53. asher_cli-1.2.0/tests/test_launcher.py +203 -0
  54. {asher_cli-1.0.2 → asher_cli-1.2.0}/tests/test_mcp_command.py +14 -0
  55. {asher_cli-1.0.2 → asher_cli-1.2.0}/tests/test_monitoring.py +7 -0
  56. asher_cli-1.2.0/tests/test_notifications.py +176 -0
  57. asher_cli-1.2.0/tests/test_tray.py +305 -0
  58. {asher_cli-1.0.2 → asher_cli-1.2.0}/tests/test_ui.py +8 -4
  59. asher_cli-1.2.0/tests/test_updates.py +272 -0
  60. asher_cli-1.2.0/tests/test_watcher.py +379 -0
  61. {asher_cli-1.0.2 → asher_cli-1.2.0}/tests/testhelpers.py +26 -5
  62. {asher_cli-1.0.2 → asher_cli-1.2.0}/uv.lock +1121 -683
  63. asher_cli-1.0.2/.claude/hooks/block-env.ps1 +0 -9
  64. asher_cli-1.0.2/.claude/skills/release/SKILL.md +0 -43
  65. asher_cli-1.0.2/asher/__main__.py +0 -58
  66. asher_cli-1.0.2/asher/constants.py +0 -22
  67. asher_cli-1.0.2/asher/helpers.py +0 -45
  68. asher_cli-1.0.2/asher/notifications.py +0 -48
  69. asher_cli-1.0.2/tests/test_notifications.py +0 -82
  70. {asher_cli-1.0.2 → asher_cli-1.2.0}/.claude/skills/pylitterbot-ref/SKILL.md +0 -0
  71. {asher_cli-1.0.2 → asher_cli-1.2.0}/.claude/skills/textual/SKILL.md +0 -0
  72. {asher_cli-1.0.2 → asher_cli-1.2.0}/.env.example +0 -0
  73. {asher_cli-1.0.2 → asher_cli-1.2.0}/.githooks/pre-push +0 -0
  74. {asher_cli-1.0.2 → asher_cli-1.2.0}/.github/pull_request_template.md +0 -0
  75. {asher_cli-1.0.2 → asher_cli-1.2.0}/.github/workflows/bandit.yml +0 -0
  76. {asher_cli-1.0.2 → asher_cli-1.2.0}/.gitignore +0 -0
  77. {asher_cli-1.0.2 → asher_cli-1.2.0}/.vscode/launch.json +0 -0
  78. {asher_cli-1.0.2 → asher_cli-1.2.0}/.vscode/settings.json +0 -0
  79. {asher_cli-1.0.2 → asher_cli-1.2.0}/.vscode/tasks.json +0 -0
  80. {asher_cli-1.0.2 → asher_cli-1.2.0}/CODEOWNERS +0 -0
  81. {asher_cli-1.0.2 → asher_cli-1.2.0}/LICENSE +0 -0
  82. {asher_cli-1.0.2 → asher_cli-1.2.0}/app.py +0 -0
  83. {asher_cli-1.0.2 → asher_cli-1.2.0}/asher/__init__.py +0 -0
  84. {asher_cli-1.0.2 → asher_cli-1.2.0}/asher/cats.py +0 -0
  85. {asher_cli-1.0.2 → asher_cli-1.2.0}/asher/commands/base.py +0 -0
  86. {asher_cli-1.0.2 → asher_cli-1.2.0}/asher/faults.py +0 -0
  87. {asher_cli-1.0.2 → asher_cli-1.2.0}/asher/login_flow.py +0 -0
  88. {asher_cli-1.0.2 → asher_cli-1.2.0}/asher/mcp_bridge.py +0 -0
  89. {asher_cli-1.0.2 → asher_cli-1.2.0}/asher/mcp_config.py +0 -0
  90. {asher_cli-1.0.2 → asher_cli-1.2.0}/asher/robot_adapters.py +0 -0
  91. {asher_cli-1.0.2 → asher_cli-1.2.0}/asher/robot_protocol.py +0 -0
  92. {asher_cli-1.0.2 → asher_cli-1.2.0}/docs/roadmap-archive/README.md +0 -0
  93. {asher_cli-1.0.2 → asher_cli-1.2.0}/docs/roadmap-archive/cat-panel-badges.md +0 -0
  94. {asher_cli-1.0.2 → asher_cli-1.2.0}/docs/roadmap-archive/config-persistence.md +0 -0
  95. {asher_cli-1.0.2 → asher_cli-1.2.0}/docs/roadmap-archive/desktop-notifications.md +0 -0
  96. {asher_cli-1.0.2 → asher_cli-1.2.0}/docs/roadmap-archive/fault-monitoring.md +0 -0
  97. {asher_cli-1.0.2 → asher_cli-1.2.0}/docs/roadmap-archive/headless-export.md +0 -0
  98. {asher_cli-1.0.2 → asher_cli-1.2.0}/docs/roadmap-archive/history-export.md +0 -0
  99. {asher_cli-1.0.2 → asher_cli-1.2.0}/docs/roadmap-archive/tab-completion.md +0 -0
  100. {asher_cli-1.0.2 → asher_cli-1.2.0}/renovate.json +0 -0
  101. {asher_cli-1.0.2 → asher_cli-1.2.0}/requirements.txt +0 -0
  102. {asher_cli-1.0.2 → asher_cli-1.2.0}/test.py +0 -0
  103. {asher_cli-1.0.2 → asher_cli-1.2.0}/tests/__init__.py +0 -0
  104. {asher_cli-1.0.2 → asher_cli-1.2.0}/tests/conftest.py +0 -0
  105. {asher_cli-1.0.2 → asher_cli-1.2.0}/tests/test_auth_pilot.py +0 -0
  106. {asher_cli-1.0.2 → asher_cli-1.2.0}/tests/test_cats.py +0 -0
  107. {asher_cli-1.0.2 → asher_cli-1.2.0}/tests/test_completion.py +0 -0
  108. {asher_cli-1.0.2 → asher_cli-1.2.0}/tests/test_connection_mixin.py +0 -0
  109. {asher_cli-1.0.2 → asher_cli-1.2.0}/tests/test_export.py +0 -0
  110. {asher_cli-1.0.2 → asher_cli-1.2.0}/tests/test_faults.py +0 -0
  111. {asher_cli-1.0.2 → asher_cli-1.2.0}/tests/test_history_view.py +0 -0
  112. {asher_cli-1.0.2 → asher_cli-1.2.0}/tests/test_lr5_commands.py +0 -0
  113. {asher_cli-1.0.2 → asher_cli-1.2.0}/tests/test_mcp_bridge.py +0 -0
  114. {asher_cli-1.0.2 → asher_cli-1.2.0}/tests/test_mcp_config.py +0 -0
  115. {asher_cli-1.0.2 → asher_cli-1.2.0}/tests/test_missing_robot_commands.py +0 -0
  116. {asher_cli-1.0.2 → asher_cli-1.2.0}/tests/test_new_commands_pilot.py +0 -0
  117. {asher_cli-1.0.2 → asher_cli-1.2.0}/tests/test_robot_adapters.py +0 -0
  118. {asher_cli-1.0.2 → asher_cli-1.2.0}/tests/test_version_command.py +0 -0
  119. {asher_cli-1.0.2 → asher_cli-1.2.0}/watchrun.py +0 -0
@@ -0,0 +1,14 @@
1
+ import json
2
+ import sys
3
+
4
+ try:
5
+ payload = json.load(sys.stdin)
6
+ file_path = payload.get("file_path", "")
7
+ except (json.JSONDecodeError, ValueError):
8
+ sys.exit(0)
9
+
10
+ if file_path.endswith(".env"):
11
+ print("Blocked: .env contains real credentials — edit it manually if needed")
12
+ sys.exit(2)
13
+
14
+ sys.exit(0)
@@ -37,7 +37,7 @@
37
37
  "hooks": [
38
38
  {
39
39
  "type": "command",
40
- "command": "powershell -NonInteractive -ExecutionPolicy Bypass -File .claude/hooks/block-env.ps1"
40
+ "command": "uv run python .claude/hooks/block-env.py"
41
41
  }
42
42
  ]
43
43
  }
@@ -0,0 +1,15 @@
1
+ ---
2
+ name: mcp-bridge
3
+ description: How asher-cli's /mcp command and the asher-mcp-launch credential bridge work. Load before changing asher/mcp_config.py, asher/mcp_bridge.py, or the /mcp slash command.
4
+ user-invocable: false
5
+ ---
6
+
7
+ # MCP bridge (`/mcp`)
8
+
9
+ 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:
10
+
11
+ - `/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.
12
+ - `/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.
13
+ - `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.
14
+ - `/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).
15
+ - Requires the `mcp` extra: `uv sync --extra mcp` / `pip install asher-cli[mcp]`. Restart Claude Desktop after toggling for the change to take effect.
@@ -0,0 +1,15 @@
1
+ # These are supported funding model platforms
2
+
3
+ github: [karanshukla]
4
+ patreon: # Replace with a single Patreon username
5
+ open_collective: # Replace with a single Open Collective username
6
+ ko_fi: # Replace with a single Ko-fi username
7
+ tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8
+ community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9
+ liberapay: # Replace with a single Liberapay username
10
+ issuehunt: # Replace with a single IssueHunt username
11
+ lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
12
+ polar: # Replace with a single Polar username
13
+ buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
14
+ thanks_dev: # Replace with a single thanks.dev username
15
+ custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
@@ -8,13 +8,19 @@ on:
8
8
  permissions:
9
9
  contents: read
10
10
 
11
+ # `uv run` re-syncs to the default groups on every invocation, which would drag
12
+ # the `tray` group (and so a PyGObject/pycairo source build) back in after the
13
+ # explicit sync below deliberately left it out. Sync once, then never re-sync.
14
+ env:
15
+ UV_NO_SYNC: "1"
16
+
11
17
  jobs:
12
18
  lint:
13
19
  runs-on: ubuntu-latest
14
20
  steps:
15
21
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
16
22
  - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
17
- - run: uv sync --dev
23
+ - run: uv sync --no-default-groups --group dev
18
24
  - run: uv run ruff check .
19
25
  - run: uv run ruff format --check .
20
26
  - run: uv run mypy asher/ --ignore-missing-imports
@@ -30,5 +36,5 @@ jobs:
30
36
  steps:
31
37
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
32
38
  - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
33
- - run: uv sync --dev
39
+ - run: uv sync --no-default-groups --group dev
34
40
  - run: uv run pytest tests/ -v --tb=short
@@ -33,7 +33,7 @@ jobs:
33
33
 
34
34
  - name: Run Claude Code Review
35
35
  id: claude-review
36
- uses: anthropics/claude-code-action@be7b93b1907a4abad570368f3c74b6fe3807510b # v1
36
+ uses: anthropics/claude-code-action@6b082c41935b4c8a3b8b0ef85ba4ba4d9eeb8975 # v1
37
37
  with:
38
38
  claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
39
39
  plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
@@ -32,7 +32,7 @@ jobs:
32
32
 
33
33
  - name: Run Claude Code
34
34
  id: claude
35
- uses: anthropics/claude-code-action@be7b93b1907a4abad570368f3c74b6fe3807510b # v1
35
+ uses: anthropics/claude-code-action@6b082c41935b4c8a3b8b0ef85ba4ba4d9eeb8975 # v1
36
36
  with:
37
37
  claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
38
38
 
@@ -8,13 +8,18 @@ on:
8
8
  permissions:
9
9
  contents: read
10
10
 
11
+ # See ci.yml: `uv run` would otherwise re-sync the `tray` group back in and try
12
+ # to build PyGObject from source.
13
+ env:
14
+ UV_NO_SYNC: "1"
15
+
11
16
  jobs:
12
17
  coverage:
13
18
  runs-on: ubuntu-latest
14
19
  steps:
15
20
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
16
21
  - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
17
- - run: uv sync --dev
22
+ - run: uv sync --no-default-groups --group dev
18
23
  - name: Run tests with coverage
19
24
  run: uv run pytest tests/ --cov=asher --cov-report=lcov --cov-report=term-missing
20
25
  - name: Upload to Coveralls
@@ -37,33 +37,36 @@ jobs:
37
37
  with:
38
38
  name: dist
39
39
  path: dist/
40
- - uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # release/v1
40
+ - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
41
41
 
42
42
  github-release:
43
43
  name: Create GitHub Release
44
44
  needs: [build, publish]
45
45
  runs-on: ubuntu-latest
46
46
  steps:
47
- # Full history (fetch-depth: 0) so git-cliff can walk commits/tags.
48
47
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
49
- with:
50
- fetch-depth: 0
51
48
  - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
52
49
  with:
53
50
  name: dist
54
51
  path: dist/
55
- - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
56
- - name: Install git-cliff
57
- run: uv tool install git-cliff
58
- - name: Generate release notes with git-cliff
52
+ - name: Extract release notes from CHANGELOG.md
59
53
  env:
60
54
  VERSION: ${{ needs.build.outputs.version }}
61
55
  run: |
62
- # Explicit -c cliff.toml avoids auto-discovering pyproject.toml.
63
- # Pin the just-released version so git-cliff groups its commits under
64
- # the right heading even before the tag is fetched, then emit only
65
- # that section (--latest) with the header stripped.
66
- git cliff -c cliff.toml --tag "v${VERSION}" --latest --strip header -o RELEASE_NOTES.md
56
+ # CHANGELOG.md is the single source of release prose: it is generated
57
+ # by git-cliff (see `poe changelog-release`) but may be hand-refined
58
+ # before the release is cut, and re-deriving the notes from commits
59
+ # here would silently discard those edits.
60
+ awk -v hdr="## [${VERSION}]" '
61
+ index($0, hdr) == 1 { found = 1; next }
62
+ found && index($0, "## [") == 1 { exit }
63
+ found { print }
64
+ ' CHANGELOG.md > RELEASE_NOTES.md
65
+ if [ ! -s RELEASE_NOTES.md ]; then
66
+ echo "::error::CHANGELOG.md has no '## [${VERSION}]' section." \
67
+ "Run 'uv run poe changelog-release ${VERSION}' and commit it before releasing."
68
+ exit 1
69
+ fi
67
70
  - name: Create release
68
71
  env:
69
72
  GH_TOKEN: ${{ github.token }}
@@ -7,6 +7,63 @@ 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.2.0] - 2026-08-16
11
+
12
+ ### 🚀 Features
13
+
14
+ - Drop "Clean now" from the tray menu
15
+ - Refuse to start a watcher that has no credentials
16
+ - Open the dashboard from the tray
17
+ - Tone the tray icon to the desktop panel, and stop repeating the drawer
18
+ - [**breaking**] Take credentials only from the keyring outside dev mode
19
+ - Start the watcher at login, and notify (never install) on new releases
20
+ - Background watcher and system-tray icon for notifications
21
+
22
+ ### 🐛 Bug Fixes
23
+
24
+ - Keep the tray installed across a plain `uv run`
25
+ - Read the selected completion row against its own background
26
+ - *(deps)* Update dependency pylitterbot to v2025.6.4
27
+
28
+ ### 📚 Documentation
29
+
30
+ - Trim the README, move the packaging detail to CLAUDE.md
31
+ - Cover the tray's new behaviour and keyring-only credentials
32
+
33
+ ### 🧪 Tests
34
+
35
+ - Stop four tests reading the developer's own machine
36
+ - Cover the notifier fallback on every platform
37
+ - Stop the notification tests toasting the desktop
38
+ - Compare the Windows Run command against its normalised path
39
+ - Pin platform branches in the daemon tests
40
+
41
+ ### 👷 CI/CD
42
+
43
+ - Stop `uv run` re-syncing the tray group back in
44
+
45
+ ## [1.1.0] - 2026-08-09
46
+
47
+ ### 🚀 Features
48
+
49
+ - Headless command surface + Catppuccin Mocha palette
50
+
51
+ ### 🐛 Bug Fixes
52
+
53
+ - *(release)* Generate notes against the release tag, not the previous one
54
+
55
+ ### 📚 Documentation
56
+
57
+ - Point CLAUDE.md at the release process
58
+
59
+ ### 👷 CI/CD
60
+
61
+ - *(release)* Build release notes from CHANGELOG.md
62
+
63
+ ### 🔧 Miscellaneous
64
+
65
+ - Trim CLAUDE.md, split MCP bridge docs into a skill, fix env-guard hook
66
+
10
67
  ## [1.0.2] - 2026-08-05
11
68
 
12
69
  ### 🐛 Bug Fixes
@@ -23,10 +80,6 @@ Generated by [git-cliff](https://git-cliff.org) from conventional commits.
23
80
 
24
81
  - *(history)* Copy-to-clipboard + discoverable footer hints
25
82
 
26
- ### 📚 Documentation
27
-
28
- - *(changelog)* Cut v1.0.1
29
-
30
83
  ## [1.0.0] - 2026-08-02
31
84
 
32
85
  ### 🚀 Features
@@ -38,7 +91,6 @@ Generated by [git-cliff](https://git-cliff.org) from conventional commits.
38
91
 
39
92
  ### 📚 Documentation
40
93
 
41
- - *(changelog)* Regenerate for next release, trim duplicate README list
42
94
  - *(roadmap)* Archive completed sections to docs/roadmap-archive/
43
95
 
44
96
  ### 🔧 Miscellaneous
@@ -57,7 +109,6 @@ Generated by [git-cliff](https://git-cliff.org) from conventional commits.
57
109
 
58
110
  ### 📚 Documentation
59
111
 
60
- - *(changelog)* Cut v0.2.0
61
112
  - *(commands)* Sync stale command lists in CLAUDE.md and slash-commands docstring
62
113
  - *(readme)* Sync command tables with code, add changelog
63
114
 
@@ -4,21 +4,9 @@ Terminal dashboard for Litter Robot (LR3/LR4/LR5) via the Whisker cloud API.
4
4
 
5
5
  ## Stack
6
6
 
7
- - **Python 3.10+**
8
- - **Textual** — async TUI framework (`textual>=0.47`)
9
- - **pylitterbot** unofficial Whisker API wrapper (`pylitterbot>=3.0`)
10
- - **python-dotenv** — credential loading (`.env` fallback)
11
- - **keyring>=24** — OS credential store (Windows Credential Manager / macOS Keychain / Linux Secret Service)
12
-
13
- ## Tooling
14
-
15
- - **uv** — dependency management and task runner (`uv sync`, `uv run`)
16
- - **poethepoet** — task aliases via `uv run poe <task>`
17
- - **ruff** — linter and formatter
18
- - **mypy** — static type checking
19
- - **pytest + pytest-asyncio + pytest-cov** — tests
20
- - **textual-dev** — CSS hot reload devtools
21
- - **watchfiles** — Python auto-restart on file change
7
+ - **keyring** resolves to the OS-native credential store: Windows Credential Manager / macOS Keychain / Linux Secret Service.
8
+
9
+ See `pyproject.toml` for the full dependency and dev-tooling list (`[project].dependencies`, `[dependency-groups].dev`, `[tool.poe.tasks]`).
22
10
 
23
11
  ## Entry points
24
12
 
@@ -37,8 +25,16 @@ asher/
37
25
  auth.py LoginScreen modal (ModalScreen[tuple[str,str]]) — available, not primary flow
38
26
  helpers.py fmt_ago(), drawer_bar(), ts(), robot_model() (pure, testable)
39
27
  constants.py STATUS_COLORS, ROBOT_MODELS
28
+ theme.py Catppuccin Mocha palette + semantic roles (BACKGROUND, MUTED, DANGER, …) + CSS_VARIABLES/apply() — the only place a hex literal belongs
40
29
  config.py runtime settings persistence — load()/save()/update() over ~/.asher-cli/config.json; holds poll interval, cat-panel visibility/colour, active pet index, notification settings (non-secret UI prefs only; credentials stay in keyring)
41
- notifications.py desktop toast + audible alert façade over plyer (fire/beep, always-safe no-op on failure/headless)
30
+ notifications.py desktop toast + audible alert façade plyer first, then the platform's own tool (osascript/notify-send); fire/beep are always-safe no-ops on failure/headless
31
+ watcher.py background notification loop with no TUI — pure WatchState (robot snapshots → Alert list) + supervising watch() that reconnects with backoff, plus WatcherRunner (asyncio on a worker thread, for the tray)
32
+ daemon.py detached watcher process control — `start` pre-flights `connection.credentials_available()` (presence only, no network, so starting offline still works) because the watcher claims the pid file *before* authenticating and would otherwise report a pid for a process that stops a moment later — pid file/log in ~/.asher-cli, start/stop/status/run, cross-platform detach + liveness (os.kill would *terminate* on Windows)
33
+ tray.py optional pystray/Pillow system-tray icon over WatcherRunner; every path degrades to a headless watcher. Icon is a panel-toned silhouette + status dot — colour rides the badge, never the whole glyph
34
+ launcher.py open_app() — start the TUI in a new terminal from the tray (a detached tray has none): new console on Windows, Terminal.app via AppleScript on macOS, first installed emulator on Linux (desktop's own preferred)
35
+ desktoptheme.py panel_is_dark() — is the tray/menu-bar background dark? kdeglobals luma / gsettings / AppleInterfaceStyle / the Personalize registry keys, behind a TTL cache; every probe degrades to a fallback, never raises
36
+ autostart.py login items — AutostartBackend ABC + launchd/systemd-user/registry subclasses + backend() factory; all per-user, no admin rights, disable() removes exactly what enable() wrote
37
+ updates.py PyPI release check — read-only over HTTPS, once a day, reports only. Never installs (see its docstring for why that stays manual)
42
38
  cats.py CATS dict (ASCII art)
43
39
  login_flow.py LoginFlow state machine — inline email/password prompt in command bar
44
40
  robot_protocol.py RobotProtocol structural Protocol for pylitterbot robot objects
@@ -47,9 +43,10 @@ asher/
47
43
  mcp_bridge.py asher-mcp-launch console script — keyring-backed pylitterbot MCP launcher
48
44
  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
45
  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
- 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()
46
+ export.py shared activity-history CSV core + exit-code contract: build_history_csv(), resolve_dest(), resolve_robot(), parse_days(), EXIT_*, ExportError — no Textual imports; the TUI `export` command and `asher export` both call build_history_csv()
47
+ headless.py headless command surface for `asher <command>` — Session/Result/CommandError, the COMMANDS registry, and run(); plain strings only, no Textual, routes model differences through RobotAdapter
51
48
  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
- __main__.py main() entry point
49
+ __main__.py main() entry point — argparse subcommands (headless) vs no-args (TUI); `--export` kept as a deprecated alias
53
50
  commands/
54
51
  base.py Command ABC, SlashCommand, CommandRegistry
55
52
  __init__.py CommandsMixin — all command classes + registry + dispatch
@@ -72,11 +69,19 @@ tests/
72
69
  test_mcp_config.py Claude Desktop config read/write
73
70
  test_config.py runtime settings persistence — load/save/update over defaults
74
71
  test_notifications.py plyer toast + beep façade — always-safe no-op paths
72
+ test_watcher.py WatchState alert transitions + snapshot/deliver (pure) + watch() against a patched open_session + WatcherRunner threading
73
+ test_daemon.py pid-file bookkeeping, start/stop/status/dispatch with Popen and os.kill mocked
74
+ test_tray.py availability probing, menu/title text, icon rendering (tone inversion + status badge), and the headless fallbacks
75
+ test_launcher.py every platform's open path driven directly (Popen always mocked), terminal preference/fallback order
76
+ test_desktoptheme.py all four panel-tone probes driven directly (so each is covered on every runner) with the readers/registry mocked, plus the TTL cache
77
+ test_autostart.py all three login-item backends driven directly (so each is covered on every runner) with launchctl/systemctl/winreg mocked
78
+ test_updates.py version comparison, the once-a-day cache, HTTPS-only fetching, and a guard that the module spawns no process
75
79
  test_mcp_bridge.py mcp_bridge launcher credential/subprocess handling
76
80
  test_mcp_command.py /mcp slash command dispatch
77
81
  test_faults.py check_faults() — safety statuses, attribute faults, graceful degradation
78
82
  test_history_view.py format_history_rows()/format_history_text() + HistoryScreen structure, copy-all + Pilot push/dismiss
79
- test_export.py build_history_csv/resolve_dest/resolve_robot/parse_days (pure) + headless _run_headless_export (no Pilot, mocks _connect_headless)
83
+ test_export.py build_history_csv/resolve_dest/resolve_robot/parse_days (pure) + the legacy `--export` flag path (no Pilot, mocks _connect_headless)
84
+ test_headless.py headless registry/rendering (pure) + every command handler against mock robots + run() exit codes + the argparse subcommand surface
80
85
  test_completion.py slash_matches/enter_completes/render_completion (pure) + Pilot overlay visibility/navigation/accept
81
86
 
82
87
  .github/workflows/
@@ -88,45 +93,46 @@ tests/
88
93
 
89
94
  Priority order on startup:
90
95
 
91
- 1. **OS keyring** — set automatically after first interactive login
92
- 2. **`.env` file** — fallback for existing users / CI
96
+ 1. **OS keyring** — cached OAuth token, then email/password; set automatically after first interactive login
97
+ 2. **`.env` file** — **development only**, gated on `ASHER_CLI_DEV_MODE=true`
93
98
  3. **Inline login flow** — shown when no credentials found anywhere (email → password prompt in command bar)
94
99
 
95
- `.env` variable names (for fallback):
100
+ In a real install the keyring is the **only** source of credentials. `.env` is a
101
+ working-copy convenience: without dev mode a stray `LITTER_ROBOT_USER` in a shell
102
+ profile or a checked-out `.env` would silently outrank the keyring and
103
+ authenticate as the wrong account.
104
+
105
+ `.env` variable names (dev mode only):
96
106
  ```
107
+ ASHER_CLI_DEV_MODE=true
97
108
  LITTER_ROBOT_USER=...
98
109
  LITTER_ROBOT_PASSWORD=...
99
110
  ```
100
111
 
112
+ Every environment read goes through `_env_credentials()` in
113
+ `asher/connection/__init__.py` — the one place the gate is applied. Don't call
114
+ `os.getenv("LITTER_ROBOT_*")` anywhere else. `helpers.dev_mode()` is the shared
115
+ predicate (it also selects the `dev` version string).
116
+
101
117
  Keyring service name: `asher-cli`, keys `email` and `password`.
102
118
  Helper functions in `asher/connection/__init__.py`: `_keyring_load()`, `_keyring_save()`, `_keyring_delete()`.
103
119
 
104
- ## MCP bridge (`/mcp`)
105
-
106
- 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:
107
-
108
- - `/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.
109
- - `/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.
110
- - `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.
111
- - `/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).
112
- - Requires the `mcp` extra: `uv sync --extra mcp` / `pip install asher-cli[mcp]`. Restart Claude Desktop after toggling for the change to take effect.
113
-
114
120
  ## Command convention
115
121
 
116
- **Normal commands** (no prefix)robot actions only:
117
- `clean`, `status`, `info`, `lock`, `unlock`, `sleep`, `wake`, `night-light on|off|auto`, `night-light-brightness <level>`, `panel-brightness <low|medium|high>`, `wait-time <minutes>`, `power on|off`, `rename <name>`, `insight [days|month]`, `sleep-schedule`, `privacy on|off`, `volume <0-100>`, `camera-audio on|off`, `drawer-reset`, `history [count|all]`, `export [days|month]`, `clear`, `help`, `quit`
122
+ Command names, slash-command names, and their args are not listed here see the `_registry` in `asher/commands/__init__.py`, which is authoritative; `/help` renders it at runtime. `/mcp`'s credential-bridging design is documented in the `mcp-bridge` skill.
118
123
 
119
- **Slash commands** (`/` prefix) app management only:
120
- `/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`
124
+ **Normal commands** (no prefix) are robot actions only; **slash commands** (`/` prefix) are app management only.
121
125
 
122
- `/refresh`, `/cat`, `/pet`, and `/notify` persist their settings to `~/.asher-cli/config.json` (via `asher.config.update()`), so they survive restarts. Credentials and the preferred-robot serial stay in the OS keyring; the config file holds only non-secret UI preferences.
126
+ `/refresh`, `/cat`, `/pet`, and `/notify` persist their settings to `~/.asher-cli/config.json` (via `asher.config.update()`), so they survive restarts. That file is also the only channel to a running watcher, which re-reads it per alert rather than caching at startup. Credentials and the preferred-robot serial stay in the OS keyring; the config file holds only non-secret UI preferences.
123
127
 
124
- > The authoritative list is the `_registry` in `asher/commands/__init__.py`; `/help` renders it at runtime. If you add a command, update the tables in `README.md` and the list in `asher/slash-commands/__init__.py`.
128
+ Do not add robot-control commands as slash commands, and do not add app-management commands as bare commands.
125
129
 
126
130
  **Special cases** (accepted both with and without `/`):
127
131
  `exit`, `quit`, `q` — exit the app
128
132
 
129
- Do not add robot-control commands as slash commands, and do not add app-management commands as bare commands.
133
+ **Headless commands** (`asher <command>`) are a parallel registry in `asher/headless.py`: same robot actions, no Textual, plain-string + JSON output. `asher watch` and `asher update` are deliberately *not* in that registry — `watch` manages a long-lived process rather than doing one thing and exiting, and `update` talks to PyPI rather than a robot, so neither should be forced through `open_session()` and made to authenticate. Both declare their subparser directly in `__main__.py`. Slash commands have no headless equivalent — they configure the TUI, which isn't running. A robot command worth scripting should exist in both registries; the shared logic lives in `RobotAdapter`, not in either command class.
134
+
135
+ > If you add a command, update the tables in `README.md` and the list in `asher/slash-commands/__init__.py`. If it's a robot command, consider adding it to `COMMANDS` in `asher/headless.py` too.
130
136
 
131
137
  ## Architecture
132
138
 
@@ -163,6 +169,10 @@ LoginScreen (ModalScreen) — available in auth.py but not the primary auth path
163
169
  | `_refresh_faults(robot)` | run `check_faults()`, render `#fault-banner`, log transitions; sets cat mode to `error` while faults active |
164
170
  | `_cycling_chip()` / `_start_cycle_timer()` / `_stop_cycle_timer()` / `_tick_cycle()` | `⟳ Cycling M:SS` chip + lazy 1s elapsed timer |
165
171
  | `_poll_status_interval()` | `@work` — poll fallback every 300s (5 min); WebSocket is primary |
172
+ | `watcher.watch()` | supervise a cloud session and notify on every change worth interrupting for — the TUI's `_notify_fault` without a TUI |
173
+ | `daemon.start/stop/status()` | detached watcher process control; `running_pid()` also gates the TUI's own toasts so alerts never double up |
174
+ | `daemon.run_foreground()` | the watcher process itself — claims the pid file (`_pid_file_held`) so a login-started watcher is still visible to `status`/`stop`, and refuses to be a second one |
175
+ | `autostart.enable/disable()` | register/remove the platform login item; `describe()` feeds the `watch status` line |
166
176
  | `_tick_cat()` | advances multi-frame cat animation every 0.4s |
167
177
  | `_dispatch_command(command, args)` | `@work` — calls `command.run(app, args)` from the registry |
168
178
  | `on_input_submitted()` | routes input to login flow or `_dispatch_command` via `CommandRegistry`; Enter on a partial `/cmd` completes it instead of submitting |
@@ -175,17 +185,15 @@ LoginScreen (ModalScreen) — available in auth.py but not the primary auth path
175
185
 
176
186
  ## Robot compatibility
177
187
 
178
- pylitterbot auto-detects robot type. Commands that differ per model are handled by `RobotAdapter` subclasses in `robot_adapters.py` — `make_adapter(robot)` returns the right one based on `type(robot).__name__`. Status-bar reads use `getattr(..., default)` for graceful degradation on older models. Tested API surface:
188
+ pylitterbot auto-detects robot type. Commands that differ per model are handled by `RobotAdapter` subclasses in `robot_adapters.py` — `make_adapter(robot)` returns the right one based on `type(robot).__name__`. Status-bar reads use `getattr(..., default)` for graceful degradation on older models. See the `pylitterbot-ref` skill for the confirmed API surface.
189
+
190
+ ## Colour
179
191
 
180
- - `robot.name`, `robot.serial`, `robot.is_online`
181
- - `robot.status` (LitterBoxStatus enum)
182
- - `robot.waste_drawer_level` (0–100)
183
- - `robot.sleep_mode_enabled`, `robot.panel_lock_enabled`, `robot.night_light_mode_enabled`
184
- - `robot.last_seen` (datetime)
185
- - `robot.refresh()`, `robot.start_cleaning()`
186
- - `robot.set_sleep_mode(bool)`, `robot.set_panel_lockout(bool)`
187
- - `robot.set_night_light_brightness(int)` or `robot.set_night_light_mode(NightLightMode)`
188
- - `robot.get_activity_history(limit=int)` → list of `Activity` objects with `.timestamp` and `.action` (`LitterBoxStatus` enum)
192
+ Every colour comes from `asher/theme.py` (Catppuccin Mocha). Reference the **semantic roles** (`theme.MUTED`, `theme.DANGER`, …), not the raw swatches (`theme.OVERLAY0`) and never a hex literal — a re-flavour then only repoints the roles.
193
+
194
+ - **Rich styles:** `style=theme.ACCENT`, or `style=f"bold {theme.ACCENT}"`. Prefer building `Text` objects with explicit styles over `Text.from_markup` with inline colours.
195
+ - **`ui/style.tcss`:** use the `$asher-*` variables; `AsherApp.get_css_variables()` supplies them.
196
+ - **Inline `CSS`/`DEFAULT_CSS` on a Screen or Widget:** wrap the block in `theme.apply(...)`, which bakes the `$asher-*` values in at class-definition time. A screen mounted on a host app that isn't `AsherApp` (as the Pilot tests do) would otherwise fail to parse.
189
197
 
190
198
  ## Code comments
191
199
 
@@ -213,26 +221,34 @@ Commands that need a confirmed cloud state before showing a result (e.g. sleep/w
213
221
 
214
222
  **Add a slash command:** create a class inheriting `SlashCommand` (sets `prefix = "/"`), implement `async def run(self, app, args)`, register it, and document in `asher/slash-commands/__init__.py`.
215
223
 
224
+ **Add a headless command:** write `async def _my_command(session, args) -> Result` in `asher/headless.py` and add a `HeadlessCommand(...)` entry to `COMMANDS`. The argparse subparser is generated from the registry — nothing to add in `__main__.py`. Build the `Result` with `_rows()` (read commands) or `_outcome()` (actions) so text and JSON stay in step, and raise `CommandError` rather than printing.
225
+
216
226
  **Change poll interval:** `self.set_interval(300, ...)` in `on_mount`.
217
227
 
218
228
  **Add a new cat state:** add entry to `CATS` dict in `asher/cats.py` (str for static, list[str] for animated), then call `_set_cat("name", "label")`.
219
229
 
220
230
  **File naming convention:** no underscores in filenames (except Python-required `__init__.py` and `__main__.py`).
221
231
 
232
+ ## Tray packaging
233
+
234
+ The `tray` extra is what a published install uses (`pip install "asher-cli[tray]"`). It is **mirrored** as a `tray` dependency-group listed in `[tool.uv] default-groups`, because `uv run` re-syncs the environment to the default *groups* on every invocation and prunes everything else — extras included. Without the group, any plain `uv run` silently removed `pystray` and left the watcher drawing an icon no desktop was hosting. Keep the two lists in step when changing either.
235
+
236
+ CI opts out with `uv sync --no-default-groups --group dev` (`ci.yml`, `coverage.yml`): PyGObject builds from source and the runners have no GObject-introspection headers. `[tool.uv] default-extras` is not an option — uv 0.11 doesn't support that key.
237
+
238
+ PyGObject is what selects pystray's `_appindicator` backend. Without it pystray falls back to a legacy XEmbed icon that KDE Plasma and GNOME no longer host: the watcher runs, draws an icon, and nothing shows it, with no error anywhere. Check with `pystray.Icon.__module__`.
239
+
222
240
  ## Dev workflow
223
241
 
224
- ```bash
225
- uv sync # install all deps (including dev group)
226
- uv run poe dev # run with CSS hot reload (textual --dev)
227
- uv run poe watch # run with Python auto-restart on file change (watchfiles)
228
- uv run poe test # run test suite
229
- uv run poe check # ruff + mypy + pytest (same as CI)
230
- uv run poe fix # auto-fix ruff issues
231
- uv run poe security # bandit scan (same config the Bandit workflow uses)
232
- ```
242
+ See `[tool.poe.tasks]` in `pyproject.toml` for the full task list (`uv run poe <task>`).
233
243
 
234
244
  Pre-push hook (`.githooks/pre-push`) runs: ruff check → ruff format --check → mypy. Tests are not in the hook — run them manually.
235
245
 
246
+ ## Releasing
247
+
248
+ Follow [README § Releasing](README.md#releasing) exactly, in order: `uv run poe changelog-release X.Y.Z` and commit the result → `uv run bump-my-version bump <part>` (this commits **and tags**) → `git push && git push --tags` → only then cut and push `release/X.Y.Z`.
249
+
250
+ Regenerate with `changelog-release X.Y.Z`, never plain `changelog` — the workflow lifts the `## [X.Y.Z]` section out of the committed `CHANGELOG.md` verbatim for the GitHub Release body, and a file still saying `## [Unreleased]` fails the release job. That extraction is also why hand-refinements to a section survive into the release notes, and why re-running either changelog task afterwards silently discards them: regeneration always re-derives from commits. Refine last.
251
+
236
252
  ## Testing notes
237
253
 
238
254
  - Pilot-based integration tests use `app.run_test()` with `await pilot.pause()` before querying widgets