karni-cli 0.3.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.
- karni_cli/__init__.py +3 -0
- karni_cli/_agent/__init__.py +82 -0
- karni_cli/_agent/__main__.py +44 -0
- karni_cli/_agent/check.py +658 -0
- karni_cli/_agent/history.py +328 -0
- karni_cli/_agent/history_chronicle.py +246 -0
- karni_cli/_agent/overrides.py +194 -0
- karni_cli/_agent/rules.py +673 -0
- karni_cli/_changelog/0.2.0.toml +304 -0
- karni_cli/_changelog/0.3.0.toml +25 -0
- karni_cli/_changelog/__init__.py +248 -0
- karni_cli/_errorcodes/__init__.py +1171 -0
- karni_cli/_errorcodes/__main__.py +34 -0
- karni_cli/_errorcodes/links.py +217 -0
- karni_cli/_templates/project/cookiecutter.json +8 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/.dockerignore +7 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/.env.example +85 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/.gitignore +10 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/Caddyfile +22 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/Dockerfile +53 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/Makefile +52 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/README.md +169 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/__init__.py +16 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/asgi.py +14 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/celery.py +17 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/settings.py +28 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/urls.py +31 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/wsgi.py +14 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/docker-compose.yml +126 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.dockerignore +2 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.jscpd.json +9 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.npmrc +7 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/Dockerfile.dev +17 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/eslint.config.js +32 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/index.html +14 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/package.json +43 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/postcss.config.js +6 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/public/favicon.svg +6 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/App.tsx +18 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/api/health.ts +21 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/index.css +11 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/layout.tsx +51 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/main.tsx +11 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/pages/home.tsx +28 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/routes.tsx +24 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/vite-env.d.ts +1 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/tailwind.config.ts +13 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/tsconfig.json +19 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/vite.config.ts +18 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/manage.py +23 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/pyproject.toml +31 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/__init__.py +0 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/test_api_schema.py +17 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/test_smoke.py +11 -0
- karni_cli/adapters.py +373 -0
- karni_cli/agent_adapters.toml +160 -0
- karni_cli/agent_data.py +260 -0
- karni_cli/brand.py +55 -0
- karni_cli/clidoc.py +254 -0
- karni_cli/commands/__init__.py +0 -0
- karni_cli/commands/add.py +144 -0
- karni_cli/commands/beat.py +36 -0
- karni_cli/commands/check.py +101 -0
- karni_cli/commands/config.py +81 -0
- karni_cli/commands/context.py +171 -0
- karni_cli/commands/createsuperuser.py +28 -0
- karni_cli/commands/doctor.py +136 -0
- karni_cli/commands/explain.py +60 -0
- karni_cli/commands/make.py +55 -0
- karni_cli/commands/manage_passthrough.py +28 -0
- karni_cli/commands/mcp.py +20 -0
- karni_cli/commands/migrate.py +23 -0
- karni_cli/commands/new.py +106 -0
- karni_cli/commands/routes.py +20 -0
- karni_cli/commands/rules.py +139 -0
- karni_cli/commands/run_tests.py +38 -0
- karni_cli/commands/schedule.py +40 -0
- karni_cli/commands/seed.py +34 -0
- karni_cli/commands/serve.py +50 -0
- karni_cli/commands/shell.py +28 -0
- karni_cli/commands/sync_roles.py +32 -0
- karni_cli/commands/types.py +72 -0
- karni_cli/commands/upgrade.py +231 -0
- karni_cli/commands/worker.py +63 -0
- karni_cli/config.py +142 -0
- karni_cli/environment.py +29 -0
- karni_cli/errorcodes.py +377 -0
- karni_cli/errordoc.py +227 -0
- karni_cli/generators/__init__.py +67 -0
- karni_cli/generators/apitest.py +206 -0
- karni_cli/generators/app.py +103 -0
- karni_cli/generators/command.py +98 -0
- karni_cli/generators/component.py +115 -0
- karni_cli/generators/core.py +319 -0
- karni_cli/generators/event.py +57 -0
- karni_cli/generators/factory.py +88 -0
- karni_cli/generators/fields.py +343 -0
- karni_cli/generators/introspect.py +263 -0
- karni_cli/generators/listener.py +168 -0
- karni_cli/generators/middleware.py +95 -0
- karni_cli/generators/migration.py +82 -0
- karni_cli/generators/model.py +235 -0
- karni_cli/generators/module.py +255 -0
- karni_cli/generators/naming.py +100 -0
- karni_cli/generators/page.py +373 -0
- karni_cli/generators/pysource.py +304 -0
- karni_cli/generators/schedule.py +174 -0
- karni_cli/generators/seeder.py +86 -0
- karni_cli/generators/serializer.py +165 -0
- karni_cli/generators/service.py +68 -0
- karni_cli/generators/task.py +84 -0
- karni_cli/generators/viewset.py +179 -0
- karni_cli/history.py +59 -0
- karni_cli/local_state.py +73 -0
- karni_cli/main.py +104 -0
- karni_cli/mcp_server.py +103 -0
- karni_cli/project.py +248 -0
- karni_cli/rules_render.py +86 -0
- karni_cli/scaffold.py +197 -0
- karni_cli/settings_edit.py +128 -0
- karni_cli/tips.py +53 -0
- karni_cli/typegen.py +101 -0
- karni_cli/upgrades/__init__.py +28 -0
- karni_cli/upgrades/changelog.py +123 -0
- karni_cli/upgrades/codemod.py +125 -0
- karni_cli/upgrades/engine.py +224 -0
- karni_cli/upgrades/legacy_agent_files.py +138 -0
- karni_cli/upgrades/python.py +670 -0
- karni_cli/upgrades/transforms/rename-export.cjs +96 -0
- karni_cli/upgrades/typescript.py +160 -0
- karni_cli/upgrades/v0_2/__init__.py +1 -0
- karni_cli/upgrades/v0_2/agent_guidance.py +269 -0
- karni_cli/upgrades/v1_0/__init__.py +5 -0
- karni_cli/upgrades/v1_0/all_objects_to_without.py +18 -0
- karni_cli/upgrades/v1_0/all_with_deleted_to_without.py +13 -0
- karni_cli/upgrades/v1_0/deleted_only_to_without_dead.py +13 -0
- karni_cli/upgrades/v1_0/object_permission_classes_to_access.py +16 -0
- karni_cli/upgrades/v1_0/related_lookups_to_optimize.py +15 -0
- karni_cli/upgrades/v1_0/scoped_related_lookups_to_optimize.py +15 -0
- karni_cli/upgrades/v1_0/soft_delete_manager_mixin_to_scoped.py +14 -0
- karni_cli-0.3.1.dist-info/METADATA +191 -0
- karni_cli-0.3.1.dist-info/RECORD +145 -0
- karni_cli-0.3.1.dist-info/WHEEL +4 -0
- karni_cli-0.3.1.dist-info/entry_points.txt +2 -0
- karni_cli-0.3.1.dist-info/licenses/LICENSE +201 -0
karni_cli/project.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Project discovery and manage.py execution.
|
|
2
|
+
|
|
3
|
+
The CLI never imports Django. Everything project-side runs through the
|
|
4
|
+
project's own interpreter, resolved in this order (per command flags):
|
|
5
|
+
explicit ``--docker/--no-docker`` > ``<project>/.venv`` > a running docker
|
|
6
|
+
compose service > a two-line error naming both remedies.
|
|
7
|
+
|
|
8
|
+
Interactive runners (shell, test, worker, ...) pass ``interactive=True``:
|
|
9
|
+
in docker mode that allocates a TTY (``docker compose exec`` without
|
|
10
|
+
``-T``) when the CLI itself runs in a terminal, so prompts, colours and
|
|
11
|
+
Ctrl-C work; scripted and agent runs (no TTY) keep ``-T``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import contextlib
|
|
17
|
+
import importlib.util
|
|
18
|
+
import os
|
|
19
|
+
import signal
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
#: Typer ``context_settings`` for runners that pass unknown options through
|
|
26
|
+
#: to the wrapped command (``karni test -x -k name``). Put ``--`` before
|
|
27
|
+
#: arguments that clash with the runner's own options.
|
|
28
|
+
PASSTHROUGH: dict[str, Any] = {"ignore_unknown_options": True, "allow_extra_args": True}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CliError(Exception):
|
|
32
|
+
"""User-facing CLI failure; the message is printed without a traceback.
|
|
33
|
+
|
|
34
|
+
``code`` is a registered code (``C101``, or a Karni code such as ``E704``
|
|
35
|
+
passed through); ``str()`` is ``"<code>: <message> See <docs url>"`` and
|
|
36
|
+
``karni explain <code>`` prints the full entry offline.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, message: str, *, code: str | None = None) -> None:
|
|
40
|
+
super().__init__(message)
|
|
41
|
+
self.message = message
|
|
42
|
+
self.code = code
|
|
43
|
+
|
|
44
|
+
def __str__(self) -> str:
|
|
45
|
+
if not self.code:
|
|
46
|
+
return self.message
|
|
47
|
+
from karni_cli.errorcodes import docs_url
|
|
48
|
+
|
|
49
|
+
sep = "\n" if "\n" in self.message else " "
|
|
50
|
+
return f"{self.code}: {self.message}{sep}See {docs_url(self.code)}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def find_project(start: Path | None = None) -> Path | None:
|
|
54
|
+
"""Walk up from ``start`` (default cwd) to the directory holding manage.py."""
|
|
55
|
+
current = (start or Path.cwd()).resolve()
|
|
56
|
+
for candidate in (current, *current.parents):
|
|
57
|
+
if (candidate / "manage.py").is_file():
|
|
58
|
+
return candidate
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def require_project(start: Path | None = None) -> Path:
|
|
63
|
+
project = find_project(start)
|
|
64
|
+
if project is None:
|
|
65
|
+
raise CliError(
|
|
66
|
+
"No manage.py found here or above — run inside a Karni project.", code="C001"
|
|
67
|
+
)
|
|
68
|
+
return project
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def venv_python(project: Path) -> Path | None:
|
|
72
|
+
"""The project's interpreter: its ``.venv``, else the active virtualenv, else this one.
|
|
73
|
+
|
|
74
|
+
``VIRTUAL_ENV`` covers the host setup where one activated venv holds
|
|
75
|
+
karni-cli and the project's dependencies (the quickstart). The interpreter
|
|
76
|
+
running karni-cli counts when it has Django and karni installed — the
|
|
77
|
+
quickstart's venv invoked by path, an MCP host that inherited no
|
|
78
|
+
``VIRTUAL_ENV``. A pipx-installed ``karni`` has neither and keeps looking
|
|
79
|
+
for the project's own.
|
|
80
|
+
"""
|
|
81
|
+
python = project / ".venv" / "bin" / "python"
|
|
82
|
+
if python.exists():
|
|
83
|
+
return python
|
|
84
|
+
active = os.environ.get("VIRTUAL_ENV")
|
|
85
|
+
if active:
|
|
86
|
+
candidate = Path(active) / "bin" / "python"
|
|
87
|
+
if candidate.exists():
|
|
88
|
+
return candidate
|
|
89
|
+
return _running_interpreter()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _running_interpreter() -> Path | None:
|
|
93
|
+
"""``sys.executable`` when it can import Django and karni (never imported here)."""
|
|
94
|
+
if importlib.util.find_spec("django") is None or importlib.util.find_spec("karni") is None:
|
|
95
|
+
return None
|
|
96
|
+
return Path(sys.executable)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def compose_service_running(project: Path, service: str) -> bool:
|
|
100
|
+
"""True when ``docker compose ps`` reports the service as running."""
|
|
101
|
+
try:
|
|
102
|
+
proc = subprocess.run(
|
|
103
|
+
["docker", "compose", "ps", "--status", "running", "--services"],
|
|
104
|
+
cwd=project,
|
|
105
|
+
text=True,
|
|
106
|
+
capture_output=True,
|
|
107
|
+
check=False,
|
|
108
|
+
)
|
|
109
|
+
except FileNotFoundError:
|
|
110
|
+
return False
|
|
111
|
+
if proc.returncode != 0:
|
|
112
|
+
return False
|
|
113
|
+
return service in proc.stdout.split()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def resolve_execution(project: Path, docker: bool | None, service: str) -> str:
|
|
117
|
+
"""Pick "venv" or "docker" per the documented resolution order."""
|
|
118
|
+
if docker is True:
|
|
119
|
+
if not compose_service_running(project, service):
|
|
120
|
+
raise CliError(
|
|
121
|
+
f'docker compose service "{service}" is not running.\n'
|
|
122
|
+
"Start the stack first: docker compose up -d",
|
|
123
|
+
code="C002",
|
|
124
|
+
)
|
|
125
|
+
return "docker"
|
|
126
|
+
if docker is False:
|
|
127
|
+
if venv_python(project) is None:
|
|
128
|
+
raise CliError(
|
|
129
|
+
"No .venv in the project and no active virtualenv (and --no-docker was given).\n"
|
|
130
|
+
"Create one (python -m venv .venv && .venv/bin/pip install -e .), activate one "
|
|
131
|
+
"that has the project's dependencies, or drop --no-docker.",
|
|
132
|
+
code="C002",
|
|
133
|
+
)
|
|
134
|
+
return "venv"
|
|
135
|
+
if venv_python(project) is not None:
|
|
136
|
+
return "venv"
|
|
137
|
+
if compose_service_running(project, service):
|
|
138
|
+
return "docker"
|
|
139
|
+
raise CliError(
|
|
140
|
+
"No way to run the project: no .venv and the compose stack is not running.\n"
|
|
141
|
+
"Either `docker compose up -d`, or create a venv "
|
|
142
|
+
"(uv venv && uv pip install -r pyproject.toml).",
|
|
143
|
+
code="C002",
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _tty() -> bool:
|
|
148
|
+
"""True when the CLI runs in a terminal (both stdin and stdout)."""
|
|
149
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def python_command(
|
|
153
|
+
project: Path,
|
|
154
|
+
args: list[str],
|
|
155
|
+
*,
|
|
156
|
+
docker: bool | None = None,
|
|
157
|
+
service: str = "django",
|
|
158
|
+
interactive: bool = False,
|
|
159
|
+
) -> list[str]:
|
|
160
|
+
"""The argv running ``python <args>`` in the resolved environment."""
|
|
161
|
+
mode = resolve_execution(project, docker, service)
|
|
162
|
+
if mode == "venv":
|
|
163
|
+
return [str(venv_python(project)), *args]
|
|
164
|
+
no_tty = [] if interactive and _tty() else ["-T"]
|
|
165
|
+
return ["docker", "compose", "exec", *no_tty, service, "python", *args]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def run_python(
|
|
169
|
+
project: Path,
|
|
170
|
+
args: list[str],
|
|
171
|
+
*,
|
|
172
|
+
docker: bool | None = None,
|
|
173
|
+
service: str = "django",
|
|
174
|
+
capture: bool = True,
|
|
175
|
+
interactive: bool = False,
|
|
176
|
+
) -> subprocess.CompletedProcess[str]:
|
|
177
|
+
"""Run ``python <args>`` through the project interpreter, cwd pinned to the project.
|
|
178
|
+
|
|
179
|
+
Returns the CompletedProcess (text mode, never raises on non-zero exit).
|
|
180
|
+
With ``capture=False`` output streams through to the terminal (stdin is
|
|
181
|
+
always inherited); ``interactive=True`` also asks docker for a TTY.
|
|
182
|
+
"""
|
|
183
|
+
cmd = python_command(project, args, docker=docker, service=service, interactive=interactive)
|
|
184
|
+
return run_process(cmd, cwd=project, capture=capture)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def run_process(
|
|
188
|
+
cmd: list[str], *, cwd: Path, capture: bool = False
|
|
189
|
+
) -> subprocess.CompletedProcess[str]:
|
|
190
|
+
"""``subprocess.run`` that passes SIGTERM on to the child.
|
|
191
|
+
|
|
192
|
+
``karni serve`` and ``karni worker`` wrap a real server: a script or an
|
|
193
|
+
agent that stops the wrapper must stop the server too, not leak it.
|
|
194
|
+
Ctrl-C needs no forwarding (the terminal signals the whole group).
|
|
195
|
+
"""
|
|
196
|
+
pipe = subprocess.PIPE if capture else None
|
|
197
|
+
with subprocess.Popen(cmd, cwd=cwd, text=True, stdout=pipe, stderr=pipe) as proc:
|
|
198
|
+
|
|
199
|
+
def forward(signum: int, _frame: Any) -> None:
|
|
200
|
+
with contextlib.suppress(ProcessLookupError):
|
|
201
|
+
proc.send_signal(signum)
|
|
202
|
+
|
|
203
|
+
previous: Any = None
|
|
204
|
+
try:
|
|
205
|
+
previous = signal.signal(signal.SIGTERM, forward)
|
|
206
|
+
except ValueError: # not the main thread: no forwarding, same run
|
|
207
|
+
previous = None
|
|
208
|
+
try:
|
|
209
|
+
out, err = proc.communicate()
|
|
210
|
+
except KeyboardInterrupt:
|
|
211
|
+
proc.wait()
|
|
212
|
+
raise
|
|
213
|
+
finally:
|
|
214
|
+
if previous is not None:
|
|
215
|
+
signal.signal(signal.SIGTERM, previous)
|
|
216
|
+
return subprocess.CompletedProcess(cmd, proc.returncode, out, err)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def run_manage(
|
|
220
|
+
project: Path,
|
|
221
|
+
args: list[str],
|
|
222
|
+
*,
|
|
223
|
+
docker: bool | None = None,
|
|
224
|
+
service: str = "django",
|
|
225
|
+
capture: bool = True,
|
|
226
|
+
interactive: bool = False,
|
|
227
|
+
) -> subprocess.CompletedProcess[str]:
|
|
228
|
+
"""Run ``manage.py <args>`` in the resolved environment (see :func:`run_python`)."""
|
|
229
|
+
return run_python(
|
|
230
|
+
project,
|
|
231
|
+
["manage.py", *args],
|
|
232
|
+
docker=docker,
|
|
233
|
+
service=service,
|
|
234
|
+
capture=capture,
|
|
235
|
+
interactive=interactive,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def run_compose(project: Path, args: list[str]) -> subprocess.CompletedProcess[str]:
|
|
240
|
+
"""Run ``docker compose <args>`` in the project, streaming to the terminal."""
|
|
241
|
+
try:
|
|
242
|
+
return run_process(["docker", "compose", *args], cwd=project)
|
|
243
|
+
except FileNotFoundError:
|
|
244
|
+
raise CliError(
|
|
245
|
+
"docker not found — install Docker with the compose plugin, "
|
|
246
|
+
"or run without it: karni serve --local",
|
|
247
|
+
code="C003",
|
|
248
|
+
) from None
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Text renderings of the agent data for terminals, agents and docs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from karni_cli.agent_data import AgentData
|
|
8
|
+
|
|
9
|
+
PROMPTS_START = "<!-- karni:procedures:start (generated: `karni rules --prompts-doc`) -->"
|
|
10
|
+
PROMPTS_END = "<!-- karni:procedures:end -->"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def render_rules(data: AgentData, fragments: dict[str, str] | None = None) -> str:
|
|
14
|
+
"""The ``karni rules`` listing: every rule's code + summary, grouped by category.
|
|
15
|
+
|
|
16
|
+
Kept under 400 tokens for Karni's own rules; project rules and module
|
|
17
|
+
guidance add to it (they are project-specific and needed to start work).
|
|
18
|
+
"""
|
|
19
|
+
lines = ["Karni rules. ! = build-enforced."]
|
|
20
|
+
for category in data.categories:
|
|
21
|
+
lines.append(f"[{category}]")
|
|
22
|
+
for rule in data.rules:
|
|
23
|
+
if rule["category"] == category:
|
|
24
|
+
mark = "!" if rule["severity"] == "error" else ""
|
|
25
|
+
lines.append(f"{rule['code']}{mark} {rule['summary']}")
|
|
26
|
+
for name, fragment in (fragments or {}).items():
|
|
27
|
+
lines.append(f"[module:{name}]")
|
|
28
|
+
lines.append(fragment.strip())
|
|
29
|
+
lines.append("Why/fix: karni explain CODE")
|
|
30
|
+
return "\n".join(lines)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def render_explain(rule: dict[str, Any]) -> str:
|
|
34
|
+
lines = [f"{rule['code']} [{rule['category']}, {rule['severity']}] {rule['rule']}"]
|
|
35
|
+
if rule.get("rationale"):
|
|
36
|
+
lines.append(f"Why: {rule['rationale']}")
|
|
37
|
+
if rule.get("fix"):
|
|
38
|
+
lines.append(f"Fix: {rule['fix']}")
|
|
39
|
+
if rule.get("enforced_by"):
|
|
40
|
+
lines.append("Enforced by: " + ", ".join(rule["enforced_by"]))
|
|
41
|
+
return "\n".join(lines)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def render_procedure(procedure: dict[str, Any], arguments: str = "<arguments>") -> str:
|
|
45
|
+
lines = [procedure["intro"].replace("{arguments}", arguments), ""]
|
|
46
|
+
lines += [f"{i}. {step}" for i, step in enumerate(procedure["steps"], start=1)]
|
|
47
|
+
if procedure.get("outro"):
|
|
48
|
+
lines += ["", procedure["outro"]]
|
|
49
|
+
if procedure.get("rules"):
|
|
50
|
+
lines += ["", "Rules that apply: " + ", ".join(procedure["rules"])]
|
|
51
|
+
return "\n".join(lines)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def render_findings(findings: list[dict[str, Any]]) -> str:
|
|
55
|
+
lines = []
|
|
56
|
+
for f in findings:
|
|
57
|
+
lines.append(f"{f['path']}:{f['line']}: {f['code']} [{f['rule_id']}] {f['message']}")
|
|
58
|
+
if f.get("fix"):
|
|
59
|
+
lines.append(f" fix: {f['fix']}")
|
|
60
|
+
return "\n".join(lines)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def render_prompts_doc(data: AgentData) -> str:
|
|
64
|
+
"""Plain-language equivalents of the slash commands (docs/agents.md block)."""
|
|
65
|
+
lines = [PROMPTS_START, ""]
|
|
66
|
+
for procedure in data.procedures:
|
|
67
|
+
name = procedure["name"]
|
|
68
|
+
lines += [
|
|
69
|
+
f"### `{name}` — {procedure['description']}",
|
|
70
|
+
"",
|
|
71
|
+
"Prompt for tools without slash commands (replace the placeholder):",
|
|
72
|
+
"",
|
|
73
|
+
(
|
|
74
|
+
f"> Run `karni rules --procedure {name}` and follow every step for: "
|
|
75
|
+
f"`{procedure['argument_hint']}`"
|
|
76
|
+
),
|
|
77
|
+
"",
|
|
78
|
+
"What `karni rules --procedure` returns:",
|
|
79
|
+
"",
|
|
80
|
+
"```text",
|
|
81
|
+
render_procedure(procedure, procedure["argument_hint"]),
|
|
82
|
+
"```",
|
|
83
|
+
"",
|
|
84
|
+
]
|
|
85
|
+
lines.append(PROMPTS_END)
|
|
86
|
+
return "\n".join(lines)
|
karni_cli/scaffold.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Template, @karni/ui source resolution and slug rules for ``karni new``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import posixpath
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
from importlib import resources
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from karni_cli.project import CliError
|
|
15
|
+
|
|
16
|
+
SLUG_RE = re.compile(r"^[a-z_][a-z0-9_]*$")
|
|
17
|
+
|
|
18
|
+
UI_PACKAGE = "@karni/ui"
|
|
19
|
+
#: The npm spec a project gets when no local @karni/ui is found.
|
|
20
|
+
UI_REGISTRY_SPEC = "^0.3.0"
|
|
21
|
+
#: What a ``file:`` consumer needs: package.json ``exports`` point into dist/.
|
|
22
|
+
UI_DIST_FILES = ("dist/index.js", "dist/styles.css", "dist/tailwind-preset.js")
|
|
23
|
+
#: Sources the dist is built from (staleness check).
|
|
24
|
+
UI_BUILD_INPUTS = ("src", "tailwind-preset.ts")
|
|
25
|
+
NPM_TIMEOUT = 900
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def derive_slug(project_name: str) -> str:
|
|
29
|
+
"""Mirror cookiecutter.json's project_slug derivation exactly."""
|
|
30
|
+
return project_name.lower().replace(" ", "_").replace("-", "_")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def validate_name(project_name: str) -> str:
|
|
34
|
+
slug = derive_slug(project_name)
|
|
35
|
+
if not SLUG_RE.match(slug):
|
|
36
|
+
raise CliError(
|
|
37
|
+
f'"{project_name}" derives the invalid slug "{slug}" '
|
|
38
|
+
"(allowed: lowercase letters, digits, underscores; must not start with a digit).",
|
|
39
|
+
code="C103",
|
|
40
|
+
)
|
|
41
|
+
return slug
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _is_karni_template(candidate: Path) -> bool:
|
|
45
|
+
"""The repo-walk must verify the directory is Karni's own template (E7)."""
|
|
46
|
+
cc = candidate / "cookiecutter.json"
|
|
47
|
+
if not cc.is_file():
|
|
48
|
+
return False
|
|
49
|
+
try:
|
|
50
|
+
keys = json.loads(cc.read_text())
|
|
51
|
+
except (OSError, json.JSONDecodeError):
|
|
52
|
+
return False
|
|
53
|
+
repo_root = candidate.parent.parent
|
|
54
|
+
return "project_slug" in keys and (repo_root / "packages" / "karni").is_dir()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def resolve_template() -> Path:
|
|
58
|
+
"""KARNI_TEMPLATES env > verified monorepo checkout > packaged copy."""
|
|
59
|
+
env = os.environ.get("KARNI_TEMPLATES")
|
|
60
|
+
if env:
|
|
61
|
+
path = Path(env).expanduser()
|
|
62
|
+
if not (path / "cookiecutter.json").is_file():
|
|
63
|
+
raise CliError(
|
|
64
|
+
f"KARNI_TEMPLATES={env} does not contain a cookiecutter template.", code="C007"
|
|
65
|
+
)
|
|
66
|
+
return path
|
|
67
|
+
|
|
68
|
+
for parent in Path(__file__).resolve().parents:
|
|
69
|
+
candidate = parent / "templates" / "project"
|
|
70
|
+
if candidate.is_dir() and _is_karni_template(candidate):
|
|
71
|
+
return candidate
|
|
72
|
+
|
|
73
|
+
packaged = resources.files("karni_cli") / "_templates" / "project"
|
|
74
|
+
with resources.as_file(packaged) as path:
|
|
75
|
+
if path.is_dir():
|
|
76
|
+
return path
|
|
77
|
+
raise CliError(
|
|
78
|
+
"No project template found (no KARNI_TEMPLATES, no monorepo checkout, "
|
|
79
|
+
"no packaged copy) — reinstall karni-cli.",
|
|
80
|
+
code="C005",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# @karni/ui source
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _is_karni_ui(candidate: Path) -> bool:
|
|
88
|
+
"""A directory is Karni's UI package when its package.json names @karni/ui."""
|
|
89
|
+
try:
|
|
90
|
+
meta = json.loads((candidate / "package.json").read_text())
|
|
91
|
+
except (OSError, json.JSONDecodeError):
|
|
92
|
+
return False
|
|
93
|
+
return isinstance(meta, dict) and meta.get("name") == UI_PACKAGE
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def resolve_ui_source(start: Path | None = None) -> Path | None:
|
|
97
|
+
"""KARNI_UI_SRC env > verified monorepo checkout > ``None`` (the npm registry).
|
|
98
|
+
|
|
99
|
+
Mirrors :func:`resolve_template`: the repo-walk only accepts
|
|
100
|
+
``packages/karni-ui`` when it really is @karni/ui AND sits next to
|
|
101
|
+
``packages/karni`` — never some unrelated directory of the same name.
|
|
102
|
+
"""
|
|
103
|
+
env = os.environ.get("KARNI_UI_SRC")
|
|
104
|
+
if env:
|
|
105
|
+
path = Path(env).expanduser().resolve()
|
|
106
|
+
if not _is_karni_ui(path):
|
|
107
|
+
raise CliError(
|
|
108
|
+
f"KARNI_UI_SRC={env} is not the {UI_PACKAGE} package "
|
|
109
|
+
"(expected a directory whose package.json is named @karni/ui, "
|
|
110
|
+
"e.g. /path/to/Karni/packages/karni-ui).",
|
|
111
|
+
code="C007",
|
|
112
|
+
)
|
|
113
|
+
return path
|
|
114
|
+
for parent in (start or Path(__file__).resolve()).parents:
|
|
115
|
+
candidate = parent / "packages" / "karni-ui"
|
|
116
|
+
if _is_karni_ui(candidate) and (parent / "packages" / "karni").is_dir():
|
|
117
|
+
return candidate
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def ui_spec(source: Path | None, project: Path | None = None) -> str:
|
|
122
|
+
"""The ``@karni/ui`` dependency spec written into frontend/package.json.
|
|
123
|
+
|
|
124
|
+
A local checkout is a ``file:`` path. When the project lives inside that
|
|
125
|
+
checkout (``examples/``), the path is relative to ``frontend/`` so the
|
|
126
|
+
project installs on any machine; elsewhere it is absolute.
|
|
127
|
+
"""
|
|
128
|
+
if source is None:
|
|
129
|
+
return UI_REGISTRY_SPEC
|
|
130
|
+
root = source.parent.parent
|
|
131
|
+
if project is not None and project.resolve().is_relative_to(root):
|
|
132
|
+
return f"file:{os.path.relpath(source, project.resolve() / 'frontend')}"
|
|
133
|
+
return f"file:{source}"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def ui_mount_target(spec: str, container_dir: str = "/app") -> str:
|
|
137
|
+
"""Where a ``file:`` spec resolves inside the frontend container (compose bind target)."""
|
|
138
|
+
if not spec.startswith("file:"):
|
|
139
|
+
return spec
|
|
140
|
+
path = spec.removeprefix("file:")
|
|
141
|
+
if posixpath.isabs(path):
|
|
142
|
+
return path
|
|
143
|
+
return posixpath.normpath(posixpath.join(container_dir, path))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def ui_dist_missing(source: Path) -> list[str]:
|
|
147
|
+
return [rel for rel in UI_DIST_FILES if not (source / rel).is_file()]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def ui_dist_stale(source: Path) -> bool:
|
|
151
|
+
"""True when a dist input (src/, the preset) changed after dist/index.js was built."""
|
|
152
|
+
built = source / UI_DIST_FILES[0]
|
|
153
|
+
if not built.is_file():
|
|
154
|
+
return False
|
|
155
|
+
stamp = built.stat().st_mtime
|
|
156
|
+
for name in UI_BUILD_INPUTS:
|
|
157
|
+
root = source / name
|
|
158
|
+
files = [root] if root.is_file() else (p for p in root.rglob("*") if p.is_file())
|
|
159
|
+
if any(path.stat().st_mtime > stamp for path in files):
|
|
160
|
+
return True
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def ui_build_commands(source: Path) -> list[list[str]]:
|
|
165
|
+
"""``npm install`` (only without node_modules) then ``npm run build``."""
|
|
166
|
+
commands = [] if (source / "node_modules").is_dir() else [["npm", "install", "--no-audit"]]
|
|
167
|
+
return [*commands, ["npm", "run", "build"]]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def run_npm(argv: list[str], cwd: Path) -> subprocess.CompletedProcess[str]:
|
|
171
|
+
"""Run one npm command (the seam tests replace)."""
|
|
172
|
+
npm = shutil.which(argv[0])
|
|
173
|
+
if npm is None:
|
|
174
|
+
return subprocess.CompletedProcess(argv, 127, stdout="", stderr="npm is not on PATH")
|
|
175
|
+
try:
|
|
176
|
+
return subprocess.run(
|
|
177
|
+
[npm, *argv[1:]],
|
|
178
|
+
cwd=cwd,
|
|
179
|
+
text=True,
|
|
180
|
+
capture_output=True,
|
|
181
|
+
check=False,
|
|
182
|
+
timeout=NPM_TIMEOUT,
|
|
183
|
+
env={**os.environ, "CI": "1"},
|
|
184
|
+
)
|
|
185
|
+
except subprocess.TimeoutExpired:
|
|
186
|
+
return subprocess.CompletedProcess(argv, 124, stdout="", stderr="timed out")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def build_ui_dist(source: Path) -> str | None:
|
|
190
|
+
"""Build @karni/ui's dist/ in ``source``; ``None`` on success, else the failure."""
|
|
191
|
+
for argv in ui_build_commands(source):
|
|
192
|
+
proc = run_npm(argv, source)
|
|
193
|
+
if proc.returncode != 0:
|
|
194
|
+
tail = "\n".join((proc.stdout + proc.stderr).strip().splitlines()[-15:])
|
|
195
|
+
return f"`{' '.join(argv)}` failed ({proc.returncode}):\n{tail}"
|
|
196
|
+
missing = ui_dist_missing(source)
|
|
197
|
+
return f"the build did not produce {', '.join(missing)}" if missing else None
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Marker-based edits to a project's config/settings.py.
|
|
2
|
+
|
|
3
|
+
``karni add`` and ``karni make app`` manage the ``# karni:apps`` block,
|
|
4
|
+
``karni make middleware`` the ``# karni:middleware`` block. Two accepted
|
|
5
|
+
shapes of a marked block (``SETTING`` is ``INSTALLED_APPS`` / ``MIDDLEWARE``):
|
|
6
|
+
|
|
7
|
+
SETTING += [ # noqa: F821
|
|
8
|
+
"existing.entry",
|
|
9
|
+
] # karni:<name>
|
|
10
|
+
|
|
11
|
+
SETTING += ["existing.entry"] # karni:<name>
|
|
12
|
+
|
|
13
|
+
Anything else errors with the manual-edit escape hatch — this code never
|
|
14
|
+
guesses at unfamiliar layouts.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from karni_cli.project import CliError
|
|
23
|
+
|
|
24
|
+
MARKER = "# karni:apps"
|
|
25
|
+
MIDDLEWARE_MARKER = "# karni:middleware"
|
|
26
|
+
|
|
27
|
+
_QUOTED = re.compile(r"""["']([^"']+)["']""")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _single_line(setting: str) -> re.Pattern[str]:
|
|
31
|
+
return re.compile(
|
|
32
|
+
rf"^(?P<head>\s*{re.escape(setting)}\s*\+=\s*)\[(?P<body>.*)\]\s*(?P<tail>#.*)$"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _block_entries(lines: list[str], open_idx: int, close_idx: int) -> list[str]:
|
|
37
|
+
"""Quoted entries on non-comment lines inside the block."""
|
|
38
|
+
entries: list[str] = []
|
|
39
|
+
for line in lines[open_idx + 1 : close_idx]:
|
|
40
|
+
stripped = line.strip()
|
|
41
|
+
if stripped.startswith("#"):
|
|
42
|
+
continue
|
|
43
|
+
entries.extend(_QUOTED.findall(stripped))
|
|
44
|
+
return entries
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _locate(lines: list[str], setting: str, marker: str) -> tuple[int, int] | None:
|
|
48
|
+
"""``(open line, marker line)`` of the block (equal for the single-line shape)."""
|
|
49
|
+
marker_idx = next((i for i, line in enumerate(lines) if marker in line), None)
|
|
50
|
+
if marker_idx is None:
|
|
51
|
+
return None
|
|
52
|
+
if _single_line(setting).match(lines[marker_idx]):
|
|
53
|
+
return marker_idx, marker_idx
|
|
54
|
+
if lines[marker_idx].strip().startswith("]"):
|
|
55
|
+
open_idx = next(
|
|
56
|
+
(i for i in range(marker_idx - 1, -1, -1) if setting in lines[i] and "[" in lines[i]),
|
|
57
|
+
None,
|
|
58
|
+
)
|
|
59
|
+
if open_idx is not None:
|
|
60
|
+
return open_idx, marker_idx
|
|
61
|
+
return -1, marker_idx
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def block_entries(text: str, setting: str = "INSTALLED_APPS", marker: str = MARKER) -> list[str]:
|
|
65
|
+
"""The entries of the marked block in settings ``text`` (``[]`` when absent)."""
|
|
66
|
+
lines = text.splitlines()
|
|
67
|
+
found = _locate(lines, setting, marker)
|
|
68
|
+
if found is None or found[0] < 0:
|
|
69
|
+
return []
|
|
70
|
+
open_idx, marker_idx = found
|
|
71
|
+
if open_idx == marker_idx:
|
|
72
|
+
single = _single_line(setting).match(lines[marker_idx])
|
|
73
|
+
return _QUOTED.findall(single.group("body")) if single else []
|
|
74
|
+
return _block_entries(lines, open_idx, marker_idx)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def add_entry(text: str, entry: str, *, setting: str, marker: str, filename: str) -> str | None:
|
|
78
|
+
"""``text`` with ``entry`` inserted into the marked block; ``None`` when already present.
|
|
79
|
+
|
|
80
|
+
:raises CliError: when the marker is missing or the block has another shape.
|
|
81
|
+
"""
|
|
82
|
+
lines = text.splitlines()
|
|
83
|
+
found = _locate(lines, setting, marker)
|
|
84
|
+
if found is None:
|
|
85
|
+
raise CliError(
|
|
86
|
+
f"No `{marker}` marker in {filename} — add {entry!r} to {setting} manually.",
|
|
87
|
+
code="C108",
|
|
88
|
+
)
|
|
89
|
+
open_idx, marker_idx = found
|
|
90
|
+
if open_idx < 0:
|
|
91
|
+
raise CliError(
|
|
92
|
+
f"Unrecognized `{marker}` block shape in {filename} — add {entry!r} to {setting} "
|
|
93
|
+
"manually.",
|
|
94
|
+
code="C108",
|
|
95
|
+
)
|
|
96
|
+
if open_idx == marker_idx:
|
|
97
|
+
single = _single_line(setting).match(lines[marker_idx])
|
|
98
|
+
assert single is not None # _locate matched it
|
|
99
|
+
entries = _QUOTED.findall(single.group("body"))
|
|
100
|
+
if entry in entries:
|
|
101
|
+
return None
|
|
102
|
+
rebuilt = [f"{single.group('head').rstrip()} [ # noqa: F821"]
|
|
103
|
+
rebuilt += [f' "{existing}",' for existing in entries]
|
|
104
|
+
rebuilt.append(f' "{entry}",')
|
|
105
|
+
rebuilt.append(f"] {marker}")
|
|
106
|
+
lines[marker_idx : marker_idx + 1] = rebuilt
|
|
107
|
+
else:
|
|
108
|
+
if entry in _block_entries(lines, open_idx, marker_idx):
|
|
109
|
+
return None
|
|
110
|
+
lines.insert(marker_idx, f' "{entry}",')
|
|
111
|
+
return "\n".join(lines) + "\n"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def add_app(settings_path: Path, app: str) -> bool:
|
|
115
|
+
"""Insert ``app`` into the ``# karni:apps`` block. Returns False when already present."""
|
|
116
|
+
if not settings_path.is_file():
|
|
117
|
+
raise CliError(f"{settings_path} not found.", code="C001")
|
|
118
|
+
updated = add_entry(
|
|
119
|
+
settings_path.read_text(),
|
|
120
|
+
app,
|
|
121
|
+
setting="INSTALLED_APPS",
|
|
122
|
+
marker=MARKER,
|
|
123
|
+
filename=settings_path.name,
|
|
124
|
+
)
|
|
125
|
+
if updated is None:
|
|
126
|
+
return False
|
|
127
|
+
settings_path.write_text(updated)
|
|
128
|
+
return True
|