comodor 0.2.1__tar.gz → 0.2.3__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.1 → comodor-0.2.3}/.gitignore +4 -0
- {comodor-0.2.1 → comodor-0.2.3}/PKG-INFO +1 -1
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/_version.py +2 -2
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/setup.py +100 -24
- comodor-0.2.3/src/comodor/ui/chooser.py +271 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/uninstall.py +80 -18
- comodor-0.2.3/tests/test_chooser.py +208 -0
- {comodor-0.2.1 → comodor-0.2.3}/tests/test_uninstall.py +39 -1
- {comodor-0.2.1 → comodor-0.2.3}/LICENSE +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/README.md +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/pyproject.toml +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/__init__.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/__main__.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/agent/__init__.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/agent/context.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/agent/loop.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/agent/prompts.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/agent/tokens.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/catalogue.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/cli.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/config.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/doctor.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/events.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/learning/__init__.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/learning/bm25.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/learning/hotindex.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/learning/memory.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/learning/progress.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/learning/reflect.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/learning/rules.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/learning/signals.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/learning/store.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/learning/writer.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/mcp/__init__.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/mcp/catalogue.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/mcp/commands.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/mcp/manager.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/mcp/protocol.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/net/__init__.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/net/http.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/net/sse.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/paths.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/providers/__init__.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/providers/anthropic.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/providers/base.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/providers/fake.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/providers/gateway.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/providers/openai_compat.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/providers/registry.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/safety/__init__.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/safety/checkpoints.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/safety/permissions.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/safety/redact.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/session/__init__.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/session/search.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/session/store.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/skills/__init__.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/skills/examples.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/skills/loader.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/skills/propose.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/skills/registry.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/tools/__init__.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/tools/base.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/tools/fs.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/tools/history.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/tools/mcp.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/tools/registry.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/tools/search.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/tools/shell.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/tools/skills.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/tools/todo.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/tools/web.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/ui/__init__.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/ui/app.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/ui/console.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/ui/input/__init__.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/ui/input/keys.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/ui/input/reader.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/ui/layout.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/ui/markdown.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/ui/screen.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/ui/theme.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/ui/widgets/__init__.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/ui/widgets/buttons.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/ui/widgets/chat.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/ui/widgets/history.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/ui/widgets/overlay.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/ui/widgets/panel.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/ui/widgets/progress.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/ui/widgets/prompt.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/ui/widgets/statusbar.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/src/comodor/ui/widgets/toast.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/tests/conftest.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/tests/support/fake_mcp_server.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/tests/test_agent_loop.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/tests/test_app.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/tests/test_doctor.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/tests/test_history.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/tests/test_input.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/tests/test_layout.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/tests/test_learning.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/tests/test_markdown.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/tests/test_mcp.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/tests/test_performance.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/tests/test_progress.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/tests/test_propose.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/tests/test_providers.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/tests/test_reflex.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/tests/test_run_loop.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/tests/test_setup.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/tests/test_skills.py +0 -0
- {comodor-0.2.1 → comodor-0.2.3}/tests/test_tools.py +0 -0
|
@@ -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.2.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 2,
|
|
21
|
+
__version__ = version = '0.2.3'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 3)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -5,10 +5,19 @@ does not ask anybody to find a dotfile, learn an environment variable or read
|
|
|
5
5
|
documentation before their first task — it asks what it needs, in the terminal,
|
|
6
6
|
with the answers numbered, and writes them down.
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
Two ways of asking, and the second one is not optional.
|
|
9
|
+
|
|
10
|
+
On a real terminal each question arrives on a screen of its own: what has
|
|
11
|
+
already been answered is summarised in two or three quiet lines at the top, and
|
|
12
|
+
below it one framed list you move through with the arrow keys. Questions no
|
|
13
|
+
longer pile up — by the fourth one the terminal used to be a transcript of
|
|
14
|
+
decisions already made — and a provider with sixty models is a list you can
|
|
15
|
+
filter by typing rather than sixty numbered rows to read.
|
|
16
|
+
|
|
17
|
+
Anywhere without a terminal — a pipe, a test, an editor's console — the
|
|
18
|
+
numbered prompt is exactly what it was. A setup wizard that only works in one
|
|
19
|
+
kind of terminal is a setup wizard that cannot be scripted, and the first thing
|
|
20
|
+
a new user meets must not be a mode their terminal might not support.
|
|
12
21
|
"""
|
|
13
22
|
|
|
14
23
|
from __future__ import annotations
|
|
@@ -24,6 +33,7 @@ from rich.text import Text
|
|
|
24
33
|
|
|
25
34
|
from . import catalogue
|
|
26
35
|
from .config import Config
|
|
36
|
+
from .ui import chooser
|
|
27
37
|
from .ui import console as console_module
|
|
28
38
|
from .ui.theme import Theme
|
|
29
39
|
|
|
@@ -57,10 +67,26 @@ class SetupWizard:
|
|
|
57
67
|
self.console = console or console_module.build(self.theme)
|
|
58
68
|
self._prompt = prompt or (lambda message: input(message))
|
|
59
69
|
self._secret = secret or (lambda message: getpass.getpass(message))
|
|
70
|
+
# An injected prompt means somebody is driving this without a keyboard,
|
|
71
|
+
# so the interactive list is off whatever the terminal says it can do.
|
|
72
|
+
self._keys = prompt is None and chooser.interactive(self.console)
|
|
73
|
+
#: What has been answered so far, shown at the top of each screen.
|
|
74
|
+
self._done: list[tuple[str, str]] = []
|
|
60
75
|
|
|
61
76
|
# -- presentation ----------------------------------------------------- #
|
|
62
77
|
|
|
63
78
|
def _rule(self, title: str, step: int, total: int) -> None:
|
|
79
|
+
"""Start a question.
|
|
80
|
+
|
|
81
|
+
On a real terminal this is where the screen is cleared. The alternative
|
|
82
|
+
— letting the questions stack — meant that by the last one the useful
|
|
83
|
+
part of the screen was a few lines at the bottom under a wall of
|
|
84
|
+
choices already made. What replaces the wall is the same information in
|
|
85
|
+
one line each, which is all it was ever worth.
|
|
86
|
+
"""
|
|
87
|
+
if self._keys:
|
|
88
|
+
self.console.clear()
|
|
89
|
+
self._recap()
|
|
64
90
|
self.console.print()
|
|
65
91
|
self.console.print(
|
|
66
92
|
Text.assemble(
|
|
@@ -69,13 +95,50 @@ class SetupWizard:
|
|
|
69
95
|
)
|
|
70
96
|
)
|
|
71
97
|
|
|
72
|
-
def
|
|
73
|
-
"""
|
|
98
|
+
def _recap(self) -> None:
|
|
99
|
+
"""The questions already answered, one quiet line each."""
|
|
100
|
+
if not self._done:
|
|
101
|
+
self.console.print(
|
|
102
|
+
Text(" Comodor setup", style=self.theme.style("dim")))
|
|
103
|
+
return
|
|
104
|
+
for label, value in self._done:
|
|
105
|
+
self.console.print(Text.assemble(
|
|
106
|
+
(" ✓ ", self.theme.style("good")),
|
|
107
|
+
(f"{label} ", self.theme.style("dim")),
|
|
108
|
+
(value, self.theme.style("value")),
|
|
109
|
+
))
|
|
74
110
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
111
|
+
def _answered(self, label: str, value: str) -> None:
|
|
112
|
+
self._done.append((label, value))
|
|
113
|
+
if not self._keys:
|
|
114
|
+
return
|
|
115
|
+
# Echoed once here, because the list it came from is erased on the way
|
|
116
|
+
# out: a choice that leaves no trace reads as a choice that did not
|
|
117
|
+
# register.
|
|
118
|
+
self.console.print(Text.assemble(
|
|
119
|
+
(" ✓ ", self.theme.style("good")),
|
|
120
|
+
(value, self.theme.style("value", bold=True)),
|
|
121
|
+
))
|
|
122
|
+
|
|
123
|
+
def _choose(self, options: Sequence[tuple[str, str, str]], default: int = 1,
|
|
124
|
+
title: str = "") -> str:
|
|
125
|
+
"""Return the chosen value, by arrow key or by number.
|
|
126
|
+
|
|
127
|
+
``options`` is ``(value, label, note)``. The numbered path re-asks on a
|
|
128
|
+
bad answer rather than falling through to a default the user did not
|
|
129
|
+
pick — a silent wrong choice here is one they would have to undo later.
|
|
78
130
|
"""
|
|
131
|
+
if self._keys:
|
|
132
|
+
picked = chooser.choose(
|
|
133
|
+
self.console, self.theme,
|
|
134
|
+
[chooser.Option(value, label, note) for value, label, note in options],
|
|
135
|
+
title=title, default=default - 1,
|
|
136
|
+
)
|
|
137
|
+
if picked is not None:
|
|
138
|
+
return picked
|
|
139
|
+
# The list could not run, or was escaped out of. Either way the
|
|
140
|
+
# question still needs an answer, so the numbered form takes over.
|
|
141
|
+
|
|
79
142
|
table = Table.grid(padding=(0, 2))
|
|
80
143
|
table.add_column(justify="right", no_wrap=True)
|
|
81
144
|
table.add_column(no_wrap=True)
|
|
@@ -124,6 +187,7 @@ class SetupWizard:
|
|
|
124
187
|
self.console.print(
|
|
125
188
|
Text(" Not needed — this one runs on your machine.",
|
|
126
189
|
style=self.theme.style("dim")))
|
|
190
|
+
self._answered("api key", "not needed")
|
|
127
191
|
|
|
128
192
|
answers.model = self._ask_model(3, total, spec, answers)
|
|
129
193
|
answers.approvals = self._ask_approvals(4, total)
|
|
@@ -152,7 +216,9 @@ class SetupWizard:
|
|
|
152
216
|
Text(" You can add more later; this is just the one to start with.\n",
|
|
153
217
|
style=self.theme.style("dim")))
|
|
154
218
|
options = [(spec.id, spec.label, spec.blurb) for spec in catalogue.offered()]
|
|
155
|
-
|
|
219
|
+
chosen = self._choose(options, default=1, title="Providers")
|
|
220
|
+
self._answered("provider", dict((v, l) for v, l, _ in options).get(chosen, chosen))
|
|
221
|
+
return chosen
|
|
156
222
|
|
|
157
223
|
def _ask_endpoint(self) -> str:
|
|
158
224
|
self.console.print()
|
|
@@ -175,24 +241,33 @@ class SetupWizard:
|
|
|
175
241
|
# off screen recordings.
|
|
176
242
|
key = self._secret(" key (input hidden): ").strip()
|
|
177
243
|
if key:
|
|
244
|
+
self._answered("api key", "set, and never shown again")
|
|
178
245
|
return key
|
|
179
246
|
self.console.print(Text(" a key is required for this provider",
|
|
180
247
|
style=self.theme.style("bad")))
|
|
181
248
|
|
|
182
249
|
def _ask_model(self, step: int, total: int,
|
|
183
250
|
spec: catalogue.ProviderSpec | None, answers: Answers) -> str:
|
|
184
|
-
|
|
185
|
-
|
|
251
|
+
# Discovery first, and the question afterwards, because asking the
|
|
252
|
+
# provider what it has takes a second or two over the network. During
|
|
253
|
+
# that second the terminal is still in its ordinary mode, so anything
|
|
254
|
+
# impatient fingers press is echoed — an arrow key arrives on screen as
|
|
255
|
+
# `^[[B` and sits there. Clearing for the question is what wipes it, so
|
|
256
|
+
# the clearing has to come second. The keystrokes themselves are
|
|
257
|
+
# discarded when the reader takes the terminal.
|
|
186
258
|
models = self._discover_models(spec, answers)
|
|
259
|
+
|
|
260
|
+
self._rule("Which model?", step, total)
|
|
187
261
|
if not models:
|
|
188
262
|
return self._ask("model id", spec.default_model if spec else "")
|
|
189
263
|
|
|
190
264
|
options = [(model, model, "recommended" if index == 0 else "")
|
|
191
265
|
for index, model in enumerate(models)]
|
|
192
266
|
options.append(("__other__", "something else", "type the model id"))
|
|
193
|
-
chosen = self._choose(options, default=1)
|
|
267
|
+
chosen = self._choose(options, default=1, title="Models")
|
|
194
268
|
if chosen == "__other__":
|
|
195
|
-
|
|
269
|
+
chosen = self._ask("model id", models[0])
|
|
270
|
+
self._answered("model", chosen)
|
|
196
271
|
return chosen
|
|
197
272
|
|
|
198
273
|
def _discover_models(self, spec: catalogue.ProviderSpec | None,
|
|
@@ -231,16 +306,17 @@ class SetupWizard:
|
|
|
231
306
|
|
|
232
307
|
def _ask_approvals(self, step: int, total: int) -> str:
|
|
233
308
|
self._rule("How much should it ask before acting?", step, total)
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
309
|
+
options = [
|
|
310
|
+
("ask", "Ask before writing or running anything",
|
|
311
|
+
"safest; you see a diff or the command first"),
|
|
312
|
+
("writes", "Write files freely, ask before running commands",
|
|
313
|
+
"a good middle ground"),
|
|
314
|
+
("auto", "Do not ask", "fastest; everything is still checkpointed"),
|
|
315
|
+
]
|
|
316
|
+
chosen = self._choose(options, default=1, title="Approvals")
|
|
317
|
+
self._answered("approvals",
|
|
318
|
+
dict((v, l) for v, l, _ in options).get(chosen, chosen))
|
|
319
|
+
return chosen
|
|
244
320
|
|
|
245
321
|
# -- applying --------------------------------------------------------- #
|
|
246
322
|
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Choosing from a list, with the arrow keys.
|
|
2
|
+
|
|
3
|
+
The wizard used to print a numbered list and read a number. That is fine for
|
|
4
|
+
three options and wrong for a hundred: picking a model meant reading a wall of
|
|
5
|
+
identifiers, finding the one you wanted, remembering its number, scrolling back
|
|
6
|
+
down because the list had pushed the prompt off the screen, and typing a digit
|
|
7
|
+
you were no longer sure of. And every question stayed on screen afterwards, so
|
|
8
|
+
by the fourth one the terminal was a transcript of decisions already made.
|
|
9
|
+
|
|
10
|
+
What is here instead is one framed list at a time.
|
|
11
|
+
|
|
12
|
+
* **The frame never grows past the terminal.** However many options there are,
|
|
13
|
+
the list is windowed to what will fit and moves with the cursor, with a count
|
|
14
|
+
of what is above and below. Nothing is ever off screen with no sign that it
|
|
15
|
+
is there.
|
|
16
|
+
* **Typing filters.** With sixty models on offer, `son` is faster than sixty
|
|
17
|
+
presses of the down arrow, and it is what anybody who has used a fuzzy finder
|
|
18
|
+
will try first.
|
|
19
|
+
* **It refuses to be the only way in.** Without a terminal — a pipe, a test, an
|
|
20
|
+
editor's console, `curl | sh` — the numbered prompt is still there. A setup
|
|
21
|
+
wizard that requires a particular kind of terminal is a setup wizard that
|
|
22
|
+
cannot be scripted.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from typing import Sequence
|
|
29
|
+
|
|
30
|
+
from rich.console import Console, Group, RenderableType
|
|
31
|
+
from rich.panel import Panel
|
|
32
|
+
from rich.table import Table
|
|
33
|
+
from rich.text import Text
|
|
34
|
+
|
|
35
|
+
from .input.keys import KeyEvent
|
|
36
|
+
from .input.reader import TerminalInput
|
|
37
|
+
from .theme import Theme
|
|
38
|
+
|
|
39
|
+
#: Rows the frame spends on itself: two borders, and the hint line under it.
|
|
40
|
+
CHROME = 4
|
|
41
|
+
#: Never show fewer than this, even on a very short terminal.
|
|
42
|
+
MIN_ROWS = 3
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class Option:
|
|
47
|
+
value: str
|
|
48
|
+
label: str
|
|
49
|
+
note: str = ""
|
|
50
|
+
|
|
51
|
+
def matches(self, needle: str) -> bool:
|
|
52
|
+
return needle in self.label.lower() or needle in self.note.lower()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def interactive(console: Console) -> bool:
|
|
56
|
+
"""Can we take over the keyboard here?"""
|
|
57
|
+
try:
|
|
58
|
+
return bool(console.is_terminal and console.file.isatty()
|
|
59
|
+
and _stdin_is_a_terminal())
|
|
60
|
+
except Exception:
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _stdin_is_a_terminal() -> bool:
|
|
65
|
+
import sys
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
return bool(sys.stdin and sys.stdin.isatty())
|
|
69
|
+
except Exception:
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Chooser:
|
|
74
|
+
"""One list, one choice."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, console: Console, theme: Theme, options: Sequence[Option],
|
|
77
|
+
title: str = "", default: int = 0) -> None:
|
|
78
|
+
self.console = console
|
|
79
|
+
self.theme = theme
|
|
80
|
+
self.options = list(options)
|
|
81
|
+
self.title = title
|
|
82
|
+
self.cursor = max(0, min(default, len(self.options) - 1))
|
|
83
|
+
self.filter = ""
|
|
84
|
+
self.offset = 0
|
|
85
|
+
|
|
86
|
+
# -- what is currently visible ---------------------------------------- #
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def matching(self) -> list[Option]:
|
|
90
|
+
if not self.filter:
|
|
91
|
+
return self.options
|
|
92
|
+
needle = self.filter.lower()
|
|
93
|
+
return [option for option in self.options if option.matches(needle)]
|
|
94
|
+
|
|
95
|
+
def rows(self) -> int:
|
|
96
|
+
"""How many options fit, given the terminal we are in."""
|
|
97
|
+
available = max(MIN_ROWS, self.console.size.height - CHROME - 6)
|
|
98
|
+
return min(len(self.matching) or 1, available)
|
|
99
|
+
|
|
100
|
+
def _scroll_into_view(self) -> None:
|
|
101
|
+
window = self.rows()
|
|
102
|
+
if self.cursor < self.offset:
|
|
103
|
+
self.offset = self.cursor
|
|
104
|
+
elif self.cursor >= self.offset + window:
|
|
105
|
+
self.offset = self.cursor - window + 1
|
|
106
|
+
self.offset = max(0, min(self.offset, max(0, len(self.matching) - window)))
|
|
107
|
+
|
|
108
|
+
# -- drawing ----------------------------------------------------------- #
|
|
109
|
+
|
|
110
|
+
def render(self) -> RenderableType:
|
|
111
|
+
theme = self.theme
|
|
112
|
+
items = self.matching
|
|
113
|
+
window = self.rows()
|
|
114
|
+
self._scroll_into_view()
|
|
115
|
+
visible = items[self.offset:self.offset + window]
|
|
116
|
+
|
|
117
|
+
table = Table.grid(padding=(0, 1))
|
|
118
|
+
table.add_column(width=2, no_wrap=True)
|
|
119
|
+
table.add_column(no_wrap=True)
|
|
120
|
+
table.add_column(overflow="ellipsis")
|
|
121
|
+
|
|
122
|
+
if not items:
|
|
123
|
+
table.add_row("", Text("nothing matches", style=theme.style("bad")),
|
|
124
|
+
Text(""))
|
|
125
|
+
|
|
126
|
+
for index, option in enumerate(visible, start=self.offset):
|
|
127
|
+
chosen = index == self.cursor
|
|
128
|
+
# The arrow, not just a colour: a highlighted row that relies on
|
|
129
|
+
# background alone disappears on a terminal that renders it faintly,
|
|
130
|
+
# and this is the only thing on screen saying where you are.
|
|
131
|
+
table.add_row(
|
|
132
|
+
Text(theme.glyphs.arrow if chosen else " ",
|
|
133
|
+
style=theme.style("accent", bold=True)),
|
|
134
|
+
Text(option.label,
|
|
135
|
+
style=theme.style("accent" if chosen else "value",
|
|
136
|
+
bold=chosen)),
|
|
137
|
+
Text(option.note, style=theme.style("dim")),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
blocks: list[RenderableType] = []
|
|
141
|
+
above = self.offset
|
|
142
|
+
below = max(0, len(items) - self.offset - window)
|
|
143
|
+
if above:
|
|
144
|
+
blocks.append(Text(f" {above} more above", style=theme.style("dim")))
|
|
145
|
+
blocks.append(table)
|
|
146
|
+
if below:
|
|
147
|
+
blocks.append(Text(f" {below} more below", style=theme.style("dim")))
|
|
148
|
+
|
|
149
|
+
subtitle = None
|
|
150
|
+
if self.filter:
|
|
151
|
+
subtitle = Text(f" filter: {self.filter} ", style=theme.style("accent"))
|
|
152
|
+
elif len(items) != len(self.options):
|
|
153
|
+
subtitle = Text(f" {len(items)} of {len(self.options)} ",
|
|
154
|
+
style=theme.style("dim"))
|
|
155
|
+
|
|
156
|
+
panel = Panel(
|
|
157
|
+
Group(*blocks),
|
|
158
|
+
box=self.theme.box,
|
|
159
|
+
border_style=theme.style("border"),
|
|
160
|
+
title=Text(f" {self.title} ", style=theme.style("title")) if self.title
|
|
161
|
+
else None,
|
|
162
|
+
title_align="left",
|
|
163
|
+
subtitle=subtitle,
|
|
164
|
+
subtitle_align="right",
|
|
165
|
+
padding=(0, 1),
|
|
166
|
+
)
|
|
167
|
+
return Group(panel, self._hint())
|
|
168
|
+
|
|
169
|
+
def _hint(self) -> Text:
|
|
170
|
+
theme = self.theme
|
|
171
|
+
hint = Text(" ", style=theme.style("dim"))
|
|
172
|
+
for key, what in (("↑↓", "move"), ("enter", "choose"),
|
|
173
|
+
("type", "filter"), ("esc", "cancel")):
|
|
174
|
+
hint.append(key, style=theme.style("accent"))
|
|
175
|
+
hint.append(f" {what} ", style=theme.style("dim"))
|
|
176
|
+
return hint
|
|
177
|
+
|
|
178
|
+
# -- the loop ----------------------------------------------------------- #
|
|
179
|
+
|
|
180
|
+
def run(self) -> str | None:
|
|
181
|
+
"""Returns the chosen value, or None if the user backed out."""
|
|
182
|
+
from rich.live import Live
|
|
183
|
+
|
|
184
|
+
items = self.matching
|
|
185
|
+
if not items:
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
with TerminalInput(mouse=False, paste=False) as terminal, Live(
|
|
189
|
+
self.render(), console=self.console, auto_refresh=False,
|
|
190
|
+
transient=True,
|
|
191
|
+
) as live:
|
|
192
|
+
while True:
|
|
193
|
+
event = terminal.wait(0.2)
|
|
194
|
+
if event is None:
|
|
195
|
+
continue
|
|
196
|
+
if not isinstance(event, KeyEvent):
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
outcome = self._handle(event)
|
|
200
|
+
if outcome is _CANCEL:
|
|
201
|
+
return None
|
|
202
|
+
if outcome is not None:
|
|
203
|
+
return outcome
|
|
204
|
+
live.update(self.render(), refresh=True)
|
|
205
|
+
|
|
206
|
+
def _handle(self, event: KeyEvent) -> object:
|
|
207
|
+
"""None to keep going, a string to accept it, ``_CANCEL`` to give up."""
|
|
208
|
+
items = self.matching
|
|
209
|
+
|
|
210
|
+
if event.matches("ctrl+c") or event.key == "escape":
|
|
211
|
+
return _CANCEL
|
|
212
|
+
if event.key == "enter":
|
|
213
|
+
return items[self.cursor].value if items else _CANCEL
|
|
214
|
+
|
|
215
|
+
if event.key in ("up", "down", "pgup", "pgdn", "home", "end"):
|
|
216
|
+
self._move(event.key, len(items))
|
|
217
|
+
return None
|
|
218
|
+
if event.key == "backspace":
|
|
219
|
+
self.filter = self.filter[:-1]
|
|
220
|
+
self._reset_cursor()
|
|
221
|
+
return None
|
|
222
|
+
if event.key == "char" and event.char and not event.ctrl and not event.alt:
|
|
223
|
+
self.filter += event.char
|
|
224
|
+
self._reset_cursor()
|
|
225
|
+
return None
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
def _move(self, key: str, count: int) -> None:
|
|
229
|
+
if count == 0:
|
|
230
|
+
return
|
|
231
|
+
window = self.rows()
|
|
232
|
+
if key == "up":
|
|
233
|
+
# Wrapping, because a list that stops dead at the top makes you
|
|
234
|
+
# reach for the mouse to get to the bottom of a long one.
|
|
235
|
+
self.cursor = (self.cursor - 1) % count
|
|
236
|
+
elif key == "down":
|
|
237
|
+
self.cursor = (self.cursor + 1) % count
|
|
238
|
+
elif key == "pgup":
|
|
239
|
+
self.cursor = max(0, self.cursor - window)
|
|
240
|
+
elif key == "pgdn":
|
|
241
|
+
self.cursor = min(count - 1, self.cursor + window)
|
|
242
|
+
elif key == "home":
|
|
243
|
+
self.cursor = 0
|
|
244
|
+
elif key == "end":
|
|
245
|
+
self.cursor = count - 1
|
|
246
|
+
|
|
247
|
+
def _reset_cursor(self) -> None:
|
|
248
|
+
"""A changed filter means a changed list; start at the top of it."""
|
|
249
|
+
self.cursor = 0
|
|
250
|
+
self.offset = 0
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class _Cancel:
|
|
254
|
+
pass
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
_CANCEL = _Cancel()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def choose(console: Console, theme: Theme, options: Sequence[Option],
|
|
261
|
+
title: str = "", default: int = 0) -> str | None:
|
|
262
|
+
"""The whole interaction, or None if there is no terminal to run it in."""
|
|
263
|
+
if not interactive(console) or not options:
|
|
264
|
+
return None
|
|
265
|
+
try:
|
|
266
|
+
return Chooser(console, theme, options, title=title, default=default).run()
|
|
267
|
+
except Exception:
|
|
268
|
+
# A terminal that will not do raw mode, a reader that will not start:
|
|
269
|
+
# the caller still has the numbered prompt, and a failed experiment
|
|
270
|
+
# must not cost somebody their first run.
|
|
271
|
+
return None
|
|
@@ -439,7 +439,7 @@ def survey(config: Config | None = None, cwd: Path | None = None) -> Survey:
|
|
|
439
439
|
detail=install.detail,
|
|
440
440
|
path=install.root,
|
|
441
441
|
size=directory_size(install.root) if install.root else 0,
|
|
442
|
-
remove=lambda tool=tool: _run_uninstaller(tool),
|
|
442
|
+
remove=lambda tool=tool, root=install.root: _run_uninstaller(tool, root),
|
|
443
443
|
deferred=sys.platform == "win32",
|
|
444
444
|
))
|
|
445
445
|
elif install.method == "venv" and install.owned:
|
|
@@ -490,9 +490,17 @@ def survey(config: Config | None = None, cwd: Path | None = None) -> Survey:
|
|
|
490
490
|
|
|
491
491
|
others = _other_launchers(BIN_DIR)
|
|
492
492
|
if others:
|
|
493
|
+
# Named, not counted. "2 other programs live there" leaves the reader
|
|
494
|
+
# with a decision they have no information for; `uv, uvx` tells them
|
|
495
|
+
# whether the line is worth keeping, and those two in particular were
|
|
496
|
+
# very likely fetched by this installer in the first place.
|
|
497
|
+
listed = ", ".join(others[:4])
|
|
498
|
+
if len(others) > 4:
|
|
499
|
+
listed += f" and {len(others) - 4} more"
|
|
493
500
|
found.notes.append(
|
|
494
|
-
f"{BIN_DIR} stays on your PATH
|
|
495
|
-
f"{
|
|
501
|
+
f"{BIN_DIR} stays on your PATH, and so does the line that put it "
|
|
502
|
+
f"there: {listed} still live in it. Remove the line by hand if "
|
|
503
|
+
f"none of those matter to you.")
|
|
496
504
|
else:
|
|
497
505
|
for profile, _ in profile_edits(BIN_DIR):
|
|
498
506
|
found.add(Item(
|
|
@@ -525,30 +533,84 @@ def _within(path: Path, root: Path) -> bool:
|
|
|
525
533
|
return False
|
|
526
534
|
|
|
527
535
|
|
|
528
|
-
def _other_launchers(directory: Path) ->
|
|
529
|
-
"""
|
|
536
|
+
def _other_launchers(directory: Path) -> list[str]:
|
|
537
|
+
"""What else lives in the launcher directory, by name."""
|
|
530
538
|
if not directory.is_dir():
|
|
531
|
-
return
|
|
539
|
+
return []
|
|
532
540
|
ours = {"comodor", "comodor.exe", "comodor.cmd", "comodor-script.py"}
|
|
533
541
|
try:
|
|
534
|
-
return
|
|
542
|
+
return sorted(entry.name for entry in directory.iterdir()
|
|
543
|
+
if entry.name not in ours)
|
|
535
544
|
except OSError:
|
|
536
|
-
return
|
|
537
|
-
|
|
545
|
+
return []
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
#: Where a tool manager lives when it is not on PATH. The same list the
|
|
549
|
+
#: installer searches, and for the same reason: `curl | sh` runs without the
|
|
550
|
+
#: user's profile, so `uv` gets bootstrapped into ~/.local/bin and nothing in
|
|
551
|
+
#: that session ever puts it on PATH.
|
|
552
|
+
TOOL_DIRS = (
|
|
553
|
+
Path.home() / ".local" / "bin",
|
|
554
|
+
Path.home() / ".cargo" / "bin",
|
|
555
|
+
Path.home() / "bin",
|
|
556
|
+
Path("/opt/homebrew/bin"),
|
|
557
|
+
Path("/usr/local/bin"),
|
|
558
|
+
Path("/home/linuxbrew/.linuxbrew/bin"),
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def find_tool(name: str) -> str | None:
|
|
563
|
+
"""`name` on PATH, or in the places an installer would have put it."""
|
|
564
|
+
found = shutil.which(name)
|
|
565
|
+
if found:
|
|
566
|
+
return found
|
|
567
|
+
suffixes = (".exe", ".cmd", "") if sys.platform == "win32" else ("",)
|
|
568
|
+
for directory in TOOL_DIRS:
|
|
569
|
+
for suffix in suffixes:
|
|
570
|
+
candidate = directory / f"{name}{suffix}"
|
|
571
|
+
if candidate.is_file():
|
|
572
|
+
return str(candidate)
|
|
573
|
+
return None
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _run_uninstaller(tool: str, root: Path | None = None) -> str:
|
|
577
|
+
"""Hand the package back to whatever installed it.
|
|
578
|
+
|
|
579
|
+
And if that is not possible, take it off anyway.
|
|
580
|
+
|
|
581
|
+
uv and pipx keep a record of the tools they manage, so asking them is the
|
|
582
|
+
tidy way: it removes the environment *and* the entry. But the installer
|
|
583
|
+
fetches `uv` into `~/.local/bin` when the machine has none, and a shell
|
|
584
|
+
that ran `curl | sh` never read a profile, so `uv` is frequently installed
|
|
585
|
+
and simultaneously not on PATH — which is how a real uninstall reported
|
|
586
|
+
"No such file or directory: 'uv'" and left seventy megabytes behind.
|
|
587
|
+
|
|
588
|
+
So the tool is looked for where an installer would have put it, and if it
|
|
589
|
+
genuinely is not there the environment is deleted directly. That leaves uv
|
|
590
|
+
with an entry for something that is gone, which is worth saying out loud —
|
|
591
|
+
and is a great deal better than leaving the thing itself.
|
|
592
|
+
"""
|
|
593
|
+
if tool == "pip":
|
|
594
|
+
command = [sys.executable, "-m", "pip", "uninstall", "-y", "comodor"]
|
|
595
|
+
else:
|
|
596
|
+
executable = find_tool(tool)
|
|
597
|
+
if executable is None:
|
|
598
|
+
if root is None:
|
|
599
|
+
raise RuntimeError(f"{tool} is not installed any more")
|
|
600
|
+
_rmtree(root)
|
|
601
|
+
return f"{root} (removed directly: {tool} is not on this machine)"
|
|
602
|
+
command = ([executable, "tool", "uninstall", "comodor"] if tool == "uv"
|
|
603
|
+
else [executable, "uninstall", "comodor"])
|
|
538
604
|
|
|
539
|
-
def _run_uninstaller(tool: str) -> str:
|
|
540
|
-
"""Hand the package back to whatever installed it."""
|
|
541
|
-
commands = {
|
|
542
|
-
"uv": ["uv", "tool", "uninstall", "comodor"],
|
|
543
|
-
"pipx": ["pipx", "uninstall", "comodor"],
|
|
544
|
-
"pip": [sys.executable, "-m", "pip", "uninstall", "-y", "comodor"],
|
|
545
|
-
}
|
|
546
|
-
command = commands[tool]
|
|
547
605
|
result = subprocess.run(command, capture_output=True, text=True, timeout=180)
|
|
548
606
|
if result.returncode != 0:
|
|
607
|
+
if root is not None and root.exists():
|
|
608
|
+
_rmtree(root)
|
|
609
|
+
reason = (result.stderr or result.stdout).strip().splitlines()
|
|
610
|
+
return f"{root} (removed directly: {reason[-1][:80] if reason else tool})"
|
|
549
611
|
raise RuntimeError((result.stderr or result.stdout).strip()[:200]
|
|
550
612
|
or f"{tool} exited {result.returncode}")
|
|
551
|
-
return " ".join(command[
|
|
613
|
+
return " ".join(command[1:])
|
|
552
614
|
|
|
553
615
|
|
|
554
616
|
# --------------------------------------------------------------------------- #
|