dev-setup 1.0.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.
dev_setup/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
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,78 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ from abc import ABC, abstractmethod
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+
9
+ class Tool(ABC):
10
+ key: str = ""
11
+ name: str = ""
12
+ description: str = ""
13
+ category: str = "custom"
14
+ install_type: str = "unknown"
15
+ builtin: bool = True
16
+ help_cmd: str = ""
17
+
18
+ @abstractmethod
19
+ def is_installed(self) -> bool: ...
20
+
21
+ @abstractmethod
22
+ def install(self) -> Optional[str]:
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 run_bash(cmd: str, **kwargs) -> subprocess.CompletedProcess:
36
+ return subprocess.run(["bash", "-c", cmd], **kwargs)
37
+
38
+
39
+ def patch_bashrc(block_name: str, content: str) -> bool:
40
+ """Idempotently append a named block to ~/.bashrc. Returns True if added."""
41
+ bashrc = Path.home() / ".bashrc"
42
+ marker = f"# {block_name}"
43
+
44
+ if bashrc.exists() and marker in bashrc.read_text():
45
+ return False
46
+
47
+ with bashrc.open("a") as f:
48
+ f.write(f"\n{marker}\n{content}\n")
49
+ return True
50
+
51
+
52
+ def remove_bashrc_block(block_name: str) -> bool:
53
+ """Remove a named block (and the line after the marker) from ~/.bashrc."""
54
+ bashrc = Path.home() / ".bashrc"
55
+ if not bashrc.exists():
56
+ return False
57
+
58
+ lines = bashrc.read_text().splitlines(keepends=True)
59
+ marker = f"# {block_name}"
60
+ out = []
61
+ i = 0
62
+ removed = False
63
+ while i < len(lines):
64
+ stripped = lines[i].rstrip()
65
+ if stripped == marker:
66
+ i += 1
67
+ while i < len(lines) and lines[i].strip():
68
+ i += 1
69
+ if i < len(lines) and not lines[i].strip():
70
+ i += 1
71
+ removed = True
72
+ else:
73
+ out.append(lines[i])
74
+ i += 1
75
+
76
+ if removed:
77
+ bashrc.write_text("".join(out))
78
+ return removed
dev_setup/cli.py ADDED
@@ -0,0 +1,38 @@
1
+ import click
2
+ from dev_setup import __version__
3
+
4
+
5
+ @click.group(
6
+ invoke_without_command=True,
7
+ context_settings={"help_option_names": ["-h", "--help"]},
8
+ )
9
+ @click.pass_context
10
+ def cli(ctx: click.Context) -> None:
11
+ if ctx.invoked_subcommand is None:
12
+ from dev_setup.commands.help_cmd import print_help
13
+ print_help()
14
+
15
+
16
+ @cli.command("version")
17
+ def version_cmd() -> None:
18
+ """Print version and exit."""
19
+ click.echo(f"dev-setup {__version__}")
20
+
21
+
22
+ def _register_commands() -> None:
23
+ from dev_setup.commands.list_cmd import list_cmd
24
+ from dev_setup.commands.install_cmd import install_cmd
25
+ from dev_setup.commands.remove_cmd import remove_cmd
26
+ from dev_setup.commands.add_cmd import add_cmd
27
+ from dev_setup.commands.delete_cmd import delete_cmd
28
+
29
+ cli.add_command(list_cmd, "list")
30
+ cli.add_command(install_cmd, "install")
31
+ cli.add_command(remove_cmd, "remove")
32
+ cli.add_command(remove_cmd, "uninstall")
33
+ cli.add_command(add_cmd, "add")
34
+ cli.add_command(delete_cmd, "delete")
35
+ cli.add_command(delete_cmd, "rm")
36
+
37
+
38
+ _register_commands()
File without changes
@@ -0,0 +1,185 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import sys
5
+ from typing import Optional
6
+
7
+ import click
8
+
9
+ from dev_setup import registry, ui
10
+ from dev_setup.generic import GenericTool
11
+
12
+ _VALID_KEY = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
13
+
14
+ _INSTALL_TEMPLATE = """\
15
+ #!/usr/bin/env bash
16
+ # Install script for {name}
17
+ # Save and close to continue. Delete all content (except this line) to abort.
18
+ set -euo pipefail
19
+
20
+ """
21
+
22
+ _REMOVE_TEMPLATE = """\
23
+ #!/usr/bin/env bash
24
+ # Remove script for {name}
25
+ # Save and close to continue. Leave only comments/blank lines to skip removal.
26
+ set -euo pipefail
27
+
28
+ """
29
+
30
+
31
+ @click.command("add")
32
+ def add_cmd() -> None:
33
+ """Add a custom package via guided wizard."""
34
+ ui.section("Add Custom Package")
35
+
36
+ install_type = ui.select(
37
+ "Package type:",
38
+ ["npm", "pip", "apt", "git", "script", "bash"],
39
+ )
40
+ if not install_type:
41
+ ui.warn("Aborted.")
42
+ return
43
+
44
+ key = _prompt_key()
45
+ if not key:
46
+ return
47
+
48
+ name = ui.text_input("Display name:", default=key, required=True)
49
+ description = ui.text_input("Short description:", required=False)
50
+
51
+ kwargs: dict = dict(
52
+ key=key,
53
+ name=name,
54
+ description=description,
55
+ category="custom",
56
+ install_type=install_type,
57
+ )
58
+
59
+ if install_type == "npm":
60
+ kwargs["npm_name"] = ui.text_input("npm package name:", default=key, required=True)
61
+ kwargs["check_cmd"] = ui.text_input("Command to check if installed:", default=key)
62
+
63
+ elif install_type == "pip":
64
+ kwargs["pip_name"] = ui.text_input("PyPI package name:", default=key, required=True)
65
+ kwargs["check_cmd"] = ui.text_input("Command to check if installed:", default=key)
66
+
67
+ elif install_type == "apt":
68
+ kwargs["apt_packages"] = ui.text_input(
69
+ "apt package(s) (space-separated):", default=key, required=True
70
+ )
71
+ kwargs["check_cmd"] = ui.text_input("Command to check if installed:", default=key)
72
+
73
+ elif install_type == "git":
74
+ kwargs["git_url"] = ui.text_input("Git repository URL:", required=True)
75
+ kwargs["git_install_cmd"] = ui.text_input(
76
+ "Post-clone install command (optional, run inside repo):", required=False
77
+ )
78
+ kwargs["git_remove_cmd"] = ui.text_input(
79
+ "Pre-delete remove command (optional):", required=False
80
+ )
81
+ kwargs["check_cmd"] = ui.text_input("Command to check if installed:", required=False)
82
+
83
+ elif install_type == "script":
84
+ kwargs["script_url"] = ui.text_input("Install script URL (curl | sh):", required=True)
85
+ kwargs["check_cmd"] = ui.text_input("Command to check if installed:", required=False)
86
+
87
+ elif install_type == "bash":
88
+ kwargs["check_cmd"] = ui.text_input(
89
+ "Command to verify installation (e.g. bat, aws):", required=False
90
+ )
91
+ install_script = _edit_script("install", name, required=True)
92
+ if install_script is None:
93
+ ui.warn("No install script provided — aborted.")
94
+ return
95
+ kwargs["install_script"] = install_script
96
+
97
+ remove_script = _edit_script("remove", name, required=False)
98
+ if remove_script:
99
+ kwargs["remove_script"] = remove_script
100
+
101
+ kwargs["help_cmd"] = ui.text_input(
102
+ "Help command (optional, e.g. tool --help):", required=False
103
+ )
104
+
105
+ ui.console.print()
106
+ ui.console.print("[bold]Summary[/]")
107
+ for k, v in kwargs.items():
108
+ if v:
109
+ display = _truncate_script(v) if k.endswith("_script") else v
110
+ ui.console.print(f" [dim]{k:<18}[/] {display}")
111
+ ui.console.print()
112
+
113
+ if not ui.confirm("Save this package?"):
114
+ ui.warn("Aborted — package not saved.")
115
+ return
116
+
117
+ tool = GenericTool(**kwargs)
118
+ tool.save()
119
+
120
+ from dev_setup import registry as _reg
121
+ _reg._registry[key] = tool
122
+ if key not in _reg._order:
123
+ _reg._order.append(key)
124
+
125
+ ui.success(f"Package '{key}' added. Install with: dev-setup install {key}")
126
+
127
+
128
+ def _prompt_key() -> str:
129
+ while True:
130
+ key = ui.text_input("Package key (lowercase, hyphens ok):", required=True)
131
+ if not _VALID_KEY.match(key):
132
+ ui.error("Key must be lowercase letters, digits, hyphens, or underscores.")
133
+ continue
134
+ if registry.exists(key):
135
+ ui.error(f"A package with key '{key}' already exists.")
136
+ if not ui.confirm("Use a different key?"):
137
+ return ""
138
+ continue
139
+ return key
140
+
141
+
142
+ def _edit_script(action: str, package_name: str, required: bool = True) -> Optional[str]:
143
+ """Open $EDITOR with a bash template. Returns stripped script or None if aborted."""
144
+ template = (_INSTALL_TEMPLATE if action == "install" else _REMOVE_TEMPLATE).format(
145
+ name=package_name
146
+ )
147
+ label = "install" if action == "install" else "remove (optional)"
148
+ ui.console.print()
149
+ ui.info(f"Opening $EDITOR for the [bold]{label}[/] script...")
150
+ ui.dim("Write your bash commands, save, and close the editor to continue.")
151
+ ui.console.print()
152
+
153
+ try:
154
+ content = click.edit(text=template, extension=".sh", require_save=False)
155
+ except Exception as exc:
156
+ ui.error(f"Could not open editor: {exc}")
157
+ ui.dim("Set the EDITOR environment variable (e.g. export EDITOR=nano) and try again.")
158
+ return None
159
+
160
+ if content is None:
161
+ return None
162
+
163
+ script = _strip_template_comments(content)
164
+ if not script and required:
165
+ ui.error("Install script cannot be empty.")
166
+ return None
167
+ return script or ""
168
+
169
+
170
+ def _strip_template_comments(content: str) -> str:
171
+ """Remove comment-only lines and leading/trailing blank lines."""
172
+ lines = [
173
+ line for line in content.splitlines()
174
+ if line.strip() and not line.strip().startswith("#")
175
+ ]
176
+ return "\n".join(lines).strip()
177
+
178
+
179
+ def _truncate_script(script: str) -> str:
180
+ lines = [l for l in script.splitlines() if l.strip()]
181
+ if not lines:
182
+ return "[dim](empty)[/]"
183
+ first = lines[0][:60]
184
+ suffix = f" [dim]+{len(lines) - 1} more lines[/]" if len(lines) > 1 else ""
185
+ return f"{first}{suffix}"
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ import click
6
+
7
+ from dev_setup import registry, ui
8
+ from dev_setup.generic import CUSTOM_DIR
9
+
10
+
11
+ @click.command("delete")
12
+ @click.argument("key")
13
+ def delete_cmd(key: str) -> None:
14
+ """Remove a custom package from the registry."""
15
+ if not registry.exists(key):
16
+ ui.error(f"No package with key '{key}' found.")
17
+ sys.exit(1)
18
+
19
+ tool = registry.get(key)
20
+ assert tool is not None
21
+
22
+ if tool.builtin:
23
+ ui.error(f"'{key}' is a built-in package and cannot be deleted.")
24
+ sys.exit(1)
25
+
26
+ pkg_file = CUSTOM_DIR / f"{key}.json"
27
+ if not pkg_file.exists():
28
+ ui.error(f"Package file not found: {pkg_file}")
29
+ sys.exit(1)
30
+
31
+ ui.console.print(f"\n [bold]{tool.name}[/] — {tool.description}\n")
32
+
33
+ if not ui.confirm(f"Delete '{key}' from the registry?", default=False):
34
+ ui.dim("Aborted.")
35
+ return
36
+
37
+ pkg_file.unlink()
38
+
39
+ if key in registry._registry:
40
+ del registry._registry[key]
41
+ if key in registry._order:
42
+ registry._order.remove(key)
43
+
44
+ ui.success(f"Package '{key}' deleted from registry.")
@@ -0,0 +1,46 @@
1
+ from dev_setup import ui
2
+ from dev_setup.generic import CUSTOM_DIR
3
+
4
+
5
+ def print_help() -> None:
6
+ ui.print_banner()
7
+ ui.console.print("[bold]USAGE[/]")
8
+ ui.console.print(" dev-setup [bold cyan]<command>[/] [OPTIONS] [ARGS]\n")
9
+
10
+ ui.console.print("[bold]COMMANDS[/]")
11
+ rows = [
12
+ ("list", "[--installed] [--available] [category]", "List packages"),
13
+ ("install", "[package ...]", "Install packages (interactive if no args)"),
14
+ ("remove", "<package ...>", "Uninstall installed packages"),
15
+ ("add", "", "Add a custom package (guided wizard)"),
16
+ ("delete", "<key>", "Remove a custom package from the registry"),
17
+ ("version", "", "Show version"),
18
+ ]
19
+ for cmd, args, desc in rows:
20
+ ui.console.print(
21
+ f" [bold cyan]{cmd:<10}[/] [dim]{args:<40}[/] {desc}"
22
+ )
23
+
24
+ ui.console.print()
25
+ ui.console.print("[bold]EXAMPLES[/]")
26
+ examples = [
27
+ "dev-setup list",
28
+ "dev-setup list core",
29
+ "dev-setup list --installed",
30
+ "dev-setup install docker nvm",
31
+ "dev-setup install",
32
+ "dev-setup remove htop",
33
+ "dev-setup add",
34
+ "dev-setup delete my-tool",
35
+ ]
36
+ for ex in examples:
37
+ ui.console.print(f" [dim]$[/] [green]{ex}[/]")
38
+
39
+ ui.console.print()
40
+ ui.console.print("[bold]CATEGORIES[/]")
41
+ ui.console.print(" [cyan]core[/] Always-installed tools (Docker, NVM, uv)")
42
+ ui.console.print(" [cyan]tools[/] Optional utilities (PHP, Starship, htop)")
43
+ ui.console.print(" [cyan]custom[/] User-added packages\n")
44
+
45
+ ui.console.print("[bold]CONFIG[/]")
46
+ ui.console.print(f" Custom packages: [dim]{CUSTOM_DIR}[/]\n")
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from typing import Tuple
5
+
6
+ import click
7
+ import questionary
8
+
9
+ from dev_setup import registry, ui
10
+ from dev_setup.base import Tool
11
+
12
+
13
+ @click.command("install")
14
+ @click.argument("packages", nargs=-1)
15
+ def install_cmd(packages: Tuple[str, ...]) -> None:
16
+ """Install packages. Interactive picker when called with no arguments."""
17
+ if not packages:
18
+ _install_interactive()
19
+ else:
20
+ failed = False
21
+ for key in packages:
22
+ if not registry.exists(key):
23
+ ui.error(f"Unknown package: '{key}'")
24
+ failed = True
25
+ continue
26
+ if not _install_one(registry.get(key)): # type: ignore[arg-type]
27
+ failed = True
28
+ if failed:
29
+ sys.exit(1)
30
+
31
+
32
+ def _install_one(tool: Tool) -> bool:
33
+ ui.section(tool.name)
34
+ if tool.is_installed():
35
+ ui.success(f"{tool.name} is already installed: {tool.get_version()}")
36
+ return True
37
+ try:
38
+ version = tool.install()
39
+ msg = f"{tool.name} installed"
40
+ if version:
41
+ msg += f": {version}"
42
+ ui.success(msg)
43
+ return True
44
+ except Exception as exc:
45
+ ui.error(f"Failed to install {tool.name}: {exc}")
46
+ return False
47
+
48
+
49
+ def _install_interactive() -> None:
50
+ ui.print_banner()
51
+ tools = registry.all_tools()
52
+
53
+ choices = [
54
+ questionary.Choice(
55
+ title=(
56
+ f"{'[installed] ' if t.is_installed() else ''}"
57
+ f"{t.key:<14} {t.description}"
58
+ ),
59
+ value=t.key,
60
+ checked=False,
61
+ )
62
+ for t in tools
63
+ ]
64
+
65
+ selected = questionary.checkbox(
66
+ "Select packages to install (Space to toggle, Enter to confirm):",
67
+ choices=choices,
68
+ style=ui._STYLE,
69
+ ).ask()
70
+
71
+ if not selected:
72
+ ui.info("No packages selected.")
73
+ return
74
+
75
+ already = [k for k in selected if registry.get(k) and registry.get(k).is_installed()] # type: ignore[union-attr]
76
+ to_install = [k for k in selected if k not in already]
77
+
78
+ if already:
79
+ ui.dim(f"Already installed: {', '.join(already)}")
80
+
81
+ if not to_install:
82
+ ui.info("Nothing new to install.")
83
+ return
84
+
85
+ if not ui.confirm(f"Install {len(to_install)} package(s)?"):
86
+ ui.warn("Aborted.")
87
+ return
88
+
89
+ failed = False
90
+ for key in to_install:
91
+ tool = registry.get(key)
92
+ if tool and not _install_one(tool):
93
+ failed = True
94
+
95
+ if failed:
96
+ sys.exit(1)
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ import click
4
+ from rich.table import Table
5
+
6
+ from dev_setup import registry, ui
7
+
8
+
9
+ @click.command("list")
10
+ @click.option("--installed", "show_filter", flag_value="installed", help="Show only installed packages")
11
+ @click.option("--available", "show_filter", flag_value="available", help="Show only uninstalled packages")
12
+ @click.argument("category", required=False)
13
+ def list_cmd(show_filter: str, category: str) -> None:
14
+ """List available packages."""
15
+ ui.print_banner()
16
+
17
+ tools = registry.all_tools()
18
+ by_cat: dict = {}
19
+
20
+ for tool in tools:
21
+ if category and tool.category != category:
22
+ continue
23
+ is_inst = tool.is_installed()
24
+ if show_filter == "installed" and not is_inst:
25
+ continue
26
+ if show_filter == "available" and is_inst:
27
+ continue
28
+ by_cat.setdefault(tool.category, []).append((tool, is_inst))
29
+
30
+ if not by_cat:
31
+ ui.warn("No packages match the given filters.")
32
+ return
33
+
34
+ for cat in ("core", "tools", "custom"):
35
+ entries = by_cat.get(cat, [])
36
+ if not entries:
37
+ continue
38
+
39
+ ui.console.print(f" [bold magenta]{cat.upper()}[/]")
40
+ ui.divider()
41
+
42
+ tbl = Table(box=None, padding=(0, 1), show_header=True, header_style="dim")
43
+ tbl.add_column("", width=2)
44
+ tbl.add_column("Package", style="bold", min_width=12)
45
+ tbl.add_column("Description", min_width=36)
46
+ tbl.add_column("Type", style="dim", min_width=8)
47
+ tbl.add_column("Version", style="dim")
48
+
49
+ for tool, is_inst in entries:
50
+ icon = "[green bold]✔[/]" if is_inst else "[red bold]✘[/]"
51
+ version = tool.get_version() if is_inst else ""
52
+ tbl.add_row(icon, tool.key, tool.description, tool.install_type, version)
53
+ if tool.help_cmd:
54
+ tbl.add_row("", "", f"[dim cyan] ? {tool.help_cmd}[/]", "", "")
55
+
56
+ ui.console.print(tbl)
57
+ ui.console.print()
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from typing import Tuple
5
+
6
+ import click
7
+
8
+ from dev_setup import registry, ui
9
+ from dev_setup.base import Tool
10
+
11
+
12
+ @click.command("remove")
13
+ @click.argument("packages", nargs=-1)
14
+ def remove_cmd(packages: Tuple[str, ...]) -> None:
15
+ """Uninstall installed packages."""
16
+ if not packages:
17
+ ui.error("Specify at least one package key. See: dev-setup list --installed")
18
+ sys.exit(1)
19
+
20
+ failed = False
21
+ for key in packages:
22
+ if not registry.exists(key):
23
+ ui.error(f"Unknown package: '{key}'")
24
+ failed = True
25
+ continue
26
+ if not _remove_one(registry.get(key)): # type: ignore[arg-type]
27
+ failed = True
28
+
29
+ if failed:
30
+ sys.exit(1)
31
+
32
+
33
+ def _remove_one(tool: Tool) -> bool:
34
+ ui.section(f"Remove {tool.name}")
35
+
36
+ if not tool.is_installed():
37
+ ui.warn(f"{tool.name} is not installed — nothing to remove.")
38
+ return True
39
+
40
+ if not ui.confirm(f"Remove {tool.name}?", default=False):
41
+ ui.dim("Skipped.")
42
+ return True
43
+
44
+ try:
45
+ tool.remove()
46
+ ui.success(f"{tool.name} removed")
47
+ return True
48
+ except Exception as exc:
49
+ ui.error(f"Failed to remove {tool.name}: {exc}")
50
+ return False