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.
@@ -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