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 +10 -0
- qtmp-0.2.0/README.md +156 -0
- qtmp-0.2.0/qtmp/__init__.py +1 -0
- qtmp-0.2.0/qtmp/cheatsheet_manager.py +75 -0
- qtmp-0.2.0/qtmp/cheatsheets/fastapi.md +24 -0
- qtmp-0.2.0/qtmp/cheatsheets/react.md +22 -0
- qtmp-0.2.0/qtmp/cli.py +234 -0
- qtmp-0.2.0/qtmp/context.py +94 -0
- qtmp-0.2.0/qtmp/doctor.py +51 -0
- qtmp-0.2.0/qtmp/executor.py +98 -0
- qtmp-0.2.0/qtmp/planner.py +84 -0
- qtmp-0.2.0/qtmp/recipes/__init__.py +51 -0
- qtmp-0.2.0/qtmp/recipes/base.py +62 -0
- qtmp-0.2.0/qtmp/recipes/database.py +112 -0
- qtmp-0.2.0/qtmp/recipes/fastapi.py +92 -0
- qtmp-0.2.0/qtmp/recipes/react.py +59 -0
- qtmp-0.2.0/qtmp/recipes/router_axios.py +69 -0
- qtmp-0.2.0/qtmp/recipes/tailwind.py +69 -0
- qtmp-0.2.0/qtmp/utils.py +180 -0
- qtmp-0.2.0/qtmp.egg-info/PKG-INFO +10 -0
- qtmp-0.2.0/qtmp.egg-info/SOURCES.txt +30 -0
- qtmp-0.2.0/qtmp.egg-info/dependency_links.txt +1 -0
- qtmp-0.2.0/qtmp.egg-info/entry_points.txt +2 -0
- qtmp-0.2.0/qtmp.egg-info/requires.txt +3 -0
- qtmp-0.2.0/qtmp.egg-info/top_level.txt +1 -0
- qtmp-0.2.0/setup.cfg +4 -0
- qtmp-0.2.0/setup.py +26 -0
- qtmp-0.2.0/tests/test_cheatsheet_manager.py +55 -0
- qtmp-0.2.0/tests/test_context.py +106 -0
- qtmp-0.2.0/tests/test_executor.py +93 -0
- qtmp-0.2.0/tests/test_planner.py +149 -0
- qtmp-0.2.0/tests/test_recipes.py +110 -0
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}")
|