asher-cli 1.0.1__tar.gz → 1.1.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 (103) hide show
  1. asher_cli-1.1.0/.claude/hooks/block-env.py +14 -0
  2. {asher_cli-1.0.1 → asher_cli-1.1.0}/.claude/settings.json +1 -1
  3. asher_cli-1.1.0/.claude/skills/mcp-bridge/SKILL.md +15 -0
  4. asher_cli-1.1.0/.github/FUNDING.yml +15 -0
  5. asher_cli-1.1.0/.github/workflows/bandit.yml +47 -0
  6. {asher_cli-1.0.1 → asher_cli-1.1.0}/.github/workflows/release.yml +15 -12
  7. {asher_cli-1.0.1 → asher_cli-1.1.0}/CHANGELOG.md +29 -2
  8. {asher_cli-1.0.1 → asher_cli-1.1.0}/CLAUDE.md +34 -52
  9. {asher_cli-1.0.1 → asher_cli-1.1.0}/PKG-INFO +61 -15
  10. {asher_cli-1.0.1 → asher_cli-1.1.0}/README.md +60 -14
  11. asher_cli-1.1.0/asher/__main__.py +116 -0
  12. {asher_cli-1.0.1 → asher_cli-1.1.0}/asher/activity_labels.py +22 -20
  13. {asher_cli-1.0.1 → asher_cli-1.1.0}/asher/app.py +9 -0
  14. {asher_cli-1.0.1 → asher_cli-1.1.0}/asher/auth.py +13 -11
  15. {asher_cli-1.0.1 → asher_cli-1.1.0}/asher/commands/__init__.py +116 -76
  16. {asher_cli-1.0.1 → asher_cli-1.1.0}/asher/completion.py +6 -4
  17. {asher_cli-1.0.1 → asher_cli-1.1.0}/asher/connection/__init__.py +4 -3
  18. asher_cli-1.1.0/asher/constants.py +24 -0
  19. {asher_cli-1.0.1 → asher_cli-1.1.0}/asher/export.py +17 -42
  20. asher_cli-1.1.0/asher/headless.py +626 -0
  21. {asher_cli-1.0.1 → asher_cli-1.1.0}/asher/helpers.py +5 -4
  22. {asher_cli-1.0.1 → asher_cli-1.1.0}/asher/history_view.py +19 -18
  23. {asher_cli-1.0.1 → asher_cli-1.1.0}/asher/mcp_bridge.py +3 -1
  24. {asher_cli-1.0.1 → asher_cli-1.1.0}/asher/monitoring/__init__.py +39 -46
  25. asher_cli-1.1.0/asher/theme.py +132 -0
  26. {asher_cli-1.0.1 → asher_cli-1.1.0}/asher/ui/__init__.py +53 -56
  27. {asher_cli-1.0.1 → asher_cli-1.1.0}/asher/ui/style.tcss +26 -26
  28. {asher_cli-1.0.1 → asher_cli-1.1.0}/cliff.toml +3 -0
  29. {asher_cli-1.0.1 → asher_cli-1.1.0}/docs/ROADMAP.md +3 -1
  30. {asher_cli-1.0.1 → asher_cli-1.1.0}/pyproject.toml +22 -2
  31. {asher_cli-1.0.1 → asher_cli-1.1.0}/tests/test_activity_labels.py +6 -5
  32. {asher_cli-1.0.1 → asher_cli-1.1.0}/tests/test_auth.py +15 -4
  33. asher_cli-1.1.0/tests/test_headless.py +529 -0
  34. {asher_cli-1.0.1 → asher_cli-1.1.0}/tests/test_ui.py +8 -4
  35. {asher_cli-1.0.1 → asher_cli-1.1.0}/tests/testhelpers.py +5 -4
  36. {asher_cli-1.0.1 → asher_cli-1.1.0}/uv.lock +170 -170
  37. {asher_cli-1.0.1 → asher_cli-1.1.0}/watchrun.py +11 -5
  38. asher_cli-1.0.1/.claude/hooks/block-env.ps1 +0 -9
  39. asher_cli-1.0.1/.claude/skills/release/SKILL.md +0 -43
  40. asher_cli-1.0.1/.github/workflows/bandit.yml +0 -52
  41. asher_cli-1.0.1/asher/__main__.py +0 -58
  42. asher_cli-1.0.1/asher/constants.py +0 -22
  43. {asher_cli-1.0.1 → asher_cli-1.1.0}/.claude/skills/pylitterbot-ref/SKILL.md +0 -0
  44. {asher_cli-1.0.1 → asher_cli-1.1.0}/.claude/skills/textual/SKILL.md +0 -0
  45. {asher_cli-1.0.1 → asher_cli-1.1.0}/.env.example +0 -0
  46. {asher_cli-1.0.1 → asher_cli-1.1.0}/.githooks/pre-push +0 -0
  47. {asher_cli-1.0.1 → asher_cli-1.1.0}/.github/pull_request_template.md +0 -0
  48. {asher_cli-1.0.1 → asher_cli-1.1.0}/.github/workflows/ci.yml +0 -0
  49. {asher_cli-1.0.1 → asher_cli-1.1.0}/.github/workflows/claude-code-review.yml +0 -0
  50. {asher_cli-1.0.1 → asher_cli-1.1.0}/.github/workflows/claude.yml +0 -0
  51. {asher_cli-1.0.1 → asher_cli-1.1.0}/.github/workflows/coverage.yml +0 -0
  52. {asher_cli-1.0.1 → asher_cli-1.1.0}/.gitignore +0 -0
  53. {asher_cli-1.0.1 → asher_cli-1.1.0}/.vscode/launch.json +0 -0
  54. {asher_cli-1.0.1 → asher_cli-1.1.0}/.vscode/settings.json +0 -0
  55. {asher_cli-1.0.1 → asher_cli-1.1.0}/.vscode/tasks.json +0 -0
  56. {asher_cli-1.0.1 → asher_cli-1.1.0}/CODEOWNERS +0 -0
  57. {asher_cli-1.0.1 → asher_cli-1.1.0}/LICENSE +0 -0
  58. {asher_cli-1.0.1 → asher_cli-1.1.0}/app.py +0 -0
  59. {asher_cli-1.0.1 → asher_cli-1.1.0}/asher/__init__.py +0 -0
  60. {asher_cli-1.0.1 → asher_cli-1.1.0}/asher/cats.py +0 -0
  61. {asher_cli-1.0.1 → asher_cli-1.1.0}/asher/commands/base.py +0 -0
  62. {asher_cli-1.0.1 → asher_cli-1.1.0}/asher/config.py +0 -0
  63. {asher_cli-1.0.1 → asher_cli-1.1.0}/asher/faults.py +0 -0
  64. {asher_cli-1.0.1 → asher_cli-1.1.0}/asher/login_flow.py +0 -0
  65. {asher_cli-1.0.1 → asher_cli-1.1.0}/asher/mcp_config.py +0 -0
  66. {asher_cli-1.0.1 → asher_cli-1.1.0}/asher/notifications.py +0 -0
  67. {asher_cli-1.0.1 → asher_cli-1.1.0}/asher/robot_adapters.py +0 -0
  68. {asher_cli-1.0.1 → asher_cli-1.1.0}/asher/robot_protocol.py +0 -0
  69. {asher_cli-1.0.1 → asher_cli-1.1.0}/asher/slash-commands/__init__.py +0 -0
  70. {asher_cli-1.0.1 → asher_cli-1.1.0}/docs/roadmap-archive/README.md +0 -0
  71. {asher_cli-1.0.1 → asher_cli-1.1.0}/docs/roadmap-archive/cat-panel-badges.md +0 -0
  72. {asher_cli-1.0.1 → asher_cli-1.1.0}/docs/roadmap-archive/config-persistence.md +0 -0
  73. {asher_cli-1.0.1 → asher_cli-1.1.0}/docs/roadmap-archive/desktop-notifications.md +0 -0
  74. {asher_cli-1.0.1 → asher_cli-1.1.0}/docs/roadmap-archive/fault-monitoring.md +0 -0
  75. {asher_cli-1.0.1 → asher_cli-1.1.0}/docs/roadmap-archive/headless-export.md +0 -0
  76. {asher_cli-1.0.1 → asher_cli-1.1.0}/docs/roadmap-archive/history-export.md +0 -0
  77. {asher_cli-1.0.1 → asher_cli-1.1.0}/docs/roadmap-archive/tab-completion.md +0 -0
  78. {asher_cli-1.0.1 → asher_cli-1.1.0}/renovate.json +0 -0
  79. {asher_cli-1.0.1 → asher_cli-1.1.0}/requirements.txt +0 -0
  80. {asher_cli-1.0.1 → asher_cli-1.1.0}/test.py +0 -0
  81. {asher_cli-1.0.1 → asher_cli-1.1.0}/tests/__init__.py +0 -0
  82. {asher_cli-1.0.1 → asher_cli-1.1.0}/tests/conftest.py +0 -0
  83. {asher_cli-1.0.1 → asher_cli-1.1.0}/tests/test_app_pilot.py +0 -0
  84. {asher_cli-1.0.1 → asher_cli-1.1.0}/tests/test_auth_pilot.py +0 -0
  85. {asher_cli-1.0.1 → asher_cli-1.1.0}/tests/test_cats.py +0 -0
  86. {asher_cli-1.0.1 → asher_cli-1.1.0}/tests/test_commands_pilot.py +0 -0
  87. {asher_cli-1.0.1 → asher_cli-1.1.0}/tests/test_completion.py +0 -0
  88. {asher_cli-1.0.1 → asher_cli-1.1.0}/tests/test_config.py +0 -0
  89. {asher_cli-1.0.1 → asher_cli-1.1.0}/tests/test_connection.py +0 -0
  90. {asher_cli-1.0.1 → asher_cli-1.1.0}/tests/test_connection_mixin.py +0 -0
  91. {asher_cli-1.0.1 → asher_cli-1.1.0}/tests/test_export.py +0 -0
  92. {asher_cli-1.0.1 → asher_cli-1.1.0}/tests/test_faults.py +0 -0
  93. {asher_cli-1.0.1 → asher_cli-1.1.0}/tests/test_history_view.py +0 -0
  94. {asher_cli-1.0.1 → asher_cli-1.1.0}/tests/test_lr5_commands.py +0 -0
  95. {asher_cli-1.0.1 → asher_cli-1.1.0}/tests/test_mcp_bridge.py +0 -0
  96. {asher_cli-1.0.1 → asher_cli-1.1.0}/tests/test_mcp_command.py +0 -0
  97. {asher_cli-1.0.1 → asher_cli-1.1.0}/tests/test_mcp_config.py +0 -0
  98. {asher_cli-1.0.1 → asher_cli-1.1.0}/tests/test_missing_robot_commands.py +0 -0
  99. {asher_cli-1.0.1 → asher_cli-1.1.0}/tests/test_monitoring.py +0 -0
  100. {asher_cli-1.0.1 → asher_cli-1.1.0}/tests/test_new_commands_pilot.py +0 -0
  101. {asher_cli-1.0.1 → asher_cli-1.1.0}/tests/test_notifications.py +0 -0
  102. {asher_cli-1.0.1 → asher_cli-1.1.0}/tests/test_robot_adapters.py +0 -0
  103. {asher_cli-1.0.1 → asher_cli-1.1.0}/tests/test_version_command.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']
@@ -0,0 +1,47 @@
1
+ # Bandit is a security linter for Python, by PyCQA (Apache v2.0).
2
+ # https://pypi.org/project/bandit/
3
+ #
4
+ # Results land under the repository's Security → Code scanning tab.
5
+ #
6
+ # Scan configuration — which paths are excluded, which checks are skipped and
7
+ # why — lives in [tool.bandit] in pyproject.toml, so `uv run poe security`
8
+ # reproduces this job's findings locally.
9
+
10
+ name: Bandit
11
+
12
+ on:
13
+ push:
14
+ branches: ["main", "release/*"]
15
+ pull_request:
16
+ # The branches below must be a subset of the branches above
17
+ branches: ["main"]
18
+ schedule:
19
+ - cron: '32 0 * * 1'
20
+
21
+ permissions:
22
+ contents: read
23
+
24
+ jobs:
25
+ bandit:
26
+ permissions:
27
+ contents: read # for actions/checkout to fetch code
28
+ security-events: write # for github/codeql-action/upload-sarif to upload SARIF results
29
+ actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status
30
+
31
+ runs-on: ubuntu-latest
32
+ steps:
33
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
34
+ - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
35
+
36
+ - name: Bandit scan
37
+ # --exit-zero: findings must not fail the job, or the upload step below
38
+ # is skipped and code scanning keeps showing the previous run's alerts.
39
+ run: >-
40
+ uvx --from 'bandit[toml,sarif]' bandit
41
+ -c pyproject.toml -r . -f sarif -o bandit.sarif --exit-zero
42
+
43
+ - name: Upload SARIF to code scanning
44
+ uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
45
+ with:
46
+ sarif_file: bandit.sarif
47
+ category: bandit
@@ -44,26 +44,29 @@ jobs:
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,35 @@ 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.1.0] - 2026-08-09
11
+
12
+ ### 🚀 Features
13
+
14
+ - *(headless)* Every robot command now runs without the TUI as `asher <command>`, with `--json` output and `--robot` selection; `--export` stays as a deprecated alias
15
+ - *(theme)* Catppuccin Mocha throughout — one palette in `asher/theme.py` drives the TUI stylesheet and every Rich style, replacing ad-hoc hexes across eleven modules
16
+
17
+ ### 📚 Documentation
18
+
19
+ - Point CLAUDE.md at the release process
20
+
21
+ ### 👷 CI/CD
22
+
23
+ - *(release)* Build release notes from CHANGELOG.md
24
+
25
+ ### 🔧 Miscellaneous
26
+
27
+ - Trim CLAUDE.md, split MCP bridge docs into a skill, fix env-guard hook
28
+
29
+ ## [1.0.2] - 2026-08-05
30
+
31
+ ### 🐛 Bug Fixes
32
+
33
+ - Silence bandit noise and resolve dependency advisories
34
+
35
+ ### 🔧 Miscellaneous
36
+
37
+ - Enable Bandit B101 for asher/ and bump version to 1.0.2
38
+
10
39
  ## [1.0.1] - 2026-08-04
11
40
 
12
41
  ### 🚀 Features
@@ -24,7 +53,6 @@ Generated by [git-cliff](https://git-cliff.org) from conventional commits.
24
53
 
25
54
  ### 📚 Documentation
26
55
 
27
- - *(changelog)* Regenerate for next release, trim duplicate README list
28
56
  - *(roadmap)* Archive completed sections to docs/roadmap-archive/
29
57
 
30
58
  ### 🔧 Miscellaneous
@@ -43,7 +71,6 @@ Generated by [git-cliff](https://git-cliff.org) from conventional commits.
43
71
 
44
72
  ### 📚 Documentation
45
73
 
46
- - *(changelog)* Cut v0.2.0
47
74
  - *(commands)* Sync stale command lists in CLAUDE.md and slash-commands docstring
48
75
  - *(readme)* Sync command tables with code, add changelog
49
76
 
@@ -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,6 +25,7 @@ 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
30
  notifications.py desktop toast + audible alert façade over plyer (fire/beep, always-safe no-op on failure/headless)
42
31
  cats.py CATS dict (ASCII art)
@@ -47,9 +36,10 @@ asher/
47
36
  mcp_bridge.py asher-mcp-launch console script — keyring-backed pylitterbot MCP launcher
48
37
  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
38
  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()
39
+ 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()
40
+ 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
41
  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
42
+ __main__.py main() entry point — argparse subcommands (headless) vs no-args (TUI); `--export` kept as a deprecated alias
53
43
  commands/
54
44
  base.py Command ABC, SlashCommand, CommandRegistry
55
45
  __init__.py CommandsMixin — all command classes + registry + dispatch
@@ -76,11 +66,13 @@ tests/
76
66
  test_mcp_command.py /mcp slash command dispatch
77
67
  test_faults.py check_faults() — safety statuses, attribute faults, graceful degradation
78
68
  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)
69
+ test_export.py build_history_csv/resolve_dest/resolve_robot/parse_days (pure) + the legacy `--export` flag path (no Pilot, mocks _connect_headless)
70
+ test_headless.py headless registry/rendering (pure) + every command handler against mock robots + run() exit codes + the argparse subcommand surface
80
71
  test_completion.py slash_matches/enter_completes/render_completion (pure) + Pilot overlay visibility/navigation/accept
81
72
 
82
73
  .github/workflows/
83
74
  ci.yml ruff + mypy + pytest on every push/PR
75
+ bandit.yml bandit security scan → SARIF → code scanning (config: [tool.bandit] in pyproject.toml)
84
76
  ```
85
77
 
86
78
  ## Credentials
@@ -100,32 +92,22 @@ LITTER_ROBOT_PASSWORD=...
100
92
  Keyring service name: `asher-cli`, keys `email` and `password`.
101
93
  Helper functions in `asher/connection/__init__.py`: `_keyring_load()`, `_keyring_save()`, `_keyring_delete()`.
102
94
 
103
- ## MCP bridge (`/mcp`)
104
-
105
- 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:
106
-
107
- - `/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.
108
- - `/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.
109
- - `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.
110
- - `/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).
111
- - Requires the `mcp` extra: `uv sync --extra mcp` / `pip install asher-cli[mcp]`. Restart Claude Desktop after toggling for the change to take effect.
112
-
113
95
  ## Command convention
114
96
 
115
- **Normal commands** (no prefix) — robot actions only:
116
- `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`
97
+ 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.
117
98
 
118
- **Slash commands** (`/` prefix) — app management only:
119
- `/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`
99
+ **Normal commands** (no prefix) are robot actions only; **slash commands** (`/` prefix) are app management only.
120
100
 
121
101
  `/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.
122
102
 
123
- > 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`.
103
+ Do not add robot-control commands as slash commands, and do not add app-management commands as bare commands.
124
104
 
125
105
  **Special cases** (accepted both with and without `/`):
126
106
  `exit`, `quit`, `q` — exit the app
127
107
 
128
- Do not add robot-control commands as slash commands, and do not add app-management commands as bare commands.
108
+ **Headless commands** (`asher <command>`) are a parallel registry in `asher/headless.py`: same robot actions, no Textual, plain-string + JSON output. 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.
109
+
110
+ > 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.
129
111
 
130
112
  ## Architecture
131
113
 
@@ -174,17 +156,15 @@ LoginScreen (ModalScreen) — available in auth.py but not the primary auth path
174
156
 
175
157
  ## Robot compatibility
176
158
 
177
- 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:
159
+ 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.
178
160
 
179
- - `robot.name`, `robot.serial`, `robot.is_online`
180
- - `robot.status` (LitterBoxStatus enum)
181
- - `robot.waste_drawer_level` (0–100)
182
- - `robot.sleep_mode_enabled`, `robot.panel_lock_enabled`, `robot.night_light_mode_enabled`
183
- - `robot.last_seen` (datetime)
184
- - `robot.refresh()`, `robot.start_cleaning()`
185
- - `robot.set_sleep_mode(bool)`, `robot.set_panel_lockout(bool)`
186
- - `robot.set_night_light_brightness(int)` or `robot.set_night_light_mode(NightLightMode)`
187
- - `robot.get_activity_history(limit=int)` → list of `Activity` objects with `.timestamp` and `.action` (`LitterBoxStatus` enum)
161
+ ## Colour
162
+
163
+ 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.
164
+
165
+ - **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.
166
+ - **`ui/style.tcss`:** use the `$asher-*` variables; `AsherApp.get_css_variables()` supplies them.
167
+ - **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.
188
168
 
189
169
  ## Code comments
190
170
 
@@ -196,6 +176,7 @@ Don't add comments above functions or inline unless the WHY is genuinely non-obv
196
176
  - All command execution runs in `@work` async workers to keep the UI responsive
197
177
  - Cat modes: `idle`, `happy`, `cleaning` (animated), `sleeping`, `error`, `full`
198
178
  - `VERSION` is read from `importlib.metadata.version("asher-cli")` — falls back to `"dev"` when running from source
179
+ - **No `assert` in `asher/`** — Bandit enforces this (B101 is enabled; only `tests/` is exempt, via `exclude_dirs`). For a `requires_robot` command, narrow with `if app._robot is None: return` rather than an assert: `_dispatch_command` already rejects the disconnected case, and unlike `assert` the guard survives `python -O`
199
180
  - The primary login path is the inline flow in `login_flow.py` (`LoginFlow` state machine: `IDLE` → `AWAITING_EMAIL` → `AWAITING_PASSWORD`). `LoginScreen` (`auth.py`) still exists as a modal but is not used in the current main flow.
200
181
  - `LoginScreen` uses `event.stop()` on `Input.Submitted` and `Button.Pressed` to prevent bubbling to the App's `on_input_submitted` (relevant if re-activating the modal path)
201
182
 
@@ -211,6 +192,8 @@ Commands that need a confirmed cloud state before showing a result (e.g. sleep/w
211
192
 
212
193
  **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`.
213
194
 
195
+ **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.
196
+
214
197
  **Change poll interval:** `self.set_interval(300, ...)` in `on_mount`.
215
198
 
216
199
  **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,17 +202,16 @@ Commands that need a confirmed cloud state before showing a result (e.g. sleep/w
219
202
 
220
203
  ## Dev workflow
221
204
 
222
- ```bash
223
- uv sync # install all deps (including dev group)
224
- uv run poe dev # run with CSS hot reload (textual --dev)
225
- uv run poe watch # run with Python auto-restart on file change (watchfiles)
226
- uv run poe test # run test suite
227
- uv run poe check # ruff + mypy + pytest (same as CI)
228
- uv run poe fix # auto-fix ruff issues
229
- ```
205
+ See `[tool.poe.tasks]` in `pyproject.toml` for the full task list (`uv run poe <task>`).
230
206
 
231
207
  Pre-push hook (`.githooks/pre-push`) runs: ruff check → ruff format --check → mypy. Tests are not in the hook — run them manually.
232
208
 
209
+ ## Releasing
210
+
211
+ 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`.
212
+
213
+ 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.
214
+
233
215
  ## Testing notes
234
216
 
235
217
  - Pilot-based integration tests use `app.run_test()` with `await pilot.pause()` before querying widgets
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: asher-cli
3
- Version: 1.0.1
3
+ Version: 1.1.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
@@ -75,7 +75,8 @@ A Claude Code-style terminal dashboard for monitoring and controlling Litter Rob
75
75
  - Slash commands for app management: `/login`, `/logout`, `/robots`, `/robot <index|name>`, `/pets`, `/pet <index|name>`, `/cat on|off|colour <hex>`, `/refresh [seconds|off]`, `/config`, `/notify on|off|sound on|off|test`, `/version`, `/mcp on|off|status`, `/exit`
76
76
  - Slash-command tab completion — type `/` and a Claude Code-style overlay lists matching commands; `↑`/`↓` to move, `Tab` or `Enter` to accept, `Esc` to dismiss
77
77
  - Inline ghost-text completion for bare commands — type a prefix (`cle`) and the rest (`an`) appears greyed; `Tab` or `→` to accept → `clean`
78
- - Headless export — `asher --export 7` writes activity history to CSV from cron / Task Scheduler / SSH without launching the TUI
78
+ - Headless mode — every robot command also runs without the TUI (`asher status`, `asher clean`, `asher night-light auto`, `asher export 7`), with `--json` output and documented exit codes for cron / Task Scheduler / SSH
79
+ - Catppuccin Mocha throughout — one palette in `asher/theme.py` drives the TUI stylesheet and every Rich style
79
80
  - Cat animation panel that reacts to robot state
80
81
  - Command history (↑/↓ arrows)
81
82
  - Real-time updates via WebSocket; 5-minute poll fallback
@@ -177,38 +178,61 @@ LITTER_ROBOT_PASSWORD=yourpassword
177
178
 
178
179
  **Keyboard shortcuts:** `Ctrl+L` clears the log, `Ctrl+C` quits. While typing a `/` slash command, `↑`/`↓` move through completions, `Tab` or `Enter` accepts, `Esc` dismisses. While typing a bare command, a greyed ghost suggestion appears — `Tab` or `→` accepts it.
179
180
 
180
- ### Headless export (cron / Task Scheduler / SSH)
181
+ ### Headless commands (cron / Task Scheduler / SSH)
181
182
 
182
- `--export` writes the same CSV as the `export` command **without launching the TUI** — so you can script activity-history exports from cron, Windows Task Scheduler, or a server over SSH. No flags launches the interactive dashboard as before.
183
+ Every robot action the dashboard offers is also a subcommand that runs **without launching the TUI** — `asher` with no arguments still opens the interactive dashboard.
183
184
 
184
185
  ```bash
185
- asher --export 7 export last 7 days to ~/Downloads
186
- asher --export 7 --output ~/hist.csv explicit output path
187
- asher --export month --robot "Asher 2" 30 days (Whisker ceiling) for a specific robot
186
+ asher status at-a-glance state
187
+ asher info model, serial, firmware, settings
188
+ asher clean start a clean cycle
189
+ asher night-light auto set night light mode
190
+ asher wait-time 7 set the clean-cycle wait time
191
+ asher history 20 recent activity, newest first
192
+ asher insight 7 cycle-usage statistics
193
+ asher export 7 --output ~/hist.csv activity history to CSV
188
194
  ```
189
195
 
190
- `--robot` accepts an index or a partial, case-insensitive name (defaults to your saved preferred robot, else the first). Credentials use the same keyring → `.env` priority as the TUI, but with **no interactive login prompt** — a scheduled task can't type a password, so sign in once with `/login` first.
196
+ `asher --help` lists every command; `asher <command> --help` shows its arguments. The full set: `status`, `info`, `robots`, `pets`, `history`, `insight`, `sleep-schedule`, `clean`, `lock`, `unlock`, `sleep`, `wake`, `night-light`, `night-light-brightness`, `panel-brightness`, `wait-time`, `power`, `rename`, `privacy`, `volume`, `camera-audio`, `drawer-reset`, `export`.
197
+
198
+ Two flags apply to all of them:
199
+
200
+ - `--robot <index|name>` — an index or a partial, case-insensitive name (defaults to your saved preferred robot, else the first)
201
+ - `--json` — machine-readable output instead of aligned text, for piping into `jq`
202
+
203
+ ```bash
204
+ asher status --json | jq -r '.drawer'
205
+ asher history 50 --json | jq '[.events[] | select(.event | startswith("Cat"))] | length'
206
+ ```
207
+
208
+ Credentials use the same keyring → `.env` priority as the TUI, but with **no interactive login prompt** — a scheduled task can't type a password, so sign in once with `/login` first.
191
209
 
192
210
  Exit codes for scripting:
193
211
 
194
212
  | Code | Meaning |
195
213
  |---|---|
196
- | `0` | export succeeded |
214
+ | `0` | command succeeded |
197
215
  | `1` | no credentials found (keyring or `.env`) |
198
216
  | `2` | connection or API failure |
199
217
  | `3` | failed to write the CSV (permissions, disk full) |
200
218
  | `4` | `--robot` matched no robot on the account |
219
+ | `5` | bad argument, or the robot/cloud rejected the command |
201
220
 
202
221
  ```bash
203
222
  # crontab — nightly export at 03:00
204
- 0 3 * * * /usr/bin/env asher --export 7 --output /home/me/litter-history.csv >> /var/log/asher-export.log 2>&1
223
+ 0 3 * * * /usr/bin/env asher export 7 --output /home/me/litter-history.csv >> /var/log/asher-export.log 2>&1
224
+
225
+ # alert when the drawer passes 85%
226
+ 0 * * * * [ "$(asher status --json | jq -r '.drawer' | tr -d %)" -gt 85 ] && notify-send "Litter drawer full"
205
227
  ```
206
228
 
207
229
  ```powershell
208
230
  # Windows Task Scheduler action
209
- asher.exe --export 7 --output C:\Users\me\litter-history.csv
231
+ asher.exe export 7 --output C:\Users\me\litter-history.csv
210
232
  ```
211
233
 
234
+ `asher --export [days]` remains as a deprecated alias for `asher export [days]`, so existing cron entries keep working.
235
+
212
236
  ## Configuration
213
237
 
214
238
  Runtime settings persist across restarts in `~/.asher-cli/config.json`, so you don't have to re-apply `/refresh 60`, `/cat colour #ff79c6`, or `/pet 1` every launch. The file is auto-created on first change and holds six non-secret UI preferences:
@@ -227,10 +251,11 @@ Credentials and the preferred-robot serial stay in the OS keyring; `.env` vars s
227
251
  ## Releasing
228
252
 
229
253
  ```bash
230
- # 1. regenerate CHANGELOG.md from conventional commits (idempotent), then commit it
231
- uv run poe changelog
254
+ # 1. file the pending commits under the version you're about to cut, refine the
255
+ # prose if a squashed commit needs unpacking, then commit it
256
+ uv run poe changelog-release X.Y.Z
232
257
  git add CHANGELOG.md
233
- git commit -m "docs(changelog): update for next release"
258
+ git commit -m "docs(changelog): cut vX.Y.Z"
234
259
 
235
260
  # 2. bump version, commit, and tag in one step, then push with tags
236
261
  uv run bump-my-version bump patch # 0.2.0 → 0.2.1
@@ -240,7 +265,7 @@ uv run bump-my-version bump major # 0.2.0 → 1.0.0
240
265
  git push && git push --tags
241
266
 
242
267
  # 3. push the release branch to trigger PyPI publish (OIDC) + GitHub Release
243
- # (release notes auto-generated by git-cliff from the same cliff.toml)
268
+ # (release notes = the `## [X.Y.Z]` section of CHANGELOG.md, verbatim)
244
269
  git checkout -b release/X.Y.Z
245
270
  git push origin release/X.Y.Z
246
271
  ```
@@ -250,6 +275,17 @@ conventional commits — see [`cliff.toml`](cliff.toml). Commit messages must us
250
275
  `feat:`/`fix:`/`docs:`/etc. prefixes (optionally scoped, e.g. `feat(robot):`)
251
276
  to appear in the changelog.
252
277
 
278
+ Use `poe changelog-release X.Y.Z` — not plain `poe changelog` — when cutting a
279
+ release. Both regenerate the whole file, but only the former files the pending
280
+ commits under `## [X.Y.Z]` instead of `## [Unreleased]`, and the release
281
+ workflow extracts exactly that section for the GitHub Release body. It fails
282
+ loudly if the section is missing, so the notes can never come out stale.
283
+
284
+ Because the workflow reads the committed file rather than re-deriving from
285
+ commits, any hand-refinement you make to a section survives into the release
286
+ notes — but re-running either task afterwards regenerates from commits and
287
+ discards it. Refine last, right before committing.
288
+
253
289
  ## Troubleshooting
254
290
 
255
291
  **`asher` not found after `pip install asher-cli`**
@@ -339,6 +375,16 @@ uv run poe types # mypy
339
375
  uv run poe check # run all of the above + tests (same as CI)
340
376
  ```
341
377
 
378
+ ### 6. Security scan
379
+
380
+ ```bash
381
+ uv run poe security # bandit, same config the Bandit workflow uses
382
+ ```
383
+
384
+ Scan settings live in `[tool.bandit]` in `pyproject.toml`, so local runs and the
385
+ Bandit workflow report identical findings. Results from `main` and pull requests
386
+ are published to the repository's **Security → Code scanning** tab.
387
+
342
388
  CI runs on Python 3.10 / 3.11 / 3.12 across Ubuntu, Windows, and macOS on every push.
343
389
 
344
390
  ## Changelog