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.
Files changed (145) hide show
  1. karni_cli/__init__.py +3 -0
  2. karni_cli/_agent/__init__.py +82 -0
  3. karni_cli/_agent/__main__.py +44 -0
  4. karni_cli/_agent/check.py +658 -0
  5. karni_cli/_agent/history.py +328 -0
  6. karni_cli/_agent/history_chronicle.py +246 -0
  7. karni_cli/_agent/overrides.py +194 -0
  8. karni_cli/_agent/rules.py +673 -0
  9. karni_cli/_changelog/0.2.0.toml +304 -0
  10. karni_cli/_changelog/0.3.0.toml +25 -0
  11. karni_cli/_changelog/__init__.py +248 -0
  12. karni_cli/_errorcodes/__init__.py +1171 -0
  13. karni_cli/_errorcodes/__main__.py +34 -0
  14. karni_cli/_errorcodes/links.py +217 -0
  15. karni_cli/_templates/project/cookiecutter.json +8 -0
  16. karni_cli/_templates/project/{{cookiecutter.project_slug}}/.dockerignore +7 -0
  17. karni_cli/_templates/project/{{cookiecutter.project_slug}}/.env.example +85 -0
  18. karni_cli/_templates/project/{{cookiecutter.project_slug}}/.gitignore +10 -0
  19. karni_cli/_templates/project/{{cookiecutter.project_slug}}/Caddyfile +22 -0
  20. karni_cli/_templates/project/{{cookiecutter.project_slug}}/Dockerfile +53 -0
  21. karni_cli/_templates/project/{{cookiecutter.project_slug}}/Makefile +52 -0
  22. karni_cli/_templates/project/{{cookiecutter.project_slug}}/README.md +169 -0
  23. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/__init__.py +16 -0
  24. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/asgi.py +14 -0
  25. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/celery.py +17 -0
  26. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/settings.py +28 -0
  27. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/urls.py +31 -0
  28. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/wsgi.py +14 -0
  29. karni_cli/_templates/project/{{cookiecutter.project_slug}}/docker-compose.yml +126 -0
  30. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.dockerignore +2 -0
  31. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.jscpd.json +9 -0
  32. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.npmrc +7 -0
  33. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/Dockerfile.dev +17 -0
  34. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/eslint.config.js +32 -0
  35. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/index.html +14 -0
  36. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/package.json +43 -0
  37. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/postcss.config.js +6 -0
  38. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/public/favicon.svg +6 -0
  39. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/App.tsx +18 -0
  40. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/api/health.ts +21 -0
  41. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/index.css +11 -0
  42. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/layout.tsx +51 -0
  43. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/main.tsx +11 -0
  44. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/pages/home.tsx +28 -0
  45. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/routes.tsx +24 -0
  46. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/vite-env.d.ts +1 -0
  47. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/tailwind.config.ts +13 -0
  48. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/tsconfig.json +19 -0
  49. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/vite.config.ts +18 -0
  50. karni_cli/_templates/project/{{cookiecutter.project_slug}}/manage.py +23 -0
  51. karni_cli/_templates/project/{{cookiecutter.project_slug}}/pyproject.toml +31 -0
  52. karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/__init__.py +0 -0
  53. karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/test_api_schema.py +17 -0
  54. karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/test_smoke.py +11 -0
  55. karni_cli/adapters.py +373 -0
  56. karni_cli/agent_adapters.toml +160 -0
  57. karni_cli/agent_data.py +260 -0
  58. karni_cli/brand.py +55 -0
  59. karni_cli/clidoc.py +254 -0
  60. karni_cli/commands/__init__.py +0 -0
  61. karni_cli/commands/add.py +144 -0
  62. karni_cli/commands/beat.py +36 -0
  63. karni_cli/commands/check.py +101 -0
  64. karni_cli/commands/config.py +81 -0
  65. karni_cli/commands/context.py +171 -0
  66. karni_cli/commands/createsuperuser.py +28 -0
  67. karni_cli/commands/doctor.py +136 -0
  68. karni_cli/commands/explain.py +60 -0
  69. karni_cli/commands/make.py +55 -0
  70. karni_cli/commands/manage_passthrough.py +28 -0
  71. karni_cli/commands/mcp.py +20 -0
  72. karni_cli/commands/migrate.py +23 -0
  73. karni_cli/commands/new.py +106 -0
  74. karni_cli/commands/routes.py +20 -0
  75. karni_cli/commands/rules.py +139 -0
  76. karni_cli/commands/run_tests.py +38 -0
  77. karni_cli/commands/schedule.py +40 -0
  78. karni_cli/commands/seed.py +34 -0
  79. karni_cli/commands/serve.py +50 -0
  80. karni_cli/commands/shell.py +28 -0
  81. karni_cli/commands/sync_roles.py +32 -0
  82. karni_cli/commands/types.py +72 -0
  83. karni_cli/commands/upgrade.py +231 -0
  84. karni_cli/commands/worker.py +63 -0
  85. karni_cli/config.py +142 -0
  86. karni_cli/environment.py +29 -0
  87. karni_cli/errorcodes.py +377 -0
  88. karni_cli/errordoc.py +227 -0
  89. karni_cli/generators/__init__.py +67 -0
  90. karni_cli/generators/apitest.py +206 -0
  91. karni_cli/generators/app.py +103 -0
  92. karni_cli/generators/command.py +98 -0
  93. karni_cli/generators/component.py +115 -0
  94. karni_cli/generators/core.py +319 -0
  95. karni_cli/generators/event.py +57 -0
  96. karni_cli/generators/factory.py +88 -0
  97. karni_cli/generators/fields.py +343 -0
  98. karni_cli/generators/introspect.py +263 -0
  99. karni_cli/generators/listener.py +168 -0
  100. karni_cli/generators/middleware.py +95 -0
  101. karni_cli/generators/migration.py +82 -0
  102. karni_cli/generators/model.py +235 -0
  103. karni_cli/generators/module.py +255 -0
  104. karni_cli/generators/naming.py +100 -0
  105. karni_cli/generators/page.py +373 -0
  106. karni_cli/generators/pysource.py +304 -0
  107. karni_cli/generators/schedule.py +174 -0
  108. karni_cli/generators/seeder.py +86 -0
  109. karni_cli/generators/serializer.py +165 -0
  110. karni_cli/generators/service.py +68 -0
  111. karni_cli/generators/task.py +84 -0
  112. karni_cli/generators/viewset.py +179 -0
  113. karni_cli/history.py +59 -0
  114. karni_cli/local_state.py +73 -0
  115. karni_cli/main.py +104 -0
  116. karni_cli/mcp_server.py +103 -0
  117. karni_cli/project.py +248 -0
  118. karni_cli/rules_render.py +86 -0
  119. karni_cli/scaffold.py +197 -0
  120. karni_cli/settings_edit.py +128 -0
  121. karni_cli/tips.py +53 -0
  122. karni_cli/typegen.py +101 -0
  123. karni_cli/upgrades/__init__.py +28 -0
  124. karni_cli/upgrades/changelog.py +123 -0
  125. karni_cli/upgrades/codemod.py +125 -0
  126. karni_cli/upgrades/engine.py +224 -0
  127. karni_cli/upgrades/legacy_agent_files.py +138 -0
  128. karni_cli/upgrades/python.py +670 -0
  129. karni_cli/upgrades/transforms/rename-export.cjs +96 -0
  130. karni_cli/upgrades/typescript.py +160 -0
  131. karni_cli/upgrades/v0_2/__init__.py +1 -0
  132. karni_cli/upgrades/v0_2/agent_guidance.py +269 -0
  133. karni_cli/upgrades/v1_0/__init__.py +5 -0
  134. karni_cli/upgrades/v1_0/all_objects_to_without.py +18 -0
  135. karni_cli/upgrades/v1_0/all_with_deleted_to_without.py +13 -0
  136. karni_cli/upgrades/v1_0/deleted_only_to_without_dead.py +13 -0
  137. karni_cli/upgrades/v1_0/object_permission_classes_to_access.py +16 -0
  138. karni_cli/upgrades/v1_0/related_lookups_to_optimize.py +15 -0
  139. karni_cli/upgrades/v1_0/scoped_related_lookups_to_optimize.py +15 -0
  140. karni_cli/upgrades/v1_0/soft_delete_manager_mixin_to_scoped.py +14 -0
  141. karni_cli-0.3.1.dist-info/METADATA +191 -0
  142. karni_cli-0.3.1.dist-info/RECORD +145 -0
  143. karni_cli-0.3.1.dist-info/WHEEL +4 -0
  144. karni_cli-0.3.1.dist-info/entry_points.txt +2 -0
  145. 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)
@@ -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()
@@ -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