lemming-cli 0.3.4__tar.gz → 0.4.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.
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/.gitignore +1 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/PKG-INFO +136 -3
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/README.md +133 -0
- lemming_cli-0.4.0/SKILL.md +86 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/docs/HOOKS.md +9 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/pyproject.toml +7 -2
- lemming_cli-0.4.0/src/lemming/api/auth.py +57 -0
- lemming_cli-0.4.0/src/lemming/api/auth_test.py +73 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/api/main.py +20 -1
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/api/tasks_test.py +25 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/cli/__init__.py +2 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/cli/brief.py +2 -2
- lemming_cli-0.4.0/src/lemming/cli/exec_cli.py +372 -0
- lemming_cli-0.4.0/src/lemming/cli/exec_cli_test.py +418 -0
- lemming_cli-0.4.0/src/lemming/cli/main.py +105 -0
- lemming_cli-0.4.0/src/lemming/cli/main_test.py +149 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/cli/operations.py +11 -1
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/cli/progress.py +2 -2
- lemming_cli-0.4.0/src/lemming/cli/skill.py +344 -0
- lemming_cli-0.4.0/src/lemming/cli/skill_test.py +151 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/cli/tasks.py +10 -7
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/orchestrator.py +90 -5
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/orchestrator_test.py +176 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/paths.py +70 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/paths_test.py +70 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/persistence.py +138 -17
- lemming_cli-0.4.0/src/lemming/persistence_test.py +378 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/prompts/hooks/50-readability.md +26 -15
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/prompts/hooks/60-testing.md +6 -2
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/prompts/hooks/70-ux.md +5 -1
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/prompts.py +17 -1
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/prompts_test.py +81 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/providers.py +20 -18
- lemming_cli-0.4.0/src/lemming/providers_test.py +150 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/runner.py +185 -17
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/runner_test.py +226 -0
- lemming_cli-0.4.0/src/lemming/scope.py +169 -0
- lemming_cli-0.4.0/src/lemming/scope_test.py +117 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/shutdown.py +5 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/tasks/__init__.py +1 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/tasks/operations_test.py +15 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/web/dashboard.spec.js +30 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/web/index.js +11 -1
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/uv.lock +4 -4
- lemming_cli-0.3.4/src/lemming/api/auth.py +0 -34
- lemming_cli-0.3.4/src/lemming/api/auth_test.py +0 -40
- lemming_cli-0.3.4/src/lemming/cli/main.py +0 -42
- lemming_cli-0.3.4/src/lemming/cli/main_test.py +0 -29
- lemming_cli-0.3.4/src/lemming/persistence_test.py +0 -187
- lemming_cli-0.3.4/src/lemming/providers_test.py +0 -73
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/.github/workflows/ci.yml +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/.github/workflows/publish.yml +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/.github/workflows/screenshots.yml +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/.prettierignore +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/.prettierrc +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/Dockerfile +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/LICENSE +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/biome.json +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/docker-compose.yml +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/docs/EVALS.md +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/docs/screenshots/dashboard-desktop.png +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/docs/screenshots/dashboard-mobile.png +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/docs/screenshots/task-log-desktop.png +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/docs/screenshots/task-log-mobile.png +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/package-lock.json +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/package.json +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/playwright.config.js +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/__init__.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/api/__init__.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/api/config.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/api/config_test.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/api/conftest.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/api/context.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/api/context_test.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/api/directories.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/api/directories_test.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/api/files.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/api/files_test.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/api/hooks.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/api/hooks_test.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/api/logging.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/api/logging_test.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/api/loop.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/api/loop_test.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/api/main_test.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/api/tasks.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/api_test.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/brief_test.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/cli/config.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/cli/config_test.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/cli/goal.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/cli/goal_test.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/cli/hooks.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/cli/hooks_test.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/cli/machine_output_test.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/cli/operations_test.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/cli/progress_test.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/cli/readability_cli.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/cli/readability_cli_test.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/cli/stop_test.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/cli/tasks_test.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/cli_test.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/conftest.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/evals/__init__.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/evals/__main__.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/evals/cli.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/evals/cli_test.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/evals/container.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/evals/container_test.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/evals/fixtures.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/evals/fixtures_test.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/evals/harness.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/evals/harness_test.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/evals/readability.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/evals/readability_test.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/evals/roadmap.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/evals/roadmap_test.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/evals/scenarios.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/evals/scenarios_test.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/evals/suites.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/evals/trial.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/evals/trial_test.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/hooks.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/hooks_test.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/integration_test.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/main.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/main_test.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/models.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/models_test.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/prompts/hooks/90-roadmap.md +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/prompts/taskrunner.md +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/shutdown_integration_test.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/shutdown_test.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/tasks/lifecycle.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/tasks/lifecycle_test.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/tasks/limits.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/tasks/operations.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/tasks/progress.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/tasks/progress_test.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/tasks/queries.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/tasks/queries_test.py +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/web/dashboard.test.js +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/web/favicon.js +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/web/favicon.spec.js +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/web/files.html +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/web/files.spec.js +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/web/index.html +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/web/logs.html +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/web/logs.test.js +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/web/mancha.js +0 -0
- {lemming_cli-0.3.4 → lemming_cli-0.4.0}/src/lemming/web/screenshots.spec.js +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: lemming-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: An autonomous, iterative task runner for AI coding agents.
|
|
5
5
|
Project-URL: Homepage, https://github.com/owahltinez/lemming
|
|
6
6
|
Project-URL: Repository, https://github.com/owahltinez/lemming
|
|
@@ -15,7 +15,7 @@ Requires-Dist: fastapi>=0.124.4
|
|
|
15
15
|
Requires-Dist: markdownify>=0.14.1
|
|
16
16
|
Requires-Dist: pydantic>=2.12.5
|
|
17
17
|
Requires-Dist: pyyaml>=6.0.3
|
|
18
|
-
Requires-Dist: readability-cli>=0.
|
|
18
|
+
Requires-Dist: readability-cli>=0.7.0
|
|
19
19
|
Requires-Dist: requests>=2.32.3
|
|
20
20
|
Requires-Dist: uvicorn>=0.33.0
|
|
21
21
|
Description-Content-Type: text/markdown
|
|
@@ -102,6 +102,71 @@ lemming run -- --model claude-3-5-sonnet
|
|
|
102
102
|
|
|
103
103
|
---
|
|
104
104
|
|
|
105
|
+
## One-Off Tasks Without a Roadmap
|
|
106
|
+
|
|
107
|
+
`lemming exec` runs a single task and exits. It is the same agent-CLI
|
|
108
|
+
normalization the orchestrator uses, addressable on its own: name a task and a
|
|
109
|
+
runner, and the agent's closing message comes back on stdout.
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
# Delegate one task to a different agent, e.g. to spare another's quota
|
|
113
|
+
lemming exec "Fix the flaky test in runner_test.py" --runner codex
|
|
114
|
+
|
|
115
|
+
# Pipe a longer handoff instead of fighting shell quoting
|
|
116
|
+
cat handoff.md | lemming exec -f - --runner agy
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
With no description there is nothing for a task runner to do, so only the
|
|
120
|
+
reviews run — against work that already exists.
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
# Review uncommitted work before opening a pull request
|
|
124
|
+
lemming exec --review readability
|
|
125
|
+
|
|
126
|
+
# Review someone else's branch, checked out in a worktree
|
|
127
|
+
lemming exec -C ../review-worktree --review testing --scope main...HEAD
|
|
128
|
+
|
|
129
|
+
# Do the work, then gate it
|
|
130
|
+
lemming exec "Add pagination to the tasks API" --review all
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`--scope` takes paths, which pass through untouched, or a git revision range,
|
|
134
|
+
which is resolved to the files it changed. It defaults to uncommitted work, or
|
|
135
|
+
to the whole tree outside a git repository.
|
|
136
|
+
|
|
137
|
+
Each run is self-contained: nothing is read from the project's roadmap or its
|
|
138
|
+
local hooks, one agent run is attempted, and the run's state directory is
|
|
139
|
+
removed unless it failed — in which case it is kept, and its path printed, so
|
|
140
|
+
the log can be read. Kept directories live in `~/.local/lemming/exec-*` and
|
|
141
|
+
are retired automatically a week later, so a recent failure is always still
|
|
142
|
+
there to inspect. Stdout carries the agent's message alone and everything
|
|
143
|
+
else goes to stderr, so the output can be consumed directly.
|
|
144
|
+
|
|
145
|
+
Note that the agent runs unattended (`--yolo` by default), so it does not
|
|
146
|
+
inherit the permission prompts of whatever launched it.
|
|
147
|
+
|
|
148
|
+
### Teaching Your Agent to Use It
|
|
149
|
+
|
|
150
|
+
Lemming ships an Agent Skill so that an agent already running — Claude Code,
|
|
151
|
+
Gemini CLI, Cursor — can reach any other agent CLI without knowing how each
|
|
152
|
+
one spells its flags. Install it once:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
# Cross-tool location (~/.agents/skills), and report any tool-specific ones
|
|
156
|
+
lemming skill install
|
|
157
|
+
|
|
158
|
+
# Cover every agent tool detected on this machine
|
|
159
|
+
lemming skill install --all
|
|
160
|
+
|
|
161
|
+
# This repository only
|
|
162
|
+
lemming skill install --to .agents/skills
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
`lemming skill uninstall` reverses it. Both refuse to touch a directory that
|
|
166
|
+
does not hold this skill, so a mistyped `--to` fails instead of deleting work.
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
105
170
|
## The Web Dashboard
|
|
106
171
|
|
|
107
172
|
Lemming includes a modern, fast Web UI to monitor your projects.
|
|
@@ -244,6 +309,17 @@ See [docs/EVALS.md](docs/EVALS.md) for details.
|
|
|
244
309
|
|
|
245
310
|
## Command Reference
|
|
246
311
|
|
|
312
|
+
### Global Options
|
|
313
|
+
|
|
314
|
+
These come before the subcommand and apply to all of them.
|
|
315
|
+
|
|
316
|
+
- **`-C, --project-dir <dir>`**: Run as if invoked from `<dir>`, addressing
|
|
317
|
+
that project's roadmap. Relative paths in other options resolve against it.
|
|
318
|
+
See [Working across projects](#working-across-projects).
|
|
319
|
+
- **`--tasks-file <path>`**: Point at a specific tasks file instead of the one
|
|
320
|
+
derived from the current directory.
|
|
321
|
+
- **`-v, --verbose`**: Show verbose output.
|
|
322
|
+
|
|
247
323
|
### Roadmap Management
|
|
248
324
|
|
|
249
325
|
- **`status [<id>]`**: Queue/history overview or deep-dive into a specific
|
|
@@ -317,6 +393,33 @@ See [docs/EVALS.md](docs/EVALS.md) for details.
|
|
|
317
393
|
- `--no-defaults`: Skip default flag injection for known runners.
|
|
318
394
|
- `--`: Use `--` to pass any flag directly to the underlying runner. A
|
|
319
395
|
per-task `--runner`/`--model` overrides anything passed here.
|
|
396
|
+
- **`exec [<description>]`**: Run one task, or one set of reviews, outside any
|
|
397
|
+
roadmap. Prints the agent's closing message to stdout and everything else to
|
|
398
|
+
stderr. Exits non-zero if the task did not complete. See
|
|
399
|
+
[One-off tasks](#one-off-tasks-without-a-roadmap).
|
|
400
|
+
- `-f/--file`: Read the description from a file, or `-` for stdin. Unlike
|
|
401
|
+
`add`, there is no length cap.
|
|
402
|
+
- `--review <names>`: Reviews to run after the task, comma-separated or
|
|
403
|
+
repeated; `all` selects every one. With no description, only the reviews
|
|
404
|
+
run. Hooks that revise the roadmap cannot be selected.
|
|
405
|
+
- `--scope <path|range>`: What the reviews look at. Paths pass through; a
|
|
406
|
+
git revision range is resolved to the files it changed. Defaults to
|
|
407
|
+
uncommitted work, or the whole tree outside a repository.
|
|
408
|
+
- `--runner`, `--model`: Which agent CLI and model to use.
|
|
409
|
+
- `--time-limit`: Minutes before the agent is killed (default 60, 0 for no
|
|
410
|
+
limit).
|
|
411
|
+
- `--yolo/--no-yolo`: Run the agent unattended (default: True).
|
|
412
|
+
- `--keep`: Keep the run's state directory even when it succeeds.
|
|
413
|
+
- **`skill install`**: Install the packaged Agent Skill so agents discover
|
|
414
|
+
Lemming. Writes to `~/.agents/skills` by default and names any tool-specific
|
|
415
|
+
directories it found.
|
|
416
|
+
- `--to <dir>`: Install into a specific skills directory.
|
|
417
|
+
- `--all`: Also cover every detected tool's own skills directory.
|
|
418
|
+
- `--link`: Symlink instead of copying, so upgrades take effect immediately.
|
|
419
|
+
- `--force`: Replace an existing installation of this skill.
|
|
420
|
+
- `--dry-run`: Print what would happen, refusals included.
|
|
421
|
+
- **`skill uninstall`**: Remove it again. Same `--to`, `--all`, and
|
|
422
|
+
`--dry-run`.
|
|
320
423
|
- **`stop`**: Stop the running loop and its runner.
|
|
321
424
|
- `--after-current-task`: Drain instead — let the running task finish, then
|
|
322
425
|
stop before claiming another. This is the safe way to change the runner
|
|
@@ -331,6 +434,36 @@ See [docs/EVALS.md](docs/EVALS.md) for details.
|
|
|
331
434
|
|
|
332
435
|
---
|
|
333
436
|
|
|
437
|
+
## Working across projects
|
|
438
|
+
|
|
439
|
+
When work on one project turns up something that belongs to another — a bug in
|
|
440
|
+
a dependency you also maintain, a doc fix in a sibling repo — file it directly
|
|
441
|
+
on that project's roadmap with `-C` instead of routing it through an external
|
|
442
|
+
issue tracker:
|
|
443
|
+
|
|
444
|
+
```bash
|
|
445
|
+
# From inside project A, queue work on project B
|
|
446
|
+
lemming -C ~/src/other-project add "check --fix drops the trailing newline"
|
|
447
|
+
|
|
448
|
+
# Attach the evidence; the brief has no length cap
|
|
449
|
+
lemming -C ~/src/other-project brief <id> --file repro.md
|
|
450
|
+
|
|
451
|
+
# Read the other project's roadmap without leaving yours
|
|
452
|
+
lemming -C ~/src/other-project status
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
`-C` works whether or not the target keeps a `tasks.yml` in its repo, so you
|
|
456
|
+
never have to know where its isolated state lives. Because it changes the
|
|
457
|
+
working directory, everything else follows too: the target's `.env`, its
|
|
458
|
+
`.lemming/hooks`, and the directory the runner executes in.
|
|
459
|
+
|
|
460
|
+
When an agent files a task this way from inside a `lemming run`, the new task
|
|
461
|
+
records the task it came from via `parent` and `parent_tasks_file`. The
|
|
462
|
+
downstream runner then sees a **Parent Task Context** section in its prompt
|
|
463
|
+
describing why the work was requested, so the report doesn't lose its origin.
|
|
464
|
+
|
|
465
|
+
---
|
|
466
|
+
|
|
334
467
|
## Advanced: Runner Customization
|
|
335
468
|
|
|
336
469
|
Lemming uses **fuzzy matching** to automatically inject the correct "YOLO"
|
|
@@ -80,6 +80,71 @@ lemming run -- --model claude-3-5-sonnet
|
|
|
80
80
|
|
|
81
81
|
---
|
|
82
82
|
|
|
83
|
+
## One-Off Tasks Without a Roadmap
|
|
84
|
+
|
|
85
|
+
`lemming exec` runs a single task and exits. It is the same agent-CLI
|
|
86
|
+
normalization the orchestrator uses, addressable on its own: name a task and a
|
|
87
|
+
runner, and the agent's closing message comes back on stdout.
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
# Delegate one task to a different agent, e.g. to spare another's quota
|
|
91
|
+
lemming exec "Fix the flaky test in runner_test.py" --runner codex
|
|
92
|
+
|
|
93
|
+
# Pipe a longer handoff instead of fighting shell quoting
|
|
94
|
+
cat handoff.md | lemming exec -f - --runner agy
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
With no description there is nothing for a task runner to do, so only the
|
|
98
|
+
reviews run — against work that already exists.
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
# Review uncommitted work before opening a pull request
|
|
102
|
+
lemming exec --review readability
|
|
103
|
+
|
|
104
|
+
# Review someone else's branch, checked out in a worktree
|
|
105
|
+
lemming exec -C ../review-worktree --review testing --scope main...HEAD
|
|
106
|
+
|
|
107
|
+
# Do the work, then gate it
|
|
108
|
+
lemming exec "Add pagination to the tasks API" --review all
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`--scope` takes paths, which pass through untouched, or a git revision range,
|
|
112
|
+
which is resolved to the files it changed. It defaults to uncommitted work, or
|
|
113
|
+
to the whole tree outside a git repository.
|
|
114
|
+
|
|
115
|
+
Each run is self-contained: nothing is read from the project's roadmap or its
|
|
116
|
+
local hooks, one agent run is attempted, and the run's state directory is
|
|
117
|
+
removed unless it failed — in which case it is kept, and its path printed, so
|
|
118
|
+
the log can be read. Kept directories live in `~/.local/lemming/exec-*` and
|
|
119
|
+
are retired automatically a week later, so a recent failure is always still
|
|
120
|
+
there to inspect. Stdout carries the agent's message alone and everything
|
|
121
|
+
else goes to stderr, so the output can be consumed directly.
|
|
122
|
+
|
|
123
|
+
Note that the agent runs unattended (`--yolo` by default), so it does not
|
|
124
|
+
inherit the permission prompts of whatever launched it.
|
|
125
|
+
|
|
126
|
+
### Teaching Your Agent to Use It
|
|
127
|
+
|
|
128
|
+
Lemming ships an Agent Skill so that an agent already running — Claude Code,
|
|
129
|
+
Gemini CLI, Cursor — can reach any other agent CLI without knowing how each
|
|
130
|
+
one spells its flags. Install it once:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
# Cross-tool location (~/.agents/skills), and report any tool-specific ones
|
|
134
|
+
lemming skill install
|
|
135
|
+
|
|
136
|
+
# Cover every agent tool detected on this machine
|
|
137
|
+
lemming skill install --all
|
|
138
|
+
|
|
139
|
+
# This repository only
|
|
140
|
+
lemming skill install --to .agents/skills
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`lemming skill uninstall` reverses it. Both refuse to touch a directory that
|
|
144
|
+
does not hold this skill, so a mistyped `--to` fails instead of deleting work.
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
83
148
|
## The Web Dashboard
|
|
84
149
|
|
|
85
150
|
Lemming includes a modern, fast Web UI to monitor your projects.
|
|
@@ -222,6 +287,17 @@ See [docs/EVALS.md](docs/EVALS.md) for details.
|
|
|
222
287
|
|
|
223
288
|
## Command Reference
|
|
224
289
|
|
|
290
|
+
### Global Options
|
|
291
|
+
|
|
292
|
+
These come before the subcommand and apply to all of them.
|
|
293
|
+
|
|
294
|
+
- **`-C, --project-dir <dir>`**: Run as if invoked from `<dir>`, addressing
|
|
295
|
+
that project's roadmap. Relative paths in other options resolve against it.
|
|
296
|
+
See [Working across projects](#working-across-projects).
|
|
297
|
+
- **`--tasks-file <path>`**: Point at a specific tasks file instead of the one
|
|
298
|
+
derived from the current directory.
|
|
299
|
+
- **`-v, --verbose`**: Show verbose output.
|
|
300
|
+
|
|
225
301
|
### Roadmap Management
|
|
226
302
|
|
|
227
303
|
- **`status [<id>]`**: Queue/history overview or deep-dive into a specific
|
|
@@ -295,6 +371,33 @@ See [docs/EVALS.md](docs/EVALS.md) for details.
|
|
|
295
371
|
- `--no-defaults`: Skip default flag injection for known runners.
|
|
296
372
|
- `--`: Use `--` to pass any flag directly to the underlying runner. A
|
|
297
373
|
per-task `--runner`/`--model` overrides anything passed here.
|
|
374
|
+
- **`exec [<description>]`**: Run one task, or one set of reviews, outside any
|
|
375
|
+
roadmap. Prints the agent's closing message to stdout and everything else to
|
|
376
|
+
stderr. Exits non-zero if the task did not complete. See
|
|
377
|
+
[One-off tasks](#one-off-tasks-without-a-roadmap).
|
|
378
|
+
- `-f/--file`: Read the description from a file, or `-` for stdin. Unlike
|
|
379
|
+
`add`, there is no length cap.
|
|
380
|
+
- `--review <names>`: Reviews to run after the task, comma-separated or
|
|
381
|
+
repeated; `all` selects every one. With no description, only the reviews
|
|
382
|
+
run. Hooks that revise the roadmap cannot be selected.
|
|
383
|
+
- `--scope <path|range>`: What the reviews look at. Paths pass through; a
|
|
384
|
+
git revision range is resolved to the files it changed. Defaults to
|
|
385
|
+
uncommitted work, or the whole tree outside a repository.
|
|
386
|
+
- `--runner`, `--model`: Which agent CLI and model to use.
|
|
387
|
+
- `--time-limit`: Minutes before the agent is killed (default 60, 0 for no
|
|
388
|
+
limit).
|
|
389
|
+
- `--yolo/--no-yolo`: Run the agent unattended (default: True).
|
|
390
|
+
- `--keep`: Keep the run's state directory even when it succeeds.
|
|
391
|
+
- **`skill install`**: Install the packaged Agent Skill so agents discover
|
|
392
|
+
Lemming. Writes to `~/.agents/skills` by default and names any tool-specific
|
|
393
|
+
directories it found.
|
|
394
|
+
- `--to <dir>`: Install into a specific skills directory.
|
|
395
|
+
- `--all`: Also cover every detected tool's own skills directory.
|
|
396
|
+
- `--link`: Symlink instead of copying, so upgrades take effect immediately.
|
|
397
|
+
- `--force`: Replace an existing installation of this skill.
|
|
398
|
+
- `--dry-run`: Print what would happen, refusals included.
|
|
399
|
+
- **`skill uninstall`**: Remove it again. Same `--to`, `--all`, and
|
|
400
|
+
`--dry-run`.
|
|
298
401
|
- **`stop`**: Stop the running loop and its runner.
|
|
299
402
|
- `--after-current-task`: Drain instead — let the running task finish, then
|
|
300
403
|
stop before claiming another. This is the safe way to change the runner
|
|
@@ -309,6 +412,36 @@ See [docs/EVALS.md](docs/EVALS.md) for details.
|
|
|
309
412
|
|
|
310
413
|
---
|
|
311
414
|
|
|
415
|
+
## Working across projects
|
|
416
|
+
|
|
417
|
+
When work on one project turns up something that belongs to another — a bug in
|
|
418
|
+
a dependency you also maintain, a doc fix in a sibling repo — file it directly
|
|
419
|
+
on that project's roadmap with `-C` instead of routing it through an external
|
|
420
|
+
issue tracker:
|
|
421
|
+
|
|
422
|
+
```bash
|
|
423
|
+
# From inside project A, queue work on project B
|
|
424
|
+
lemming -C ~/src/other-project add "check --fix drops the trailing newline"
|
|
425
|
+
|
|
426
|
+
# Attach the evidence; the brief has no length cap
|
|
427
|
+
lemming -C ~/src/other-project brief <id> --file repro.md
|
|
428
|
+
|
|
429
|
+
# Read the other project's roadmap without leaving yours
|
|
430
|
+
lemming -C ~/src/other-project status
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
`-C` works whether or not the target keeps a `tasks.yml` in its repo, so you
|
|
434
|
+
never have to know where its isolated state lives. Because it changes the
|
|
435
|
+
working directory, everything else follows too: the target's `.env`, its
|
|
436
|
+
`.lemming/hooks`, and the directory the runner executes in.
|
|
437
|
+
|
|
438
|
+
When an agent files a task this way from inside a `lemming run`, the new task
|
|
439
|
+
records the task it came from via `parent` and `parent_tasks_file`. The
|
|
440
|
+
downstream runner then sees a **Parent Task Context** section in its prompt
|
|
441
|
+
describing why the work was requested, so the report doesn't lose its origin.
|
|
442
|
+
|
|
443
|
+
---
|
|
444
|
+
|
|
312
445
|
## Advanced: Runner Customization
|
|
313
446
|
|
|
314
447
|
Lemming uses **fuzzy matching** to automatically inject the correct "YOLO"
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: lemming
|
|
3
|
+
description: Delegate a single coding task to another AI agent CLI (agy, claude, codex) through one interface, or run a code review — readability, testing, UX — over changed files, a PR branch, or a path. Use when handing work to a different agent to spare a quota or play to its strengths, or when reviewing a diff before opening a pull request. Not for driving a multi-task project; that is a roadmap.
|
|
4
|
+
license: MIT
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# lemming
|
|
8
|
+
|
|
9
|
+
One interface over several agent CLIs. You do not need to know how `agy`,
|
|
10
|
+
`claude`, or `codex` spell their flags — only `lemming exec`.
|
|
11
|
+
|
|
12
|
+
## Delegate one task
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
lemming exec "Fix the flaky heartbeat test in src/runner_test.py" --runner codex
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The agent's closing message comes back on **stdout**; progress and the event
|
|
19
|
+
trace go to stderr. Exit code is 0 only if the task completed. That message is
|
|
20
|
+
the return value — read it instead of hunting through logs.
|
|
21
|
+
|
|
22
|
+
Pipe a longer handoff rather than fighting shell quoting. There is no length
|
|
23
|
+
limit, unlike a roadmap task description:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
cat handoff.md | lemming exec -f - --runner agy
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Delegate when another agent's quota, price, or strengths suit the work better
|
|
30
|
+
than yours. Give the task everything it needs: it starts with an empty context
|
|
31
|
+
and sees nothing of your conversation.
|
|
32
|
+
|
|
33
|
+
## Run a review
|
|
34
|
+
|
|
35
|
+
With no description there is nothing for a task runner to do, so only the
|
|
36
|
+
reviews run — against work that already exists.
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
lemming exec --review readability # uncommitted work
|
|
40
|
+
lemming exec --review testing --scope main...HEAD
|
|
41
|
+
lemming exec --review all --scope src/api/
|
|
42
|
+
lemming exec "Add pagination" --review all # do the work, then gate it
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`--scope` takes paths, which pass through untouched, or a git revision range,
|
|
46
|
+
which is resolved to the files it changed. It defaults to uncommitted work.
|
|
47
|
+
A clean tree stops the run rather than reviewing everything.
|
|
48
|
+
|
|
49
|
+
Reviews **edit the workspace**: readability applies fixes and reruns checks.
|
|
50
|
+
That is the point — but it means a review is not read-only.
|
|
51
|
+
|
|
52
|
+
## Reviewing someone else's branch
|
|
53
|
+
|
|
54
|
+
Check it out in a worktree so your own tree is untouched, and point `-C` at it:
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
git worktree add /tmp/pr-123 && (cd /tmp/pr-123 && gh pr checkout 123)
|
|
58
|
+
lemming -C /tmp/pr-123 exec --review testing --scope main...HEAD
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## What to know before running it
|
|
62
|
+
|
|
63
|
+
- **The agent runs unattended.** `--yolo` is the default, so it does not
|
|
64
|
+
inherit the permission prompts of whatever launched it. Treat `lemming exec`
|
|
65
|
+
as granting an agent unsupervised write access to the working directory.
|
|
66
|
+
- **One agent run, no retry.** A failure is final; it does not silently try
|
|
67
|
+
again. `--time-limit` caps the wall clock (default 60 minutes).
|
|
68
|
+
- **Interrupting leaves partial edits.** The workspace is not restored, so
|
|
69
|
+
there is no atomicity to rely on.
|
|
70
|
+
- **Failures keep their log.** The state directory path is printed on stderr;
|
|
71
|
+
read it with `lemming logs` pointed at that tasks file.
|
|
72
|
+
|
|
73
|
+
## Running several at once
|
|
74
|
+
|
|
75
|
+
Concurrent runs in one checkout will interleave their edits. Give each write
|
|
76
|
+
task its own worktree and address it with `-C`. Read-only work parallelizes
|
|
77
|
+
safely as-is.
|
|
78
|
+
|
|
79
|
+
## When not to use it
|
|
80
|
+
|
|
81
|
+
`exec` is for one unit of work. Anything needing more context than a single
|
|
82
|
+
agent run can hold — a migration across a large codebase, a multi-step feature
|
|
83
|
+
— belongs on a roadmap: `lemming add`, then `lemming run`. In particular,
|
|
84
|
+
`--scope .` over a large repository *samples*; it does not cover.
|
|
85
|
+
|
|
86
|
+
Run `lemming exec --help` for the full flag list.
|
|
@@ -158,6 +158,9 @@ Your hook template can use the following placeholders:
|
|
|
158
158
|
description, recent progress, and a recent execution-log excerpt capped at
|
|
159
159
|
16 KiB).
|
|
160
160
|
- `{{finished_task_id}}`: The ID of the task that just finished.
|
|
161
|
+
- `{{scope}}`: What the hook should look at, and how broadly. Under `lemming
|
|
162
|
+
run` this names the work the finished task left behind; under `lemming exec
|
|
163
|
+
--review` it is whatever `--scope` resolved to.
|
|
161
164
|
- `{{tasks_file_name}}`: The filename of the tasks YAML file.
|
|
162
165
|
- `{{tasks_file_path}}`: The full path to the tasks YAML file.
|
|
163
166
|
|
|
@@ -166,6 +169,12 @@ over older completed history. Readability, testing, and UX hooks receive a
|
|
|
166
169
|
smaller roadmap summary because their primary context is the finished task and
|
|
167
170
|
the workspace.
|
|
168
171
|
|
|
172
|
+
A review hook should take its breadth from `{{scope}}` rather than narrowing
|
|
173
|
+
itself in prose. Hardcoding "the files changed in the last task" makes the hook
|
|
174
|
+
unusable outside the orchestrator loop, where there is no last task. Depth
|
|
175
|
+
limits are different and belong in the prompt: "verification, not a security
|
|
176
|
+
audit" describes the kind of review, not its extent.
|
|
177
|
+
|
|
169
178
|
## Developer Ergonomics
|
|
170
179
|
|
|
171
180
|
### Listing Hooks
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "lemming-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "An autonomous, iterative task runner for AI coding agents."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [
|
|
@@ -20,7 +20,7 @@ dependencies = [
|
|
|
20
20
|
"markdownify>=0.14.1",
|
|
21
21
|
"pydantic>=2.12.5",
|
|
22
22
|
"pyyaml>=6.0.3",
|
|
23
|
-
"readability-cli>=0.
|
|
23
|
+
"readability-cli>=0.7.0",
|
|
24
24
|
"requests>=2.32.3",
|
|
25
25
|
"uvicorn>=0.33.0",
|
|
26
26
|
]
|
|
@@ -50,6 +50,11 @@ allow-direct-references = true
|
|
|
50
50
|
[tool.hatch.build.targets.wheel]
|
|
51
51
|
packages = ["src/lemming"]
|
|
52
52
|
|
|
53
|
+
# SKILL.md is authored at the repository root, where it stays visible, and
|
|
54
|
+
# mapped into the package so `lemming skill install` can find it in a wheel.
|
|
55
|
+
[tool.hatch.build.targets.wheel.force-include]
|
|
56
|
+
"SKILL.md" = "lemming/skills/lemming/SKILL.md"
|
|
57
|
+
|
|
53
58
|
[tool.ruff]
|
|
54
59
|
line-length = 80
|
|
55
60
|
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Share-token authentication middleware for remotely shared servers."""
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
|
|
5
|
+
import fastapi
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _token_matches(candidate: str | None, share_token: str) -> bool:
|
|
9
|
+
"""Compares a client-supplied token against the share token.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
candidate: Token from the request, or None when absent.
|
|
13
|
+
share_token: The token this server requires.
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
True when the candidate matches.
|
|
17
|
+
"""
|
|
18
|
+
if candidate is None:
|
|
19
|
+
return False
|
|
20
|
+
|
|
21
|
+
# Encode first: compare_digest rejects non-ASCII str outright, which a
|
|
22
|
+
# client could otherwise turn into a 500 by sending one.
|
|
23
|
+
return secrets.compare_digest(candidate.encode(), share_token.encode())
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def share_token_middleware(request: fastapi.Request, call_next):
|
|
27
|
+
"""Require a share token for every request when one is configured.
|
|
28
|
+
|
|
29
|
+
Accepts the token via the ``token`` query parameter (persisting it in a
|
|
30
|
+
cookie) or via the ``lemming_share_token`` cookie. Returns 401 when the
|
|
31
|
+
token is missing or invalid.
|
|
32
|
+
|
|
33
|
+
There is deliberately no local-request exemption. A token is only set in
|
|
34
|
+
tunnel mode, so this is already inert locally, and both a client-supplied
|
|
35
|
+
``Host`` header and the peer address are forgeable or misleading behind a
|
|
36
|
+
tunnel (the tunnel daemon reaches the origin over loopback, so every
|
|
37
|
+
public request also appears to come from 127.0.0.1). Requiring the token
|
|
38
|
+
unconditionally keeps correctness independent of how each tunnel provider
|
|
39
|
+
handles those.
|
|
40
|
+
"""
|
|
41
|
+
share_token = getattr(request.app.state, "share_token", None)
|
|
42
|
+
if not share_token:
|
|
43
|
+
return await call_next(request)
|
|
44
|
+
|
|
45
|
+
token = request.query_params.get("token")
|
|
46
|
+
if _token_matches(token, share_token):
|
|
47
|
+
response = await call_next(request)
|
|
48
|
+
response.set_cookie(
|
|
49
|
+
key="lemming_share_token", value=token, httponly=True
|
|
50
|
+
)
|
|
51
|
+
return response
|
|
52
|
+
|
|
53
|
+
cookie_token = request.cookies.get("lemming_share_token")
|
|
54
|
+
if _token_matches(cookie_token, share_token):
|
|
55
|
+
return await call_next(request)
|
|
56
|
+
|
|
57
|
+
return fastapi.Response("Unauthorized", status_code=401)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import fastapi.testclient
|
|
2
|
+
|
|
3
|
+
from lemming import api
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_share_token_middleware():
|
|
7
|
+
# Setup test client
|
|
8
|
+
original_token = getattr(api.app.state, "share_token", None)
|
|
9
|
+
try:
|
|
10
|
+
api.app.state.share_token = "secret123"
|
|
11
|
+
# We need a fresh client for each test that modifies app state
|
|
12
|
+
# middleware if it uses the app state, but here TestClient is
|
|
13
|
+
# created with api.app
|
|
14
|
+
client = fastapi.testclient.TestClient(api.app)
|
|
15
|
+
|
|
16
|
+
# Missing token -> 401
|
|
17
|
+
response = client.get("/api/data")
|
|
18
|
+
assert response.status_code == 401
|
|
19
|
+
|
|
20
|
+
# Valid token via query
|
|
21
|
+
response = client.get("/api/data?token=secret123")
|
|
22
|
+
assert response.status_code == 200
|
|
23
|
+
assert "lemming_share_token=secret123" in response.headers.get(
|
|
24
|
+
"set-cookie", ""
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Valid token via cookie
|
|
28
|
+
client.cookies.set("lemming_share_token", "secret123")
|
|
29
|
+
response = client.get("/api/data")
|
|
30
|
+
assert response.status_code == 200
|
|
31
|
+
|
|
32
|
+
# A client-supplied local host header must not bypass the token
|
|
33
|
+
client.cookies.clear()
|
|
34
|
+
for host in ("127.0.0.1:8999", "localhost:8999", "localhost"):
|
|
35
|
+
response = client.get("/api/data", headers={"host": host})
|
|
36
|
+
assert response.status_code == 401, host
|
|
37
|
+
finally:
|
|
38
|
+
# Restore
|
|
39
|
+
api.app.state.share_token = original_token
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_share_token_middleware_rejects_exotic_tokens():
|
|
43
|
+
"""Odd tokens must be rejected, not crash the comparison."""
|
|
44
|
+
original_token = getattr(api.app.state, "share_token", None)
|
|
45
|
+
try:
|
|
46
|
+
api.app.state.share_token = "secret123"
|
|
47
|
+
client = fastapi.testclient.TestClient(api.app)
|
|
48
|
+
|
|
49
|
+
# Non-ASCII and differing lengths must stay a clean 401. A digest
|
|
50
|
+
# comparison rejects both only if the values are encoded first.
|
|
51
|
+
for token in ("sécret123", "secret1234567890", "s", ""):
|
|
52
|
+
response = client.get("/api/data", params={"token": token})
|
|
53
|
+
assert response.status_code == 401, token
|
|
54
|
+
finally:
|
|
55
|
+
# Restore
|
|
56
|
+
api.app.state.share_token = original_token
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_share_token_middleware_inert_without_token():
|
|
60
|
+
"""Local (non-tunnel) mode sets no share token, so nothing is required."""
|
|
61
|
+
original_token = getattr(api.app.state, "share_token", None)
|
|
62
|
+
try:
|
|
63
|
+
api.app.state.share_token = None
|
|
64
|
+
client = fastapi.testclient.TestClient(api.app)
|
|
65
|
+
|
|
66
|
+
response = client.get("/api/data")
|
|
67
|
+
assert response.status_code == 200
|
|
68
|
+
assert "lemming_share_token" not in response.headers.get(
|
|
69
|
+
"set-cookie", ""
|
|
70
|
+
)
|
|
71
|
+
finally:
|
|
72
|
+
# Restore
|
|
73
|
+
api.app.state.share_token = original_token
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
"""FastAPI application setup: middleware, routers, and static files."""
|
|
2
2
|
|
|
3
3
|
import importlib.resources
|
|
4
|
+
import logging
|
|
4
5
|
import pathlib
|
|
5
6
|
|
|
6
7
|
import fastapi
|
|
7
8
|
import fastapi.responses
|
|
8
9
|
import fastapi.staticfiles
|
|
9
10
|
|
|
10
|
-
from .. import paths
|
|
11
|
+
from .. import paths, persistence
|
|
11
12
|
from . import auth, config, directories, files, hooks, tasks
|
|
12
13
|
from . import logging as lemming_logging
|
|
13
14
|
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
14
17
|
# Re-exported so logging configs can reference lemming.api.QuietPollFilter
|
|
15
18
|
QuietPollFilter = lemming_logging.QuietPollFilter
|
|
16
19
|
|
|
@@ -40,6 +43,22 @@ app.include_router(directories.router)
|
|
|
40
43
|
app.include_router(hooks.router)
|
|
41
44
|
app.include_router(config.router)
|
|
42
45
|
|
|
46
|
+
|
|
47
|
+
@app.exception_handler(persistence.CorruptedTasksError)
|
|
48
|
+
def handle_corrupted_tasks(
|
|
49
|
+
request: fastapi.Request, exc: Exception
|
|
50
|
+
) -> fastapi.responses.JSONResponse:
|
|
51
|
+
"""Reports an unreadable tasks file in the shape the UI already expects.
|
|
52
|
+
|
|
53
|
+
Without this every route touching the roadmap would answer a corrupt file
|
|
54
|
+
with a raw stack trace instead of an actionable message.
|
|
55
|
+
"""
|
|
56
|
+
logger.error("%s could not read the tasks file: %s", request.url.path, exc)
|
|
57
|
+
return fastapi.responses.JSONResponse(
|
|
58
|
+
status_code=500, content={"detail": str(exc)}
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
43
62
|
# Static files and root routes
|
|
44
63
|
web_dir = pathlib.Path(
|
|
45
64
|
str(importlib.resources.files("lemming").joinpath("web"))
|