qtmp 0.2.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,51 @@
1
+ """
2
+ qtmp.recipes
3
+ --------------
4
+ The registry every other module reads from. Adding a new recipe means:
5
+ 1. Write a Recipe subclass in its own file (see any existing recipe).
6
+ 2. Add one line here.
7
+ Nothing in planner.py, executor.py, or cli.py needs to change.
8
+ """
9
+
10
+ from qtmp.recipes.react import ReactRecipe
11
+ from qtmp.recipes.tailwind import TailwindRecipe
12
+ from qtmp.recipes.router_axios import RouterRecipe, AxiosRecipe, ShadcnRecipe
13
+ from qtmp.recipes.fastapi import FastAPIRecipe
14
+ from qtmp.recipes.database import PostgresRecipe, DbDriverPostgresRecipe, SQLAlchemyRecipe
15
+
16
+ REGISTRY = {
17
+ r.provides: r
18
+ for r in [
19
+ ReactRecipe(),
20
+ TailwindRecipe(),
21
+ RouterRecipe(),
22
+ AxiosRecipe(),
23
+ ShadcnRecipe(),
24
+ FastAPIRecipe(),
25
+ PostgresRecipe(),
26
+ DbDriverPostgresRecipe(),
27
+ SQLAlchemyRecipe(),
28
+ ]
29
+ }
30
+
31
+ CREATABLE = {key: r for key, r in REGISTRY.items() if r.creatable}
32
+
33
+ ALIASES = {
34
+ "reactjs": "react",
35
+ "react.js": "react",
36
+ "fast": "fastapi",
37
+ "postgresql": "postgres",
38
+ "psql": "postgres",
39
+ "pg": "postgres",
40
+ "router": "router",
41
+ "react-router": "router",
42
+ "react-router-dom": "router",
43
+ "sqlalchemy": "sqlalchemy",
44
+ "shadcn-ui": "shadcn",
45
+ "shadcn/ui": "shadcn",
46
+ }
47
+
48
+
49
+ def resolve_name(name: str) -> str:
50
+ key = name.strip().lower()
51
+ return ALIASES.get(key, key)
qtmp/recipes/base.py ADDED
@@ -0,0 +1,62 @@
1
+ """
2
+ qtmp.recipes.base
3
+ -------------------
4
+ A Recipe is the only thing a developer needs to write to teach qtmp
5
+ about a new piece of the stack. It answers three questions:
6
+
7
+ - requires(ctx) -> what other capabilities must exist first?
8
+ - detect(ctx) -> is this capability already satisfied? (idempotency)
9
+ - plan(ctx) -> what Steps would satisfy it, if not?
10
+
11
+ Nothing in qtmp/planner.py, qtmp/executor.py, or qtmp/cli.py needs to
12
+ change when a new Recipe is added — only qtmp/recipes/__init__.py's
13
+ registry needs one new line.
14
+ """
15
+
16
+ from abc import ABC, abstractmethod
17
+ from typing import List
18
+
19
+ from qtmp.planner import Step
20
+
21
+
22
+ class Recipe(ABC):
23
+ #: human-readable name, e.g. "Tailwind CSS"
24
+ name: str = ""
25
+ #: capability id this recipe satisfies, e.g. "tailwind" — this is
26
+ #: what `qtmp add <id>` and `requires()` refer to.
27
+ provides: str = ""
28
+ #: True if this recipe supports `qtmp create <provides>` (scaffolding
29
+ #: a brand-new project) in addition to `qtmp add`.
30
+ creatable: bool = False
31
+
32
+ def requires(self, ctx) -> List[str]:
33
+ """
34
+ Capability ids that must be satisfied before this one can run.
35
+ Can depend on `ctx` — e.g. SQLAlchemy's driver requirement
36
+ depends on which database is actually in play.
37
+ """
38
+ return []
39
+
40
+ @abstractmethod
41
+ def detect(self, ctx) -> bool:
42
+ """Is this capability already present in ctx.cwd?"""
43
+ raise NotImplementedError
44
+
45
+ @abstractmethod
46
+ def plan(self, ctx) -> List[Step]:
47
+ """
48
+ Steps needed to satisfy this capability. Only ever called when
49
+ detect(ctx) is False — recipes don't need their own "already
50
+ installed" guard, the planner handles that centrally.
51
+ """
52
+ raise NotImplementedError
53
+
54
+ def create(self, ctx, **kwargs):
55
+ """
56
+ Only for creatable recipes: scaffold a brand-new project.
57
+ Returns the new project's directory. Not part of the
58
+ detect/plan/requires idempotency contract — creating "my-app"
59
+ twice is expected to be a user decision (a new folder), not
60
+ something the planner should intervene in.
61
+ """
62
+ raise NotImplementedError(f"{self.name} does not support 'create'")
@@ -0,0 +1,112 @@
1
+ """
2
+ qtmp.recipes.database
3
+ -----------------------
4
+ Three capabilities that show real dependency resolution instead of a
5
+ single flat "postgres" module:
6
+
7
+ postgres -> a local server + database/user exist
8
+ db-driver-postgres -> psycopg2-binary is installed in the venv
9
+ sqlalchemy -> SQLAlchemy is installed, *and* requires a
10
+ driver for whatever database is in play
11
+
12
+ Running `qtmp add sqlalchemy` on a fresh FastAPI project resolves to:
13
+ fastapi (already installed, skipped)
14
+ -> db-driver-postgres (installs psycopg2-binary)
15
+ -> sqlalchemy (installs SQLAlchemy)
16
+
17
+ This is the "add SQLAlchemy -> driver required -> resolve -> install"
18
+ flow, expressed as two lines of `requires()` rather than special-cased
19
+ CLI logic.
20
+ """
21
+
22
+ import os
23
+ import subprocess
24
+ from typing import List
25
+
26
+ from qtmp.planner import Step
27
+ from qtmp.recipes.base import Recipe
28
+ from qtmp.utils import prompt, which
29
+
30
+
31
+ class PostgresRecipe(Recipe):
32
+ name = "PostgreSQL (local server)"
33
+ provides = "postgres"
34
+
35
+ def detect(self, ctx) -> bool:
36
+ return which("psql") is not None
37
+
38
+ def plan(self, ctx) -> List[Step]:
39
+ # We can't silently "apt install postgresql" for someone — this
40
+ # is a step deliberately left as guidance, not automation, since
41
+ # it usually needs sudo / a package manager choice we shouldn't
42
+ # make on the user's behalf.
43
+ return [
44
+ Step(
45
+ "PostgreSQL is not installed — install it yourself, then re-run:\n"
46
+ " macOS: brew install postgresql\n"
47
+ " Ubuntu: sudo apt install postgresql",
48
+ apply_fn=lambda: None,
49
+ )
50
+ ]
51
+
52
+
53
+ class DbDriverPostgresRecipe(Recipe):
54
+ name = "psycopg2 (PostgreSQL driver)"
55
+ provides = "db-driver-postgres"
56
+
57
+ def requires(self, ctx) -> List[str]:
58
+ # Always declare fastapi as a dependency — resolve_plan() checks
59
+ # FastAPIRecipe.detect(ctx) itself and marks it already_satisfied
60
+ # if true, so this never double-installs. (A venv existing is
61
+ # not the same thing as fastapi being installed in it — don't
62
+ # use has_venv as a shortcut here.)
63
+ return ["fastapi"]
64
+
65
+ def detect(self, ctx) -> bool:
66
+ python = ctx.venv_python()
67
+ if not os.path.isfile(python):
68
+ return False
69
+ result = subprocess.run(
70
+ [python, "-c", "import psycopg2"], capture_output=True
71
+ )
72
+ return result.returncode == 0
73
+
74
+ def plan(self, ctx) -> List[Step]:
75
+ return [
76
+ Step(
77
+ "Install psycopg2-binary",
78
+ command=[ctx.venv_pip(), "install", "psycopg2-binary"],
79
+ )
80
+ ]
81
+
82
+
83
+ class SQLAlchemyRecipe(Recipe):
84
+ name = "SQLAlchemy"
85
+ provides = "sqlalchemy"
86
+
87
+ def requires(self, ctx) -> List[str]:
88
+ # This is the resolution step from your diagram: SQLAlchemy
89
+ # itself doesn't care which database, but *something* has to
90
+ # decide which driver to install. Default to Postgres — a
91
+ # future recipe (e.g. db-driver-sqlite) could be chosen instead
92
+ # based on a --db flag or a detected DATABASE_URL scheme.
93
+ # fastapi is always declared too (see DbDriverPostgresRecipe for
94
+ # why has_venv can't be used as a substitute check).
95
+ return ["fastapi", "db-driver-postgres"]
96
+
97
+ def detect(self, ctx) -> bool:
98
+ python = ctx.venv_python()
99
+ if not os.path.isfile(python):
100
+ return False
101
+ result = subprocess.run(
102
+ [python, "-c", "import sqlalchemy"], capture_output=True
103
+ )
104
+ return result.returncode == 0
105
+
106
+ def plan(self, ctx) -> List[Step]:
107
+ return [
108
+ Step(
109
+ "Install SQLAlchemy",
110
+ command=[ctx.venv_pip(), "install", "sqlalchemy"],
111
+ )
112
+ ]
@@ -0,0 +1,92 @@
1
+ """
2
+ qtmp.recipes.fastapi
3
+ ----------------------
4
+ Provides "fastapi". Creates a venv + installs fastapi/uvicorn + writes
5
+ a working main.py. Also the anchor of the Python dependency chain that
6
+ SQLAlchemy's driver resolution hangs off of.
7
+ """
8
+
9
+ import os
10
+ import sys
11
+ from typing import List
12
+
13
+ from qtmp.planner import Step
14
+ from qtmp.recipes.base import Recipe
15
+ from qtmp.utils import prompt, which
16
+
17
+
18
+ MAIN_PY_TEMPLATE = '''from fastapi import FastAPI
19
+
20
+ app = FastAPI(title="{title}")
21
+
22
+
23
+ @app.get("/")
24
+ def read_root():
25
+ return {{"message": "{title} is running"}}
26
+
27
+
28
+ @app.get("/health")
29
+ def health():
30
+ return {{"status": "ok"}}
31
+ '''
32
+
33
+
34
+ def _write_main_py(cwd, title):
35
+ with open(os.path.join(cwd, "main.py"), "w") as f:
36
+ f.write(MAIN_PY_TEMPLATE.format(title=title))
37
+
38
+
39
+ class FastAPIRecipe(Recipe):
40
+ name = "FastAPI"
41
+ provides = "fastapi"
42
+ creatable = True
43
+
44
+ def detect(self, ctx) -> bool:
45
+ main_py = os.path.join(ctx.cwd, "main.py")
46
+ if not os.path.isfile(main_py):
47
+ return False
48
+ with open(main_py) as f:
49
+ return "fastapi" in f.read().lower()
50
+
51
+ def plan(self, ctx) -> List[Step]:
52
+ title = os.path.basename(ctx.cwd.rstrip("/")) or "app"
53
+ return [
54
+ Step(
55
+ "Create virtual environment",
56
+ command=[sys.executable, "-m", "venv", "venv"],
57
+ cwd=ctx.cwd,
58
+ ),
59
+ Step(
60
+ "Install fastapi + uvicorn",
61
+ command=[ctx.venv_pip(), "install", "fastapi", "uvicorn[standard]"],
62
+ ),
63
+ Step(
64
+ "Write main.py",
65
+ apply_fn=lambda: _write_main_py(ctx.cwd, title),
66
+ ),
67
+ ]
68
+
69
+ def create(self, ctx, **kwargs):
70
+ if not which(sys.executable):
71
+ raise RuntimeError("Python interpreter not found.")
72
+
73
+ name = prompt("Project name", default="my-fastapi-app")
74
+ project_dir = os.path.join(ctx.cwd, name)
75
+ os.makedirs(project_dir, exist_ok=True)
76
+
77
+ from qtmp.utils import Spinner, run, ok
78
+ from qtmp.context import ProjectContext
79
+
80
+ venv_dir = os.path.join(project_dir, "venv")
81
+ with Spinner("Creating virtual environment"):
82
+ run([sys.executable, "-m", "venv", venv_dir])
83
+
84
+ sub_ctx = ProjectContext.detect(project_dir)
85
+ pip = sub_ctx.venv_pip()
86
+ with Spinner("Installing fastapi + uvicorn"):
87
+ run([pip, "install", "--upgrade", "pip"])
88
+ run([pip, "install", "fastapi", "uvicorn[standard]"])
89
+
90
+ _write_main_py(project_dir, name)
91
+ ok(f"FastAPI project ready at {project_dir}")
92
+ return project_dir, False
qtmp/recipes/react.py ADDED
@@ -0,0 +1,59 @@
1
+ """
2
+ qtmp.recipes.react
3
+ --------------------
4
+ Provides "react". Creatable (scaffolds a brand-new Vite project) and
5
+ also a dependency target — e.g. `qtmp add tailwind` in an empty folder
6
+ will pull this in automatically via requires().
7
+ """
8
+
9
+ import os
10
+ from typing import List
11
+
12
+ from qtmp.planner import Step
13
+ from qtmp.recipes.base import Recipe
14
+ from qtmp.utils import prompt, confirm, which
15
+
16
+
17
+ class ReactRecipe(Recipe):
18
+ name = "React"
19
+ provides = "react"
20
+ creatable = True
21
+
22
+ def detect(self, ctx) -> bool:
23
+ return "react" in ctx.node_deps
24
+
25
+ def plan(self, ctx) -> List[Step]:
26
+ # `plan()` for react-as-a-dependency assumes a name isn't being
27
+ # asked interactively (that only happens via `create`), so this
28
+ # covers the case "something requires react and it's missing" —
29
+ # it scaffolds a project named after the current folder, in place.
30
+ folder_name = os.path.basename(ctx.cwd.rstrip("/")) or "app"
31
+ return [
32
+ Step(
33
+ f"Scaffold React (Vite) into ./{folder_name}",
34
+ command=["npm", "create", "vite@latest", ".", "--", "--template", "react"],
35
+ cwd=ctx.cwd,
36
+ ),
37
+ Step("Install npm dependencies", command=["npm", "install"], cwd=ctx.cwd),
38
+ ]
39
+
40
+ def create(self, ctx, **kwargs):
41
+ if not which("npm"):
42
+ raise RuntimeError("npm not found on PATH. Install Node.js first: https://nodejs.org")
43
+
44
+ name = prompt("Project name", default="my-react-app")
45
+ level = prompt("Beginner or advanced setup? [b/a]", default="b").strip().lower()
46
+ advanced = level.startswith("a")
47
+ template = "react-ts" if advanced else "react"
48
+
49
+ from qtmp.utils import Spinner, run, ok
50
+
51
+ with Spinner(f"Scaffolding {name} with Vite"):
52
+ run(["npm", "create", "vite@latest", name, "--", "--template", template], cwd=ctx.cwd)
53
+
54
+ project_dir = os.path.join(ctx.cwd, name)
55
+ with Spinner("Installing dependencies"):
56
+ run(["npm", "install"], cwd=project_dir)
57
+
58
+ ok(f"React project ready at {project_dir}")
59
+ return project_dir, advanced
@@ -0,0 +1,69 @@
1
+ """
2
+ qtmp.recipes.router_axios
3
+ ---------------------------
4
+ Two small recipes, both requiring "react", showing what a minimal
5
+ add-on recipe looks like once the base project is a dependency someone
6
+ else already resolved.
7
+ """
8
+
9
+ from typing import List
10
+
11
+ from qtmp.planner import Step
12
+ from qtmp.recipes.base import Recipe
13
+
14
+
15
+ class RouterRecipe(Recipe):
16
+ name = "React Router"
17
+ provides = "router"
18
+
19
+ def requires(self, ctx) -> List[str]:
20
+ return [] if ctx.is_node else ["react"]
21
+
22
+ def detect(self, ctx) -> bool:
23
+ return "react-router-dom" in ctx.node_deps
24
+
25
+ def plan(self, ctx) -> List[Step]:
26
+ return [
27
+ Step(
28
+ "Install react-router-dom",
29
+ command=["npm", "install", "react-router-dom"],
30
+ cwd=ctx.cwd,
31
+ )
32
+ ]
33
+
34
+
35
+ class AxiosRecipe(Recipe):
36
+ name = "Axios"
37
+ provides = "axios"
38
+
39
+ def requires(self, ctx) -> List[str]:
40
+ return [] if ctx.is_node else ["react"]
41
+
42
+ def detect(self, ctx) -> bool:
43
+ return "axios" in ctx.node_deps
44
+
45
+ def plan(self, ctx) -> List[Step]:
46
+ return [
47
+ Step("Install axios", command=["npm", "install", "axios"], cwd=ctx.cwd)
48
+ ]
49
+
50
+
51
+ class ShadcnRecipe(Recipe):
52
+ name = "shadcn/ui"
53
+ provides = "shadcn"
54
+
55
+ def requires(self, ctx) -> List[str]:
56
+ return ["tailwind"] # shadcn/ui always needs Tailwind configured first
57
+
58
+ def detect(self, ctx) -> bool:
59
+ import os
60
+ return os.path.isfile(f"{ctx.cwd}/components.json")
61
+
62
+ def plan(self, ctx) -> List[Step]:
63
+ return [
64
+ Step(
65
+ "Initialize shadcn/ui",
66
+ command=["npx", "shadcn@latest", "init", "-d"],
67
+ cwd=ctx.cwd,
68
+ )
69
+ ]
@@ -0,0 +1,69 @@
1
+ """
2
+ qtmp.recipes.tailwind
3
+ -----------------------
4
+ Provides "tailwind". Demonstrates the simplest form of dependency
5
+ resolution: if you `qtmp add tailwind` in an empty folder, it pulls in
6
+ "react" first, because Tailwind needs *some* JS project to attach to.
7
+ If a node project already exists, it attaches directly.
8
+ """
9
+
10
+ import os
11
+ from typing import List
12
+
13
+ from qtmp.planner import Step
14
+ from qtmp.recipes.base import Recipe
15
+
16
+
17
+ TAILWIND_CONFIG = (
18
+ "/** @type {import('tailwindcss').Config} */\n"
19
+ "export default {\n"
20
+ ' content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],\n'
21
+ " theme: { extend: {} },\n"
22
+ " plugins: [],\n"
23
+ "}\n"
24
+ )
25
+
26
+
27
+ def _write_config(cwd):
28
+ with open(os.path.join(cwd, "tailwind.config.js"), "w") as f:
29
+ f.write(TAILWIND_CONFIG)
30
+
31
+
32
+ def _append_directives(cwd):
33
+ css_path = os.path.join(cwd, "src", "index.css")
34
+ if os.path.isdir(os.path.join(cwd, "src")):
35
+ with open(css_path, "a") as f:
36
+ f.write("\n@tailwind base;\n@tailwind components;\n@tailwind utilities;\n")
37
+
38
+
39
+ class TailwindRecipe(Recipe):
40
+ name = "Tailwind CSS"
41
+ provides = "tailwind"
42
+
43
+ def requires(self, ctx) -> List[str]:
44
+ return [] if ctx.is_node else ["react"]
45
+
46
+ def detect(self, ctx) -> bool:
47
+ return ctx.has_tailwind_config
48
+
49
+ def plan(self, ctx) -> List[Step]:
50
+ return [
51
+ Step(
52
+ "Install tailwindcss, postcss, autoprefixer",
53
+ command=["npm", "install", "-D", "tailwindcss", "postcss", "autoprefixer"],
54
+ cwd=ctx.cwd,
55
+ ),
56
+ Step(
57
+ "Run tailwindcss init",
58
+ command=["npx", "tailwindcss", "init", "-p"],
59
+ cwd=ctx.cwd,
60
+ ),
61
+ Step(
62
+ "Write tailwind.config.js content globs",
63
+ apply_fn=lambda: _write_config(ctx.cwd),
64
+ ),
65
+ Step(
66
+ "Add @tailwind directives to src/index.css",
67
+ apply_fn=lambda: _append_directives(ctx.cwd),
68
+ ),
69
+ ]
qtmp/utils.py ADDED
@@ -0,0 +1,180 @@
1
+ """
2
+ qtmp.utils
3
+ ----------------
4
+ Small helpers: colored terminal output, safe subprocess execution,
5
+ and a minimal spinner. Pure stdlib, no third-party deps.
6
+ """
7
+
8
+ import subprocess
9
+ import sys
10
+ import shutil
11
+ import time
12
+ import threading
13
+ import itertools
14
+
15
+
16
+ class C:
17
+ """ANSI color codes (auto-disabled on unsupported terminals)."""
18
+ _enabled = sys.stdout.isatty()
19
+ RESET = "\033[0m" if _enabled else ""
20
+ BOLD = "\033[1m" if _enabled else ""
21
+ DIM = "\033[2m" if _enabled else ""
22
+ ITALIC = "\033[3m" if _enabled else ""
23
+ GREEN = "\033[32m" if _enabled else ""
24
+ RED = "\033[31m" if _enabled else ""
25
+ YELLOW = "\033[33m" if _enabled else ""
26
+ CYAN = "\033[36m" if _enabled else ""
27
+ MAGENTA = "\033[35m" if _enabled else ""
28
+ BLUE = "\033[34m" if _enabled else ""
29
+ WHITE = "\033[97m" if _enabled else ""
30
+ # Bright variants read better on dark terminal backgrounds.
31
+ B_CYAN = "\033[96m" if _enabled else ""
32
+ B_MAGENTA = "\033[95m" if _enabled else ""
33
+ B_GREEN = "\033[92m" if _enabled else ""
34
+ B_YELLOW = "\033[93m" if _enabled else ""
35
+
36
+
37
+ LOGO = r"""
38
+ _
39
+ __ _| |_ _ __ ___ _ __
40
+ / _` | __| '_ ` _ \| '_ \
41
+ | (_| | |_| | | | | | |_) |
42
+ \__, |\__|_| |_| |_| .__/
43
+ |_| |_|
44
+ """
45
+
46
+
47
+ def print_logo(tagline: str = "scaffold your stack, without the busywork"):
48
+ print(f"{C.B_CYAN}{LOGO}{C.RESET}")
49
+ print(f" {C.DIM}{C.ITALIC}{tagline}{C.RESET}\n")
50
+
51
+
52
+ def banner(text: str):
53
+ width = max(len(text) + 4, 20)
54
+ top = "╭" + "─" * (width - 2) + "╮"
55
+ bottom = "╰" + "─" * (width - 2) + "╯"
56
+ pad = width - 2 - len(text)
57
+ left_pad = pad // 2
58
+ right_pad = pad - left_pad
59
+ print(f"\n{C.B_MAGENTA}{top}{C.RESET}")
60
+ print(f"{C.B_MAGENTA}│{C.RESET}{' ' * left_pad}{C.BOLD}{C.WHITE}{text}{C.RESET}{' ' * right_pad}{C.B_MAGENTA}│{C.RESET}")
61
+ print(f"{C.B_MAGENTA}{bottom}{C.RESET}")
62
+
63
+
64
+ def info(text: str):
65
+ print(f"{C.B_CYAN}i{C.RESET} {text}")
66
+
67
+
68
+ def ok(text: str):
69
+ print(f"{C.B_GREEN}✓{C.RESET} {text}")
70
+
71
+
72
+ def warn(text: str):
73
+ print(f"{C.B_YELLOW}!{C.RESET} {text}")
74
+
75
+
76
+ def err(text: str):
77
+ print(f"{C.RED}✗{C.RESET} {text}")
78
+
79
+
80
+ def which(binary: str) -> bool:
81
+ """Check whether a binary exists on PATH."""
82
+ return shutil.which(binary) is not None
83
+
84
+
85
+ class Spinner:
86
+ """A tiny context-manager spinner for long-running commands."""
87
+
88
+ FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
89
+
90
+ def __init__(self, message: str):
91
+ self.message = message
92
+ self._stop = threading.Event()
93
+ self._thread = None
94
+
95
+ def _spin(self):
96
+ for frame in itertools.cycle(self.FRAMES):
97
+ if self._stop.is_set():
98
+ break
99
+ sys.stdout.write(f"\r{C.MAGENTA}{frame}{C.RESET} {self.message} ")
100
+ sys.stdout.flush()
101
+ time.sleep(0.08)
102
+ sys.stdout.write("\r" + " " * (len(self.message) + 10) + "\r")
103
+ sys.stdout.flush()
104
+
105
+ def __enter__(self):
106
+ if sys.stdout.isatty():
107
+ self._thread = threading.Thread(target=self._spin, daemon=True)
108
+ self._thread.start()
109
+ else:
110
+ print(f"... {self.message}")
111
+ return self
112
+
113
+ def __exit__(self, *exc):
114
+ self._stop.set()
115
+ if self._thread:
116
+ self._thread.join()
117
+
118
+
119
+ def run(cmd, cwd=None, spinner_msg=None, check=True) -> subprocess.CompletedProcess:
120
+ """
121
+ Run a shell command, streaming output live to the console.
122
+ `cmd` can be a list (preferred) or a string.
123
+
124
+ Returns the CompletedProcess. Raises CalledProcessError if check=True
125
+ and the command fails (caller can catch this to report cleanly).
126
+ """
127
+ shown = cmd if isinstance(cmd, str) else " ".join(cmd)
128
+ print(f"{C.DIM}$ {shown}{C.RESET}")
129
+
130
+ result = subprocess.run(
131
+ cmd,
132
+ cwd=cwd,
133
+ shell=isinstance(cmd, str),
134
+ text=True,
135
+ )
136
+
137
+ if check and result.returncode != 0:
138
+ raise subprocess.CalledProcessError(result.returncode, cmd)
139
+
140
+ return result
141
+
142
+
143
+ def prompt(msg: str, default: str = None) -> str:
144
+ suffix = f" [{default}]" if default else ""
145
+ val = input(f"{C.BOLD}{msg}{suffix}: {C.RESET}").strip()
146
+ return val or (default or "")
147
+
148
+
149
+ def confirm(msg: str, default: bool = True) -> bool:
150
+ suffix = "[Y/n]" if default else "[y/N]"
151
+ val = input(f"{C.BOLD}{msg} {suffix}: {C.RESET}").strip().lower()
152
+ if not val:
153
+ return default
154
+ return val in ("y", "yes")
155
+
156
+
157
+ def prompt_directory(default_cwd: str) -> str:
158
+ """
159
+ Simple directory picker: default is "create right here" — just hit
160
+ Enter. Only asks for a name if the person wants a fresh subfolder,
161
+ never a full path.
162
+ """
163
+ import os as _os
164
+
165
+ info(f"Creating in: {default_cwd}")
166
+ name = prompt("Folder name (Enter = use this directory as-is)", default="")
167
+
168
+ if not name:
169
+ return default_cwd
170
+
171
+ path = _os.path.join(default_cwd, name)
172
+ if _os.path.isfile(path):
173
+ err(f"{path} is a file, not a directory.")
174
+ return prompt_directory(default_cwd)
175
+
176
+ if not _os.path.isdir(path):
177
+ _os.makedirs(path, exist_ok=True)
178
+ ok(f"Created ./{name}")
179
+
180
+ return path