cli-tools-kit 0.6.0__py3-none-any.whl
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.
- cli_tools_kit/__init__.py +59 -0
- cli_tools_kit/__main__.py +43 -0
- cli_tools_kit/advertise.py +77 -0
- cli_tools_kit/cron_installer.py +159 -0
- cli_tools_kit/gui_installer.py +5928 -0
- cli_tools_kit/host.py +239 -0
- cli_tools_kit/identity.py +229 -0
- cli_tools_kit/onboarding.py +261 -0
- cli_tools_kit/skills.py +97 -0
- cli_tools_kit/sources.py +335 -0
- cli_tools_kit/taxonomy/__init__.py +40 -0
- cli_tools_kit/taxonomy/build.py +171 -0
- cli_tools_kit/taxonomy/capability.py +116 -0
- cli_tools_kit/taxonomy/cluster.py +261 -0
- cli_tools_kit/taxonomy/corpus.py +268 -0
- cli_tools_kit/taxonomy/embedder.py +170 -0
- cli_tools_kit/taxonomy/groups.py +108 -0
- cli_tools_kit/taxonomy/llm_groups.py +748 -0
- cli_tools_kit/tool_installer.py +572 -0
- cli_tools_kit/tui_installer.py +462 -0
- cli_tools_kit-0.6.0.dist-info/METADATA +505 -0
- cli_tools_kit-0.6.0.dist-info/RECORD +26 -0
- cli_tools_kit-0.6.0.dist-info/WHEEL +5 -0
- cli_tools_kit-0.6.0.dist-info/entry_points.txt +3 -0
- cli_tools_kit-0.6.0.dist-info/licenses/LICENSE +21 -0
- cli_tools_kit-0.6.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
"""Curses installer for terminals without a display.
|
|
2
|
+
|
|
3
|
+
The tkinter engine in :mod:`gui_installer` needs an X or Wayland session and
|
|
4
|
+
the ``python3-tk`` package. A lab PC reached over SSH, WSL without a display
|
|
5
|
+
server, or a minimal server image has neither, so ``installer.py`` falls back
|
|
6
|
+
to this screen: the same rows (install / skill / status) and the same Apply
|
|
7
|
+
step, drawn with the stdlib ``curses`` module.
|
|
8
|
+
|
|
9
|
+
installer.py --tui force this screen
|
|
10
|
+
installer.py --gui force tkinter
|
|
11
|
+
installer.py tkinter when it can open a window, else this
|
|
12
|
+
|
|
13
|
+
Everything that touches the host goes through :mod:`gui_installer`'s
|
|
14
|
+
primitives, looked up on that module at call time, so a wrapper that replaced
|
|
15
|
+
them (FAU-WW3-Tools routes each tool through its own venv) is honoured here
|
|
16
|
+
as well.
|
|
17
|
+
|
|
18
|
+
Skill targets
|
|
19
|
+
-------------
|
|
20
|
+
A tool that advertises ``skill_name`` can register its Claude Code skill in
|
|
21
|
+
more than one place. The default target, :func:`claude_target`, writes
|
|
22
|
+
``~/.claude/skills/<name>/`` through the tool's own ``--install-skill``. A
|
|
23
|
+
wrapper passes further :class:`SkillTarget` values via
|
|
24
|
+
``run(skill_targets=[...])``; the screen lists them and the user picks which
|
|
25
|
+
ones the Apply step writes to. Targets the user left unticked are not touched.
|
|
26
|
+
|
|
27
|
+
Keys
|
|
28
|
+
----
|
|
29
|
+
``Up``/``Down`` or ``j``/``k`` move, ``Space`` ticks Install, ``s`` ticks
|
|
30
|
+
Skill, ``1``..``9`` tick a skill target, ``a``/``n`` tick all/none, ``Enter``
|
|
31
|
+
applies, ``q`` quits.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import os
|
|
37
|
+
import queue
|
|
38
|
+
import re
|
|
39
|
+
import sys
|
|
40
|
+
import threading
|
|
41
|
+
from collections import Counter
|
|
42
|
+
from dataclasses import dataclass, field
|
|
43
|
+
from typing import Callable, Dict, List, NamedTuple, Optional, Set, Tuple
|
|
44
|
+
|
|
45
|
+
from . import gui_installer as gi
|
|
46
|
+
from . import host
|
|
47
|
+
from .gui_installer import ToolEntry
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SkillTarget(NamedTuple):
|
|
51
|
+
"""One place a tool's Claude Code skill can be registered."""
|
|
52
|
+
key: str # "claude" — also the tick label
|
|
53
|
+
label: str # shown in the target line
|
|
54
|
+
installed: Callable[[ToolEntry], bool] # is the skill there already?
|
|
55
|
+
install: Callable[[ToolEntry], Tuple[bool, str]] # (ok, output)
|
|
56
|
+
uninstall: Callable[[ToolEntry], Tuple[bool, str]] # (ok, output)
|
|
57
|
+
stale: Callable[[ToolEntry], bool] = lambda tool: False # reinstall even if present
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def claude_target() -> SkillTarget:
|
|
61
|
+
"""The default target: ``~/.claude/skills/<name>/`` via ``--install-skill``."""
|
|
62
|
+
return SkillTarget(
|
|
63
|
+
key="claude",
|
|
64
|
+
label="Claude Code (~/.claude/skills)",
|
|
65
|
+
installed=lambda tool: gi._skill_installed(tool.skill_name),
|
|
66
|
+
install=lambda tool: gi.install_skill_for_tool(tool),
|
|
67
|
+
uninstall=lambda tool: gi.uninstall_skill_for_tool(tool),
|
|
68
|
+
stale=lambda tool: tool.skill_status == "stale",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def prefer_tui(*, force_tui: bool = False, force_gui: bool = False,
|
|
73
|
+
have_tk: bool = True, environ: Optional[Dict[str, str]] = None) -> bool:
|
|
74
|
+
"""Decide between this screen and tkinter.
|
|
75
|
+
|
|
76
|
+
Explicit flags win. Without them, tkinter runs only when it is importable
|
|
77
|
+
and a display is reachable (``DISPLAY`` or ``WAYLAND_DISPLAY`` set).
|
|
78
|
+
|
|
79
|
+
Windows has no DISPLAY variable and ships tkinter with Python, so there the
|
|
80
|
+
answer is just whether ``--tui`` was passed.
|
|
81
|
+
"""
|
|
82
|
+
if force_tui:
|
|
83
|
+
return True
|
|
84
|
+
if force_gui:
|
|
85
|
+
return False
|
|
86
|
+
if host.IS_WINDOWS:
|
|
87
|
+
return force_tui
|
|
88
|
+
if not have_tk:
|
|
89
|
+
return True
|
|
90
|
+
env = os.environ if environ is None else environ
|
|
91
|
+
return not (env.get("DISPLAY") or env.get("WAYLAND_DISPLAY"))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class Row:
|
|
96
|
+
tool: ToolEntry
|
|
97
|
+
install: bool
|
|
98
|
+
skill: bool
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def has_skill(self) -> bool:
|
|
102
|
+
return bool(self.tool.skill_name)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def default_rows(tools: List[ToolEntry], preselect: Optional[bool] = None,
|
|
106
|
+
skill_installed: Optional[Callable[[ToolEntry], bool]] = None) -> List[Row]:
|
|
107
|
+
"""Initial tick state.
|
|
108
|
+
|
|
109
|
+
``preselect=None`` ticks everything on a host where none of the tools is
|
|
110
|
+
installed yet (a first run) and otherwise mirrors what is installed.
|
|
111
|
+
``True`` always ticks everything, ``False`` always mirrors the host.
|
|
112
|
+
"""
|
|
113
|
+
installed = [gi.is_installed(t) for t in tools]
|
|
114
|
+
if preselect is None:
|
|
115
|
+
preselect = not any(installed)
|
|
116
|
+
if skill_installed is None:
|
|
117
|
+
skill_installed = lambda tool: gi._skill_installed(tool.skill_name) # noqa: E731
|
|
118
|
+
rows = []
|
|
119
|
+
for tool, is_in in zip(tools, installed):
|
|
120
|
+
want = True if preselect else is_in
|
|
121
|
+
skill = bool(tool.skill_name) and (want if preselect else skill_installed(tool))
|
|
122
|
+
rows.append(Row(tool, want, skill))
|
|
123
|
+
return rows
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class Step(NamedTuple):
|
|
127
|
+
kind: str # install | update | remove | skill_install | skill_remove
|
|
128
|
+
tool: ToolEntry
|
|
129
|
+
target: Optional[SkillTarget] = None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def plan(rows: List[Row], targets: List[SkillTarget], active: Set[str]) -> List[Step]:
|
|
133
|
+
"""What Apply would do, in order: tools first, then skills per active target."""
|
|
134
|
+
steps: List[Step] = []
|
|
135
|
+
for row in rows:
|
|
136
|
+
current = gi.is_installed(row.tool)
|
|
137
|
+
if row.install and not current:
|
|
138
|
+
steps.append(Step("install", row.tool))
|
|
139
|
+
elif row.install and current and gi.needs_update(row.tool):
|
|
140
|
+
steps.append(Step("update", row.tool))
|
|
141
|
+
elif not row.install and current:
|
|
142
|
+
steps.append(Step("remove", row.tool))
|
|
143
|
+
for row in rows:
|
|
144
|
+
if not row.has_skill:
|
|
145
|
+
continue
|
|
146
|
+
for target in targets:
|
|
147
|
+
if target.key not in active:
|
|
148
|
+
continue
|
|
149
|
+
have = target.installed(row.tool)
|
|
150
|
+
if row.skill and row.install and (not have or target.stale(row.tool)):
|
|
151
|
+
steps.append(Step("skill_install", row.tool, target))
|
|
152
|
+
elif not row.skill and have:
|
|
153
|
+
steps.append(Step("skill_remove", row.tool, target))
|
|
154
|
+
return steps
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
_HINT_RE = re.compile(r"^(?:Run|run):\s*(.+)$")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _hint(output: str) -> Optional[str]:
|
|
161
|
+
"""The ``Run: ...`` line a tool prints after installing, if any."""
|
|
162
|
+
for line in output.splitlines():
|
|
163
|
+
match = _HINT_RE.match(line.strip())
|
|
164
|
+
if match:
|
|
165
|
+
return match.group(1).strip()
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def execute(steps: List[Step], log: Callable[[str], None]) -> Dict[str, object]:
|
|
170
|
+
"""Run the steps, logging one line per action plus the tool's own output."""
|
|
171
|
+
counts: Counter = Counter()
|
|
172
|
+
hint = None
|
|
173
|
+
for step in steps:
|
|
174
|
+
tool, target = step.tool, step.target
|
|
175
|
+
if step.kind == "install":
|
|
176
|
+
log(f"Installing {tool.name}")
|
|
177
|
+
ok, out = gi.install_tool(tool)
|
|
178
|
+
elif step.kind == "update":
|
|
179
|
+
log(f"Updating {tool.name}")
|
|
180
|
+
old_alias = gi._find_alias_for_script(tool.script_path)
|
|
181
|
+
if old_alias and old_alias != tool.alias:
|
|
182
|
+
aliases = gi._load_aliases()
|
|
183
|
+
if old_alias in aliases:
|
|
184
|
+
del aliases[old_alias]
|
|
185
|
+
gi._save_aliases(aliases)
|
|
186
|
+
log(f" alias {old_alias} -> {tool.alias}")
|
|
187
|
+
ok, out = gi.install_tool(tool)
|
|
188
|
+
elif step.kind == "remove":
|
|
189
|
+
log(f"Removing {tool.name}")
|
|
190
|
+
if gi.is_autostart_enabled(tool):
|
|
191
|
+
gi.disable_autostart(tool)
|
|
192
|
+
ok, out = gi.remove_tool(tool)
|
|
193
|
+
elif step.kind == "skill_install" and target is not None:
|
|
194
|
+
log(f"Skill {tool.skill_name} -> {target.key}")
|
|
195
|
+
ok, out = target.install(tool)
|
|
196
|
+
elif step.kind == "skill_remove" and target is not None:
|
|
197
|
+
log(f"Skill {tool.skill_name} removed from {target.key}")
|
|
198
|
+
ok, out = target.uninstall(tool)
|
|
199
|
+
else: # pragma: no cover - plan() never emits anything else
|
|
200
|
+
continue
|
|
201
|
+
counts[step.kind if ok else "errors"] += 1
|
|
202
|
+
log(f" {'ok' if ok else 'FAILED'}")
|
|
203
|
+
for line in (out or "").splitlines():
|
|
204
|
+
log(f" {line}")
|
|
205
|
+
if ok and out:
|
|
206
|
+
hint = _hint(out) or hint
|
|
207
|
+
gi.refresh_desktop_database()
|
|
208
|
+
result: Dict[str, object] = dict(counts)
|
|
209
|
+
result["hint"] = hint
|
|
210
|
+
return result
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def summary(result: Dict[str, object]) -> str:
|
|
214
|
+
parts = []
|
|
215
|
+
for kind, label in (("install", "installed"), ("update", "updated"),
|
|
216
|
+
("remove", "removed"), ("skill_install", "skills written"),
|
|
217
|
+
("skill_remove", "skills removed"), ("errors", "errors")):
|
|
218
|
+
if result.get(kind):
|
|
219
|
+
parts.append(f"{result[kind]} {label}")
|
|
220
|
+
return ", ".join(parts) if parts else "nothing to do"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# --- headless apply -----------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
def _matches(tool: ToolEntry, name: str) -> bool:
|
|
226
|
+
name = name.strip().lower()
|
|
227
|
+
return name in {tool.alias.lower(), tool.name.lower(), tool.skill_name.lower(),
|
|
228
|
+
tool.desktop_file.lower().removesuffix(".desktop"),
|
|
229
|
+
os.path.basename(os.path.dirname(tool.script_path)).lower()}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def apply_headless(tools: List[ToolEntry], names: str, target_keys: str = "claude",
|
|
233
|
+
targets: Optional[List[SkillTarget]] = None) -> int:
|
|
234
|
+
"""``--apply NAMES [--skill-target KEYS]``: install the named tools and their
|
|
235
|
+
skills without a screen. Tools not named are left as they are. Returns 0,
|
|
236
|
+
1 on errors, 2 on an unknown name."""
|
|
237
|
+
targets = list(targets) if targets else [claude_target()]
|
|
238
|
+
wanted = [n.strip() for n in names.split(",") if n.strip()]
|
|
239
|
+
if wanted == ["all"]:
|
|
240
|
+
chosen = list(tools)
|
|
241
|
+
else:
|
|
242
|
+
chosen = []
|
|
243
|
+
for name in wanted:
|
|
244
|
+
hits = [t for t in tools if _matches(t, name)]
|
|
245
|
+
if not hits:
|
|
246
|
+
known = ", ".join(sorted({t.alias or t.name for t in tools}))
|
|
247
|
+
print(f"Unknown tool '{name}'. Known: {known}", file=sys.stderr)
|
|
248
|
+
return 2
|
|
249
|
+
chosen.extend(h for h in hits if h not in chosen)
|
|
250
|
+
keys = {k.strip() for k in target_keys.split(",") if k.strip()} - {"none"}
|
|
251
|
+
unknown = keys - {t.key for t in targets}
|
|
252
|
+
if unknown:
|
|
253
|
+
print(f"Unknown skill target(s) {sorted(unknown)}. Known: "
|
|
254
|
+
f"{[t.key for t in targets]}", file=sys.stderr)
|
|
255
|
+
return 2
|
|
256
|
+
rows = [Row(t, True, bool(t.skill_name) and bool(keys)) for t in chosen]
|
|
257
|
+
steps = plan(rows, targets, keys)
|
|
258
|
+
if not steps:
|
|
259
|
+
print("Nothing to do: already installed.")
|
|
260
|
+
return 0
|
|
261
|
+
result = execute(steps, print)
|
|
262
|
+
print(f"Done: {summary(result)}.")
|
|
263
|
+
hint = result.get("hint")
|
|
264
|
+
if hint:
|
|
265
|
+
print(f"Run: {hint}")
|
|
266
|
+
elif result.get("install") or result.get("update"):
|
|
267
|
+
print(host.shell_hint())
|
|
268
|
+
return 1 if result.get("errors") else 0
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# --- the screen ---------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
@dataclass
|
|
274
|
+
class _State:
|
|
275
|
+
rows: List[Row]
|
|
276
|
+
targets: List[SkillTarget]
|
|
277
|
+
active: Set[str]
|
|
278
|
+
title: str
|
|
279
|
+
cursor: int = 0
|
|
280
|
+
status: Dict[int, str] = field(default_factory=dict) # row index -> label
|
|
281
|
+
log: List[str] = field(default_factory=list)
|
|
282
|
+
busy: bool = False
|
|
283
|
+
result: Optional[Dict[str, object]] = None
|
|
284
|
+
events: "queue.Queue" = field(default_factory=queue.Queue)
|
|
285
|
+
|
|
286
|
+
def refresh_status(self) -> None:
|
|
287
|
+
for i, row in enumerate(self.rows):
|
|
288
|
+
if gi.is_installed(row.tool):
|
|
289
|
+
self.status[i] = "update" if gi.needs_update(row.tool) else "installed"
|
|
290
|
+
else:
|
|
291
|
+
self.status[i] = "-"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _worker(steps: List[Step], events: "queue.Queue") -> None:
|
|
295
|
+
try:
|
|
296
|
+
result = execute(steps, lambda msg: events.put(("log", msg)))
|
|
297
|
+
except Exception as exc: # the screen must come back whatever a tool did
|
|
298
|
+
events.put(("log", f" FAILED: {exc}"))
|
|
299
|
+
result = {"errors": 1, "hint": None}
|
|
300
|
+
events.put(("done", result))
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _fit(text: str, width: int) -> str:
|
|
304
|
+
if width <= 0:
|
|
305
|
+
return ""
|
|
306
|
+
return text if len(text) <= width else text[:max(width - 1, 0)] + "…"
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _put(scr, y: int, x: int, text: str, attr: int = 0) -> None:
|
|
310
|
+
"""addstr that tolerates the bottom-right cell and a shrinking terminal."""
|
|
311
|
+
import curses
|
|
312
|
+
height, width = scr.getmaxyx()
|
|
313
|
+
if y < 0 or y >= height or x >= width:
|
|
314
|
+
return
|
|
315
|
+
try:
|
|
316
|
+
scr.addstr(y, x, _fit(text, width - x - 1), attr)
|
|
317
|
+
except curses.error:
|
|
318
|
+
pass
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _draw(scr, st: _State) -> None:
|
|
322
|
+
import curses
|
|
323
|
+
scr.erase()
|
|
324
|
+
height, width = scr.getmaxyx()
|
|
325
|
+
_put(scr, 0, 1, st.title, curses.A_BOLD)
|
|
326
|
+
_put(scr, 1, 1, "Space install s skill 1-9 skill target a all n none "
|
|
327
|
+
"Enter apply q quit", curses.A_DIM)
|
|
328
|
+
tline = "Skills go to:"
|
|
329
|
+
for i, target in enumerate(st.targets, start=1):
|
|
330
|
+
tick = "x" if target.key in st.active else " "
|
|
331
|
+
tline += f" [{tick}] {i} {target.label}"
|
|
332
|
+
_put(scr, 2, 1, tline)
|
|
333
|
+
_put(scr, 3, 1, "─" * (width - 2))
|
|
334
|
+
name_w = max(12, min(28, max((len(r.tool.name) for r in st.rows), default=12)))
|
|
335
|
+
header = f" Inst Skill {'Tool':<{name_w}} {'Alias / shortcut':<20} Status"
|
|
336
|
+
_put(scr, 4, 1, header, curses.A_UNDERLINE)
|
|
337
|
+
|
|
338
|
+
log_lines = max(3, min(10, height - 8 - len(st.rows)))
|
|
339
|
+
list_top, list_height = 5, max(1, height - 5 - log_lines - 2)
|
|
340
|
+
first = max(0, st.cursor - list_height + 1) if st.cursor >= list_height else 0
|
|
341
|
+
for shown, i in enumerate(range(first, min(len(st.rows), first + list_height))):
|
|
342
|
+
row = st.rows[i]
|
|
343
|
+
tool = row.tool
|
|
344
|
+
inst = "[x]" if row.install else "[ ]"
|
|
345
|
+
if row.has_skill:
|
|
346
|
+
skill = "[x]" if row.skill else "[ ]"
|
|
347
|
+
else:
|
|
348
|
+
skill = " "
|
|
349
|
+
handle = tool.alias if "Icon" not in tool.tags else tool.desktop_file
|
|
350
|
+
line = (f"{inst} {skill} {_fit(tool.name, name_w):<{name_w}} "
|
|
351
|
+
f"{_fit(handle, 20):<20} {st.status.get(i, '')}")
|
|
352
|
+
attr = curses.A_REVERSE if i == st.cursor and not st.busy else 0
|
|
353
|
+
_put(scr, list_top + shown, 1, (">" if i == st.cursor else " ") + line, attr)
|
|
354
|
+
if st.rows and st.cursor < len(st.rows):
|
|
355
|
+
desc = st.rows[st.cursor].tool.description
|
|
356
|
+
_put(scr, list_top + list_height, 1, _fit(desc, width - 3), curses.A_DIM)
|
|
357
|
+
|
|
358
|
+
log_top = list_top + list_height + 1
|
|
359
|
+
label = " working… " if st.busy else (f" done: {summary(st.result)} " if st.result else " log ")
|
|
360
|
+
_put(scr, log_top, 1, "─" * 3 + label + "─" * max(0, width - 6 - len(label)))
|
|
361
|
+
for shown, line in enumerate(st.log[-(log_lines - 1):] if log_lines > 1 else []):
|
|
362
|
+
_put(scr, log_top + 1 + shown, 1, line)
|
|
363
|
+
scr.refresh()
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _drain(st: _State) -> None:
|
|
367
|
+
while True:
|
|
368
|
+
try:
|
|
369
|
+
kind, payload = st.events.get_nowait()
|
|
370
|
+
except queue.Empty:
|
|
371
|
+
return
|
|
372
|
+
if kind == "log":
|
|
373
|
+
st.log.append(str(payload))
|
|
374
|
+
elif kind == "done":
|
|
375
|
+
st.result = payload
|
|
376
|
+
st.busy = False
|
|
377
|
+
st.refresh_status()
|
|
378
|
+
hint = payload.get("hint")
|
|
379
|
+
st.log.append(f"Done: {summary(payload)}" + (f" — run: {hint}" if hint else ""))
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _loop(scr, st: _State) -> None:
|
|
383
|
+
import curses
|
|
384
|
+
curses.curs_set(0)
|
|
385
|
+
try:
|
|
386
|
+
curses.use_default_colors()
|
|
387
|
+
except curses.error:
|
|
388
|
+
pass
|
|
389
|
+
scr.keypad(True)
|
|
390
|
+
scr.timeout(100)
|
|
391
|
+
while True:
|
|
392
|
+
_drain(st)
|
|
393
|
+
_draw(scr, st)
|
|
394
|
+
ch = scr.getch()
|
|
395
|
+
if ch == -1 or st.busy:
|
|
396
|
+
continue
|
|
397
|
+
if ch in (ord("q"), 27):
|
|
398
|
+
return
|
|
399
|
+
if ch in (curses.KEY_UP, ord("k")):
|
|
400
|
+
st.cursor = max(0, st.cursor - 1)
|
|
401
|
+
elif ch in (curses.KEY_DOWN, ord("j")):
|
|
402
|
+
st.cursor = min(len(st.rows) - 1, st.cursor + 1)
|
|
403
|
+
elif ch == ord(" ") and st.rows:
|
|
404
|
+
row = st.rows[st.cursor]
|
|
405
|
+
row.install = not row.install
|
|
406
|
+
row.skill = row.install and row.has_skill
|
|
407
|
+
elif ch == ord("s") and st.rows:
|
|
408
|
+
row = st.rows[st.cursor]
|
|
409
|
+
if row.install and row.has_skill:
|
|
410
|
+
row.skill = not row.skill
|
|
411
|
+
elif ord("1") <= ch <= ord("9"):
|
|
412
|
+
idx = ch - ord("1")
|
|
413
|
+
if idx < len(st.targets):
|
|
414
|
+
key = st.targets[idx].key
|
|
415
|
+
st.active.symmetric_difference_update({key})
|
|
416
|
+
elif ch == ord("a"):
|
|
417
|
+
for row in st.rows:
|
|
418
|
+
row.install, row.skill = True, row.has_skill
|
|
419
|
+
elif ch == ord("n"):
|
|
420
|
+
for row in st.rows:
|
|
421
|
+
row.install = row.skill = False
|
|
422
|
+
elif ch in (curses.KEY_ENTER, 10, 13, ord("i")):
|
|
423
|
+
steps = plan(st.rows, st.targets, st.active)
|
|
424
|
+
if not steps:
|
|
425
|
+
st.log.append("Nothing to do.")
|
|
426
|
+
continue
|
|
427
|
+
st.result = None
|
|
428
|
+
st.busy = True
|
|
429
|
+
threading.Thread(target=_worker, args=(steps, st.events), daemon=True).start()
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def run_tui(tools: List[ToolEntry], *, targets: Optional[List[SkillTarget]] = None,
|
|
433
|
+
preselect: Optional[bool] = None, title: str = "Tools installer") -> int:
|
|
434
|
+
"""Open the screen. Returns 0, or 1 when the last Apply reported errors."""
|
|
435
|
+
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
|
436
|
+
print("The text installer needs an interactive terminal. "
|
|
437
|
+
"Use --list for a listing, or run this from a shell.", file=sys.stderr)
|
|
438
|
+
return 2
|
|
439
|
+
if not tools:
|
|
440
|
+
print("No tools found.", file=sys.stderr)
|
|
441
|
+
return 1
|
|
442
|
+
try:
|
|
443
|
+
import curses
|
|
444
|
+
except ImportError:
|
|
445
|
+
print("The text screen needs the curses module, which Python for "
|
|
446
|
+
"Windows does not include. Install it with: "
|
|
447
|
+
"pip install windows-curses", file=sys.stderr)
|
|
448
|
+
return 1
|
|
449
|
+
targets = list(targets) if targets else [claude_target()]
|
|
450
|
+
rows = default_rows(tools, preselect, skill_installed=targets[0].installed)
|
|
451
|
+
st = _State(rows=rows, targets=targets, active={targets[0].key}, title=title)
|
|
452
|
+
st.refresh_status()
|
|
453
|
+
curses.wrapper(lambda scr: _loop(scr, st))
|
|
454
|
+
if st.result is not None:
|
|
455
|
+
print(f"Done: {summary(st.result)}.")
|
|
456
|
+
hint = st.result.get("hint")
|
|
457
|
+
if hint:
|
|
458
|
+
print(f"Run: {hint}")
|
|
459
|
+
elif st.result.get("install") or st.result.get("update"):
|
|
460
|
+
print(host.shell_hint())
|
|
461
|
+
return 1 if st.result.get("errors") else 0
|
|
462
|
+
return 0
|