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
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""``karni make viewset <Model>`` — a BaseViewSet with its mandatory ``access``.
|
|
2
|
+
|
|
3
|
+
ADR 0001 Q2.1: every viewset declares who reads and who writes. The default
|
|
4
|
+
is ``Granted()`` (ADR 0002, P2): the roles' grants for the model, row scope
|
|
5
|
+
included, so a generated viewset is scoped without anyone remembering to
|
|
6
|
+
scope it. ``--access`` picks one of the other common shapes; whichever
|
|
7
|
+
applies is printed so it is never silent. Anything richer (``Owned(...) |
|
|
8
|
+
HasRole(...)``, ``Where(...)``) is an edit of the generated declaration.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import ast
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
|
|
18
|
+
from karni_cli.generators.core import (
|
|
19
|
+
APP_OPTION,
|
|
20
|
+
DRY_RUN_OPTION,
|
|
21
|
+
FORCE_OPTION,
|
|
22
|
+
App,
|
|
23
|
+
Workspace,
|
|
24
|
+
project_workspace,
|
|
25
|
+
resolve_app,
|
|
26
|
+
split_csv,
|
|
27
|
+
)
|
|
28
|
+
from karni_cli.generators.introspect import ModelInfo, require_model, viewsets_file
|
|
29
|
+
from karni_cli.generators.naming import default_route, require_pascal
|
|
30
|
+
from karni_cli.generators.pysource import (
|
|
31
|
+
Import,
|
|
32
|
+
call,
|
|
33
|
+
ensure_imports,
|
|
34
|
+
parse,
|
|
35
|
+
pystr,
|
|
36
|
+
replace_or_append,
|
|
37
|
+
tuple_literal,
|
|
38
|
+
)
|
|
39
|
+
from karni_cli.project import CliError
|
|
40
|
+
|
|
41
|
+
NAME = "viewset"
|
|
42
|
+
SUMMARY = "Add a BaseViewSet for a model, with its access declaration (--access)."
|
|
43
|
+
|
|
44
|
+
DEFAULT_ACCESS = "granted"
|
|
45
|
+
ACCESS_CHOICES = "granted|authenticated|public-read|owned:<field>|staff|role:<name>"
|
|
46
|
+
_ROLE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]*$")
|
|
47
|
+
_PATH = re.compile(r"^[a-z_][a-z0-9_]*(__[a-z_][a-z0-9_]*)*$")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def access_declaration(spec: str, model: ModelInfo | None) -> tuple[list[str], list[str]]:
|
|
51
|
+
"""``(Access(...) keyword arguments, predicate names to import)`` for ``--access``.
|
|
52
|
+
|
|
53
|
+
``granted`` is the bare ``Granted()`` declaration: ``(["Granted()"], ["Granted"])``.
|
|
54
|
+
"""
|
|
55
|
+
kind, _, arg = spec.partition(":")
|
|
56
|
+
if kind == "granted" and not arg:
|
|
57
|
+
return ["Granted()"], ["Granted"]
|
|
58
|
+
if kind == "authenticated" and not arg:
|
|
59
|
+
return ["read=Authenticated()", "write=Authenticated()"], ["Authenticated"]
|
|
60
|
+
if kind == "public-read" and not arg:
|
|
61
|
+
return ["read=Public()", "write=Authenticated()"], ["Authenticated", "Public"]
|
|
62
|
+
if kind == "staff" and not arg:
|
|
63
|
+
return ['read=HasRole("staff")', 'write=HasRole("staff")'], ["HasRole"]
|
|
64
|
+
if kind == "role":
|
|
65
|
+
if not _ROLE.match(arg):
|
|
66
|
+
raise CliError(
|
|
67
|
+
f"--access role:<name> needs a role (group) name, got {spec!r}.", code="C106"
|
|
68
|
+
)
|
|
69
|
+
role = pystr(arg)
|
|
70
|
+
return [f"read=HasRole({role})", f"write=HasRole({role})"], ["HasRole"]
|
|
71
|
+
if kind == "owned":
|
|
72
|
+
if not _PATH.match(arg):
|
|
73
|
+
raise CliError(
|
|
74
|
+
"--access owned:<field> needs the relation to the user, e.g. owned:organizer "
|
|
75
|
+
f'or owned:event__organizer (got {spec!r}). Owned("created_by") only when '
|
|
76
|
+
"whoever created the row is its owner.",
|
|
77
|
+
code="C106",
|
|
78
|
+
)
|
|
79
|
+
first = arg.split("__", 1)[0]
|
|
80
|
+
if model is not None and not model.is_relation(first):
|
|
81
|
+
raise CliError(
|
|
82
|
+
f"--access {spec}: {model.name}.{first} is not a relation field. Owned() needs "
|
|
83
|
+
f"a ForeignKey to the user, e.g. add owner:fk:User to {model.name} first.",
|
|
84
|
+
code="C106",
|
|
85
|
+
)
|
|
86
|
+
path = pystr(arg)
|
|
87
|
+
return [f"read=Owned({path})", f"write=Owned({path})"], ["Owned"]
|
|
88
|
+
raise CliError(f"--access {spec!r}: choose one of {ACCESS_CHOICES}.", code="C106")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def plan(
|
|
92
|
+
ws: Workspace,
|
|
93
|
+
app: App,
|
|
94
|
+
model_name: str,
|
|
95
|
+
*,
|
|
96
|
+
access: str | None,
|
|
97
|
+
search: list[str],
|
|
98
|
+
) -> str:
|
|
99
|
+
"""Plan ``<Model>ViewSet`` in the app's viewsets module; returns its route."""
|
|
100
|
+
model = require_model(ws, app, model_name)
|
|
101
|
+
declaration, predicates = access_declaration(access or DEFAULT_ACCESS, model)
|
|
102
|
+
for name in search:
|
|
103
|
+
if not model.has_field(name):
|
|
104
|
+
raise CliError(f"--search {name}: {model.name} has no field {name!r}.", code="C106")
|
|
105
|
+
path = viewsets_file(ws, app)
|
|
106
|
+
rel = ws.rel(path)
|
|
107
|
+
text = ws.read(path) or ""
|
|
108
|
+
class_name = f"{model.name}ViewSet"
|
|
109
|
+
if text:
|
|
110
|
+
for node in parse(ws, rel, text).body:
|
|
111
|
+
if isinstance(node, ast.ClassDef) and node.name != class_name:
|
|
112
|
+
for stmt in node.body:
|
|
113
|
+
if (
|
|
114
|
+
isinstance(stmt, ast.Assign)
|
|
115
|
+
and isinstance(stmt.value, ast.Name)
|
|
116
|
+
and stmt.value.id == model.name
|
|
117
|
+
and any(isinstance(t, ast.Name) and t.id == "model" for t in stmt.targets)
|
|
118
|
+
):
|
|
119
|
+
raise CliError(
|
|
120
|
+
f"{rel} already routes {model.name} through {node.name} — edit "
|
|
121
|
+
"that class (or delete it first).",
|
|
122
|
+
code="C107",
|
|
123
|
+
)
|
|
124
|
+
line = ws.line_length
|
|
125
|
+
body = [f"class {class_name}(BaseViewSet):", f" model = {model.name}"]
|
|
126
|
+
granted = predicates == ["Granted"]
|
|
127
|
+
if granted:
|
|
128
|
+
body.append(" access = Granted()")
|
|
129
|
+
imports = [Import("karni.api", name) for name in ("BaseViewSet", "Granted")]
|
|
130
|
+
shown = "Granted()"
|
|
131
|
+
else:
|
|
132
|
+
body.append(call("access = Access", declaration, " ", line))
|
|
133
|
+
imports = [Import("karni.api", name) for name in ("Access", "BaseViewSet", *predicates)]
|
|
134
|
+
shown = "Access(" + ", ".join(declaration) + ")"
|
|
135
|
+
if search:
|
|
136
|
+
# A tuple: a list class attribute is a mutable default (ruff RUF012).
|
|
137
|
+
body.append(tuple_literal("search_fields = ", [pystr(s) for s in search], " ", line))
|
|
138
|
+
block = "\n".join(body)
|
|
139
|
+
imports.append(Import(model.import_module, model.name))
|
|
140
|
+
text = ensure_imports(ws, rel, text, imports)
|
|
141
|
+
ws.write(path, replace_or_append(ws, rel, text, class_name, block))
|
|
142
|
+
if access is None:
|
|
143
|
+
ws.note(
|
|
144
|
+
f"access: {shown} — the default: what KARNI_ROLES grants for {model.name}, "
|
|
145
|
+
f"row-scoped; choose with --access {ACCESS_CHOICES}"
|
|
146
|
+
)
|
|
147
|
+
else:
|
|
148
|
+
ws.note(f"access: {shown}")
|
|
149
|
+
route = default_route(model.name)
|
|
150
|
+
ws.note(f"route: /api/{route}/ (auto-registered)")
|
|
151
|
+
return route
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def command(
|
|
155
|
+
model: str = typer.Argument(..., help="Model class name (e.g. Event)."),
|
|
156
|
+
access: str | None = typer.Option(
|
|
157
|
+
None,
|
|
158
|
+
"--access",
|
|
159
|
+
help=f"Who reads/writes: {ACCESS_CHOICES}. Default: granted (the roles' grants).",
|
|
160
|
+
),
|
|
161
|
+
search: list[str] | None = typer.Option(
|
|
162
|
+
None, "--search", help="search_fields, comma-separated or repeated (e.g. title,venue)."
|
|
163
|
+
),
|
|
164
|
+
app: str | None = APP_OPTION,
|
|
165
|
+
force: bool = FORCE_OPTION,
|
|
166
|
+
dry_run: bool = DRY_RUN_OPTION,
|
|
167
|
+
) -> None:
|
|
168
|
+
"""Add `<Model>ViewSet(BaseViewSet)` to APP/viewsets.py — CRUD at /api/<models>/.
|
|
169
|
+
|
|
170
|
+
Declares `model = <Model>` and the mandatory `access` (ADR 0001):
|
|
171
|
+
granted (default: whatever KARNI_ROLES grants for the model, row-scoped),
|
|
172
|
+
authenticated, public-read (anonymous reads), owned:<field> (rows whose
|
|
173
|
+
<field> is the user), staff (role "staff"), role:<name>.
|
|
174
|
+
"""
|
|
175
|
+
ws = project_workspace(force)
|
|
176
|
+
target = resolve_app(ws, app)
|
|
177
|
+
name = require_pascal(model.removesuffix("ViewSet"), "Model")
|
|
178
|
+
plan(ws, target, name, access=access, search=split_csv(search))
|
|
179
|
+
ws.commit(dry_run=dry_run)
|
karni_cli/history.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""The CLI's side of history providers (``karni.agent.history``).
|
|
2
|
+
|
|
3
|
+
Providers run in the CLI's own environment, through
|
|
4
|
+
:func:`karni_cli.agent_data.history_module` (karni's copy, else the bundled
|
|
5
|
+
one). With no module, no provider or no store, every function here answers
|
|
6
|
+
"nothing" — Karni behaves exactly as if history did not exist.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from karni_cli import agent_data
|
|
15
|
+
from karni_cli.project import CliError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def target(project: Path, raw: str) -> str:
|
|
19
|
+
"""``raw`` (relative to the cwd, or absolute) as a project-relative POSIX path."""
|
|
20
|
+
candidate = Path(raw).expanduser()
|
|
21
|
+
absolute = (candidate if candidate.is_absolute() else Path.cwd() / candidate).resolve()
|
|
22
|
+
try:
|
|
23
|
+
relative = absolute.relative_to(project.resolve())
|
|
24
|
+
except ValueError:
|
|
25
|
+
raise CliError(f"{raw} is outside the project ({project}).", code="C010") from None
|
|
26
|
+
if relative == Path("."):
|
|
27
|
+
raise CliError("--with-history PATH names a file, not the project root.", code="C010")
|
|
28
|
+
return relative.as_posix()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def block(project: Path, path: str | None) -> str | None:
|
|
32
|
+
"""The capped ``HISTORY:`` block for ``path`` (or the project), or ``None``."""
|
|
33
|
+
module = agent_data.history_module()
|
|
34
|
+
if module is None:
|
|
35
|
+
return None
|
|
36
|
+
result = module.compose(project, path)
|
|
37
|
+
return result if isinstance(result, str) and result else None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def discovered() -> Any | None:
|
|
41
|
+
""":class:`karni.agent.history.Discovered`, or ``None`` without the module."""
|
|
42
|
+
module = agent_data.history_module()
|
|
43
|
+
return None if module is None else module.discover()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def detected_names(project: Path) -> list[str]:
|
|
47
|
+
module = agent_data.history_module()
|
|
48
|
+
if module is None:
|
|
49
|
+
return []
|
|
50
|
+
return [provider.name for provider in module.detected(project)]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def provider_present(project: Path) -> bool:
|
|
54
|
+
"""A provider detects a store here, or a third-party provider is installed."""
|
|
55
|
+
module = agent_data.history_module()
|
|
56
|
+
if module is None:
|
|
57
|
+
return False
|
|
58
|
+
found = module.discover()
|
|
59
|
+
return bool(found.third_party()) or module.store_detected(project, found.providers)
|
karni_cli/local_state.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Machine-local CLI state: ``.karni/local/state.json`` in the project.
|
|
2
|
+
|
|
3
|
+
Never committed: ``.karni/local/`` carries its own ``.gitignore`` (``*``), so
|
|
4
|
+
it stays untracked in any project without touching the root ``.gitignore``
|
|
5
|
+
(the scaffold's ``.gitignore`` lists it too). Holds what must not be shared
|
|
6
|
+
with the team — e.g. that a one-time tip was already shown on this machine.
|
|
7
|
+
Unreadable or corrupt state reads as empty; a failed write is reported to the
|
|
8
|
+
caller, never raised.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import contextlib
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import tempfile
|
|
17
|
+
from datetime import UTC, datetime
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
LOCAL_DIR = Path(".karni") / "local"
|
|
22
|
+
STATE_FILE = "state.json"
|
|
23
|
+
GITIGNORE = "# Machine-local karni state (karni_cli.local_state); never commit it.\n*\n"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def state_path(project: Path) -> Path:
|
|
27
|
+
return project / LOCAL_DIR / STATE_FILE
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def read_state(project: Path) -> dict[str, Any]:
|
|
31
|
+
try:
|
|
32
|
+
data = json.loads(state_path(project).read_text(encoding="utf-8"))
|
|
33
|
+
except (OSError, ValueError):
|
|
34
|
+
return {}
|
|
35
|
+
return data if isinstance(data, dict) else {}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def write_state(project: Path, state: dict[str, Any]) -> bool:
|
|
39
|
+
"""Write ``state`` atomically (creating the self-ignoring directory); False on failure."""
|
|
40
|
+
directory = project / LOCAL_DIR
|
|
41
|
+
try:
|
|
42
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
ignore = directory / ".gitignore"
|
|
44
|
+
if not ignore.is_file():
|
|
45
|
+
ignore.write_text(GITIGNORE, encoding="utf-8")
|
|
46
|
+
fd, tmp = tempfile.mkstemp(dir=directory, prefix=".state-", suffix=".json")
|
|
47
|
+
try:
|
|
48
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
49
|
+
json.dump(state, handle, indent=2, sort_keys=True)
|
|
50
|
+
handle.write("\n")
|
|
51
|
+
os.replace(tmp, directory / STATE_FILE)
|
|
52
|
+
except BaseException:
|
|
53
|
+
with contextlib.suppress(OSError):
|
|
54
|
+
os.unlink(tmp)
|
|
55
|
+
raise
|
|
56
|
+
except (OSError, TypeError, ValueError):
|
|
57
|
+
return False
|
|
58
|
+
return True
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def tip_shown(project: Path, tip: str) -> bool:
|
|
62
|
+
shown = read_state(project).get("tips_shown")
|
|
63
|
+
return isinstance(shown, dict) and tip in shown
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def mark_tip_shown(project: Path, tip: str) -> bool:
|
|
67
|
+
state = read_state(project)
|
|
68
|
+
shown = state.get("tips_shown")
|
|
69
|
+
if not isinstance(shown, dict):
|
|
70
|
+
shown = {}
|
|
71
|
+
shown[tip] = datetime.now(UTC).isoformat(timespec="seconds")
|
|
72
|
+
state["tips_shown"] = shown
|
|
73
|
+
return write_state(project, state)
|
karni_cli/main.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Entry point for the ``karni`` command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from karni_cli import __version__
|
|
8
|
+
from karni_cli.commands.add import add
|
|
9
|
+
from karni_cli.commands.beat import beat
|
|
10
|
+
from karni_cli.commands.check import check
|
|
11
|
+
from karni_cli.commands.config import config_app
|
|
12
|
+
from karni_cli.commands.context import context
|
|
13
|
+
from karni_cli.commands.createsuperuser import createsuperuser
|
|
14
|
+
from karni_cli.commands.doctor import doctor
|
|
15
|
+
from karni_cli.commands.explain import explain
|
|
16
|
+
from karni_cli.commands.make import make_app, register_aliases
|
|
17
|
+
from karni_cli.commands.manage_passthrough import manage
|
|
18
|
+
from karni_cli.commands.mcp import mcp
|
|
19
|
+
from karni_cli.commands.migrate import migrate
|
|
20
|
+
from karni_cli.commands.new import new
|
|
21
|
+
from karni_cli.commands.routes import routes
|
|
22
|
+
from karni_cli.commands.rules import rules_app
|
|
23
|
+
from karni_cli.commands.run_tests import test
|
|
24
|
+
from karni_cli.commands.schedule import schedule_app
|
|
25
|
+
from karni_cli.commands.seed import seed
|
|
26
|
+
from karni_cli.commands.serve import serve
|
|
27
|
+
from karni_cli.commands.shell import shell
|
|
28
|
+
from karni_cli.commands.sync_roles import sync_roles
|
|
29
|
+
from karni_cli.commands.types import types
|
|
30
|
+
from karni_cli.commands.upgrade import upgrade
|
|
31
|
+
from karni_cli.commands.worker import worker
|
|
32
|
+
from karni_cli.project import PASSTHROUGH, CliError
|
|
33
|
+
|
|
34
|
+
app = typer.Typer(
|
|
35
|
+
name="karni",
|
|
36
|
+
help="The Karni framework CLI.",
|
|
37
|
+
no_args_is_help=True,
|
|
38
|
+
add_completion=False,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _show_version(value: bool) -> None:
|
|
43
|
+
if value:
|
|
44
|
+
typer.echo(f"karni-cli {__version__}")
|
|
45
|
+
raise typer.Exit()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@app.callback()
|
|
49
|
+
def _root(
|
|
50
|
+
version: bool = typer.Option(
|
|
51
|
+
False,
|
|
52
|
+
"--version",
|
|
53
|
+
"-V",
|
|
54
|
+
callback=_show_version,
|
|
55
|
+
is_eager=True,
|
|
56
|
+
help="Print the karni-cli version and exit (the project's karni: karni doctor).",
|
|
57
|
+
),
|
|
58
|
+
) -> None:
|
|
59
|
+
"""The Karni framework CLI."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
app.command(help="Scaffold a project from templates/project")(new)
|
|
63
|
+
app.command(help="Install an optional module and wire it into settings")(add)
|
|
64
|
+
app.command(help="Generate TS types and typed hooks from the backend OpenAPI")(types)
|
|
65
|
+
app.command(help="Print a compact machine-readable project inventory")(context)
|
|
66
|
+
app.command(help="Run architecture and system checks")(check)
|
|
67
|
+
app.command(help="Upgrade across Karni versions: codemods, diff, manual-change report")(upgrade)
|
|
68
|
+
app.add_typer(rules_app, name="rules")
|
|
69
|
+
app.command(help="Serve context, rules and check as MCP tools (needs karni-cli[mcp])")(mcp)
|
|
70
|
+
app.command(help="Report the environment: project, interpreter, Node, adapters, history")(doctor)
|
|
71
|
+
app.command(help="Explain an error code (E104, C101, PAY004, K106): why and the fix")(explain)
|
|
72
|
+
app.add_typer(config_app, name="config")
|
|
73
|
+
|
|
74
|
+
# Runners: each wraps one project command (named in its --help), run through
|
|
75
|
+
# the project interpreter (.venv, else the running compose service).
|
|
76
|
+
app.command()(serve)
|
|
77
|
+
app.command(context_settings=PASSTHROUGH)(migrate)
|
|
78
|
+
app.command(context_settings=PASSTHROUGH)(test)
|
|
79
|
+
app.command(context_settings=PASSTHROUGH)(shell)
|
|
80
|
+
app.command()(routes)
|
|
81
|
+
app.command(context_settings=PASSTHROUGH)(worker)
|
|
82
|
+
app.command(context_settings=PASSTHROUGH)(beat)
|
|
83
|
+
app.add_typer(schedule_app, name="schedule")
|
|
84
|
+
app.command(context_settings=PASSTHROUGH)(seed)
|
|
85
|
+
app.command("sync-roles", context_settings=PASSTHROUGH)(sync_roles)
|
|
86
|
+
app.command(context_settings=PASSTHROUGH)(createsuperuser)
|
|
87
|
+
app.command(context_settings=PASSTHROUGH)(manage)
|
|
88
|
+
|
|
89
|
+
# Generators: `karni make <type>` (+ hidden `karni make:<type>` aliases).
|
|
90
|
+
app.add_typer(make_app, name="make")
|
|
91
|
+
register_aliases(app)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def run() -> None:
|
|
95
|
+
"""Console-script entry that renders CliError without a traceback."""
|
|
96
|
+
try:
|
|
97
|
+
app(standalone_mode=True)
|
|
98
|
+
except CliError as exc:
|
|
99
|
+
typer.echo(f"error: {exc}", err=True)
|
|
100
|
+
raise SystemExit(1) from None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__":
|
|
104
|
+
run()
|
karni_cli/mcp_server.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""``karni mcp`` — Karni's agent surface as MCP tools (stdio).
|
|
2
|
+
|
|
3
|
+
Tools: ``context`` (the ``karni context`` inventory), ``rules`` (the
|
|
4
|
+
conventions, one rule in full, or a procedure) and ``check`` (static
|
|
5
|
+
violations for one file). The tool functions are plain functions so they are
|
|
6
|
+
testable without a client; :func:`build_server` registers them on the
|
|
7
|
+
official MCP Python SDK server (``pip install 'karni-cli[mcp]'``): ``MCPServer``
|
|
8
|
+
in mcp 2.x, ``FastMCP`` in mcp 1.x.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from karni_cli import agent_data
|
|
17
|
+
from karni_cli.adapters import module_fragments
|
|
18
|
+
from karni_cli.commands.context import build_context_text
|
|
19
|
+
from karni_cli.commands.rules import check_text
|
|
20
|
+
from karni_cli.project import CliError, find_project
|
|
21
|
+
from karni_cli.rules_render import render_explain, render_procedure, render_rules
|
|
22
|
+
|
|
23
|
+
INSTRUCTIONS = (
|
|
24
|
+
"Karni project tools. Call `context` and `rules` before any work and follow what they "
|
|
25
|
+
"return; call `check` on every file you write."
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
_project_override: Path | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _project() -> Path | None:
|
|
32
|
+
return _project_override or find_project()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def context_tool() -> str:
|
|
36
|
+
"""Live project inventory: apps, models, viewsets, UI components, hooks, pages, modules."""
|
|
37
|
+
project = _project()
|
|
38
|
+
if project is None:
|
|
39
|
+
return "error: not inside a Karni project (no manage.py found)."
|
|
40
|
+
try:
|
|
41
|
+
return build_context_text(project)
|
|
42
|
+
except CliError as exc:
|
|
43
|
+
return f"error: {exc}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def rules_tool(code: str = "", procedure: str = "") -> str:
|
|
47
|
+
"""Karni conventions. No arguments: every rule's code + summary. ``code``: one rule in
|
|
48
|
+
full (why + fix). ``procedure``: steps for new-model, new-page, add-module or new-module."""
|
|
49
|
+
project = _project()
|
|
50
|
+
try:
|
|
51
|
+
data = agent_data.load(project)
|
|
52
|
+
except CliError as exc:
|
|
53
|
+
return f"error: {exc}"
|
|
54
|
+
if code:
|
|
55
|
+
rule = data.rule(code)
|
|
56
|
+
return render_explain(rule) if rule else f"error: no rule {code!r}"
|
|
57
|
+
if procedure:
|
|
58
|
+
found = data.procedure(procedure)
|
|
59
|
+
return render_procedure(found) if found else f"error: no procedure {procedure!r}"
|
|
60
|
+
return render_rules(data, module_fragments(project) if project else {})
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def check_tool(path: str) -> str:
|
|
64
|
+
"""Static check of one file against Karni's rules; returns violations with fixes."""
|
|
65
|
+
project = _project()
|
|
66
|
+
target = Path(path)
|
|
67
|
+
if not target.is_absolute() and project is not None:
|
|
68
|
+
target = project / target
|
|
69
|
+
try:
|
|
70
|
+
text, _count = check_text([target], project)
|
|
71
|
+
except CliError as exc:
|
|
72
|
+
return f"error: {exc}"
|
|
73
|
+
return text
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
TOOLS: dict[str, Any] = {"context": context_tool, "rules": rules_tool, "check": check_tool}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _server_class() -> Any:
|
|
80
|
+
try:
|
|
81
|
+
from mcp.server.mcpserver import MCPServer
|
|
82
|
+
|
|
83
|
+
return MCPServer
|
|
84
|
+
except ImportError:
|
|
85
|
+
pass
|
|
86
|
+
try:
|
|
87
|
+
from mcp.server.fastmcp import FastMCP # type: ignore[attr-defined,unused-ignore]
|
|
88
|
+
|
|
89
|
+
return FastMCP
|
|
90
|
+
except ImportError:
|
|
91
|
+
raise CliError(
|
|
92
|
+
"karni mcp needs the MCP SDK: pip install 'karni-cli[mcp]'", code="C006"
|
|
93
|
+
) from None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def build_server(project: Path | None = None) -> Any:
|
|
97
|
+
"""The configured (not yet running) MCP server."""
|
|
98
|
+
global _project_override
|
|
99
|
+
_project_override = project
|
|
100
|
+
server = _server_class()(name="karni", instructions=INSTRUCTIONS)
|
|
101
|
+
for name, function in TOOLS.items():
|
|
102
|
+
server.tool(name=name, description=function.__doc__)(function)
|
|
103
|
+
return server
|