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.
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import click
6
+
7
+ from dev_setup import catalog, ui
8
+
9
+
10
+ @click.group("catalog")
11
+ def catalog_cmd() -> None:
12
+ """Manage YAML tool catalogs."""
13
+
14
+
15
+ @catalog_cmd.command("path")
16
+ def path_cmd() -> None:
17
+ """Print the user catalog path."""
18
+ click.echo(catalog.USER_CATALOG_PATH)
19
+
20
+
21
+ @catalog_cmd.command("export")
22
+ @click.argument("path", required=False, type=click.Path(path_type=Path))
23
+ def export_cmd(path: Path | None) -> None:
24
+ """Export the effective catalog to PATH."""
25
+ out = path or Path("dev-setup-tools.yaml")
26
+ catalog.export_catalog(out)
27
+ ui.success(f"Exported catalog to {out}")
28
+
29
+
30
+ @catalog_cmd.command("import")
31
+ @click.argument("path", type=click.Path(exists=True, dir_okay=False, path_type=Path))
32
+ def import_cmd(path: Path) -> None:
33
+ """Import and merge a YAML catalog into the user catalog."""
34
+ keys = catalog.import_catalog(path)
35
+ ui.success(f"Imported {len(keys)} package(s) into {catalog.USER_CATALOG_PATH}")
36
+
37
+
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ import click
6
+
7
+ from dev_setup import catalog, registry, ui
8
+
9
+
10
+ @click.command("delete")
11
+ @click.argument("key")
12
+ def delete_cmd(key: str) -> None:
13
+ """Remove a custom package from the registry."""
14
+ if not registry.exists(key):
15
+ ui.error(f"No package with key '{key}' found.")
16
+ sys.exit(1)
17
+
18
+ tool = registry.get(key)
19
+ assert tool is not None
20
+
21
+ if not catalog.user_has_tool(key):
22
+ ui.error(f"'{key}' is a built-in package and cannot be deleted.")
23
+ sys.exit(1)
24
+
25
+ ui.console.print(f"\n [bold]{tool.name}[/] — {tool.description}\n")
26
+
27
+ if not ui.confirm(f"Delete user catalog entry '{key}'?", default=False):
28
+ ui.dim("Aborted.")
29
+ return
30
+
31
+ catalog.delete_user_tool(key)
32
+ registry.reload()
33
+ if registry.exists(key):
34
+ ui.success(f"User override for '{key}' removed — built-in version is now active.")
35
+ else:
36
+ ui.success(f"Package '{key}' deleted from user catalog.")
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ import webbrowser
5
+
6
+ import click
7
+
8
+ from dev_setup import registry, ui
9
+
10
+
11
+ @click.command("docs")
12
+ @click.argument("package")
13
+ def docs_cmd(package: str) -> None:
14
+ """Open the documentation site for a package in your browser."""
15
+ if not registry.exists(package):
16
+ ui.error(f"Unknown package: '{package}'")
17
+ sys.exit(1)
18
+
19
+ tool = registry.get(package)
20
+ assert tool is not None
21
+
22
+ if not tool.docs_url:
23
+ ui.warn(f"No documentation URL is configured for '{package}'.")
24
+ ui.dim("For custom packages, add docs_url in your YAML catalog.")
25
+ sys.exit(1)
26
+
27
+ ui.console.print(f"\n [bold]{tool.name}[/] docs")
28
+ ui.console.print(f" [cyan]{tool.docs_url}[/]\n")
29
+
30
+ opened = webbrowser.open(tool.docs_url)
31
+ if opened:
32
+ ui.success("Opened in browser.")
33
+ else:
34
+ ui.warn("Could not open a browser — copy the URL above to open manually.")
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ import click
6
+
7
+ from dev_setup import function_runner as runner
8
+ from dev_setup import functions_registry, ui
9
+ from dev_setup.functions_catalog import USER_CATALOG_PATH
10
+
11
+
12
+ @click.group("functions")
13
+ def functions_cmd() -> None:
14
+ """Manage functions/scripts. Invoke one with: devstuff run <key>."""
15
+
16
+
17
+ @functions_cmd.command("list")
18
+ def list_cmd() -> None:
19
+ """List available functions."""
20
+ fns = functions_registry.all_functions()
21
+ if not fns:
22
+ ui.info("No functions defined.")
23
+ return
24
+
25
+ by_cat: dict[str, list] = {}
26
+ for f in fns:
27
+ by_cat.setdefault(f.category, []).append(f)
28
+
29
+ key_width = max((len(f.key) for f in fns), default=12) + 2
30
+ for cat in sorted(by_cat, key=lambda c: (c == "custom", c)):
31
+ ui.console.print(f"\n [bold]{cat.upper()}[/]")
32
+ for f in sorted(by_cat[cat], key=lambda f: f.key):
33
+ mode = f.type if f.type == "script" else f"{f.type} ({f.register})"
34
+ ui.console.print(
35
+ f" [bold cyan]{f.key:<{key_width}}[/] [dim]{mode:<22}[/] {f.description}"
36
+ )
37
+ params = " ".join(f"<{p.name}>" for p in f.params)
38
+ if params:
39
+ ui.dim(f" args: {params}")
40
+
41
+
42
+ @functions_cmd.command("enable")
43
+ @click.argument("key")
44
+ def enable_cmd(key: str) -> None:
45
+ """Register a bashrc-backed function into ~/.bashrc."""
46
+ fn = functions_registry.get(key)
47
+ if fn is None:
48
+ ui.error(f"Unknown function: '{key}'")
49
+ sys.exit(1)
50
+ if not (fn.type == "shell-eval" and fn.register == "bashrc"):
51
+ ui.error(f"'{fn.key}' does not use bashrc registration.")
52
+ if fn.type == "script":
53
+ ui.dim(f"Run it directly: devstuff run {fn.key}")
54
+ else:
55
+ ui.dim(f'Use: eval "$(devstuff run {fn.key} ...)"')
56
+ sys.exit(1)
57
+
58
+ added = runner.enable_bashrc_function(fn)
59
+ if added:
60
+ ui.success(f"Registered '{fn.key}' in ~/.bashrc")
61
+ ui.dim("Run: source ~/.bashrc (or open a new shell)")
62
+ args = " ".join(f"<{p.name}>" for p in fn.params)
63
+ ui.dim(f"Then call it directly: {fn.key} {args}".rstrip())
64
+ else:
65
+ ui.info(f"'{fn.key}' is already registered in ~/.bashrc")
66
+
67
+
68
+ @functions_cmd.command("disable")
69
+ @click.argument("key")
70
+ def disable_cmd(key: str) -> None:
71
+ """Remove a bashrc-backed function from ~/.bashrc."""
72
+ fn = functions_registry.get(key)
73
+ if fn is None:
74
+ ui.error(f"Unknown function: '{key}'")
75
+ sys.exit(1)
76
+
77
+ removed = runner.disable_bashrc_function(fn)
78
+ if removed:
79
+ ui.success(f"Removed '{fn.key}' from ~/.bashrc")
80
+ else:
81
+ ui.info(f"'{fn.key}' is not registered in ~/.bashrc")
82
+
83
+
84
+ @functions_cmd.command("path")
85
+ def path_cmd() -> None:
86
+ """Print the path to the user functions catalog."""
87
+ click.echo(str(USER_CATALOG_PATH))
@@ -0,0 +1,59 @@
1
+ from dev_setup import ui
2
+ from dev_setup.catalog import USER_CATALOG_PATH
3
+
4
+
5
+ def print_help() -> None:
6
+ ui.print_banner()
7
+ ui.console.print("[bold]USAGE[/]")
8
+ ui.console.print(" devstuff [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
+ ("update", "[package ...] [--version]", "Update packages (interactive if no args)"),
16
+ ("add", "", "Add a custom package (guided wizard)"),
17
+ ("delete", "<key>", "Remove a custom package from the registry"),
18
+ ("catalog", "<path|export|import>", "Manage YAML tool catalogs"),
19
+ ("docs", "<package>", "Open documentation in browser"),
20
+ ("run", "<function> [args...]", "Run a function/script"),
21
+ ("functions", "<list|enable|disable|path>", "Manage functions/scripts"),
22
+ ("version", "", "Show version"),
23
+ ]
24
+ for cmd, args, desc in rows:
25
+ ui.console.print(
26
+ f" [bold cyan]{cmd:<10}[/] [dim]{args:<40}[/] {desc}"
27
+ )
28
+
29
+ ui.console.print()
30
+ ui.console.print("[bold]EXAMPLES[/]")
31
+ examples = [
32
+ "devstuff list",
33
+ "devstuff list core",
34
+ "devstuff list --installed",
35
+ "devstuff install docker nvm",
36
+ "devstuff install",
37
+ "devstuff remove htop",
38
+ "devstuff update nvm",
39
+ "devstuff update pi --version 1.2.3",
40
+ "devstuff update",
41
+ "devstuff add",
42
+ "devstuff delete my-tool",
43
+ "devstuff catalog export",
44
+ "devstuff functions list",
45
+ "devstuff functions enable ssh-agent-key",
46
+ "ssh-agent-key ~/.ssh/id_ed25519",
47
+ ]
48
+ for ex in examples:
49
+ ui.console.print(f" [dim]$[/] [green]{ex}[/]")
50
+
51
+ ui.console.print()
52
+ ui.console.print("[bold]CATEGORIES[/]")
53
+ ui.console.print(" [cyan]core[/] Always-installed tools (Docker, NVM, uv)")
54
+ ui.console.print(" [cyan]tools[/] Optional utilities (PHP, Starship, htop)")
55
+ ui.console.print(" [cyan]languages[/] Programming language runtimes")
56
+ ui.console.print(" [cyan]custom[/] User-added packages\n")
57
+
58
+ ui.console.print("[bold]CONFIG[/]")
59
+ ui.console.print(f" User catalog: [dim]{USER_CATALOG_PATH}[/]\n")
@@ -0,0 +1,129 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from concurrent.futures import ThreadPoolExecutor
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.option("--verbose", "-v", is_flag=True, help="Stream install output to the terminal.")
15
+ @click.argument("packages", nargs=-1)
16
+ def install_cmd(packages: tuple[str, ...], verbose: bool) -> None:
17
+ """Install packages. Interactive picker when called with no arguments."""
18
+ from dev_setup import generic
19
+ generic._verbose = verbose
20
+
21
+ if not packages:
22
+ _install_interactive()
23
+ else:
24
+ failed = False
25
+ for key in packages:
26
+ if not registry.exists(key):
27
+ ui.error(f"Unknown package: '{key}'")
28
+ failed = True
29
+ continue
30
+ if not _install_one(registry.get(key)): # type: ignore[arg-type]
31
+ failed = True
32
+ if failed:
33
+ sys.exit(1)
34
+
35
+
36
+ def _install_one(tool: Tool) -> bool:
37
+ ui.section(tool.name)
38
+ if tool.is_installed():
39
+ ui.success(f"{tool.name} is already installed: {tool.get_version()}")
40
+ return True
41
+ missing = registry.missing_requires(tool)
42
+ if missing:
43
+ ui.error(f"Cannot install {tool.name} — missing required tools: {', '.join(missing)}")
44
+ ui.dim(f"Install first: devstuff install {' '.join(missing)}")
45
+ return False
46
+ try:
47
+ version = tool.install()
48
+ msg = f"{tool.name} installed"
49
+ if version:
50
+ msg += f": {version}"
51
+ ui.success(msg)
52
+ return True
53
+ except Exception as exc:
54
+ ui.error(f"Failed to install {tool.name}: {exc}")
55
+ return False
56
+
57
+
58
+ def _install_interactive() -> None:
59
+ ui.print_banner()
60
+ tools = registry.all_tools()
61
+
62
+ # Probe installed status concurrently — each check shells out.
63
+ with ui.spinner("Checking installed packages..."), ThreadPoolExecutor(max_workers=8) as pool:
64
+ statuses = pool.map(lambda t: t.is_installed(), tools)
65
+ installed = dict(zip((t.key for t in tools), statuses, strict=True))
66
+
67
+ by_cat: dict[str, list[Tool]] = {}
68
+ for t in tools:
69
+ by_cat.setdefault(t.category, []).append(t)
70
+
71
+ key_width = max((len(t.key) for t in tools), default=12) + 2
72
+ desc_width = max(20, ui.console.width - key_width - 14)
73
+
74
+ choices: list = []
75
+ _ORDER = {"core": 0, "tools": 1, "custom": 999}
76
+ for cat in sorted(by_cat, key=lambda c: (_ORDER.get(c, 500), c)):
77
+ entries = sorted(by_cat[cat], key=lambda t: t.key)
78
+ n_inst = sum(installed[t.key] for t in entries)
79
+ choices.append(questionary.Separator(
80
+ f"\n {cat.upper()} ({n_inst}/{len(entries)} installed)"
81
+ ))
82
+ for t in entries:
83
+ is_inst = installed[t.key]
84
+ missing = [] if is_inst else registry.missing_requires(t)
85
+ desc = t.description
86
+ if len(desc) > desc_width:
87
+ desc = desc[: desc_width - 1] + "…"
88
+ if is_inst:
89
+ disabled = True
90
+ elif missing:
91
+ disabled = f"requires {', '.join(missing)}"
92
+ else:
93
+ disabled = None
94
+ title = [
95
+ ("class:check", "✔ " if is_inst else " "),
96
+ ("class:text", f"{t.key:<{key_width}}{desc}"),
97
+ ]
98
+ choices.append(questionary.Choice(
99
+ title=title,
100
+ value=t.key,
101
+ checked=False,
102
+ disabled=disabled,
103
+ ))
104
+
105
+ selected = questionary.checkbox(
106
+ "Select packages to install:",
107
+ choices=choices,
108
+ instruction="(Space toggle · Enter confirm · installed items are skipped)",
109
+ style=ui._STYLE,
110
+ ).ask()
111
+
112
+ if not selected:
113
+ ui.info("No packages selected.")
114
+ return
115
+
116
+ to_install = selected
117
+
118
+ if not ui.confirm(f"Install {len(to_install)} package(s)?"):
119
+ ui.warn("Aborted.")
120
+ return
121
+
122
+ failed = False
123
+ for key in to_install:
124
+ tool = registry.get(key)
125
+ if tool and not _install_one(tool):
126
+ failed = True
127
+
128
+ if failed:
129
+ sys.exit(1)
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ from concurrent.futures import ThreadPoolExecutor
4
+
5
+ import click
6
+ from rich.table import Table
7
+
8
+ from dev_setup import registry, ui
9
+ from dev_setup.base import Tool
10
+
11
+ _MAX_WORKERS = 8
12
+
13
+
14
+ def _probe(tool: Tool) -> tuple[Tool, bool, str]:
15
+ """Gather subprocess-heavy status for one tool (runs in a worker thread)."""
16
+ is_inst = tool.is_installed()
17
+ version = tool.get_version() if is_inst else ""
18
+ return tool, is_inst, version
19
+
20
+
21
+ @click.command("list")
22
+ @click.option("--installed", "show_filter", flag_value="installed", help="Show only installed packages")
23
+ @click.option("--available", "show_filter", flag_value="available", help="Show only uninstalled packages")
24
+ @click.argument("category", required=False)
25
+ def list_cmd(show_filter: str, category: str) -> None:
26
+ """List available packages."""
27
+ ui.print_banner()
28
+
29
+ tools = registry.all_tools()
30
+ if category:
31
+ tools = [t for t in tools if t.category == category]
32
+
33
+ # Probe installed status + version concurrently — each check shells out,
34
+ # so this dominates the command's runtime when done serially.
35
+ with ThreadPoolExecutor(max_workers=_MAX_WORKERS) as pool:
36
+ probed = list(pool.map(_probe, tools))
37
+
38
+ by_cat: dict = {}
39
+ for tool, is_inst, version in probed:
40
+ if show_filter == "installed" and not is_inst:
41
+ continue
42
+ if show_filter == "available" and is_inst:
43
+ continue
44
+ by_cat.setdefault(tool.category, []).append((tool, is_inst, version))
45
+
46
+ if not by_cat:
47
+ ui.warn("No packages match the given filters.")
48
+ return
49
+
50
+ _ORDER = {"core": 0, "tools": 1, "custom": 999}
51
+ for cat in sorted(by_cat, key=lambda c: (_ORDER.get(c, 500), c)):
52
+ entries = sorted(by_cat.get(cat, []), key=lambda e: e[0].key)
53
+ if not entries:
54
+ continue
55
+
56
+ ui.console.print(f" [bold magenta]{cat.upper()}[/]")
57
+ ui.divider()
58
+
59
+ tbl = Table(box=None, padding=(0, 1), show_header=True, header_style="dim")
60
+ tbl.add_column("", width=2)
61
+ tbl.add_column("Package", style="bold", min_width=12)
62
+ tbl.add_column("Description", min_width=36)
63
+ tbl.add_column("Type", style="dim", min_width=8)
64
+ tbl.add_column("Version", style="dim")
65
+
66
+ for tool, is_inst, version in entries:
67
+ missing = registry.missing_requires(tool) if not is_inst else []
68
+ icon = "[green bold]✔[/]" if is_inst else "[red bold]✘[/]"
69
+ tbl.add_row(icon, tool.key, tool.description, tool.install_type, version)
70
+ if tool.help_cmd:
71
+ tbl.add_row("", "", f"[dim cyan] ? {tool.help_cmd}[/]", "", "")
72
+ if missing:
73
+ tbl.add_row("", "", f"[yellow] ⚠ requires: {', '.join(missing)}[/]", "", "")
74
+
75
+ ui.console.print(tbl)
76
+ ui.console.print()
@@ -0,0 +1,53 @@
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.base import Tool
9
+
10
+
11
+ @click.command("remove")
12
+ @click.option("--verbose", "-v", is_flag=True, help="Stream removal output to the terminal.")
13
+ @click.argument("packages", nargs=-1)
14
+ def remove_cmd(packages: tuple[str, ...], verbose: bool) -> None:
15
+ """Uninstall installed packages."""
16
+ from dev_setup import generic
17
+ generic._verbose = verbose
18
+
19
+ if not packages:
20
+ ui.error("Specify at least one package key. See: devstuff list --installed")
21
+ sys.exit(1)
22
+
23
+ failed = False
24
+ for key in packages:
25
+ if not registry.exists(key):
26
+ ui.error(f"Unknown package: '{key}'")
27
+ failed = True
28
+ continue
29
+ if not _remove_one(registry.get(key)): # type: ignore[arg-type]
30
+ failed = True
31
+
32
+ if failed:
33
+ sys.exit(1)
34
+
35
+
36
+ def _remove_one(tool: Tool) -> bool:
37
+ ui.section(f"Remove {tool.name}")
38
+
39
+ if not tool.is_installed():
40
+ ui.warn(f"{tool.name} is not installed — nothing to remove.")
41
+ return True
42
+
43
+ if not ui.confirm(f"Remove {tool.name}?", default=False):
44
+ ui.dim("Skipped.")
45
+ return True
46
+
47
+ try:
48
+ tool.remove()
49
+ ui.success(f"{tool.name} removed")
50
+ return True
51
+ except Exception as exc:
52
+ ui.error(f"Failed to remove {tool.name}: {exc}")
53
+ return False
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ import click
6
+
7
+ from dev_setup import function_runner as runner
8
+ from dev_setup import functions_registry, ui
9
+ from dev_setup.function_runner import ParamResolutionError
10
+ from dev_setup.functions_registry import FunctionDef, FunctionParam
11
+
12
+
13
+ @click.command("run")
14
+ @click.argument("key")
15
+ @click.argument("args", nargs=-1)
16
+ def run_cmd(key: str, args: tuple[str, ...]) -> None:
17
+ """Run a function/script. See: devstuff functions list"""
18
+ fn = functions_registry.get(key)
19
+ if fn is None:
20
+ ui.error(f"Unknown function: '{key}'")
21
+ sys.exit(1)
22
+
23
+ if fn.type == "script":
24
+ _run_script(fn, args)
25
+ elif fn.type == "shell-eval" and fn.register == "eval":
26
+ _run_eval(fn, args)
27
+ else: # shell-eval + bashrc — dev-setup can't mutate the calling shell itself
28
+ ui.error(f"'{fn.key}' is registered via ~/.bashrc, not run directly.")
29
+ ui.dim(f"Enable it once: devstuff functions enable {fn.key}")
30
+ ui.dim(f"Then call it directly in your shell: {fn.key} ...")
31
+ sys.exit(1)
32
+
33
+
34
+ def _prompt_param(p: FunctionParam) -> str:
35
+ label = p.description or p.name
36
+ suffix = "" if p.required else " (optional)"
37
+ return ui.text_input(f"{label}{suffix}:", default=p.default, required=p.required)
38
+
39
+
40
+ def _run_script(fn: FunctionDef, args: tuple[str, ...]) -> None:
41
+ ui.section(fn.name)
42
+ # Only prompt when there's an actual terminal to prompt on — otherwise a missing
43
+ # required param would hit an unreadable stdin and fail as an opaque, unrelated
44
+ # exception instead of the clean "Missing required parameter(s)" message.
45
+ prompt = _prompt_param if sys.stdin.isatty() else None
46
+ try:
47
+ runner.run_script_function(fn, args, prompt=prompt)
48
+ ui.success(f"{fn.name} completed")
49
+ except ParamResolutionError as exc:
50
+ ui.error(str(exc))
51
+ sys.exit(1)
52
+ except Exception as exc:
53
+ ui.error(f"'{fn.name}' failed: {exc}")
54
+ sys.exit(1)
55
+
56
+
57
+ def _run_eval(fn: FunctionDef, args: tuple[str, ...]) -> None:
58
+ # Diagnostics must go to stderr and stdout must carry ONLY the resolved script —
59
+ # the caller does `eval "$(dev-setup run ...)"`, so anything else printed to
60
+ # stdout becomes part of what gets evaluated in their shell.
61
+ try:
62
+ script = runner.render_eval_script(fn, args)
63
+ except ParamResolutionError as exc:
64
+ click.echo(str(exc), err=True)
65
+ sys.exit(1)
66
+ click.echo(script)