lcode-cli 0.14.0__tar.gz → 0.15.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.
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/CHANGELOG.md +23 -1
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/PKG-INFO +5 -2
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/README.md +4 -1
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/docs/configuration.md +4 -0
- lcode_cli-0.15.0/docs/hooks.md +134 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/docs/how-it-works.md +1 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/docs/quickstart.md +1 -1
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/docs/usage.md +2 -1
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/mkdocs.yml +1 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/__init__.py +1 -1
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/agent.py +11 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/cli.py +17 -1
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/config.py +6 -1
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/extensions.py +18 -3
- lcode_cli-0.15.0/src/lcode/hooks.py +241 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/permissions.py +19 -1
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/repl.py +17 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/subagents.py +15 -4
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/tools.py +46 -6
- lcode_cli-0.15.0/tests/test_hooks.py +203 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/.editorconfig +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/.github/CODEOWNERS +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/.github/dependabot.yml +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/.github/workflows/ci.yml +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/.github/workflows/docs.yml +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/.github/workflows/release.yml +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/.gitignore +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/.pre-commit-config.yaml +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/AGENTS.md +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/CODE_OF_CONDUCT.md +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/CONTRIBUTING.md +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/LICENSE +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/SECURITY.md +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/docs/agents.md +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/docs/assets/demo.svg +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/docs/assets/extra.css +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/docs/assets/logo.svg +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/docs/assets/social-preview.png +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/docs/bench.md +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/docs/changelog.md +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/docs/commands.md +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/docs/contributing.md +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/docs/index.md +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/docs/installation.md +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/docs/lsp.md +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/docs/mcp.md +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/docs/memory.md +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/docs/models.md +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/docs/sandbox.md +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/docs/search.md +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/docs/troubleshooting.md +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/examples/README.md +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/examples/commands/review.md +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/examples/skills/commit-message/SKILL.md +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/examples/skills/commit-message/scripts/staged.sh +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/install.sh +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/overrides/main.html +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/pyproject.toml +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/scripts/record_demo.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/scripts/social-preview.html +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/__main__.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/bench.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/bench_backend.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/catalog.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/checkpoints.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/codesearch.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/context.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/frontmatter.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/hardware.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/limits.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/lsp.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/mcp/__init__.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/mcp/auth.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/mcp/catalog.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/mcp/catalog.toml +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/mcp/client.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/mcp/commands.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/mcp/config.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/mcp/http.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/mcp/manager.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/mcp/protocol.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/mcp/server.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/mcp/stdio.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/memory.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/models.toml +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/ollama.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/planning.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/render.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/repomap.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/sandbox.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/servers/__init__.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/servers/encord.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/servers/valohai.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/sessions.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/vision.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/src/lcode/web.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/tests/conftest.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/tests/fake_lsp_server.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/tests/fake_mcp_server.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/tests/test_agent.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/tests/test_bench.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/tests/test_catalog.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/tests/test_checkpoints.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/tests/test_cli.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/tests/test_codesearch.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/tests/test_config.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/tests/test_context.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/tests/test_context_picker.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/tests/test_extensions.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/tests/test_lsp.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/tests/test_mcp.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/tests/test_memory.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/tests/test_permissions.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/tests/test_plan_mode.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/tests/test_repomap.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/tests/test_sandbox.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/tests/test_servers.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/tests/test_sessions.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/tests/test_subagents.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/tests/test_tools.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/tests/test_vision.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/tests/test_web.py +0 -0
- {lcode_cli-0.14.0 → lcode_cli-0.15.0}/uv.lock +0 -0
|
@@ -6,6 +6,27 @@ All notable changes to lcode are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.15.0] - 2026-10-05
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Hooks: shell commands that run on lcode's events (`before_tool`, `after_tool`, `after_request`,
|
|
14
|
+
`session_start`, `notification`), filtered by tool and file, with the event as JSON on stdin. A
|
|
15
|
+
`before_tool` hook that exits with code 2 blocks the call and tells the model why; an
|
|
16
|
+
`after_tool` hook's output reaches the model when it fails (a linter) or with `feedback = true`.
|
|
17
|
+
Notification hooks run when lcode waits for you and when a long request is done.
|
|
18
|
+
- Permission rules: `[permissions]` allow and deny lists for shell commands, file edits, web
|
|
19
|
+
domains and MCP tools, checked before lcode asks. Deny always wins, even in `yolo` mode and for
|
|
20
|
+
read-only commands, and is enforced for subagents too.
|
|
21
|
+
- Both go in `config.toml` or a repository's `.lcode/settings.toml`, which is used only once
|
|
22
|
+
approved (and approved again after any change). `lcode doctor` lists them. See
|
|
23
|
+
[Hooks and permission rules](https://nasser1941.github.io/lcode/hooks/), with recipes for ruff,
|
|
24
|
+
prettier, blocking force-pushes, running tests and desktop notifications.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- `lcode config set` no longer drops tables you added to `config.toml` by hand.
|
|
29
|
+
|
|
9
30
|
## [0.14.0] - 2026-10-05
|
|
10
31
|
|
|
11
32
|
### Added
|
|
@@ -364,7 +385,8 @@ First public release.
|
|
|
364
385
|
- `AGENTS.md` project instructions and `/init` to generate them.
|
|
365
386
|
- One-line installer for Linux and macOS.
|
|
366
387
|
|
|
367
|
-
[Unreleased]: https://github.com/nasser1941/lcode/compare/v0.
|
|
388
|
+
[Unreleased]: https://github.com/nasser1941/lcode/compare/v0.15.0...HEAD
|
|
389
|
+
[0.15.0]: https://github.com/nasser1941/lcode/compare/v0.14.0...v0.15.0
|
|
368
390
|
[0.14.0]: https://github.com/nasser1941/lcode/compare/v0.13.0...v0.14.0
|
|
369
391
|
[0.13.0]: https://github.com/nasser1941/lcode/compare/v0.12.0...v0.13.0
|
|
370
392
|
[0.12.0]: https://github.com/nasser1941/lcode/compare/v0.11.1...v0.12.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lcode-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.15.0
|
|
4
4
|
Summary: A local-first terminal coding agent powered by open-weight models on your own GPU or Mac, via Ollama.
|
|
5
5
|
Project-URL: Homepage, https://nasser1941.github.io/lcode/
|
|
6
6
|
Project-URL: Documentation, https://nasser1941.github.io/lcode/
|
|
@@ -89,6 +89,8 @@ through [Ollama](https://ollama.com), and when it needs current information it c
|
|
|
89
89
|
and sees the errors an edit introduced right away.
|
|
90
90
|
- **Commands and skills.** Your own `/commands` as prompt templates, and skills in the
|
|
91
91
|
[Agent Skills](https://agentskills.io) format that the model loads only when a task needs them.
|
|
92
|
+
- **Hooks and rules.** Format every edited file, block force-pushes, run the tests after each
|
|
93
|
+
request: shell hooks on lcode's events, and allow and deny lists that hold even in `yolo` mode.
|
|
92
94
|
- **Plan mode.** `/plan <request>` or Shift+Tab: the model explores read-only and presents a plan;
|
|
93
95
|
you approve, edit or send it back before anything changes.
|
|
94
96
|
- **Undo.** lcode saves a checkpoint before the model changes files; `/undo` takes back the last
|
|
@@ -206,7 +208,8 @@ fills up. Details: [How it works](https://nasser1941.github.io/lcode/how-it-work
|
|
|
206
208
|
|
|
207
209
|
See the pinned [Roadmap issue](https://github.com/nasser1941/lcode/issues/10). Next up:
|
|
208
210
|
|
|
209
|
-
- [
|
|
211
|
+
- [More local backends: LM Studio, llama.cpp, vLLM and MLX](https://github.com/nasser1941/lcode/issues/47)
|
|
212
|
+
- [Git workflow: /commit, /review, /pr and parallel worktrees](https://github.com/nasser1941/lcode/issues/50)
|
|
210
213
|
- More models in the catalog, and test reports from your hardware ([report one](https://github.com/nasser1941/lcode/issues/new?template=model_request.yml))
|
|
211
214
|
|
|
212
215
|
## Contributing
|
|
@@ -53,6 +53,8 @@ through [Ollama](https://ollama.com), and when it needs current information it c
|
|
|
53
53
|
and sees the errors an edit introduced right away.
|
|
54
54
|
- **Commands and skills.** Your own `/commands` as prompt templates, and skills in the
|
|
55
55
|
[Agent Skills](https://agentskills.io) format that the model loads only when a task needs them.
|
|
56
|
+
- **Hooks and rules.** Format every edited file, block force-pushes, run the tests after each
|
|
57
|
+
request: shell hooks on lcode's events, and allow and deny lists that hold even in `yolo` mode.
|
|
56
58
|
- **Plan mode.** `/plan <request>` or Shift+Tab: the model explores read-only and presents a plan;
|
|
57
59
|
you approve, edit or send it back before anything changes.
|
|
58
60
|
- **Undo.** lcode saves a checkpoint before the model changes files; `/undo` takes back the last
|
|
@@ -170,7 +172,8 @@ fills up. Details: [How it works](https://nasser1941.github.io/lcode/how-it-work
|
|
|
170
172
|
|
|
171
173
|
See the pinned [Roadmap issue](https://github.com/nasser1941/lcode/issues/10). Next up:
|
|
172
174
|
|
|
173
|
-
- [
|
|
175
|
+
- [More local backends: LM Studio, llama.cpp, vLLM and MLX](https://github.com/nasser1941/lcode/issues/47)
|
|
176
|
+
- [Git workflow: /commit, /review, /pr and parallel worktrees](https://github.com/nasser1941/lcode/issues/50)
|
|
174
177
|
- More models in the catalog, and test reports from your hardware ([report one](https://github.com/nasser1941/lcode/issues/new?template=model_request.yml))
|
|
175
178
|
|
|
176
179
|
## Contributing
|
|
@@ -40,6 +40,10 @@ lcode config path # print the file location
|
|
|
40
40
|
| `max_parallel_agents` | `1` | Subagents that run at the same time; more needs `OLLAMA_NUM_PARALLEL` on the Ollama server ([details](agents.md#several-at-once)) |
|
|
41
41
|
| `checkpoints` | `true` | Save a checkpoint before the model changes files, so [`/undo`](usage.md#undo-and-checkpoints) can restore them |
|
|
42
42
|
|
|
43
|
+
Besides these settings, `config.toml` can hold [hooks](hooks.md) (`[[hooks]]`) and
|
|
44
|
+
[permission rules](hooks.md#permission-rules) (`[permissions]`); a repository can have its own in
|
|
45
|
+
`.lcode/settings.toml`. `lcode config set` keeps them when it rewrites the file.
|
|
46
|
+
|
|
43
47
|
Example file:
|
|
44
48
|
|
|
45
49
|
```toml
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# Hooks and permission rules
|
|
2
|
+
|
|
3
|
+
Some things should happen whatever the model does: format every edited file, never force-push,
|
|
4
|
+
run the tests when a request is done. Hooks and permission rules do that without relying on the
|
|
5
|
+
model to follow instructions.
|
|
6
|
+
|
|
7
|
+
They go in `~/.config/lcode/config.toml` (yours, for every repository) or in a repository's
|
|
8
|
+
`.lcode/settings.toml` (shared with everyone who works on it). A repository's settings are used only
|
|
9
|
+
after you [approve them](commands.md#approving-a-repositorys-commands-skills-and-agents), because
|
|
10
|
+
hooks run commands; lcode asks again whenever the file changes.
|
|
11
|
+
|
|
12
|
+
## Permission rules
|
|
13
|
+
|
|
14
|
+
Rules are checked before lcode asks for permission. An **allow** rule lets an action through
|
|
15
|
+
without asking; a **deny** rule blocks it, even in `yolo` mode and even for commands that are
|
|
16
|
+
otherwise read-only. Deny always wins.
|
|
17
|
+
|
|
18
|
+
```toml
|
|
19
|
+
[permissions]
|
|
20
|
+
allow = ["bash:pytest *", "bash:npm test", "bash:ruff *", "edit:src/**", "web:docs.python.org"]
|
|
21
|
+
deny = ["bash:git push --force*", "bash:git push -f*", "bash:rm -rf /*", "edit:.env", "edit:**/*.pem"]
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
| Rule | Matches |
|
|
25
|
+
|---|---|
|
|
26
|
+
| `bash:<pattern>` | Shell commands, with `*` and `?` wildcards. A leading `cd <folder> &&` is ignored. A deny rule matches any part of a chained command (`git add . && git push --force`); an allow rule needs the whole command, or every part of it, to be allowed |
|
|
27
|
+
| `edit:<pattern>` | Files the model writes or edits, relative to the repository's root. A pattern without a `/` matches the file name anywhere (`.env`, `*.pem`); `src/**` matches everything under `src` |
|
|
28
|
+
| `web:<domain>` | Pages fetched from a domain; `web:search` for searches (only asked about with `web = "ask"`) |
|
|
29
|
+
| `mcp:<server>:<tool>` | An [MCP](mcp.md) server's tools, e.g. `mcp:github:list_*` |
|
|
30
|
+
|
|
31
|
+
When a rule blocks something, the model is told which rule, and not to work around it.
|
|
32
|
+
|
|
33
|
+
## Hooks
|
|
34
|
+
|
|
35
|
+
```toml
|
|
36
|
+
[[hooks]]
|
|
37
|
+
event = "after_tool"
|
|
38
|
+
tools = ["edit_file", "write_file"]
|
|
39
|
+
paths = ["*.py"]
|
|
40
|
+
command = "ruff format {path} && ruff check --fix {path}"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
| Event | Runs | What it can do |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| `before_tool` | Before a tool call | Exit with code **2** to block the call; what the hook printed tells the model why |
|
|
46
|
+
| `after_tool` | After a tool call | Its output goes to the model when it exits with an error, or always with `feedback = true` |
|
|
47
|
+
| `after_request` | When a request is done | Its output is shown to you |
|
|
48
|
+
| `session_start` | When a session starts | Its output is shown, and with `feedback = true` given to the model |
|
|
49
|
+
| `notification` | When lcode waits for your answer, and when a request that took over 30 seconds is done | Show a desktop notification, ring a bell |
|
|
50
|
+
|
|
51
|
+
| Key | |
|
|
52
|
+
|---|---|
|
|
53
|
+
| `event` | One of the events above (required) |
|
|
54
|
+
| `command` | Run with `bash -c` in the working directory (required). `{path}` and `{tool}` are replaced, quoted |
|
|
55
|
+
| `tools` | Only for these tools (wildcards allowed), e.g. `["edit_file", "write_file"]` or `["mcp__*"]` |
|
|
56
|
+
| `paths` | Only for these files, like the `edit:` rules: `["*.py"]`, `["src/**"]` |
|
|
57
|
+
| `timeout` | Seconds before the hook is stopped (default 60) |
|
|
58
|
+
| `feedback` | `true` to give the hook's output to the model even when it succeeds |
|
|
59
|
+
|
|
60
|
+
Each hook gets the event as JSON on its standard input (the tool, its arguments, the file's full
|
|
61
|
+
path, the result for `after_tool`, the working directory), and in the environment variables
|
|
62
|
+
`LCODE_EVENT`, `LCODE_TOOL` and `LCODE_PATH`. Hooks run on your machine, also with the
|
|
63
|
+
[sandbox](sandbox.md) on.
|
|
64
|
+
|
|
65
|
+
## Recipes
|
|
66
|
+
|
|
67
|
+
**Format and lint Python files after every edit** (lint problems go back to the model):
|
|
68
|
+
|
|
69
|
+
```toml
|
|
70
|
+
[[hooks]]
|
|
71
|
+
event = "after_tool"
|
|
72
|
+
tools = ["edit_file", "write_file"]
|
|
73
|
+
paths = ["*.py"]
|
|
74
|
+
command = "ruff format --quiet {path} && ruff check --fix --quiet {path}"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**Prettier for JavaScript and TypeScript:**
|
|
78
|
+
|
|
79
|
+
```toml
|
|
80
|
+
[[hooks]]
|
|
81
|
+
event = "after_tool"
|
|
82
|
+
tools = ["edit_file", "write_file"]
|
|
83
|
+
paths = ["*.ts", "*.tsx", "*.js", "*.jsx", "*.css", "*.json"]
|
|
84
|
+
command = "npx --no-install prettier --write {path} > /dev/null"
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**Never force-push.** A rule is enough:
|
|
88
|
+
|
|
89
|
+
```toml
|
|
90
|
+
[permissions]
|
|
91
|
+
deny = ["bash:git push --force*", "bash:git push -f*", "bash:git push * --force*"]
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
or, with a hook that explains the house rule to the model:
|
|
95
|
+
|
|
96
|
+
```toml
|
|
97
|
+
[[hooks]]
|
|
98
|
+
event = "before_tool"
|
|
99
|
+
tools = ["bash"]
|
|
100
|
+
command = '''
|
|
101
|
+
if jq -r .arguments.command | grep -Eq 'git push.*(--force|-f)'; then
|
|
102
|
+
echo "Force-pushing isn't allowed here: push to a new branch and open a pull request instead."
|
|
103
|
+
exit 2
|
|
104
|
+
fi
|
|
105
|
+
'''
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
**Run the tests when a request is done:**
|
|
109
|
+
|
|
110
|
+
```toml
|
|
111
|
+
[[hooks]]
|
|
112
|
+
event = "after_request"
|
|
113
|
+
command = "pytest -q -x 2>&1 | tail -3"
|
|
114
|
+
timeout = 300
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**A desktop notification** when lcode needs you or finishes a long request:
|
|
118
|
+
|
|
119
|
+
```toml
|
|
120
|
+
[[hooks]]
|
|
121
|
+
event = "notification"
|
|
122
|
+
command = "notify-send lcode \"$(jq -r .message)\"" # macOS: osascript -e "display notification \"…\""
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
**Tell the model what branch it's on** at the start of every session:
|
|
126
|
+
|
|
127
|
+
```toml
|
|
128
|
+
[[hooks]]
|
|
129
|
+
event = "session_start"
|
|
130
|
+
command = "git status --short --branch | head -20"
|
|
131
|
+
feedback = true
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`lcode doctor` lists the hooks and rules it found, and any it couldn't read.
|
|
@@ -106,6 +106,7 @@ the Ollama server you configure, it only contacts the web when the model searche
|
|
|
106
106
|
| `bench.py` | `lcode bench`: the benchmark tasks, their checks and the reports |
|
|
107
107
|
| `vision.py` | Looking at images with a model that can see |
|
|
108
108
|
| `subagents.py` | Subagents: agent types, running them (also in parallel), worktrees for workers |
|
|
109
|
+
| `hooks.py` | Hooks on lcode's events, and allow and deny rules for permissions |
|
|
109
110
|
| `repomap.py`, `codesearch.py` | The repository map, and semantic code search with an embedding model |
|
|
110
111
|
| `lsp.py` | Language servers: the `lsp` tool and errors after edits |
|
|
111
112
|
| `extensions.py` | Custom commands and skills, and approving a repository's own |
|
|
@@ -147,7 +147,8 @@ Read-only commands run without asking: `ls`, `cat`, `head`, `tail`, `grep`, `rg`
|
|
|
147
147
|
output, chain commands or run subshells. A `cd <folder> &&` in front is fine.
|
|
148
148
|
|
|
149
149
|
When asked, answer ++y++ (once), ++a++ (always, for this command or for all edits, until you quit)
|
|
150
|
-
or ++n++.
|
|
150
|
+
or ++n++. For lasting answers, write [permission rules](hooks.md#permission-rules): allow lists and
|
|
151
|
+
deny lists, per user or per repository; deny always wins, even in `yolo`. Text after ++n++ goes to the model as instructions: `n run the tests with -x first`.
|
|
151
152
|
|
|
152
153
|
!!! warning "yolo mode"
|
|
153
154
|
|
|
@@ -216,6 +216,9 @@ class Agent:
|
|
|
216
216
|
self.checkpoints = Checkpoints(self.console, settings.checkpoints)
|
|
217
217
|
self.mcp: McpManager | None = None # set by the CLI when MCP servers are configured
|
|
218
218
|
self.lsp = None # an lcode.lsp.Manager, set by the CLI when language servers are installed
|
|
219
|
+
from lcode.hooks import HookSet
|
|
220
|
+
|
|
221
|
+
self.hooks = HookSet() # set by the CLI from the settings (lcode.hooks)
|
|
219
222
|
self._repo_map: tuple[Path, repomap.RepoMap, str] | None = None
|
|
220
223
|
self._code_index: tuple[Path, codesearch.Index | None] | None = None
|
|
221
224
|
self.sandbox = (
|
|
@@ -722,6 +725,14 @@ class Agent:
|
|
|
722
725
|
self._run_turn(user_text)
|
|
723
726
|
finally:
|
|
724
727
|
self.checkpoints.end_turn()
|
|
728
|
+
if self.hooks.hooks and self.allowed_tools is None:
|
|
729
|
+
payload = {"request": user_text[:2000], "cwd": str(self.cwd), "session": self.session_id}
|
|
730
|
+
for outcome in self.hooks.run("after_request", payload, self.cwd):
|
|
731
|
+
style = "dim" if outcome.code == 0 else "yellow"
|
|
732
|
+
if outcome.output or outcome.code:
|
|
733
|
+
self.console.print(
|
|
734
|
+
Text(f" ⎿ after_request hook ({outcome.code}): {outcome.output[:500]}", style=style)
|
|
735
|
+
)
|
|
725
736
|
|
|
726
737
|
def sandbox_root(self) -> Path | None:
|
|
727
738
|
"""The folder the model is limited to while the sandbox is on (None when it's off)."""
|
|
@@ -368,7 +368,16 @@ def cmd_doctor(args) -> None:
|
|
|
368
368
|
line("", f"{n} × {slots[1]} context may not fit in memory here; {slots[0]} would", None)
|
|
369
369
|
for problem in problems:
|
|
370
370
|
line("", problem, None)
|
|
371
|
-
from lcode import codesearch, extensions, lsp
|
|
371
|
+
from lcode import codesearch, extensions, hooks, lsp
|
|
372
|
+
|
|
373
|
+
hook_set, rules = hooks.load(Path.cwd(), extensions.status(Path.cwd()) == "approved")
|
|
374
|
+
if hook_set.hooks or rules or hook_set.problems:
|
|
375
|
+
events = sorted({h.event for h in hook_set.hooks})
|
|
376
|
+
summary = f"{len(hook_set.hooks)} hook(s) ({', '.join(events)})" if hook_set.hooks else "no hooks"
|
|
377
|
+
summary += f", {len(rules.allow)} allow and {len(rules.deny)} deny rule(s)"
|
|
378
|
+
line("Hooks", summary, not hook_set.problems or None)
|
|
379
|
+
for problem in hook_set.problems:
|
|
380
|
+
line("", problem, None)
|
|
372
381
|
from lcode.checkpoints import work_tree_for
|
|
373
382
|
|
|
374
383
|
if cfg["embed_model"] == "off":
|
|
@@ -602,6 +611,13 @@ def cmd_chat(args) -> None:
|
|
|
602
611
|
)
|
|
603
612
|
agent = Agent(ollama, settings, cwd, console=console)
|
|
604
613
|
agent.interactive = not args.prompt
|
|
614
|
+
from lcode import hooks
|
|
615
|
+
|
|
616
|
+
agent.hooks, agent.perms.rules = hooks.load(cwd, trust_project)
|
|
617
|
+
for problem in agent.hooks.problems:
|
|
618
|
+
console.print(f"[yellow]Settings: {problem}[/]")
|
|
619
|
+
if agent.hooks.for_event("notification"):
|
|
620
|
+
agent.perms.on_prompt = lambda title: agent.hooks.notify(f"lcode needs you: {title}", agent.cwd)
|
|
605
621
|
if cfg["lsp"] == "auto":
|
|
606
622
|
from lcode import lsp
|
|
607
623
|
from lcode.checkpoints import work_tree_for
|
|
@@ -187,5 +187,10 @@ def save(updates: dict, path: Path | None = None, remove: tuple[str, ...] = ())
|
|
|
187
187
|
if data.get(k) is not None:
|
|
188
188
|
v = data[k]
|
|
189
189
|
lines.append(f"{k} = {str(v).lower() if isinstance(v, bool) else json.dumps(v)}")
|
|
190
|
+
tables = "" # [[hooks]], [permissions] and anything else after the settings stay as they were
|
|
191
|
+
if path.is_file():
|
|
192
|
+
text = path.read_text()
|
|
193
|
+
m = re.search(r"^\s*\[", text, re.M)
|
|
194
|
+
tables = text[m.start() :] if m else ""
|
|
190
195
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
191
|
-
path.write_text("\n".join(lines) + "\n")
|
|
196
|
+
path.write_text("\n".join(lines) + "\n" + ("\n" + tables.rstrip() + "\n" if tables.strip() else ""))
|
|
@@ -286,7 +286,7 @@ def approvals_path() -> Path:
|
|
|
286
286
|
def project_files(cwd: Path) -> tuple[Path, list[Path]]:
|
|
287
287
|
"""The repository's root and the files of its commands, skills and agents."""
|
|
288
288
|
root = work_tree_for(cwd)
|
|
289
|
-
files: list[Path] = []
|
|
289
|
+
files: list[Path] = [root / ".lcode" / "settings.toml"] if (root / ".lcode" / "settings.toml").is_file() else []
|
|
290
290
|
for folder in PROJECT_FOLDERS:
|
|
291
291
|
base = root / folder
|
|
292
292
|
if not base.is_dir():
|
|
@@ -359,6 +359,21 @@ def describe(cwd: Path) -> list[str]:
|
|
|
359
359
|
lines.append(f"skills: {', '.join(project_skills)}")
|
|
360
360
|
if agents:
|
|
361
361
|
lines.append(f"agents: {', '.join(agents)}")
|
|
362
|
+
settings = root / ".lcode" / "settings.toml"
|
|
363
|
+
if settings.is_file():
|
|
364
|
+
from lcode.hooks import HookError, HookSet, Rules, read_toml
|
|
365
|
+
|
|
366
|
+
try:
|
|
367
|
+
data = read_toml(settings)
|
|
368
|
+
hook_set, rules = HookSet(), Rules()
|
|
369
|
+
hook_set.add(data, "")
|
|
370
|
+
rules.add(data, "")
|
|
371
|
+
commands = sorted({h.command.split()[0] for h in hook_set.hooks if h.command.split()})
|
|
372
|
+
parts = [f"{len(hook_set.hooks)} hook(s) running {', '.join(commands)}" if hook_set.hooks else ""]
|
|
373
|
+
parts.append(f"{len(rules.allow)} allow and {len(rules.deny)} deny rule(s)" if rules else "")
|
|
374
|
+
lines.append("settings: " + "; ".join(p for p in parts if p) if any(parts) else "settings: (empty)")
|
|
375
|
+
except HookError as e:
|
|
376
|
+
lines.append(f"settings: {e}")
|
|
362
377
|
return lines
|
|
363
378
|
|
|
364
379
|
|
|
@@ -378,8 +393,8 @@ def trust_project(cwd: Path, console: Console, interactive: bool) -> bool:
|
|
|
378
393
|
for line in describe(cwd) or ["(files in " + ", ".join(PROJECT_FOLDERS) + ")"]:
|
|
379
394
|
console.print(f" {escape(line)}")
|
|
380
395
|
console.print(
|
|
381
|
-
"[dim]They can steer the model and include scripts it may run
|
|
382
|
-
"
|
|
396
|
+
"[dim]They can steer the model and include scripts it may run, and its settings can run hooks "
|
|
397
|
+
"(shell commands) on lcode's events.[/]"
|
|
383
398
|
)
|
|
384
399
|
try:
|
|
385
400
|
answer = input(" Use them? [y/N] ").strip().lower()
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Hooks and permission rules: what should always happen, whatever the model does.
|
|
2
|
+
|
|
3
|
+
Hooks are shell commands that run on lcode's events, set in `~/.config/lcode/config.toml` or, per
|
|
4
|
+
repository, in `.lcode/settings.toml` (used once you approve it, like the repository's commands):
|
|
5
|
+
|
|
6
|
+
[[hooks]]
|
|
7
|
+
event = "after_tool" # before_tool | after_tool | after_request | session_start | notification
|
|
8
|
+
tools = ["edit_file", "write_file"]
|
|
9
|
+
paths = ["*.py"]
|
|
10
|
+
command = "ruff format {path} && ruff check --fix {path}"
|
|
11
|
+
|
|
12
|
+
The event comes as JSON on stdin (and `{path}`, `{tool}` in the command are replaced, quoted). A
|
|
13
|
+
`before_tool` hook that exits with code 2 blocks the call, and what it printed tells the model why.
|
|
14
|
+
An `after_tool` hook's output reaches the model when it fails, or always with `feedback = true`.
|
|
15
|
+
|
|
16
|
+
Permission rules are checked before lcode asks, and deny always wins, even in yolo mode:
|
|
17
|
+
|
|
18
|
+
[permissions]
|
|
19
|
+
allow = ["bash:pytest *", "bash:npm test", "edit:src/**"]
|
|
20
|
+
deny = ["bash:git push --force*", "edit:.env"]
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import fnmatch
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import shlex
|
|
30
|
+
import subprocess
|
|
31
|
+
import sys
|
|
32
|
+
import threading
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
if sys.version_info >= (3, 11):
|
|
37
|
+
import tomllib
|
|
38
|
+
else: # pragma: no cover
|
|
39
|
+
import tomli as tomllib
|
|
40
|
+
|
|
41
|
+
from lcode import config
|
|
42
|
+
from lcode.checkpoints import work_tree_for
|
|
43
|
+
|
|
44
|
+
EVENTS = ("before_tool", "after_tool", "after_request", "session_start", "notification")
|
|
45
|
+
BLOCK = 2 # exit code of a before_tool hook that blocks the call
|
|
46
|
+
MAX_OUTPUT = 4000
|
|
47
|
+
FILE_TOOLS = {"read_file", "write_file", "edit_file", "view_image", "list_dir"}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class HookError(ValueError):
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def project_settings(cwd: Path) -> Path:
|
|
55
|
+
return work_tree_for(cwd) / ".lcode" / "settings.toml"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def read_toml(path: Path) -> dict:
|
|
59
|
+
try:
|
|
60
|
+
return tomllib.loads(path.read_text())
|
|
61
|
+
except OSError:
|
|
62
|
+
return {}
|
|
63
|
+
except tomllib.TOMLDecodeError as e:
|
|
64
|
+
raise HookError(f"{path} isn't valid TOML: {e}") from e
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ----------------------------------------------------------------------------- permission rules
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def strip_cd(command: str) -> str:
|
|
71
|
+
return re.sub(r"^\s*(cd\s+\S+\s*&&\s*)+", "", command).strip()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def matches_path(pattern: str, path: str) -> bool:
|
|
75
|
+
"""gitignore-like: a pattern without a slash matches the file name anywhere."""
|
|
76
|
+
if "/" not in pattern.rstrip("/"):
|
|
77
|
+
return fnmatch.fnmatch(Path(path).name, pattern) or fnmatch.fnmatch(path, pattern)
|
|
78
|
+
pattern = pattern.removeprefix("./").removeprefix("/")
|
|
79
|
+
return fnmatch.fnmatch(path, pattern) or (pattern.startswith("**/") and fnmatch.fnmatch(path, pattern[3:]))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class Rules:
|
|
84
|
+
allow: list[tuple[str, str]] = field(default_factory=list) # (rule, where it's from)
|
|
85
|
+
deny: list[tuple[str, str]] = field(default_factory=list)
|
|
86
|
+
|
|
87
|
+
def add(self, data: dict, source: str) -> None:
|
|
88
|
+
section = data.get("permissions") or {}
|
|
89
|
+
for kind in ("allow", "deny"):
|
|
90
|
+
values = section.get(kind) or []
|
|
91
|
+
if not isinstance(values, list) or not all(isinstance(v, str) and ":" in v for v in values):
|
|
92
|
+
raise HookError(f'{source}: permissions.{kind} must be a list of rules like "bash:pytest *"')
|
|
93
|
+
getattr(self, kind).extend((v, source) for v in values)
|
|
94
|
+
|
|
95
|
+
def _matches(self, rule: str, kind: str, target: str) -> bool:
|
|
96
|
+
rule_kind, _, pattern = rule.partition(":")
|
|
97
|
+
if rule_kind != kind or not target:
|
|
98
|
+
return False
|
|
99
|
+
if kind == "bash":
|
|
100
|
+
return fnmatch.fnmatch(strip_cd(target), pattern)
|
|
101
|
+
if kind == "edit":
|
|
102
|
+
return matches_path(pattern, target)
|
|
103
|
+
return fnmatch.fnmatch(target, pattern)
|
|
104
|
+
|
|
105
|
+
def check(self, kind: str, target: str) -> tuple[str | None, str]:
|
|
106
|
+
"""("deny" or "allow", the rule and where it's from), or (None, "")."""
|
|
107
|
+
if kind == "bash": # a denied command stays denied inside a chain: git add . && git push --force
|
|
108
|
+
parts = [p for p in re.split(r"\s*(?:&&|\|\||;|\|)\s*", strip_cd(target)) if p]
|
|
109
|
+
for rule, source in self.deny:
|
|
110
|
+
if any(self._matches(rule, kind, part) for part in [target, *parts]):
|
|
111
|
+
return "deny", f"{rule} ({source})"
|
|
112
|
+
for rule, source in self.allow:
|
|
113
|
+
if self._matches(rule, kind, target):
|
|
114
|
+
return "allow", f"{rule} ({source})"
|
|
115
|
+
if parts and all(any(self._matches(r, kind, part) for r, _ in self.allow) for part in parts):
|
|
116
|
+
return "allow", "every part of the command is allowed"
|
|
117
|
+
return None, ""
|
|
118
|
+
for rule, source in self.deny:
|
|
119
|
+
if self._matches(rule, kind, target):
|
|
120
|
+
return "deny", f"{rule} ({source})"
|
|
121
|
+
for rule, source in self.allow:
|
|
122
|
+
if self._matches(rule, kind, target):
|
|
123
|
+
return "allow", f"{rule} ({source})"
|
|
124
|
+
return None, ""
|
|
125
|
+
|
|
126
|
+
def __bool__(self) -> bool:
|
|
127
|
+
return bool(self.allow or self.deny)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ----------------------------------------------------------------------------- hooks
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class Hook:
|
|
135
|
+
event: str
|
|
136
|
+
command: str
|
|
137
|
+
source: str
|
|
138
|
+
tools: list[str] = field(default_factory=list)
|
|
139
|
+
paths: list[str] = field(default_factory=list)
|
|
140
|
+
timeout: float = 60.0
|
|
141
|
+
feedback: bool = False
|
|
142
|
+
|
|
143
|
+
def applies(self, tool: str = "", path: str = "") -> bool:
|
|
144
|
+
if self.tools and not any(fnmatch.fnmatch(tool, t) for t in self.tools):
|
|
145
|
+
return False
|
|
146
|
+
return not self.paths or bool(path and any(matches_path(p, path) for p in self.paths))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass
|
|
150
|
+
class Outcome:
|
|
151
|
+
hook: Hook
|
|
152
|
+
code: int
|
|
153
|
+
output: str
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass
|
|
157
|
+
class HookSet:
|
|
158
|
+
hooks: list[Hook] = field(default_factory=list)
|
|
159
|
+
problems: list[str] = field(default_factory=list)
|
|
160
|
+
|
|
161
|
+
def add(self, data: dict, source: str) -> None:
|
|
162
|
+
entries = data.get("hooks") or []
|
|
163
|
+
if not isinstance(entries, list):
|
|
164
|
+
raise HookError(f"{source}: hooks must be [[hooks]] tables")
|
|
165
|
+
for entry in entries:
|
|
166
|
+
event, command = entry.get("event"), entry.get("command")
|
|
167
|
+
if event not in EVENTS or not isinstance(command, str) or not command.strip():
|
|
168
|
+
self.problems.append(f"{source}: a hook needs an event ({', '.join(EVENTS)}) and a command")
|
|
169
|
+
continue
|
|
170
|
+
self.hooks.append(
|
|
171
|
+
Hook(
|
|
172
|
+
event=event,
|
|
173
|
+
command=command,
|
|
174
|
+
source=source,
|
|
175
|
+
tools=list(entry.get("tools") or []),
|
|
176
|
+
paths=list(entry.get("paths") or []),
|
|
177
|
+
timeout=float(entry.get("timeout") or 60),
|
|
178
|
+
feedback=bool(entry.get("feedback", False)),
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def for_event(self, event: str, tool: str = "", path: str = "") -> list[Hook]:
|
|
183
|
+
return [h for h in self.hooks if h.event == event and h.applies(tool, path)]
|
|
184
|
+
|
|
185
|
+
def run(self, event: str, payload: dict, cwd: Path, tool: str = "", path: str = "") -> list[Outcome]:
|
|
186
|
+
outcomes = []
|
|
187
|
+
for hook in self.for_event(event, tool, path):
|
|
188
|
+
outcomes.append(run_hook(hook, {"event": event, **payload}, cwd, tool, path))
|
|
189
|
+
return outcomes
|
|
190
|
+
|
|
191
|
+
def notify(self, message: str, cwd: Path) -> None:
|
|
192
|
+
"""Notification hooks, in the background: lcode doesn't wait for them."""
|
|
193
|
+
hooks = self.for_event("notification")
|
|
194
|
+
if hooks:
|
|
195
|
+
|
|
196
|
+
def go() -> None:
|
|
197
|
+
for hook in hooks:
|
|
198
|
+
run_hook(hook, {"event": "notification", "message": message}, cwd, "", "")
|
|
199
|
+
|
|
200
|
+
threading.Thread(target=go, daemon=True).start()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def run_hook(hook: Hook, payload: dict, cwd: Path, tool: str, path: str) -> Outcome:
|
|
204
|
+
command = hook.command.replace("{path}", shlex.quote(path) if path else "").replace("{tool}", shlex.quote(tool))
|
|
205
|
+
env = {**os.environ, "LCODE_EVENT": payload.get("event", ""), "LCODE_TOOL": tool, "LCODE_PATH": path}
|
|
206
|
+
try:
|
|
207
|
+
r = subprocess.run(
|
|
208
|
+
["bash", "-c", command],
|
|
209
|
+
input=json.dumps(payload, default=str),
|
|
210
|
+
capture_output=True,
|
|
211
|
+
text=True,
|
|
212
|
+
timeout=hook.timeout,
|
|
213
|
+
cwd=cwd,
|
|
214
|
+
env=env,
|
|
215
|
+
)
|
|
216
|
+
except subprocess.TimeoutExpired:
|
|
217
|
+
return Outcome(hook, 124, f"the hook took longer than {hook.timeout:.0f}s and was stopped")
|
|
218
|
+
except OSError as e:
|
|
219
|
+
return Outcome(hook, 127, str(e))
|
|
220
|
+
output = (r.stdout + ("\n" if r.stdout and r.stderr else "") + r.stderr).strip()
|
|
221
|
+
return Outcome(hook, r.returncode, output[:MAX_OUTPUT])
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# ----------------------------------------------------------------------------- loading
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def load(cwd: Path, trust_project: bool) -> tuple[HookSet, Rules]:
|
|
228
|
+
"""The user's hooks and rules, plus the repository's when its settings are approved."""
|
|
229
|
+
hooks, rules = HookSet(), Rules()
|
|
230
|
+
sources = [(config.CONFIG_PATH, str(config.CONFIG_PATH))]
|
|
231
|
+
if trust_project:
|
|
232
|
+
project = project_settings(cwd)
|
|
233
|
+
sources.append((project, str(project)))
|
|
234
|
+
for path, label in sources:
|
|
235
|
+
try:
|
|
236
|
+
data = read_toml(path)
|
|
237
|
+
hooks.add(data, label)
|
|
238
|
+
rules.add(data, label)
|
|
239
|
+
except HookError as e:
|
|
240
|
+
hooks.problems.append(str(e))
|
|
241
|
+
return hooks, rules
|