qtmp 0.2.0__tar.gz

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.
qtmp-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: qtmp
3
+ Version: 0.2.0
4
+ Summary: Qtmp -- Developer Environment Automation CLI. Detects project environments, resolves dependencies, generates execution plans, and runs package-manager commands, with idempotent operations and dry-run planning built in.
5
+ Requires-Python: >=3.8
6
+ Provides-Extra: dev
7
+ Requires-Dist: pytest; extra == "dev"
8
+ Dynamic: provides-extra
9
+ Dynamic: requires-python
10
+ Dynamic: summary
qtmp-0.2.0/README.md ADDED
@@ -0,0 +1,156 @@
1
+ # qtmp — Q-Templates: Developer Environment Automation CLI
2
+
3
+ A Python CLI that detects your project environment, resolves
4
+ dependencies between tech stacks, generates an execution plan, and
5
+ runs the package-manager commands — instead of you hunting through
6
+ docs and typing them one by one.
7
+
8
+ ```bash
9
+ pip3 install qtmp
10
+ qtmp create react
11
+ qtmp add tailwind
12
+ qtmp plan add sqlalchemy # dry run first
13
+ qtmp add sqlalchemy # then actually run it
14
+ qtmp doctor
15
+ ```
16
+
17
+ ## Why this isn't just "a script that runs npm install"
18
+
19
+ - **Recipe architecture** — every piece of the stack (React, Tailwind,
20
+ FastAPI, SQLAlchemy, ...) is a self-contained `Recipe` with three
21
+ methods: `requires()`, `detect()`, `plan()`. Adding a new one is one
22
+ file + one line in a registry — nothing in the CLI, planner, or
23
+ executor changes.
24
+ - **Real dependency resolution** — `qtmp add sqlalchemy` in an empty
25
+ folder resolves to: scaffold FastAPI → install the Postgres driver
26
+ → install SQLAlchemy, in that order, with shared dependencies
27
+ (a "diamond") only ever installed once.
28
+ - **Project detection** — reads `package.json`, `vite.config.*`,
29
+ `pyproject.toml`, `requirements.txt`, `docker-compose.yml`,
30
+ `tailwind.config.*` once per run into a `ProjectContext`, so every
31
+ recipe and `qtmp doctor` see the same picture of "what's already here."
32
+ - **Dry-run planning** — `qtmp plan add X` shows the exact commands that
33
+ would run, against the real dependency graph, without touching the
34
+ filesystem. `qtmp add X` is the same resolution path, one step later.
35
+ - **Idempotent by construction** — every recipe's `detect()` is checked
36
+ *before* its `plan()` is even called. Running `qtmp add tailwind`
37
+ twice reports "already installed, nothing to do" instead of
38
+ reinstalling or corrupting config.
39
+ - **Fail-fast, resumable execution** — if a step fails (bad network,
40
+ missing tool), everything after it stops. Already-applied steps are
41
+ skipped on the next attempt; nothing after the failure was touched.
42
+
43
+ ## Commands
44
+
45
+ | Command | What it does |
46
+ |---|---|
47
+ | `qtmp create react` / `qtmp create fastapi` | Scaffold a brand-new project (asks name, and for React, beginner/advanced) |
48
+ | `qtmp add <capability>` | Resolve dependencies and install/configure a capability |
49
+ | `qtmp plan add <capability>` | Same resolution, dry-run only — prints the commands, changes nothing |
50
+ | `qtmp doctor` | Diagnose which tools are on PATH and what this directory looks like |
51
+ | `qtmp list` | List every registered capability |
52
+ | `qtmp cheatsheet [name]` | View built-in or your own personal cheatsheets |
53
+ | `qtmp --version` | Print the installed version |
54
+
55
+ Beginner React (`b`) gets Vite + Tailwind automatically. Advanced (`a`)
56
+ gets TypeScript, Tailwind, shadcn/ui, React Router, Axios, and a
57
+ `src/components|pages|hooks|lib` layout — all resolved and applied
58
+ through the same planner/executor path as a manual `qtmp add`.
59
+
60
+ ## Architecture
61
+
62
+ ```
63
+ qtmp/
64
+ ├── context.py ProjectContext.detect(cwd) -- one detection pass, shared everywhere
65
+ ├── planner.py Step, Plan, resolve_plan() -- pure dependency-graph resolution
66
+ ├── executor.py apply(plan) -- runs pending steps, skips satisfied ones, fails fast
67
+ ├── doctor.py read-only environment + project diagnostics
68
+ ├── recipes/
69
+ │ ├── base.py Recipe ABC: requires() / detect() / plan() / (optional) create()
70
+ │ ├── react.py provides "react", creatable
71
+ │ ├── tailwind.py provides "tailwind", requires "react" only if no node project exists yet
72
+ │ ├── router_axios.py provides "router" / "axios" / "shadcn" (shadcn requires "tailwind")
73
+ │ ├── fastapi.py provides "fastapi", creatable
74
+ │ ├── database.py provides "postgres" / "db-driver-postgres" / "sqlalchemy"
75
+ │ └── __init__.py REGISTRY = {capability_name: recipe_instance} -- the only file
76
+ │ you edit to add a new recipe
77
+ ├── cheatsheet_manager.py bundled vs. personal (~/.qtmp/cheatsheets/) cheatsheets
78
+ └── cli.py argparse subcommands, all going through resolve_plan()/apply()
79
+ ```
80
+
81
+ ### The dependency-resolution example, end to end
82
+
83
+ `qtmp add sqlalchemy` on an empty folder resolves like this:
84
+
85
+ ```
86
+ sqlalchemy
87
+ ├── requires: fastapi -> not detected -> scaffold venv, install fastapi+uvicorn, write main.py
88
+ └── requires: db-driver-postgres
89
+ └── requires: fastapi -> already resolved above, not repeated
90
+ -> not detected -> pip install psycopg2-binary
91
+ -> not detected -> pip install sqlalchemy
92
+ ```
93
+
94
+ Run it for real and check the output of `qtmp plan add sqlalchemy` first
95
+ — that's the exact plan the executor will follow, nothing hidden.
96
+
97
+ ### Adding a new recipe
98
+
99
+ ```python
100
+ # qtmp/recipes/docker.py
101
+ from qtmp.recipes.base import Recipe
102
+ from qtmp.planner import Step
103
+
104
+ class DockerRecipe(Recipe):
105
+ name = "Docker"
106
+ provides = "docker"
107
+
108
+ def detect(self, ctx):
109
+ return ctx.has_docker_compose
110
+
111
+ def plan(self, ctx):
112
+ return [Step("Write docker-compose.yml", apply_fn=lambda: ...)]
113
+ ```
114
+ ```python
115
+ # qtmp/recipes/__init__.py
116
+ from qtmp.recipes.docker import DockerRecipe
117
+ REGISTRY["docker"] = DockerRecipe()
118
+ ```
119
+ That's the entire integration surface. `cli.py`, `planner.py`, and
120
+ `executor.py` never need to know Docker exists.
121
+
122
+ ## Testing
123
+
124
+ ```bash
125
+ pip install -e ".[dev]"
126
+ pytest -v
127
+ ```
128
+
129
+ 40 tests covering:
130
+ - **Project detection** — Node/Vite/Python/venv/Docker/Tailwind sniffing, and that a malformed `package.json` doesn't crash detection.
131
+ - **Dependency resolution** — ordering, diamond dependencies installed once, unknown capabilities raising a clear error, and the exact FastAPI→driver→SQLAlchemy shape from the design doc.
132
+ - **Idempotency & safe execution** — a fully-satisfied chain is a no-op; a failure mid-plan halts everything after it and reports which step failed; already-applied steps are never re-run.
133
+ - **Individual recipes** — `detect()`/`plan()`/`requires()` logic for React, Tailwind, Router, Axios, shadcn, FastAPI, and the database chain, with **no real npm/pip/network calls** — commands are asserted on, never executed, in unit tests.
134
+ - **Cheatsheets** — personal overrides taking precedence over bundled ones, and falling back correctly on removal.
135
+
136
+ Recipe tests never shell out for real — `plan()` is asserted on
137
+ directly, so these pass identically whether or not Node/Python/Postgres
138
+ happen to be installed on the machine running CI.
139
+
140
+ ## Honest limitations (not yet done)
141
+
142
+ - **Cross-platform**: path handling is OS-aware (`os.name == "nt"`
143
+ branches for venv paths), but this has only actually been *run* on
144
+ macOS/Linux. Windows support is written for, not verified.
145
+ - **Package manager choice**: assumes `npm` and `pip`. No `pnpm`/`yarn`/
146
+ `poetry`/`uv` alternative paths yet — a natural next recipe-level
147
+ feature (a `--pm` flag resolved per-ecosystem).
148
+ - **`postgres` recipe** deliberately does *not* auto-install a local
149
+ Postgres server (that usually needs `sudo` / a package-manager
150
+ choice we shouldn't make for you) — it only guides you and checks
151
+ for `psql` on PATH. Automating a *sudo-requiring* step didn't feel
152
+ like something a CLI should do silently, even in service of a demo.
153
+ - **AI-assisted setup** from an earlier iteration was removed in this
154
+ rewrite to keep the core deterministic and testable; if it comes
155
+ back, it'll be a strictly optional layer on top of `resolve_plan()`,
156
+ never a replacement for it.
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -0,0 +1,75 @@
1
+ """
2
+ qtmp.cheatsheet_manager
3
+ -------------------------------
4
+ Two sources of cheatsheets:
5
+
6
+ 1. Bundled -> qtmp/cheatsheets/*.md (shipped with the package)
7
+ 2. Personal -> ~/.qtmp/cheatsheets/*.md (your own handmade notes)
8
+
9
+ Personal cheatsheets always take priority: if you drop a `react.md` in
10
+ your personal folder, it overrides the bundled one — so you can keep
11
+ your own handwritten reference without losing it on an upgrade.
12
+ """
13
+
14
+ import os
15
+ import shutil
16
+
17
+ BUNDLED_DIR = os.path.join(os.path.dirname(__file__), "cheatsheets")
18
+ PERSONAL_DIR = os.path.join(os.path.expanduser("~"), ".qtmp", "cheatsheets")
19
+
20
+
21
+ def _ensure_personal_dir():
22
+ os.makedirs(PERSONAL_DIR, exist_ok=True)
23
+
24
+
25
+ def list_cheatsheets():
26
+ """Return {name: (path, source)} merged, personal overriding bundled."""
27
+ _ensure_personal_dir()
28
+ sheets = {}
29
+
30
+ if os.path.isdir(BUNDLED_DIR):
31
+ for fname in sorted(os.listdir(BUNDLED_DIR)):
32
+ if fname.endswith(".md"):
33
+ name = fname[:-3]
34
+ sheets[name] = (os.path.join(BUNDLED_DIR, fname), "bundled")
35
+
36
+ for fname in sorted(os.listdir(PERSONAL_DIR)):
37
+ if fname.endswith(".md"):
38
+ name = fname[:-3]
39
+ sheets[name] = (os.path.join(PERSONAL_DIR, fname), "personal")
40
+
41
+ return sheets
42
+
43
+
44
+ def view(name: str) -> str:
45
+ sheets = list_cheatsheets()
46
+ if name not in sheets:
47
+ return None
48
+ path, _source = sheets[name]
49
+ with open(path, "r") as f:
50
+ return f.read()
51
+
52
+
53
+ def add(name: str, source_path: str) -> str:
54
+ """
55
+ Copy a user's own handmade cheatsheet file into the personal folder,
56
+ so it survives package upgrades and overrides any bundled sheet of
57
+ the same name.
58
+ """
59
+ _ensure_personal_dir()
60
+ source_path = os.path.expanduser(source_path)
61
+ if not os.path.isfile(source_path):
62
+ raise FileNotFoundError(source_path)
63
+
64
+ dest = os.path.join(PERSONAL_DIR, f"{name}.md")
65
+ shutil.copyfile(source_path, dest)
66
+ return dest
67
+
68
+
69
+ def remove(name: str) -> bool:
70
+ """Remove a personal cheatsheet override (bundled ones can't be removed)."""
71
+ path = os.path.join(PERSONAL_DIR, f"{name}.md")
72
+ if os.path.isfile(path):
73
+ os.remove(path)
74
+ return True
75
+ return False
@@ -0,0 +1,24 @@
1
+ # FastAPI Cheatsheet
2
+
3
+ ## Dev commands
4
+ uvicorn main:app --reload start dev server (localhost:8000)
5
+
6
+ ## Built-in docs
7
+ /docs Swagger UI
8
+ /redoc ReDoc
9
+
10
+ ## Minimal app
11
+ from fastapi import FastAPI
12
+ app = FastAPI()
13
+
14
+ @app.get("/")
15
+ def read_root():
16
+ return {"message": "hello"}
17
+
18
+ ## PostgreSQL (via SQLAlchemy)
19
+ DATABASE_URL = "postgresql://user:pass@localhost:5432/dbname"
20
+
21
+ ## Gotchas
22
+ - `uvicorn[standard]` pulls in the faster event loop + websockets support;
23
+ plain `uvicorn` is a lighter install if you don't need those.
24
+ - `--reload` is for development only — never use it in production.
@@ -0,0 +1,22 @@
1
+ # React + Vite Cheatsheet
2
+
3
+ ## Dev commands
4
+ npm run dev start dev server
5
+ npm run build production build
6
+ npm run preview preview the build
7
+
8
+ ## Common add-ons
9
+ npm install react-router-dom # routing
10
+ npm install axios # HTTP client
11
+ npx shadcn@latest add button # add a shadcn component
12
+
13
+ ## File structure (default Vite scaffold)
14
+ src/
15
+ main.jsx entry point, mounts <App />
16
+ App.jsx root component
17
+ index.html Vite's HTML entry (not public/index.html like CRA)
18
+
19
+ ## Gotchas
20
+ - Env vars must be prefixed `VITE_` to be exposed to the client
21
+ (e.g. `VITE_API_URL`, accessed via `import.meta.env.VITE_API_URL`).
22
+ - Vite dev server default port: 5173 (not 3000).
qtmp-0.2.0/qtmp/cli.py ADDED
@@ -0,0 +1,234 @@
1
+ """
2
+ qtmp.cli
3
+ ---------
4
+ Real subcommands, not a menu loop:
5
+
6
+ qtmp create react
7
+ qtmp create fastapi
8
+ qtmp add tailwind
9
+ qtmp add sqlalchemy
10
+ qtmp plan add sqlalchemy # dry run: show what would happen, change nothing
11
+ qtmp doctor
12
+ qtmp list
13
+ qtmp cheatsheet [name]
14
+
15
+ Every `add`/`plan add` goes through the same dependency-resolution
16
+ path (qtmp.planner.resolve_plan), so the dry run and the real run can
17
+ never drift apart from each other.
18
+ """
19
+
20
+ import argparse
21
+ import os
22
+ import sys
23
+
24
+ from qtmp import __version__
25
+ from qtmp.context import ProjectContext
26
+ from qtmp.recipes import REGISTRY, CREATABLE, resolve_name
27
+ from qtmp.planner import resolve_plan, ResolutionError
28
+ from qtmp.executor import apply as execute_plan, report as report_result
29
+ from qtmp.doctor import run_doctor
30
+ from qtmp.utils import banner, info, warn, ok, err, print_logo, C
31
+ from qtmp import cheatsheet_manager as cheats
32
+
33
+
34
+ def cmd_create(args):
35
+ key = resolve_name(args.recipe)
36
+ recipe = CREATABLE.get(key)
37
+ if recipe is None:
38
+ available = ", ".join(sorted(CREATABLE.keys()))
39
+ err(f"'{args.recipe}' isn't creatable. Available: {available}")
40
+ sys.exit(1)
41
+
42
+ ctx = ProjectContext.detect(args.cwd)
43
+ banner(f"Create: {recipe.name}")
44
+ project_dir, advanced = recipe.create(ctx)
45
+
46
+ if key == "react" and advanced:
47
+ sub_ctx = ProjectContext.detect(project_dir)
48
+ info("Advanced preset: adding Tailwind, shadcn/ui, Router, Axios automatically.")
49
+ for capability in ("tailwind", "shadcn", "router", "axios"):
50
+ _add_capability(capability, sub_ctx)
51
+ elif key == "react":
52
+ sub_ctx = ProjectContext.detect(project_dir)
53
+ info("Beginner preset: adding Tailwind automatically.")
54
+ _add_capability("tailwind", sub_ctx)
55
+
56
+ ok(f"Done. cd {project_dir}")
57
+
58
+
59
+ def cmd_add(args):
60
+ ctx = ProjectContext.detect(args.cwd)
61
+ key = resolve_name(args.capability)
62
+ _add_capability(key, ctx)
63
+
64
+
65
+ def cmd_plan(args):
66
+ ctx = ProjectContext.detect(args.cwd)
67
+ key = resolve_name(args.capability)
68
+ _print_plan(key, ctx)
69
+
70
+
71
+ def _add_capability(key: str, ctx):
72
+ try:
73
+ plan = resolve_plan(key, ctx, REGISTRY)
74
+ except ResolutionError as e:
75
+ err(str(e))
76
+ return
77
+
78
+ banner(f"add: {key}")
79
+ if plan.is_noop():
80
+ for step in plan.steps:
81
+ ok(step.description)
82
+ info("Nothing to do.")
83
+ return
84
+
85
+ for step in plan.pending_steps:
86
+ print(f" {C.DIM}->{C.RESET} {step.description}")
87
+
88
+ result = execute_plan(plan)
89
+ report_result(result, key)
90
+
91
+
92
+ def _print_plan(key: str, ctx):
93
+ try:
94
+ plan = resolve_plan(key, ctx, REGISTRY)
95
+ except ResolutionError as e:
96
+ err(str(e))
97
+ return
98
+
99
+ banner(f"plan: {key}")
100
+ if plan.is_noop():
101
+ for step in plan.steps:
102
+ ok(f"{step.description} (already satisfied)")
103
+ info("Nothing to do — running 'qtmp add' now would be a no-op.")
104
+ return
105
+
106
+ for step in plan.steps:
107
+ if step.already_satisfied:
108
+ print(f" {C.DIM}✓ {step.description} (already satisfied){C.RESET}")
109
+ else:
110
+ cmd_preview = " ".join(step.command) if step.command else "(custom action)"
111
+ print(f" {C.B_YELLOW}→{C.RESET} {step.description}")
112
+ print(f" {C.DIM}$ {cmd_preview}{C.RESET}")
113
+
114
+ info("Dry run only — nothing was changed. Run 'qtmp add "
115
+ f"{key}' to execute this plan.")
116
+
117
+
118
+ def cmd_doctor(args):
119
+ ctx = ProjectContext.detect(args.cwd)
120
+ run_doctor(ctx)
121
+
122
+
123
+ def cmd_list(args):
124
+ banner("Available capabilities")
125
+ for cap, recipe in sorted(REGISTRY.items()):
126
+ tag = f"{C.DIM}(creatable){C.RESET}" if recipe.creatable else ""
127
+ print(f" {C.CYAN}{cap}{C.RESET} {tag}")
128
+ info("qtmp add <capability> | qtmp create <capability> | qtmp plan add <capability>")
129
+
130
+
131
+ def cmd_cheatsheet(args):
132
+ extra = args.extra or []
133
+ if not extra:
134
+ sheets = cheats.list_cheatsheets()
135
+ if not sheets:
136
+ info("No cheatsheets yet. Add one with: qtmp cheatsheet add <name> <path/to/file.md>")
137
+ return
138
+ banner("Cheatsheets")
139
+ for name, (_path, source) in sheets.items():
140
+ tag = f"{C.DIM}(yours){C.RESET}" if source == "personal" else f"{C.DIM}(built-in){C.RESET}"
141
+ print(f" {C.CYAN}{name}{C.RESET} {tag}")
142
+ info("View one with: qtmp cheatsheet <name>")
143
+ return
144
+
145
+ if extra[0] == "add":
146
+ if len(extra) < 3:
147
+ warn("Usage: qtmp cheatsheet add <name> <path/to/your-notes.md>")
148
+ return
149
+ name, path = extra[1], extra[2]
150
+ try:
151
+ dest = cheats.add(name, path)
152
+ ok(f"Saved your cheatsheet as '{name}' -> {dest}")
153
+ except FileNotFoundError:
154
+ warn(f"File not found: {path}")
155
+ return
156
+
157
+ if extra[0] == "remove":
158
+ if len(extra) < 2:
159
+ warn("Usage: qtmp cheatsheet remove <name>")
160
+ return
161
+ removed = cheats.remove(extra[1])
162
+ if removed:
163
+ ok(f"Removed your custom cheatsheet '{extra[1]}'.")
164
+ else:
165
+ warn(f"No personal cheatsheet named '{extra[1]}' to remove "
166
+ "(built-in ones can't be removed, only overridden).")
167
+ return
168
+
169
+ name = extra[0]
170
+ content = cheats.view(name)
171
+ if content is None:
172
+ warn(f"No cheatsheet named '{name}'. Run 'qtmp cheatsheet' to see what's available.")
173
+ return
174
+ print(f"\n{content}")
175
+
176
+
177
+ def build_parser():
178
+ parser = argparse.ArgumentParser(
179
+ prog="qtmp",
180
+ description="Developer Environment Automation CLI — detects your project, "
181
+ "resolves dependencies, and executes package-manager commands so you don't "
182
+ "have to hunt through docs.",
183
+ )
184
+ parser.add_argument("--version", action="version", version=f"qtmp {__version__}")
185
+ parser.add_argument("--cwd", default=os.getcwd(), help="Directory to operate in.")
186
+
187
+ sub = parser.add_subparsers(dest="command")
188
+
189
+ p_create = sub.add_parser("create", help="Scaffold a brand-new project (react, fastapi).")
190
+ p_create.add_argument("recipe")
191
+ p_create.set_defaults(func=cmd_create)
192
+
193
+ p_add = sub.add_parser("add", help="Add a capability, resolving dependencies, and run it.")
194
+ p_add.add_argument("capability")
195
+ p_add.set_defaults(func=cmd_add)
196
+
197
+ p_plan = sub.add_parser("plan", help="Dry-run: show what 'add' would do without doing it.")
198
+ plan_sub = p_plan.add_subparsers(dest="plan_command")
199
+ p_plan_add = plan_sub.add_parser("add")
200
+ p_plan_add.add_argument("capability")
201
+ p_plan_add.set_defaults(func=cmd_plan)
202
+
203
+ p_doctor = sub.add_parser("doctor", help="Diagnose the environment and this project.")
204
+ p_doctor.set_defaults(func=cmd_doctor)
205
+
206
+ p_list = sub.add_parser("list", help="List all available capabilities.")
207
+ p_list.set_defaults(func=cmd_list)
208
+
209
+ p_cheat = sub.add_parser("cheatsheet", help="View or manage cheatsheets.")
210
+ p_cheat.add_argument("extra", nargs="*")
211
+ p_cheat.set_defaults(func=cmd_cheatsheet)
212
+
213
+ return parser
214
+
215
+
216
+ def main():
217
+ parser = build_parser()
218
+ args = parser.parse_args()
219
+ args.cwd = os.path.abspath(args.cwd)
220
+
221
+ if not getattr(args, "command", None):
222
+ print_logo()
223
+ parser.print_help()
224
+ return
225
+
226
+ if args.command == "plan" and getattr(args, "plan_command", None) != "add":
227
+ parser.parse_args(["plan", "--help"])
228
+ return
229
+
230
+ args.func(args)
231
+
232
+
233
+ if __name__ == "__main__":
234
+ main()
@@ -0,0 +1,94 @@
1
+ """
2
+ qtmp.context
3
+ -------------
4
+ Detects what kind of project directory we're standing in, once per run.
5
+ Every recipe reads from this instead of re-implementing its own file
6
+ sniffing — that's what makes `qtmp doctor` and dependency resolution
7
+ consistent with each other.
8
+ """
9
+
10
+ import json
11
+ import os
12
+ from dataclasses import dataclass, field
13
+
14
+
15
+ @dataclass
16
+ class ProjectContext:
17
+ cwd: str
18
+
19
+ # Node / JS
20
+ has_package_json: bool = False
21
+ is_node: bool = False
22
+ is_vite: bool = False
23
+ node_deps: set = field(default_factory=set)
24
+
25
+ # Python
26
+ has_pyproject: bool = False
27
+ has_requirements: bool = False
28
+ is_python: bool = False
29
+ has_venv: bool = False
30
+
31
+ # Infra
32
+ has_docker_compose: bool = False
33
+ has_tailwind_config: bool = False
34
+
35
+ @classmethod
36
+ def detect(cls, cwd: str) -> "ProjectContext":
37
+ ctx = cls(cwd=cwd)
38
+
39
+ pkg_path = os.path.join(cwd, "package.json")
40
+ if os.path.isfile(pkg_path):
41
+ ctx.has_package_json = True
42
+ ctx.is_node = True
43
+ try:
44
+ with open(pkg_path) as f:
45
+ data = json.load(f)
46
+ deps = {**data.get("dependencies", {}), **data.get("devDependencies", {})}
47
+ ctx.node_deps = set(deps.keys())
48
+ except (json.JSONDecodeError, OSError):
49
+ pass
50
+
51
+ for fname in ("vite.config.js", "vite.config.ts"):
52
+ if os.path.isfile(os.path.join(cwd, fname)):
53
+ ctx.is_vite = True
54
+
55
+ if os.path.isfile(os.path.join(cwd, "pyproject.toml")):
56
+ ctx.has_pyproject = True
57
+ ctx.is_python = True
58
+ if os.path.isfile(os.path.join(cwd, "requirements.txt")):
59
+ ctx.has_requirements = True
60
+ ctx.is_python = True
61
+ if os.path.isdir(os.path.join(cwd, "venv")) or os.path.isdir(os.path.join(cwd, ".venv")):
62
+ ctx.has_venv = True
63
+ ctx.is_python = True
64
+
65
+ for fname in ("docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"):
66
+ if os.path.isfile(os.path.join(cwd, fname)):
67
+ ctx.has_docker_compose = True
68
+
69
+ for fname in ("tailwind.config.js", "tailwind.config.ts"):
70
+ if os.path.isfile(os.path.join(cwd, fname)):
71
+ ctx.has_tailwind_config = True
72
+
73
+ return ctx
74
+
75
+ # ---- venv path helpers (kept here so recipes never hardcode OS paths) --
76
+
77
+ def _venv_dir(self):
78
+ for name in ("venv", ".venv"):
79
+ candidate = os.path.join(self.cwd, name)
80
+ if os.path.isdir(candidate):
81
+ return candidate
82
+ return os.path.join(self.cwd, "venv")
83
+
84
+ def venv_python(self) -> str:
85
+ venv_dir = self._venv_dir()
86
+ if os.name == "nt":
87
+ return os.path.join(venv_dir, "Scripts", "python.exe")
88
+ return os.path.join(venv_dir, "bin", "python")
89
+
90
+ def venv_pip(self) -> str:
91
+ venv_dir = self._venv_dir()
92
+ if os.name == "nt":
93
+ return os.path.join(venv_dir, "Scripts", "pip.exe")
94
+ return os.path.join(venv_dir, "bin", "pip")
@@ -0,0 +1,51 @@
1
+ """
2
+ qtmp.doctor
3
+ ------------
4
+ `qtmp doctor` — one command that answers "what does qtmp think this
5
+ environment looks like, and what's missing." Read-only: it never
6
+ installs anything, only reports.
7
+ """
8
+
9
+ from qtmp.utils import C, ok, warn, info, which
10
+
11
+
12
+ TOOLS = [
13
+ ("node", "Node.js"),
14
+ ("npm", "npm"),
15
+ ("python3", "Python 3"),
16
+ ("pip3", "pip"),
17
+ ("psql", "PostgreSQL client"),
18
+ ("docker", "Docker"),
19
+ ]
20
+
21
+
22
+ def run_doctor(ctx):
23
+ print(f"\n{C.BOLD}Tools on PATH{C.RESET}")
24
+ for binary, label in TOOLS:
25
+ if which(binary):
26
+ ok(f"{label} ({binary})")
27
+ else:
28
+ warn(f"{label} ({binary}) not found")
29
+
30
+ print(f"\n{C.BOLD}Project detected at {ctx.cwd}{C.RESET}")
31
+
32
+ flags = [
33
+ (ctx.has_package_json, "package.json (Node project)"),
34
+ (ctx.is_vite, "Vite config"),
35
+ (ctx.has_tailwind_config, "Tailwind config"),
36
+ (ctx.has_pyproject, "pyproject.toml (Python project)"),
37
+ (ctx.has_requirements, "requirements.txt"),
38
+ (ctx.has_venv, "virtual environment (venv/.venv)"),
39
+ (ctx.has_docker_compose, "docker-compose file"),
40
+ ]
41
+ any_detected = False
42
+ for present, label in flags:
43
+ if present:
44
+ ok(label)
45
+ any_detected = True
46
+
47
+ if not any_detected:
48
+ info("No recognizable project here yet — 'qtmp create react' or 'qtmp create fastapi' to start one.")
49
+
50
+ if ctx.node_deps:
51
+ print(f"\n{C.DIM}Detected npm dependencies: {', '.join(sorted(ctx.node_deps))}{C.RESET}")