devstuff 1.13.1__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.
dev_setup/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("devstuff")
5
+ except PackageNotFoundError:
6
+ __version__ = "dev"
dev_setup/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ from dev_setup.cli import cli
2
+
3
+
4
+ def main() -> None:
5
+ cli()
6
+
7
+
8
+ if __name__ == "__main__":
9
+ main()
dev_setup/base.py ADDED
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from pathlib import Path
5
+
6
+
7
+ class Tool(ABC):
8
+ key: str = ""
9
+ name: str = ""
10
+ description: str = ""
11
+ category: str = "custom"
12
+ install_type: str = "unknown"
13
+ builtin: bool = True
14
+ help_cmd: str = ""
15
+ docs_url: str = ""
16
+ requires: list = [] # keys of tools that must be installed before this one
17
+
18
+ @abstractmethod
19
+ def is_installed(self) -> bool: ...
20
+
21
+ @abstractmethod
22
+ def install(self) -> str | None:
23
+ """Install the tool. Return version string if available. Raise on failure."""
24
+ ...
25
+
26
+ @abstractmethod
27
+ def remove(self) -> None:
28
+ """Uninstall the tool. Raise on failure."""
29
+ ...
30
+
31
+ def get_version(self) -> str:
32
+ return ""
33
+
34
+
35
+ def patch_bashrc(block_name: str, content: str) -> bool:
36
+ """Idempotently append a named block to ~/.bashrc. Returns True if added."""
37
+ bashrc = Path.home() / ".bashrc"
38
+ marker = f"# {block_name}"
39
+
40
+ if bashrc.exists() and marker in bashrc.read_text():
41
+ return False
42
+
43
+ with bashrc.open("a") as f:
44
+ f.write(f"\n{marker}\n{content}\n")
45
+ return True
46
+
47
+
48
+ def remove_bashrc_block(block_name: str) -> bool:
49
+ """Remove a named block (and the line after the marker) from ~/.bashrc."""
50
+ bashrc = Path.home() / ".bashrc"
51
+ if not bashrc.exists():
52
+ return False
53
+
54
+ lines = bashrc.read_text().splitlines(keepends=True)
55
+ marker = f"# {block_name}"
56
+ out = []
57
+ i = 0
58
+ removed = False
59
+ while i < len(lines):
60
+ stripped = lines[i].rstrip()
61
+ if stripped == marker:
62
+ i += 1
63
+ while i < len(lines) and lines[i].strip():
64
+ i += 1
65
+ if i < len(lines) and not lines[i].strip():
66
+ i += 1
67
+ removed = True
68
+ else:
69
+ out.append(lines[i])
70
+ i += 1
71
+
72
+ if removed:
73
+ bashrc.write_text("".join(out))
74
+ return removed
dev_setup/catalog.py ADDED
@@ -0,0 +1,188 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import re
5
+ from importlib import resources
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import yaml
10
+
11
+ CONFIG_DIR = Path.home() / ".config" / "dev-setup"
12
+ USER_CATALOG_PATH = CONFIG_DIR / "tools.yaml"
13
+ BUNDLED_CATALOG = "tools.yaml"
14
+
15
+ VERSION = 1
16
+ VALID_KEY = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
17
+ SUPPORTED_FIELDS = {
18
+ "name",
19
+ "description",
20
+ "category",
21
+ "type",
22
+ "check_cmd",
23
+ "version_cmd",
24
+ "help_cmd",
25
+ "docs_url",
26
+ "requires",
27
+ "npm_name",
28
+ "pip_name",
29
+ "apt_packages",
30
+ "git_url",
31
+ "git_install_cmd",
32
+ "git_remove_cmd",
33
+ "script_url",
34
+ "sha256",
35
+ "install_script",
36
+ "remove_script",
37
+ }
38
+
39
+
40
+ class CatalogError(RuntimeError):
41
+ pass
42
+
43
+
44
+ def bundled_catalog_path() -> str:
45
+ return f"dev_setup/{BUNDLED_CATALOG}"
46
+
47
+
48
+ def load_catalog_file(path: Path, *, required: bool = False) -> dict[str, dict[str, Any]]:
49
+ if not path.exists():
50
+ if required:
51
+ raise CatalogError(f"Catalog file not found: {path}")
52
+ return {}
53
+
54
+ try:
55
+ raw = yaml.safe_load(path.read_text()) or {}
56
+ except yaml.YAMLError as exc:
57
+ raise CatalogError(f"Invalid YAML in {path}: {exc}") from exc
58
+
59
+ return validate_catalog(raw, source=path)
60
+
61
+
62
+ def validate_catalog(raw: Any, *, source: Path | str = "<catalog>") -> dict[str, dict[str, Any]]:
63
+ if not isinstance(raw, dict):
64
+ raise CatalogError(f"{source}: catalog must be a mapping")
65
+ if raw.get("version") != VERSION:
66
+ raise CatalogError(f"{source}: version must be {VERSION}")
67
+
68
+ tools = raw.get("tools")
69
+ if tools is None:
70
+ return {}
71
+ if not isinstance(tools, dict):
72
+ raise CatalogError(f"{source}: tools must be a mapping")
73
+
74
+ validated: dict[str, dict[str, Any]] = {}
75
+ for key, data in tools.items():
76
+ if not isinstance(key, str) or not VALID_KEY.match(key):
77
+ raise CatalogError(f"{source}: invalid tool key {key!r}")
78
+ if not isinstance(data, dict):
79
+ raise CatalogError(f"{source}: tool {key!r} must be a mapping")
80
+
81
+ unknown = sorted(set(data) - SUPPORTED_FIELDS)
82
+ if unknown:
83
+ fields = ", ".join(unknown)
84
+ raise CatalogError(f"{source}: tool {key!r} has unknown field(s): {fields}")
85
+
86
+ item = copy.deepcopy(data)
87
+ item.setdefault("name", key)
88
+ item.setdefault("description", "")
89
+ item.setdefault("category", "custom")
90
+ item.setdefault("type", "unknown")
91
+
92
+ requires = item.get("requires")
93
+ if requires is None:
94
+ if item["type"] == "npm":
95
+ item["requires"] = ["nvm"]
96
+ elif item["type"] in ("pip", "uvx"):
97
+ item["requires"] = ["uv"]
98
+ else:
99
+ item["requires"] = []
100
+ elif not isinstance(requires, list) or not all(isinstance(v, str) for v in requires):
101
+ raise CatalogError(f"{source}: tool {key!r} requires must be a list of strings")
102
+
103
+ validated[key] = item
104
+
105
+ return validated
106
+
107
+
108
+ def catalog_document(tools: dict[str, dict[str, Any]]) -> dict[str, Any]:
109
+ return {"version": VERSION, "tools": tools}
110
+
111
+
112
+ def read_user_catalog() -> dict[str, dict[str, Any]]:
113
+ return load_catalog_file(USER_CATALOG_PATH)
114
+
115
+
116
+ def write_user_catalog(tools: dict[str, dict[str, Any]]) -> None:
117
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
118
+ USER_CATALOG_PATH.write_text(_dump(catalog_document(tools)))
119
+
120
+
121
+ def load_bundled_catalog() -> dict[str, dict[str, Any]]:
122
+ resource = resources.files("dev_setup").joinpath(BUNDLED_CATALOG)
123
+ try:
124
+ raw = yaml.safe_load(resource.read_text()) or {}
125
+ except FileNotFoundError as exc:
126
+ raise CatalogError(f"Catalog file not found: {bundled_catalog_path()}") from exc
127
+ except yaml.YAMLError as exc:
128
+ raise CatalogError(f"Invalid YAML in {bundled_catalog_path()}: {exc}") from exc
129
+ return validate_catalog(raw, source=bundled_catalog_path())
130
+
131
+
132
+ def load_effective_catalog() -> tuple[
133
+ dict[str, dict[str, Any]],
134
+ dict[str, dict[str, Any]],
135
+ dict[str, dict[str, Any]],
136
+ ]:
137
+ bundled = load_bundled_catalog()
138
+ user = read_user_catalog()
139
+ effective = merge_catalogs(bundled, user)
140
+ return effective, bundled, user
141
+
142
+
143
+ def merge_catalogs(
144
+ bundled: dict[str, dict[str, Any]],
145
+ user: dict[str, dict[str, Any]],
146
+ ) -> dict[str, dict[str, Any]]:
147
+ merged = copy.deepcopy(bundled)
148
+ for key, data in user.items():
149
+ merged[key] = copy.deepcopy(data)
150
+ return merged
151
+
152
+
153
+ def save_user_tool(key: str, data: dict[str, Any]) -> None:
154
+ validate_catalog(catalog_document({key: data}), source=USER_CATALOG_PATH)
155
+ user = read_user_catalog()
156
+ user[key] = copy.deepcopy(data)
157
+ write_user_catalog(user)
158
+
159
+
160
+ def delete_user_tool(key: str) -> bool:
161
+ user = read_user_catalog()
162
+ if key not in user:
163
+ return False
164
+ del user[key]
165
+ write_user_catalog(user)
166
+ return True
167
+
168
+
169
+ def user_has_tool(key: str) -> bool:
170
+ return key in read_user_catalog()
171
+
172
+
173
+ def import_catalog(path: Path) -> list[str]:
174
+ incoming = load_catalog_file(path, required=True)
175
+ user = read_user_catalog()
176
+ for key, data in incoming.items():
177
+ user[key] = copy.deepcopy(data)
178
+ write_user_catalog(user)
179
+ return list(incoming)
180
+
181
+
182
+ def export_catalog(path: Path) -> None:
183
+ effective, _bundled, _user = load_effective_catalog()
184
+ path.write_text(_dump(catalog_document(effective)))
185
+
186
+
187
+ def _dump(data: dict[str, Any]) -> str:
188
+ return yaml.safe_dump(data, sort_keys=False, default_flow_style=False)
dev_setup/cli.py ADDED
@@ -0,0 +1,49 @@
1
+ import click
2
+
3
+ from dev_setup import __version__
4
+
5
+
6
+ @click.group(
7
+ invoke_without_command=True,
8
+ context_settings={"help_option_names": ["-h", "--help"]},
9
+ )
10
+ @click.pass_context
11
+ def cli(ctx: click.Context) -> None:
12
+ if ctx.invoked_subcommand is None:
13
+ from dev_setup.commands.help_cmd import print_help
14
+ print_help()
15
+
16
+
17
+ @cli.command("version")
18
+ def version_cmd() -> None:
19
+ """Print version and exit."""
20
+ click.echo(f"devstuff {__version__}")
21
+
22
+
23
+ def _register_commands() -> None:
24
+ from dev_setup.commands.add_cmd import add_cmd
25
+ from dev_setup.commands.catalog_cmd import catalog_cmd
26
+ from dev_setup.commands.delete_cmd import delete_cmd
27
+ from dev_setup.commands.docs_cmd import docs_cmd
28
+ from dev_setup.commands.functions_cmd import functions_cmd
29
+ from dev_setup.commands.install_cmd import install_cmd
30
+ from dev_setup.commands.list_cmd import list_cmd
31
+ from dev_setup.commands.remove_cmd import remove_cmd
32
+ from dev_setup.commands.run_cmd import run_cmd
33
+ from dev_setup.commands.update_cmd import update_cmd
34
+
35
+ cli.add_command(list_cmd, "list")
36
+ cli.add_command(install_cmd, "install")
37
+ cli.add_command(remove_cmd, "remove")
38
+ cli.add_command(remove_cmd, "uninstall")
39
+ cli.add_command(update_cmd, "update")
40
+ cli.add_command(add_cmd, "add")
41
+ cli.add_command(delete_cmd, "delete")
42
+ cli.add_command(delete_cmd, "rm")
43
+ cli.add_command(docs_cmd, "docs")
44
+ cli.add_command(catalog_cmd, "catalog")
45
+ cli.add_command(run_cmd, "run")
46
+ cli.add_command(functions_cmd, "functions")
47
+
48
+
49
+ _register_commands()
File without changes
@@ -0,0 +1,334 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ import click
6
+
7
+ from dev_setup import registry, ui
8
+ from dev_setup.generic import GenericTool
9
+
10
+ _VALID_KEY = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
11
+
12
+ _INSTALL_TEMPLATE = """\
13
+ #!/usr/bin/env bash
14
+ # Install script for {name}
15
+ # Save and close to continue. Delete all content (except this line) to abort.
16
+ set -euo pipefail
17
+
18
+ """
19
+
20
+ _REMOVE_TEMPLATE = """\
21
+ #!/usr/bin/env bash
22
+ # Remove script for {name}
23
+ # Save and close to continue. Leave only comments/blank lines to skip removal.
24
+ set -euo pipefail
25
+
26
+ """
27
+
28
+
29
+ @click.command("add")
30
+ def add_cmd() -> None:
31
+ """Add a custom package via guided wizard."""
32
+ ui.section("Add Custom Package")
33
+
34
+ install_type = ui.select(
35
+ "Package type:",
36
+ ["npm", "uvx", "apt", "git", "script", "bash"],
37
+ )
38
+ if not install_type:
39
+ ui.warn("Aborted.")
40
+ return
41
+
42
+ key = _prompt_key()
43
+ if not key:
44
+ return
45
+
46
+ name = ui.text_input("Display name:", default=key, required=True)
47
+ description = ui.text_input("Short description:", required=False)
48
+
49
+ kwargs: dict = dict(
50
+ key=key,
51
+ name=name,
52
+ description=description,
53
+ category="custom",
54
+ install_type=install_type,
55
+ )
56
+
57
+ if install_type == "npm":
58
+ kwargs["npm_name"] = ui.text_input("npm package name:", default=key, required=True)
59
+ kwargs["check_cmd"] = ui.text_input("Command to check if installed:", default=key)
60
+
61
+ elif install_type == "uvx":
62
+ kwargs["pip_name"] = ui.text_input("PyPI package name:", default=key, required=True)
63
+ kwargs["check_cmd"] = ui.text_input("Command to check if installed:", default=key)
64
+
65
+ elif install_type == "apt":
66
+ kwargs["apt_packages"] = ui.text_input(
67
+ "apt package(s) (space-separated):", default=key, required=True
68
+ )
69
+ kwargs["check_cmd"] = ui.text_input("Command to check if installed:", default=key)
70
+
71
+ elif install_type == "git":
72
+ kwargs["git_url"] = ui.text_input("Git repository URL:", required=True)
73
+ kwargs["git_install_cmd"] = ui.text_input(
74
+ "Post-clone install command (optional, run inside repo):", required=False
75
+ )
76
+ kwargs["git_remove_cmd"] = ui.text_input(
77
+ "Pre-delete remove command (optional):", required=False
78
+ )
79
+ kwargs["check_cmd"] = ui.text_input("Command to check if installed:", required=False)
80
+
81
+ elif install_type == "script":
82
+ kwargs["script_url"] = ui.text_input("Install script URL (curl | sh):", required=True)
83
+ kwargs["check_cmd"] = ui.text_input("Command to check if installed:", required=False)
84
+
85
+ elif install_type == "bash":
86
+ kwargs["check_cmd"] = ui.text_input(
87
+ "Command to verify installation (e.g. bat, aws):", required=False
88
+ )
89
+ install_script = _edit_script("install", name, required=True)
90
+ if install_script is None:
91
+ ui.warn("No install script provided — aborted.")
92
+ return
93
+ kwargs["install_script"] = install_script
94
+
95
+ remove_script = _edit_script("remove", name, required=False)
96
+ remove_script = _validate_remove_script(
97
+ remove_script or "",
98
+ install_script,
99
+ kwargs.get("check_cmd", ""),
100
+ name,
101
+ )
102
+ if remove_script:
103
+ kwargs["remove_script"] = remove_script
104
+
105
+ kwargs["help_cmd"] = ui.text_input(
106
+ "Help command (optional, e.g. tool --help):", required=False
107
+ )
108
+ kwargs["docs_url"] = ui.text_input(
109
+ "Documentation URL (optional):", required=False
110
+ )
111
+
112
+ ui.console.print()
113
+ ui.console.print("[bold]Summary[/]")
114
+ for k, v in kwargs.items():
115
+ if v:
116
+ display = _truncate_script(v) if k.endswith("_script") else v
117
+ ui.console.print(f" [dim]{k:<18}[/] {display}")
118
+ ui.console.print()
119
+
120
+ if not ui.confirm("Save this package?"):
121
+ ui.warn("Aborted — package not saved.")
122
+ return
123
+
124
+ tool = GenericTool(**kwargs)
125
+ tool.save()
126
+
127
+ from dev_setup import registry
128
+ registry.register(tool)
129
+
130
+ ui.success(f"Package '{key}' added. Install with: devstuff install {key}")
131
+
132
+
133
+ def _prompt_key() -> str:
134
+ while True:
135
+ key = ui.text_input("Package key (lowercase, hyphens ok):", required=True)
136
+ if not _VALID_KEY.match(key):
137
+ ui.error("Key must be lowercase letters, digits, hyphens, or underscores.")
138
+ continue
139
+ if registry.exists(key):
140
+ ui.error(f"A package with key '{key}' already exists.")
141
+ if not ui.confirm("Use a different key?"):
142
+ return ""
143
+ continue
144
+ return key
145
+
146
+
147
+ def _edit_script(
148
+ action: str,
149
+ package_name: str,
150
+ required: bool = True,
151
+ prefill: str = "",
152
+ quiet: bool = False,
153
+ ) -> str | None:
154
+ """Open $EDITOR with a bash template. Returns stripped script or None if aborted."""
155
+ if prefill:
156
+ template = prefill
157
+ else:
158
+ template = (_INSTALL_TEMPLATE if action == "install" else _REMOVE_TEMPLATE).format(
159
+ name=package_name
160
+ )
161
+ if not quiet:
162
+ label = "install" if action == "install" else "remove (optional)"
163
+ ui.console.print()
164
+ ui.info(f"Opening $EDITOR for the [bold]{label}[/] script...")
165
+ ui.dim("Write your bash commands, save, and close the editor to continue.")
166
+ ui.console.print()
167
+
168
+ try:
169
+ content = click.edit(text=template, extension=".sh", require_save=False)
170
+ except Exception as exc:
171
+ ui.error(f"Could not open editor: {exc}")
172
+ ui.dim("Set the EDITOR environment variable (e.g. export EDITOR=nano) and try again.")
173
+ return None
174
+
175
+ if content is None:
176
+ return None
177
+
178
+ script = _strip_template_comments(content)
179
+ if not script and required:
180
+ ui.error("Install script cannot be empty.")
181
+ return None
182
+ return script or ""
183
+
184
+
185
+ def _strip_template_comments(content: str) -> str:
186
+ """Remove comment-only lines and leading/trailing blank lines."""
187
+ lines = [
188
+ line for line in content.splitlines()
189
+ if line.strip() and not line.strip().startswith("#")
190
+ ]
191
+ return "\n".join(lines).strip()
192
+
193
+
194
+ def _is_safe_literal_path(path: str) -> bool:
195
+ """Return True only for fully literal paths — no shell expansion or injection chars."""
196
+ return not any(c in path for c in ('$', '`', '(', ')', ';', '&', '|', ' ', '"', "'", '\\'))
197
+
198
+
199
+ def _extract_installed_paths(install_script: str) -> list[str]:
200
+ """Find definitive binary install destinations (literal paths only)."""
201
+ paths: list[str] = []
202
+
203
+ # curl ... -o /path/binary
204
+ for m in re.finditer(r'\bcurl\b[^|\n]*?-o\s+(\S+)', install_script):
205
+ p = m.group(1).strip()
206
+ if (
207
+ p.startswith(("/", "~/"))
208
+ and _is_safe_literal_path(p)
209
+ and not p.endswith(('.sh', '.tar.gz', '.zip', '.tmp', '.tar', '.bz2', '.gz'))
210
+ ):
211
+ paths.append(p)
212
+
213
+ # sudo mv /tmp/x /usr/local/bin/y — destination only
214
+ for m in re.finditer(
215
+ r'\bmv\s+\S+\s+((?:/usr|/opt)[\w./+-]*)', install_script
216
+ ):
217
+ p = m.group(1).strip()
218
+ if _is_safe_literal_path(p) and "." not in p.split("/")[-1]:
219
+ paths.append(p)
220
+
221
+ return list(dict.fromkeys(paths))
222
+
223
+
224
+ def _suggest_remove_script(install_script: str, check_cmd: str, name: str) -> str:
225
+ """Generate a best-effort remove script from patterns in the install script."""
226
+ svc_lines: list[str] = []
227
+ actions: list[str] = []
228
+
229
+ # systemd service teardown (must precede file removal)
230
+ for m in re.finditer(r'\bsystemctl\s+(?:enable|start)\s+(\S+)', install_script):
231
+ svc = m.group(1).rstrip(";")
232
+ if not any(svc in a for a in svc_lines):
233
+ svc_lines.append(f"sudo systemctl stop {svc} 2>/dev/null || true")
234
+ svc_lines.append(f"sudo systemctl disable {svc} 2>/dev/null || true")
235
+
236
+ # apt packages
237
+ apt_packages: list[str] = []
238
+ for m in re.finditer(r'\bapt(?:-get)?\s+install\s+(?:-y\s+)?(.+)', install_script):
239
+ for pkg in m.group(1).split():
240
+ if not pkg.startswith("-") and pkg not in apt_packages:
241
+ apt_packages.append(pkg)
242
+ if apt_packages:
243
+ actions.append(f"sudo apt-get remove -y {' '.join(apt_packages)}")
244
+
245
+ # Binary paths from curl / mv (skip /tmp — temp downloads are gone after mv)
246
+ for path in _extract_installed_paths(install_script):
247
+ if path.startswith("/tmp/"):
248
+ continue
249
+ prefix = "sudo " if path.startswith(("/usr/", "/opt/", "/etc/")) else ""
250
+ actions.append(f"{prefix}rm -rf {path}")
251
+
252
+ # Fallback: derive removal from check_cmd if nothing else found
253
+ if not actions and not svc_lines and check_cmd:
254
+ actions.append(f'CMD=$(command -v {check_cmd} 2>/dev/null || true)')
255
+ actions.append('[ -n "$CMD" ] && sudo rm -f "$CMD"')
256
+
257
+ if not svc_lines and not actions:
258
+ return ""
259
+
260
+ lines = ["#!/usr/bin/env bash", "set -euo pipefail", ""] + svc_lines + actions
261
+ return "\n".join(lines)
262
+
263
+
264
+ def _remove_needs_review(remove_script: str, install_script: str) -> bool:
265
+ """Return True when the remove script warrants a review prompt."""
266
+ if not remove_script.strip():
267
+ return True
268
+
269
+ installed_paths = _extract_installed_paths(install_script)
270
+ if not installed_paths:
271
+ return False # can't tell — don't be noisy
272
+
273
+ def _references_target(path: str) -> bool:
274
+ parts = [p for p in path.rstrip("/").split("/") if p]
275
+ candidates = [path]
276
+ if parts:
277
+ candidates.append(parts[-1]) # basename
278
+ for i in range(2, len(parts)): # ancestor dirs
279
+ candidates.append("/" + "/".join(parts[:i]))
280
+ return any(c in remove_script for c in candidates)
281
+
282
+ # Flag only when NONE of the detected targets is referenced
283
+ return not any(_references_target(p) for p in installed_paths)
284
+
285
+
286
+ def _validate_remove_script(
287
+ remove_script: str,
288
+ install_script: str,
289
+ check_cmd: str,
290
+ name: str,
291
+ ) -> str:
292
+ """If the remove script looks empty or mismatched, offer a recommended alternative."""
293
+ if not _remove_needs_review(remove_script, install_script):
294
+ return remove_script
295
+
296
+ recommended = _suggest_remove_script(install_script, check_cmd, name)
297
+
298
+ ui.console.print()
299
+ if not remove_script.strip():
300
+ ui.warn("Remove script is empty — uninstalling will fail without one.")
301
+ else:
302
+ ui.warn("Remove script may not undo the installation (possible path mismatch).")
303
+
304
+ if recommended:
305
+ ui.console.print()
306
+ ui.info("Suggested remove script:")
307
+ ui.console.print()
308
+ ui.code_block(recommended)
309
+ ui.console.print()
310
+
311
+ choices: list[str] = []
312
+ if recommended:
313
+ choices.append("Use recommended")
314
+ choices.append("Keep mine (empty)" if not remove_script.strip() else "Keep mine")
315
+ choices.append("Edit manually")
316
+
317
+ choice = ui.select("How would you like to proceed?", choices)
318
+
319
+ if choice == "Use recommended":
320
+ return _strip_template_comments(recommended)
321
+ if choice == "Edit manually":
322
+ prefill = recommended or remove_script or _REMOVE_TEMPLATE.format(name=name)
323
+ edited = _edit_script("remove", name, required=False, prefill=prefill, quiet=True)
324
+ return edited if edited is not None else remove_script
325
+ return remove_script # "Keep mine" / "Keep mine (empty)"
326
+
327
+
328
+ def _truncate_script(script: str) -> str:
329
+ lines = [ln for ln in script.splitlines() if ln.strip()]
330
+ if not lines:
331
+ return "[dim](empty)[/]"
332
+ first = lines[0][:60]
333
+ suffix = f" [dim]+{len(lines) - 1} more lines[/]" if len(lines) > 1 else ""
334
+ return f"{first}{suffix}"