comodor 0.2.2__tar.gz → 0.3.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.
- {comodor-0.2.2 → comodor-0.3.0}/PKG-INFO +49 -21
- {comodor-0.2.2 → comodor-0.3.0}/README.md +48 -20
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/_version.py +2 -2
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/cli.py +16 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/config.py +3 -0
- comodor-0.3.0/src/comodor/tools/browser.py +398 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/tools/registry.py +19 -1
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/app.py +1 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/uninstall.py +80 -18
- comodor-0.3.0/src/comodor/workspace.py +127 -0
- comodor-0.3.0/tests/test_browser.py +320 -0
- {comodor-0.2.2 → comodor-0.3.0}/tests/test_uninstall.py +39 -1
- {comodor-0.2.2 → comodor-0.3.0}/.gitignore +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/LICENSE +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/pyproject.toml +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/__init__.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/__main__.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/agent/__init__.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/agent/context.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/agent/loop.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/agent/prompts.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/agent/tokens.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/catalogue.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/doctor.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/events.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/learning/__init__.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/learning/bm25.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/learning/hotindex.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/learning/memory.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/learning/progress.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/learning/reflect.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/learning/rules.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/learning/signals.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/learning/store.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/learning/writer.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/mcp/__init__.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/mcp/catalogue.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/mcp/commands.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/mcp/manager.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/mcp/protocol.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/net/__init__.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/net/http.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/net/sse.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/paths.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/providers/__init__.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/providers/anthropic.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/providers/base.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/providers/fake.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/providers/gateway.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/providers/openai_compat.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/providers/registry.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/safety/__init__.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/safety/checkpoints.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/safety/permissions.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/safety/redact.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/session/__init__.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/session/search.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/session/store.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/setup.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/skills/__init__.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/skills/examples.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/skills/loader.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/skills/propose.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/skills/registry.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/tools/__init__.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/tools/base.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/tools/fs.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/tools/history.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/tools/mcp.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/tools/search.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/tools/shell.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/tools/skills.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/tools/todo.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/tools/web.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/__init__.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/chooser.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/console.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/input/__init__.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/input/keys.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/input/reader.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/layout.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/markdown.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/screen.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/theme.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/widgets/__init__.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/widgets/buttons.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/widgets/chat.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/widgets/history.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/widgets/overlay.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/widgets/panel.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/widgets/progress.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/widgets/prompt.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/widgets/statusbar.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/widgets/toast.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/tests/conftest.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/tests/support/fake_mcp_server.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/tests/test_agent_loop.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/tests/test_app.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/tests/test_chooser.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/tests/test_doctor.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/tests/test_history.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/tests/test_input.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/tests/test_layout.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/tests/test_learning.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/tests/test_markdown.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/tests/test_mcp.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/tests/test_performance.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/tests/test_progress.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/tests/test_propose.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/tests/test_providers.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/tests/test_reflex.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/tests/test_run_loop.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/tests/test_setup.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/tests/test_skills.py +0 -0
- {comodor-0.2.2 → comodor-0.3.0}/tests/test_tools.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: comodor
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Comodor — a self-improving terminal coding agent with a Rich TUI
|
|
5
5
|
Project-URL: Homepage, https://comodor.ai
|
|
6
6
|
Project-URL: Repository, https://github.com/ifekri/Comodor
|
|
@@ -113,12 +113,27 @@ Four questions, once. Nothing to create beforehand — no config file, no
|
|
|
113
113
|
environment variable, no documentation to read first.
|
|
114
114
|
|
|
115
115
|
```
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
116
|
+
✓ provider Ollama (local)
|
|
117
|
+
✓ api key not needed
|
|
118
|
+
|
|
119
|
+
3/4 Which model?
|
|
120
|
+
┌─ Models ──────────────────────────────────────────────┐
|
|
121
|
+
│ › qwen2.5-coder:14b recommended │
|
|
122
|
+
│ llama3.3 │
|
|
123
|
+
│ deepseek-r1:14b │
|
|
124
|
+
└─────────────────────────────────────────────────────────┘
|
|
125
|
+
↑↓ move enter choose type filter esc cancel
|
|
120
126
|
```
|
|
121
127
|
|
|
128
|
+
One question per screen, answered with the arrow keys. Where a provider offers
|
|
129
|
+
sixty models, typing filters them. Piped or scripted, the same questions arrive
|
|
130
|
+
as a numbered list, so it can still be automated.
|
|
131
|
+
|
|
132
|
+
Then it shows you the directory it is about to work in and asks once — the
|
|
133
|
+
project root is found by walking upwards, and the answer is occasionally a
|
|
134
|
+
surprise worth seeing before anything reads it. Approved folders are
|
|
135
|
+
remembered.
|
|
136
|
+
|
|
122
137
|
You are not asked again. Change your mind later with `comodor setup`.
|
|
123
138
|
|
|
124
139
|
**No API key?** `comodor --demo` runs the whole interface offline — every
|
|
@@ -191,6 +206,35 @@ Everything you have ever asked is searchable.
|
|
|
191
206
|
The agent searches it too, on its own, when you refer to earlier work — *"like
|
|
192
207
|
we did last time"*, *"that bug from last week"*.
|
|
193
208
|
|
|
209
|
+
### It can browse, not just fetch
|
|
210
|
+
|
|
211
|
+
Most agents get one page at a time: download a URL, strip the markup, and the
|
|
212
|
+
links go with it — so the only way onward is guessing another URL. Comodor
|
|
213
|
+
browses.
|
|
214
|
+
|
|
215
|
+
```
|
|
216
|
+
› find out how the GitHub MCP server handles rate limits
|
|
217
|
+
|
|
218
|
+
⚙ search: github mcp server rate limit 1.2s
|
|
219
|
+
⚙ browse https://github.com/modelcontextprotocol/servers 0.8s
|
|
220
|
+
Links on this page:
|
|
221
|
+
1. src/github → …/tree/main/src/github
|
|
222
|
+
⚙ follow link 1 0.6s
|
|
223
|
+
⚙ find "rate limit" on the page 0.0s
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
The links come back numbered and resolved, so the next move is `follow 4`
|
|
227
|
+
rather than a guess — and links inside the content rank above the navigation
|
|
228
|
+
bar that every page of a documentation site repeats. It is one session, so
|
|
229
|
+
cookies, redirects and consent pages survive the hop. Long pages are handed
|
|
230
|
+
over a screenful at a time with `find` to jump, instead of being cut off at
|
|
231
|
+
40,000 characters. Moving around a page it has already fetched touches no
|
|
232
|
+
network and asks no permission; a new host does.
|
|
233
|
+
|
|
234
|
+
There is no JavaScript engine, and there is not going to be one — that means a
|
|
235
|
+
real browser, which means a real dependency. A page that draws itself in the
|
|
236
|
+
client says so and points at the Puppeteer server below rather than pretending.
|
|
237
|
+
|
|
194
238
|
### It connects to other tools
|
|
195
239
|
|
|
196
240
|
Comodor speaks the [Model Context Protocol](https://modelcontextprotocol.io),
|
|
@@ -271,22 +315,6 @@ not by searching your disk. Three things it will not do: touch a source
|
|
|
271
315
|
checkout, take a directory off your PATH that other programs are still using,
|
|
272
316
|
or claim to have deleted a file the operating system would not let go of.
|
|
273
317
|
|
|
274
|
-
### It can prove it is improving
|
|
275
|
-
|
|
276
|
-
Every tool claims to get better over time. Comodor shows the numbers.
|
|
277
|
-
|
|
278
|
-
```
|
|
279
|
-
◈ Steps per task down 40% since the first tasks in this project.
|
|
280
|
-
|
|
281
|
-
metric trend now vs first
|
|
282
|
-
Steps per task ▇██▇▇▆▇▅▅▅▅▆▄▅▄▅▄▄▂▄▂▁▂▂▂▁▁▁▁▁ 5.3 ↓40%
|
|
283
|
-
Corrections per task ████▆▇▆█▆▆▆▇▆▆▅▃▃▅▆▆▅▃▆▃▆▃▂▁▁▃ 0.9 ↓65%
|
|
284
|
-
Approvals asked █▇▇▇▇▇▇▇▇▇▅▅▅▅▅▅▅▅▅▅▃▃▃▃▃▃▃▃▃▁ 0.8 ↓73%
|
|
285
|
-
```
|
|
286
|
-
|
|
287
|
-
The panel is built to under-claim: with too little history it says so, and a
|
|
288
|
-
fall from 0.4 to 0 is never allowed to headline as "down 100%".
|
|
289
|
-
|
|
290
318
|
---
|
|
291
319
|
|
|
292
320
|
## You stay in control
|
|
@@ -87,12 +87,27 @@ Four questions, once. Nothing to create beforehand — no config file, no
|
|
|
87
87
|
environment variable, no documentation to read first.
|
|
88
88
|
|
|
89
89
|
```
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
90
|
+
✓ provider Ollama (local)
|
|
91
|
+
✓ api key not needed
|
|
92
|
+
|
|
93
|
+
3/4 Which model?
|
|
94
|
+
┌─ Models ──────────────────────────────────────────────┐
|
|
95
|
+
│ › qwen2.5-coder:14b recommended │
|
|
96
|
+
│ llama3.3 │
|
|
97
|
+
│ deepseek-r1:14b │
|
|
98
|
+
└─────────────────────────────────────────────────────────┘
|
|
99
|
+
↑↓ move enter choose type filter esc cancel
|
|
94
100
|
```
|
|
95
101
|
|
|
102
|
+
One question per screen, answered with the arrow keys. Where a provider offers
|
|
103
|
+
sixty models, typing filters them. Piped or scripted, the same questions arrive
|
|
104
|
+
as a numbered list, so it can still be automated.
|
|
105
|
+
|
|
106
|
+
Then it shows you the directory it is about to work in and asks once — the
|
|
107
|
+
project root is found by walking upwards, and the answer is occasionally a
|
|
108
|
+
surprise worth seeing before anything reads it. Approved folders are
|
|
109
|
+
remembered.
|
|
110
|
+
|
|
96
111
|
You are not asked again. Change your mind later with `comodor setup`.
|
|
97
112
|
|
|
98
113
|
**No API key?** `comodor --demo` runs the whole interface offline — every
|
|
@@ -165,6 +180,35 @@ Everything you have ever asked is searchable.
|
|
|
165
180
|
The agent searches it too, on its own, when you refer to earlier work — *"like
|
|
166
181
|
we did last time"*, *"that bug from last week"*.
|
|
167
182
|
|
|
183
|
+
### It can browse, not just fetch
|
|
184
|
+
|
|
185
|
+
Most agents get one page at a time: download a URL, strip the markup, and the
|
|
186
|
+
links go with it — so the only way onward is guessing another URL. Comodor
|
|
187
|
+
browses.
|
|
188
|
+
|
|
189
|
+
```
|
|
190
|
+
› find out how the GitHub MCP server handles rate limits
|
|
191
|
+
|
|
192
|
+
⚙ search: github mcp server rate limit 1.2s
|
|
193
|
+
⚙ browse https://github.com/modelcontextprotocol/servers 0.8s
|
|
194
|
+
Links on this page:
|
|
195
|
+
1. src/github → …/tree/main/src/github
|
|
196
|
+
⚙ follow link 1 0.6s
|
|
197
|
+
⚙ find "rate limit" on the page 0.0s
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
The links come back numbered and resolved, so the next move is `follow 4`
|
|
201
|
+
rather than a guess — and links inside the content rank above the navigation
|
|
202
|
+
bar that every page of a documentation site repeats. It is one session, so
|
|
203
|
+
cookies, redirects and consent pages survive the hop. Long pages are handed
|
|
204
|
+
over a screenful at a time with `find` to jump, instead of being cut off at
|
|
205
|
+
40,000 characters. Moving around a page it has already fetched touches no
|
|
206
|
+
network and asks no permission; a new host does.
|
|
207
|
+
|
|
208
|
+
There is no JavaScript engine, and there is not going to be one — that means a
|
|
209
|
+
real browser, which means a real dependency. A page that draws itself in the
|
|
210
|
+
client says so and points at the Puppeteer server below rather than pretending.
|
|
211
|
+
|
|
168
212
|
### It connects to other tools
|
|
169
213
|
|
|
170
214
|
Comodor speaks the [Model Context Protocol](https://modelcontextprotocol.io),
|
|
@@ -245,22 +289,6 @@ not by searching your disk. Three things it will not do: touch a source
|
|
|
245
289
|
checkout, take a directory off your PATH that other programs are still using,
|
|
246
290
|
or claim to have deleted a file the operating system would not let go of.
|
|
247
291
|
|
|
248
|
-
### It can prove it is improving
|
|
249
|
-
|
|
250
|
-
Every tool claims to get better over time. Comodor shows the numbers.
|
|
251
|
-
|
|
252
|
-
```
|
|
253
|
-
◈ Steps per task down 40% since the first tasks in this project.
|
|
254
|
-
|
|
255
|
-
metric trend now vs first
|
|
256
|
-
Steps per task ▇██▇▇▆▇▅▅▅▅▆▄▅▄▅▄▄▂▄▂▁▂▂▂▁▁▁▁▁ 5.3 ↓40%
|
|
257
|
-
Corrections per task ████▆▇▆█▆▆▆▇▆▆▅▃▃▅▆▆▅▃▆▃▆▃▂▁▁▃ 0.9 ↓65%
|
|
258
|
-
Approvals asked █▇▇▇▇▇▇▇▇▇▅▅▅▅▅▅▅▅▅▅▃▃▃▃▃▃▃▃▃▁ 0.8 ↓73%
|
|
259
|
-
```
|
|
260
|
-
|
|
261
|
-
The panel is built to under-claim: with too little history it says so, and a
|
|
262
|
-
fall from 0.4 to 0 is never allowed to headline as "down 100%".
|
|
263
|
-
|
|
264
292
|
---
|
|
265
293
|
|
|
266
294
|
## You stay in control
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.
|
|
22
|
-
__version_tuple__ = version_tuple = (0,
|
|
21
|
+
__version__ = version = '0.3.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 3, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -484,6 +484,22 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
484
484
|
if config.needs_setup:
|
|
485
485
|
return 1
|
|
486
486
|
|
|
487
|
+
# Which directory is this about? Asked once per folder, before the agent
|
|
488
|
+
# exists — the project root is worked out by walking upwards, and the
|
|
489
|
+
# answer is occasionally a surprise worth seeing before anything reads it.
|
|
490
|
+
# `--cwd` is the user naming it themselves, so it does not ask again.
|
|
491
|
+
if not args.cwd:
|
|
492
|
+
from .ui import console as console_module
|
|
493
|
+
from .workspace import confirm
|
|
494
|
+
|
|
495
|
+
theme = console_module.prepare_theme(config.ui.theme,
|
|
496
|
+
config.ui.ascii_borders, no_color=False)
|
|
497
|
+
chosen = confirm(config, console_module.build(theme), theme)
|
|
498
|
+
if chosen is None:
|
|
499
|
+
return 0
|
|
500
|
+
if chosen != config.paths.project:
|
|
501
|
+
config = apply_overrides(load_config(str(chosen)), args)
|
|
502
|
+
|
|
487
503
|
from .ui.app import App
|
|
488
504
|
|
|
489
505
|
resume = None
|
|
@@ -227,6 +227,9 @@ class SafetyConfig:
|
|
|
227
227
|
deny_commands: list[str] = field(default_factory=lambda: list(DEFAULT_DENY))
|
|
228
228
|
workspace_only: bool = True
|
|
229
229
|
max_file_read_bytes: int = 512_000
|
|
230
|
+
#: Directories the user has confirmed as a workspace. Exact paths, never
|
|
231
|
+
#: prefixes: approving ~/work/api must not quietly approve ~/work.
|
|
232
|
+
trusted_folders: list[str] = field(default_factory=list)
|
|
230
233
|
|
|
231
234
|
|
|
232
235
|
# Patterns that are never worth running from an agent, however it is prompted.
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
"""Browsing, rather than fetching.
|
|
2
|
+
|
|
3
|
+
`web_fetch` downloads one page and strips it to text. That is the right tool
|
|
4
|
+
for "read this URL" and useless for anything past it, because stripping the
|
|
5
|
+
markup takes the links with it: the agent gets a wall of prose describing a
|
|
6
|
+
navigation it has no way to perform, and the only route onward is guessing a
|
|
7
|
+
URL. Which it does.
|
|
8
|
+
|
|
9
|
+
A browser is the same reduction plus the three things that make a page part of
|
|
10
|
+
a site:
|
|
11
|
+
|
|
12
|
+
* **The links come back.** Numbered, resolved to absolute URLs, de-duplicated,
|
|
13
|
+
and cut off at a sensible count — so the next move is `follow 4` rather than
|
|
14
|
+
a guess. Navigation bars repeat on every page of a documentation site, so
|
|
15
|
+
they are ranked below links that appear inside the content.
|
|
16
|
+
* **It is a session.** One cookie jar and one connection pool for the whole
|
|
17
|
+
visit, so a redirect chain, a consent banner or a login survives the hop to
|
|
18
|
+
the next page. `web_fetch` starts from nothing every time.
|
|
19
|
+
* **It remembers where it has been.** `back` exists, and a page already visited
|
|
20
|
+
comes out of the history rather than off the network.
|
|
21
|
+
|
|
22
|
+
Long pages are paged rather than truncated. `web_fetch` cuts at forty thousand
|
|
23
|
+
characters and says so, which for a specification is the interesting half
|
|
24
|
+
thrown away; here the page is kept whole in the session and handed over a
|
|
25
|
+
screenful at a time, with `find` to jump straight to the part that matters.
|
|
26
|
+
|
|
27
|
+
**What it is not.** There is no JavaScript engine here and there is not going
|
|
28
|
+
to be one — that means a real browser, which means a real dependency, and this
|
|
29
|
+
package has one. A page that renders itself in the client comes back as whatever
|
|
30
|
+
its HTML actually contains, and says so. For those, the Puppeteer server in the
|
|
31
|
+
MCP catalogue drives a real Chrome, and this tool tells the model to reach for
|
|
32
|
+
it rather than pretending.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import html as html_module
|
|
38
|
+
import re
|
|
39
|
+
from dataclasses import dataclass, field
|
|
40
|
+
from typing import Any
|
|
41
|
+
from urllib.parse import urljoin, urlsplit
|
|
42
|
+
|
|
43
|
+
from ..net import http
|
|
44
|
+
from ..safety import Risk
|
|
45
|
+
from .base import Tool, ToolContext, ToolResult
|
|
46
|
+
from .web import USER_AGENT, html_to_text
|
|
47
|
+
|
|
48
|
+
#: Characters handed over per view. Roughly a long screenful.
|
|
49
|
+
PAGE_CHARS = 12_000
|
|
50
|
+
#: Links listed per page. Beyond this it is a sitemap, not a page.
|
|
51
|
+
MAX_LINKS = 40
|
|
52
|
+
#: Pages kept in the session. Each one is text, so this is cheap.
|
|
53
|
+
MAX_HISTORY = 20
|
|
54
|
+
|
|
55
|
+
_LINK = re.compile(
|
|
56
|
+
r"<a\b[^>]*?href\s*=\s*[\"']([^\"'#>]+)[^>]*>(.*?)</a>",
|
|
57
|
+
re.IGNORECASE | re.DOTALL,
|
|
58
|
+
)
|
|
59
|
+
_TITLE = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
|
|
60
|
+
_TAG = re.compile(r"<[^>]+>")
|
|
61
|
+
_SPACE = re.compile(r"\s+")
|
|
62
|
+
#: Where a site's own furniture lives. Links inside these are ranked last.
|
|
63
|
+
_CHROME = re.compile(r"<(nav|header|footer|aside)\b.*?</\1>",
|
|
64
|
+
re.IGNORECASE | re.DOTALL)
|
|
65
|
+
#: A page that renders itself in the browser leaves almost nothing behind.
|
|
66
|
+
_THIN = 400
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class Link:
|
|
71
|
+
url: str
|
|
72
|
+
text: str
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class Page:
|
|
77
|
+
url: str
|
|
78
|
+
title: str
|
|
79
|
+
text: str
|
|
80
|
+
links: list[Link] = field(default_factory=list)
|
|
81
|
+
#: Where the last view stopped, so `more` continues rather than repeats.
|
|
82
|
+
cursor: int = 0
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def pages(self) -> int:
|
|
86
|
+
return max(1, -(-len(self.text) // PAGE_CHARS))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class Visit:
|
|
91
|
+
"""One browsing session: cookies, history, and where we are."""
|
|
92
|
+
|
|
93
|
+
session: Any = None
|
|
94
|
+
history: list[Page] = field(default_factory=list)
|
|
95
|
+
index: int = -1
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def current(self) -> Page | None:
|
|
99
|
+
return self.history[self.index] if 0 <= self.index < len(self.history) else None
|
|
100
|
+
|
|
101
|
+
def open(self) -> Any:
|
|
102
|
+
if self.session is None:
|
|
103
|
+
self.session = http.Session(
|
|
104
|
+
headers={"User-Agent": USER_AGENT,
|
|
105
|
+
"Accept": "text/html,application/xhtml+xml,*/*;q=0.8",
|
|
106
|
+
"Accept-Language": "en"},
|
|
107
|
+
timeout=(10.0, 30.0),
|
|
108
|
+
)
|
|
109
|
+
return self.session
|
|
110
|
+
|
|
111
|
+
def visited(self, url: str) -> Page | None:
|
|
112
|
+
for page in self.history:
|
|
113
|
+
if page.url == url:
|
|
114
|
+
return page
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
def push(self, page: Page) -> None:
|
|
118
|
+
# Anything forward of here is a branch nobody took.
|
|
119
|
+
del self.history[self.index + 1:]
|
|
120
|
+
self.history.append(page)
|
|
121
|
+
del self.history[:-MAX_HISTORY]
|
|
122
|
+
self.index = len(self.history) - 1
|
|
123
|
+
|
|
124
|
+
def close(self) -> None:
|
|
125
|
+
if self.session is not None:
|
|
126
|
+
try:
|
|
127
|
+
self.session.close()
|
|
128
|
+
except Exception:
|
|
129
|
+
pass
|
|
130
|
+
self.session = None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _clean(markup: str) -> str:
|
|
134
|
+
return _SPACE.sub(" ", html_module.unescape(_TAG.sub(" ", markup))).strip()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def extract_links(markup: str, base: str) -> list[Link]:
|
|
138
|
+
"""Every link worth offering, in the order they are worth offering."""
|
|
139
|
+
chrome_spans = [match.span() for match in _CHROME.finditer(markup)]
|
|
140
|
+
|
|
141
|
+
def in_chrome(position: int) -> bool:
|
|
142
|
+
return any(start <= position < end for start, end in chrome_spans)
|
|
143
|
+
|
|
144
|
+
seen: set[str] = set()
|
|
145
|
+
content: list[Link] = []
|
|
146
|
+
furniture: list[Link] = []
|
|
147
|
+
|
|
148
|
+
for match in _LINK.finditer(markup):
|
|
149
|
+
href = html_module.unescape(match.group(1).strip())
|
|
150
|
+
if not href or href.lower().startswith(("javascript:", "mailto:", "tel:",
|
|
151
|
+
"data:")):
|
|
152
|
+
continue
|
|
153
|
+
url = urljoin(base, href)
|
|
154
|
+
if urlsplit(url).scheme not in ("http", "https") or url in seen:
|
|
155
|
+
continue
|
|
156
|
+
text = _clean(match.group(2))
|
|
157
|
+
if not text:
|
|
158
|
+
continue
|
|
159
|
+
seen.add(url)
|
|
160
|
+
(furniture if in_chrome(match.start()) else content).append(
|
|
161
|
+
Link(url, text[:90]))
|
|
162
|
+
|
|
163
|
+
return (content + furniture)[:MAX_LINKS]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def render(page: Page, offset: int, note: str = "") -> str:
|
|
167
|
+
"""One view of a page: a slice of its text, then where it can go next."""
|
|
168
|
+
body = page.text[offset:offset + PAGE_CHARS]
|
|
169
|
+
end = offset + len(body)
|
|
170
|
+
|
|
171
|
+
header = f"{page.title or page.url}\n{page.url}"
|
|
172
|
+
if page.pages > 1:
|
|
173
|
+
showing = offset // PAGE_CHARS + 1
|
|
174
|
+
header += f"\n[part {showing} of {page.pages}]"
|
|
175
|
+
|
|
176
|
+
parts = [header, "", body]
|
|
177
|
+
if end < len(page.text):
|
|
178
|
+
parts.append(f"\n[{len(page.text) - end:,} more characters — "
|
|
179
|
+
f"browser(action='more')]")
|
|
180
|
+
|
|
181
|
+
if page.links:
|
|
182
|
+
listed = "\n".join(f" {index:>2}. {link.text} → {link.url}"
|
|
183
|
+
for index, link in enumerate(page.links, start=1))
|
|
184
|
+
parts.append(f"\nLinks on this page:\n{listed}")
|
|
185
|
+
parts.append("\nFollow one with browser(action='follow', link=<number>).")
|
|
186
|
+
|
|
187
|
+
if note:
|
|
188
|
+
parts.append(f"\n{note}")
|
|
189
|
+
return "\n".join(parts)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class Browser(Tool):
|
|
193
|
+
name = "browser"
|
|
194
|
+
description = (
|
|
195
|
+
"Browse the web across pages, not just one at a time. Actions: "
|
|
196
|
+
"'open' a url · 'follow' a numbered link from the current page · "
|
|
197
|
+
"'back' · 'more' of a long page · 'find' text within it · 'links' to "
|
|
198
|
+
"list them again. Cookies and the connection are kept for the whole "
|
|
199
|
+
"session, so redirects and consent pages work. Prefer this over "
|
|
200
|
+
"web_fetch whenever the answer might be a page or two away."
|
|
201
|
+
)
|
|
202
|
+
risk = Risk.DANGEROUS # it leaves the machine, so it asks first
|
|
203
|
+
parameters = {
|
|
204
|
+
"type": "object",
|
|
205
|
+
"properties": {
|
|
206
|
+
"action": {
|
|
207
|
+
"type": "string",
|
|
208
|
+
"enum": ["open", "follow", "back", "more", "find", "links"],
|
|
209
|
+
"description": "What to do. Defaults to 'open' when a url is given.",
|
|
210
|
+
},
|
|
211
|
+
"url": {"type": "string", "description": "For 'open'."},
|
|
212
|
+
"link": {"type": "integer",
|
|
213
|
+
"description": "For 'follow': the number beside the link."},
|
|
214
|
+
"text": {"type": "string", "description": "For 'find'."},
|
|
215
|
+
},
|
|
216
|
+
"required": [],
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
def __init__(self) -> None:
|
|
220
|
+
self.visit = Visit()
|
|
221
|
+
|
|
222
|
+
def summary(self, args: dict[str, Any]) -> str:
|
|
223
|
+
action = str(args.get("action") or ("open" if args.get("url") else "?"))
|
|
224
|
+
if action == "open":
|
|
225
|
+
return f"browse {args.get('url', '?')}"
|
|
226
|
+
if action == "follow":
|
|
227
|
+
return f"follow link {args.get('link', '?')}"
|
|
228
|
+
if action == "find":
|
|
229
|
+
return f"find {str(args.get('text', ''))[:40]!r} on the page"
|
|
230
|
+
return f"browser: {action}"
|
|
231
|
+
|
|
232
|
+
def permission_key(self, args: dict[str, Any]) -> str:
|
|
233
|
+
"""One approval per host, and none at all for moving within a page.
|
|
234
|
+
|
|
235
|
+
`more`, `find`, `links` and `back` do not touch the network — they read
|
|
236
|
+
what is already in the session. Asking for them trains people to
|
|
237
|
+
approve without reading.
|
|
238
|
+
"""
|
|
239
|
+
action = str(args.get("action") or ("open" if args.get("url") else ""))
|
|
240
|
+
if action in ("more", "find", "links", "back"):
|
|
241
|
+
return f"{self.name}:local"
|
|
242
|
+
if action == "follow":
|
|
243
|
+
page = self.visit.current
|
|
244
|
+
number = _as_int(args.get("link"))
|
|
245
|
+
if page and number and 1 <= number <= len(page.links):
|
|
246
|
+
return f"{self.name}:{urlsplit(page.links[number - 1].url).hostname}"
|
|
247
|
+
return f"{self.name}:?"
|
|
248
|
+
host = urlsplit(str(args.get("url", ""))).hostname or "?"
|
|
249
|
+
return f"{self.name}:{host}"
|
|
250
|
+
|
|
251
|
+
# -- the actions ------------------------------------------------------- #
|
|
252
|
+
|
|
253
|
+
def run(self, ctx: ToolContext, action: str = "", url: str = "",
|
|
254
|
+
link: Any = None, text: str = "", **_: Any) -> ToolResult:
|
|
255
|
+
action = (action or ("open" if url else "")).strip().lower()
|
|
256
|
+
if not action:
|
|
257
|
+
return ToolResult.failure(
|
|
258
|
+
"say what to do: open a url, or follow / back / more / find / links")
|
|
259
|
+
|
|
260
|
+
handler = {
|
|
261
|
+
"open": lambda: self._open(url),
|
|
262
|
+
"follow": lambda: self._follow(_as_int(link)),
|
|
263
|
+
"back": self._back,
|
|
264
|
+
"more": self._more,
|
|
265
|
+
"find": lambda: self._find(text),
|
|
266
|
+
"links": self._links,
|
|
267
|
+
}.get(action)
|
|
268
|
+
|
|
269
|
+
if handler is None:
|
|
270
|
+
return ToolResult.failure(f"unknown action {action!r}")
|
|
271
|
+
return handler()
|
|
272
|
+
|
|
273
|
+
def _open(self, url: str) -> ToolResult:
|
|
274
|
+
if not url:
|
|
275
|
+
return ToolResult.failure("open needs a url")
|
|
276
|
+
if not url.lower().startswith(("http://", "https://")):
|
|
277
|
+
url = "https://" + url
|
|
278
|
+
|
|
279
|
+
seen = self.visit.visited(url)
|
|
280
|
+
if seen is not None:
|
|
281
|
+
self.visit.push(seen)
|
|
282
|
+
seen.cursor = min(PAGE_CHARS, len(seen.text))
|
|
283
|
+
return self._result(seen, 0, note="(already visited this session)")
|
|
284
|
+
|
|
285
|
+
try:
|
|
286
|
+
response = self.visit.open().get(url)
|
|
287
|
+
except http.RequestError as exc:
|
|
288
|
+
return ToolResult.failure(f"could not open {url}: {exc}")
|
|
289
|
+
|
|
290
|
+
with response:
|
|
291
|
+
if not response.ok:
|
|
292
|
+
return ToolResult.failure(
|
|
293
|
+
f"{url} returned {response.status_code} {response.reason}")
|
|
294
|
+
content_type = response.headers.get("Content-Type", "")
|
|
295
|
+
landed = getattr(response, "url", url) or url
|
|
296
|
+
body = response.text
|
|
297
|
+
|
|
298
|
+
page = self._page(landed, body, content_type)
|
|
299
|
+
self.visit.push(page)
|
|
300
|
+
page.cursor = min(PAGE_CHARS, len(page.text))
|
|
301
|
+
|
|
302
|
+
note = ""
|
|
303
|
+
if landed != url:
|
|
304
|
+
note = f"(redirected from {url})"
|
|
305
|
+
if "html" in content_type.lower() and len(page.text) < _THIN:
|
|
306
|
+
note = (note + " " if note else "") + (
|
|
307
|
+
"This page carries almost no text, which usually means it draws "
|
|
308
|
+
"itself with JavaScript. There is no JavaScript engine here — "
|
|
309
|
+
"for a page like this, use the Puppeteer server from "
|
|
310
|
+
"`comodor mcp catalogue`.")
|
|
311
|
+
return self._result(page, 0, note=note.strip())
|
|
312
|
+
|
|
313
|
+
def _follow(self, number: int | None) -> ToolResult:
|
|
314
|
+
page = self.visit.current
|
|
315
|
+
if page is None:
|
|
316
|
+
return ToolResult.failure("nothing is open yet")
|
|
317
|
+
if not number or not 1 <= number <= len(page.links):
|
|
318
|
+
return ToolResult.failure(
|
|
319
|
+
f"pick a link between 1 and {len(page.links)}")
|
|
320
|
+
return self._open(page.links[number - 1].url)
|
|
321
|
+
|
|
322
|
+
def _back(self) -> ToolResult:
|
|
323
|
+
if self.visit.index <= 0:
|
|
324
|
+
return ToolResult.failure("nothing to go back to")
|
|
325
|
+
self.visit.index -= 1
|
|
326
|
+
page = self.visit.current
|
|
327
|
+
assert page is not None
|
|
328
|
+
page.cursor = min(PAGE_CHARS, len(page.text))
|
|
329
|
+
return self._result(page, 0)
|
|
330
|
+
|
|
331
|
+
def _more(self) -> ToolResult:
|
|
332
|
+
page = self.visit.current
|
|
333
|
+
if page is None:
|
|
334
|
+
return ToolResult.failure("nothing is open yet")
|
|
335
|
+
if page.cursor >= len(page.text):
|
|
336
|
+
return ToolResult.success(content=f"{page.url}: that was the end.")
|
|
337
|
+
offset = page.cursor
|
|
338
|
+
page.cursor = min(page.cursor + PAGE_CHARS, len(page.text))
|
|
339
|
+
return self._result(page, offset)
|
|
340
|
+
|
|
341
|
+
def _find(self, needle: str) -> ToolResult:
|
|
342
|
+
page = self.visit.current
|
|
343
|
+
if page is None:
|
|
344
|
+
return ToolResult.failure("nothing is open yet")
|
|
345
|
+
if not needle:
|
|
346
|
+
return ToolResult.failure("find needs something to look for")
|
|
347
|
+
|
|
348
|
+
position = page.text.lower().find(needle.lower())
|
|
349
|
+
if position < 0:
|
|
350
|
+
return ToolResult.failure(f"{needle!r} is not on this page")
|
|
351
|
+
|
|
352
|
+
# Start a little before the match, so it arrives with its context.
|
|
353
|
+
offset = max(0, position - 400)
|
|
354
|
+
page.cursor = min(offset + PAGE_CHARS, len(page.text))
|
|
355
|
+
return self._result(page, offset, note=f"(found {needle!r})")
|
|
356
|
+
|
|
357
|
+
def _links(self) -> ToolResult:
|
|
358
|
+
page = self.visit.current
|
|
359
|
+
if page is None:
|
|
360
|
+
return ToolResult.failure("nothing is open yet")
|
|
361
|
+
if not page.links:
|
|
362
|
+
return ToolResult.success(content=f"{page.url} has no links.")
|
|
363
|
+
listed = "\n".join(f" {index:>2}. {link.text} → {link.url}"
|
|
364
|
+
for index, link in enumerate(page.links, start=1))
|
|
365
|
+
return ToolResult.success(
|
|
366
|
+
content=f"Links on {page.url}:\n{listed}", display=listed, url=page.url)
|
|
367
|
+
|
|
368
|
+
# -- helpers ------------------------------------------------------------ #
|
|
369
|
+
|
|
370
|
+
def _page(self, url: str, body: str, content_type: str) -> Page:
|
|
371
|
+
is_html = "html" in content_type.lower() or body.lstrip().startswith("<")
|
|
372
|
+
if not is_html:
|
|
373
|
+
return Page(url=url, title=url.rsplit("/", 1)[-1], text=body)
|
|
374
|
+
|
|
375
|
+
match = _TITLE.search(body)
|
|
376
|
+
return Page(
|
|
377
|
+
url=url,
|
|
378
|
+
title=_clean(match.group(1)) if match else "",
|
|
379
|
+
text=html_to_text(body),
|
|
380
|
+
links=extract_links(body, url),
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
def _result(self, page: Page, offset: int, note: str = "") -> ToolResult:
|
|
384
|
+
content = render(page, offset, note)
|
|
385
|
+
return ToolResult.success(
|
|
386
|
+
content=content, display=content, url=page.url,
|
|
387
|
+
title=page.title, links=len(page.links), parts=page.pages,
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
def close(self) -> None:
|
|
391
|
+
self.visit.close()
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _as_int(value: Any) -> int | None:
|
|
395
|
+
try:
|
|
396
|
+
return int(value)
|
|
397
|
+
except (TypeError, ValueError):
|
|
398
|
+
return None
|
|
@@ -14,6 +14,7 @@ from typing import Any, Iterable
|
|
|
14
14
|
from ..providers.base import ToolSpec
|
|
15
15
|
from ..safety import Risk
|
|
16
16
|
from .base import Tool, ToolContext, ToolResult
|
|
17
|
+
from .browser import Browser
|
|
17
18
|
from .fs import EditFile, ListDir, ReadFile, WriteFile
|
|
18
19
|
from .history import SearchHistory
|
|
19
20
|
from .search import Glob, Grep
|
|
@@ -26,7 +27,7 @@ DEFAULT_TOOLS: tuple[type[Tool], ...] = (
|
|
|
26
27
|
ReadFile, WriteFile, EditFile, ListDir,
|
|
27
28
|
Glob, Grep,
|
|
28
29
|
RunShell, RunPython,
|
|
29
|
-
WebFetch, WebSearch,
|
|
30
|
+
Browser, WebFetch, WebSearch,
|
|
30
31
|
TodoWrite,
|
|
31
32
|
)
|
|
32
33
|
|
|
@@ -59,6 +60,23 @@ class ToolRegistry:
|
|
|
59
60
|
def add(self, tool: Tool) -> None:
|
|
60
61
|
self._tools[tool.name] = tool
|
|
61
62
|
|
|
63
|
+
def close(self) -> None:
|
|
64
|
+
"""Let go of anything a tool is holding open.
|
|
65
|
+
|
|
66
|
+
The browser keeps a connection pool and a cookie jar for the length of
|
|
67
|
+
a session, which is the point of it; leaving them to the garbage
|
|
68
|
+
collector means a socket that outlives the program's own shutdown
|
|
69
|
+
message. A tool with nothing to release simply has no `close`.
|
|
70
|
+
"""
|
|
71
|
+
for tool in self._tools.values():
|
|
72
|
+
closer = getattr(tool, "close", None)
|
|
73
|
+
if closer is None:
|
|
74
|
+
continue
|
|
75
|
+
try:
|
|
76
|
+
closer()
|
|
77
|
+
except Exception:
|
|
78
|
+
pass
|
|
79
|
+
|
|
62
80
|
def remove(self, name: str) -> None:
|
|
63
81
|
self._tools.pop(name, None)
|
|
64
82
|
|