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,260 @@
1
+ """Where the CLI gets Karni's agent rules from (``karni.agent``).
2
+
3
+ The CLI never imports project code, but ``karni.agent`` is Karni's own
4
+ stdlib-only data module, so it may be imported when available. Resolution
5
+ (first that works; force one with ``KARNI_AGENT_SOURCE=local|project|bundled``):
6
+
7
+ 1. ``local`` — ``import karni.agent`` in the CLI's own environment.
8
+ 2. ``project`` — ``python -m karni.agent`` through the project's interpreter
9
+ (``.venv``, else a running compose service — the same resolution as
10
+ ``karni context``); answers with the project's own karni version.
11
+ 3. ``bundled`` — the copy of ``karni/agent`` shipped inside karni-cli (the
12
+ monorepo source when run from a checkout), loaded by path. This is what
13
+ ``karni new`` uses on a machine where only karni-cli is installed.
14
+
15
+ Every path yields the same JSON-able shape (``karni.agent.dump``), so the
16
+ renderers below never care which one answered.
17
+
18
+ History providers (``karni.agent.history``, :func:`history_module`) are the
19
+ exception: they always run in the CLI's own environment, and in ``project``
20
+ mode the CLI hands its detection to the project interpreter
21
+ (``KARNI_AGENT_CONDITIONS``) so conditional rules follow the same providers.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import importlib.util
27
+ import json
28
+ import os
29
+ import subprocess
30
+ import sys
31
+ from dataclasses import dataclass
32
+ from importlib import resources
33
+ from pathlib import Path
34
+ from types import ModuleType
35
+ from typing import Any
36
+
37
+ from karni_cli import project as project_mod
38
+ from karni_cli.project import CliError
39
+
40
+ SOURCES = ("local", "project", "bundled")
41
+ _BUNDLED_NAME = "karni_cli._bundled_karni_agent"
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class AgentData:
46
+ raw: dict[str, Any]
47
+ source: str
48
+
49
+ @property
50
+ def rules(self) -> list[dict[str, Any]]:
51
+ return list(self.raw["rules"])
52
+
53
+ @property
54
+ def procedures(self) -> list[dict[str, Any]]:
55
+ return list(self.raw["procedures"])
56
+
57
+ @property
58
+ def categories(self) -> list[str]:
59
+ return list(self.raw["categories"])
60
+
61
+ @property
62
+ def adapter_overrides(self) -> dict[str, Any]:
63
+ return dict(self.raw.get("adapters") or {})
64
+
65
+ def rule(self, code: str) -> dict[str, Any] | None:
66
+ return next((r for r in self.rules if r["code"] == code.upper()), None)
67
+
68
+ def procedure(self, name: str) -> dict[str, Any] | None:
69
+ return next((p for p in self.procedures if p["name"] == name), None)
70
+
71
+
72
+ def _local() -> ModuleType | None:
73
+ try:
74
+ from karni import agent
75
+ except ImportError:
76
+ return None
77
+ return agent
78
+
79
+
80
+ def _bundled_dir() -> Path | None:
81
+ for parent in Path(__file__).resolve().parents:
82
+ candidate = parent / "packages" / "karni" / "karni" / "agent"
83
+ if (candidate / "rules.py").is_file():
84
+ return candidate
85
+ packaged = resources.files("karni_cli") / "_agent"
86
+ with resources.as_file(packaged) as path:
87
+ if (path / "rules.py").is_file():
88
+ return path
89
+ return None
90
+
91
+
92
+ def _bundled() -> ModuleType | None:
93
+ if _BUNDLED_NAME in sys.modules:
94
+ return sys.modules[_BUNDLED_NAME]
95
+ directory = _bundled_dir()
96
+ if directory is None:
97
+ return None
98
+ spec = importlib.util.spec_from_file_location(
99
+ _BUNDLED_NAME, directory / "__init__.py", submodule_search_locations=[str(directory)]
100
+ )
101
+ if spec is None or spec.loader is None:
102
+ return None
103
+ module = importlib.util.module_from_spec(spec)
104
+ sys.modules[_BUNDLED_NAME] = module
105
+ try:
106
+ spec.loader.exec_module(module)
107
+ except Exception:
108
+ del sys.modules[_BUNDLED_NAME]
109
+ raise
110
+ return module
111
+
112
+
113
+ def history_module() -> ModuleType | None:
114
+ """``karni.agent.history`` for the CLI's own environment.
115
+
116
+ History providers always run in the CLI's environment (third-party
117
+ providers are discovered from its entry points): karni's module when
118
+ importable here (unless ``KARNI_AGENT_SOURCE=bundled``), else the bundled
119
+ copy, which carries the built-in Gigai Chronicle provider by itself.
120
+ """
121
+ if os.environ.get("KARNI_AGENT_SOURCE", "").strip() != "bundled":
122
+ try:
123
+ from karni.agent import history
124
+
125
+ return history
126
+ except ImportError:
127
+ pass # karni absent here, or a karni older than history providers
128
+ bundled = _bundled()
129
+ if bundled is None:
130
+ return None
131
+ try:
132
+ return importlib.import_module(f"{bundled.__name__}.history")
133
+ except ImportError:
134
+ return None
135
+
136
+
137
+ def _conditions_env(project: Path) -> dict[str, str]:
138
+ """The CLI's own condition detection, handed to the project interpreter so a
139
+ conditional rule follows the same providers as ``karni context --with-history``
140
+ (and a compose container that cannot see a store above its mount)."""
141
+ module = history_module()
142
+ if module is None:
143
+ return {}
144
+ return {module.CONDITIONS_ENV: ",".join(sorted(module.active_conditions(project)))}
145
+
146
+
147
+ def _project_command(project: Path, env: dict[str, str] | None = None) -> list[str] | None:
148
+ python = project_mod.venv_python(project)
149
+ if python is not None:
150
+ return [str(python), "-m", "karni.agent"]
151
+ service = os.environ.get("KARNI_COMPOSE_SERVICE", "django")
152
+ if project_mod.compose_service_running(project, service):
153
+ flags = [flag for key, value in (env or {}).items() for flag in ("-e", f"{key}={value}")]
154
+ return ["docker", "compose", "exec", "-T", *flags, service, "python", "-m", "karni.agent"]
155
+ return None
156
+
157
+
158
+ def _run_project(project: Path, args: list[str], env: dict[str, str] | None = None) -> Any | None:
159
+ command = _project_command(project, env)
160
+ if command is None:
161
+ return None
162
+ proc = subprocess.run(
163
+ [*command, *args],
164
+ cwd=project,
165
+ text=True,
166
+ capture_output=True,
167
+ check=False,
168
+ env={**os.environ, **env} if env else None,
169
+ )
170
+ if proc.returncode == 2:
171
+ raise CliError(proc.stderr.strip(), code="C009")
172
+ if proc.returncode != 0:
173
+ return None # e.g. an older karni without karni.agent: try the next source
174
+ return json.loads(proc.stdout)
175
+
176
+
177
+ def _order() -> tuple[str, ...]:
178
+ forced = os.environ.get("KARNI_AGENT_SOURCE", "").strip()
179
+ if not forced:
180
+ return SOURCES
181
+ if forced not in SOURCES:
182
+ raise CliError(
183
+ f"KARNI_AGENT_SOURCE must be one of {', '.join(SOURCES)}, got {forced!r}.", code="C007"
184
+ )
185
+ return (forced,)
186
+
187
+
188
+ def _module_for(source: str) -> ModuleType | None:
189
+ return _local() if source == "local" else _bundled()
190
+
191
+
192
+ def _call(module: ModuleType, name: str, *args: Any) -> Any:
193
+ try:
194
+ return getattr(module, name)(*args)
195
+ except module.OverrideError as exc:
196
+ raise CliError(_bare(exc), code=getattr(exc, "code", "E701")) from None
197
+
198
+
199
+ def load(project: Path | None) -> AgentData:
200
+ """Rules + procedures with ``<project>/karni.rules.py`` applied."""
201
+ for source in _order():
202
+ if source == "project":
203
+ if project is None:
204
+ continue
205
+ raw = _run_project(project, ["dump", "--root", "."], _conditions_env(project))
206
+ if raw is not None:
207
+ return AgentData(raw, source)
208
+ continue
209
+ module = _module_for(source)
210
+ if module is not None:
211
+ return AgentData(_call(module, "dump", project), source)
212
+ raise CliError(
213
+ "Karni's agent rules are unavailable: karni is not importable here, the project "
214
+ "interpreter is unreachable, and no bundled copy ships with this karni-cli.\n"
215
+ "Install karni next to karni-cli, or run inside a project with a .venv.",
216
+ code="C005",
217
+ )
218
+
219
+
220
+ def check(paths: list[Path], project: Path | None) -> list[dict[str, Any]]:
221
+ """Static findings for ``paths`` (each a dict with path/code/rule_id/line/message/fix)."""
222
+ for source in _order():
223
+ if source == "project":
224
+ if project is None:
225
+ continue
226
+ rel = [_relative(p, project) for p in paths]
227
+ found = _run_project(project, ["check", "--root", ".", *rel])
228
+ if found is not None:
229
+ return [{**f, "path": str(p)} for f, p in _rejoin(found, rel, paths)]
230
+ continue
231
+ module = _module_for(source)
232
+ if module is not None:
233
+ try:
234
+ return list(_call(module, "check_paths", paths, project))
235
+ except (OSError, ValueError) as exc:
236
+ raise CliError(_bare(exc), code=getattr(exc, "code", "C010")) from None
237
+ raise CliError("Karni's agent checks are unavailable (see `karni rules`).", code="C005")
238
+
239
+
240
+ def _bare(exc: BaseException) -> str:
241
+ """The message without the code prefix the bundled exception adds to ``str()``."""
242
+ return str(exc.args[0]) if exc.args else str(exc)
243
+
244
+
245
+ def _relative(path: Path, project: Path) -> str:
246
+ try:
247
+ return str(path.resolve().relative_to(project.resolve()))
248
+ except ValueError:
249
+ raise CliError(
250
+ f"{path} is outside the project; the project interpreter can only check "
251
+ "files inside it.",
252
+ code="C010",
253
+ ) from None
254
+
255
+
256
+ def _rejoin(
257
+ found: list[dict[str, Any]], rel: list[str], paths: list[Path]
258
+ ) -> list[tuple[dict[str, Any], Path]]:
259
+ by_rel = dict(zip(rel, paths, strict=True))
260
+ return [(f, by_rel.get(f["path"], Path(f["path"]))) for f in found]
karni_cli/brand.py ADDED
@@ -0,0 +1,55 @@
1
+ """The Karni mark for the terminal: a few lines, only where a person will see them.
2
+
3
+ ``print_mark()`` writes a small truecolor rendering of the crown when stdout
4
+ is an interactive terminal; it writes nothing when the output is piped, when
5
+ ``NO_COLOR`` is set (https://no-color.org) or when ``TERM`` is ``dumb``, so
6
+ no machine-readable output ever carries it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import sys
13
+ from collections.abc import Mapping
14
+ from typing import IO
15
+
16
+ ACCENT = "\x1b[38;2;251;93;8m"
17
+ LINE = "\x1b[38;2;255;255;255m"
18
+ RESET = "\x1b[0m"
19
+
20
+ #: Rows of the mark, as (ink, text) runs — rings above the crown's legs, peak and crossings.
21
+ ROWS: tuple[tuple[tuple[str, str], ...], ...] = (
22
+ ((ACCENT, " ● ● ●"),),
23
+ ((LINE, " │╲ ╱ ╲ ╱│"),),
24
+ ((LINE, " │ ╳ ╳ │"),),
25
+ ((LINE, " │╱ ╲ ╱ ╲│"),),
26
+ )
27
+
28
+
29
+ def wants_mark(stream: IO[str] | None = None, environ: Mapping[str, str] | None = None) -> bool:
30
+ """Is this an interactive, colour-capable terminal that has not opted out?"""
31
+ stream = stream or sys.stdout
32
+ env: Mapping[str, str] = os.environ if environ is None else environ
33
+ if env.get("NO_COLOR") or env.get("TERM", "") == "dumb":
34
+ return False
35
+ isatty = getattr(stream, "isatty", None)
36
+ return bool(isatty and isatty())
37
+
38
+
39
+ def mark_lines(*, colour: bool = True) -> list[str]:
40
+ """The mark's rows, with or without ANSI colour."""
41
+ lines = []
42
+ for row in ROWS:
43
+ text = "".join(f"{ink}{run}{RESET}" if colour else run for ink, run in row)
44
+ lines.append(text)
45
+ return lines
46
+
47
+
48
+ def print_mark(stream: IO[str] | None = None) -> bool:
49
+ """Print the mark when :func:`wants_mark`; returns whether it was printed."""
50
+ stream = stream or sys.stdout
51
+ if not wants_mark(stream):
52
+ return False
53
+ stream.write("\n".join(mark_lines()) + "\n\n")
54
+ stream.flush()
55
+ return True
karni_cli/clidoc.py ADDED
@@ -0,0 +1,254 @@
1
+ """Render docs/cli.md from the typer app — the CLI reference is generated, never hand-written.
2
+
3
+ python -m karni_cli.clidoc > docs/cli.md
4
+
5
+ ``tests/test_clidoc.py`` fails when docs/cli.md differs from :func:`render`,
6
+ so a changed option or help text must be regenerated in the same commit.
7
+ Everything comes from the click command tree typer builds (help texts,
8
+ arguments, options, defaults) plus :data:`karni_cli.generators.GENERATORS`
9
+ and the field DSL reference (:data:`karni_cli.generators.fields.FIELD_DOCS`).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import textwrap
15
+ from pathlib import PurePath
16
+ from typing import Any
17
+
18
+ import typer
19
+
20
+ from karni_cli.commands.make import list_text
21
+ from karni_cli.generators.fields import FIELD_DOCS
22
+
23
+ #: Section → top-level commands, in reading order. Anything else lands in "Other".
24
+ SECTIONS: tuple[tuple[str, tuple[str, ...]], ...] = (
25
+ ("Project", ("new", "add", "upgrade", "doctor", "config")),
26
+ ("Agents and checks", ("context", "rules", "check", "explain", "types", "mcp")),
27
+ (
28
+ "Runners",
29
+ (
30
+ "serve",
31
+ "migrate",
32
+ "test",
33
+ "shell",
34
+ "routes",
35
+ "worker",
36
+ "beat",
37
+ "schedule",
38
+ "seed",
39
+ "sync-roles",
40
+ "createsuperuser",
41
+ "manage",
42
+ ),
43
+ ),
44
+ ("Generators", ("make",)),
45
+ )
46
+
47
+ HEADER = """---
48
+ description: Every karni command with its arguments and options, generated from the CLI's own help text.
49
+ ---
50
+
51
+ # The `karni` command
52
+
53
+ > Generated from the CLI itself — `python -m karni_cli.clidoc > docs/cli.md`;
54
+ > `packages/karni-cli/tests/test_clidoc.py` fails when this file is stale.
55
+ > Change a command's help, then regenerate; never edit this file by hand.
56
+
57
+ `karni <command> --help` prints the same information. Runners execute in the
58
+ project environment: the project's `.venv`, else the running docker compose
59
+ service (`--docker/--no-docker` forces one; `--service` / `KARNI_COMPOSE_SERVICE`
60
+ names it). Put `--` before passthrough arguments that clash with karni's own
61
+ options.
62
+ """
63
+
64
+ GENERATOR_RULES = """Every generator follows the same rules:
65
+
66
+ - It writes project code only — never framework logic.
67
+ - It never overwrites: an existing target is an error naming the file and
68
+ `--force` (which replaces it).
69
+ - `--dry-run` prints the files and the diff it would write, and writes nothing.
70
+ - It prints every created and modified file, then any notes (the chosen
71
+ `access`, the route, the next command).
72
+ - The target app is `--app <label>`, else the project's only local app (local
73
+ apps are the `# karni:apps` entries of config/settings.py).
74
+ - Existing files change only through marker comments (`# karni:apps`,
75
+ `# karni:middleware`, `// karni:page-imports`, `// karni:routes`) or
76
+ AST-located appends; an unfamiliar layout is an error that names the manual
77
+ step. Generated Python is ruff-clean (isort sections, ruff format layout).
78
+ - It never imports Django: `karni make migration` (and `make model --migrate`)
79
+ run `manage.py makemigrations` through the project interpreter.
80
+ - Every generator is also available as a hidden Laravel-style alias:
81
+ `karni make:<type>` (e.g. `karni make:model Event title:str`).
82
+ """
83
+
84
+
85
+ def _md(text: str) -> str:
86
+ """Escape ``<`` / ``>`` outside backtick code spans (Markdown would eat ``<field>``)."""
87
+ parts = text.split("`")
88
+ for i in range(0, len(parts), 2):
89
+ parts[i] = parts[i].replace("<", "&lt;").replace(">", "&gt;")
90
+ return "`".join(parts)
91
+
92
+
93
+ def _escape(text: str) -> str:
94
+ """A table cell: Markdown-escaped, pipes escaped, one line."""
95
+ return _md(text).replace("|", "\\|").replace("\n", " ")
96
+
97
+
98
+ def _paragraphs(help_text: str | None) -> str:
99
+ """Help text as Markdown: paragraphs re-flowed, ``Example:`` paragraphs as code."""
100
+ if not help_text:
101
+ return ""
102
+ blocks: list[str] = []
103
+ for raw in textwrap.dedent(help_text).strip().split("\n\n"):
104
+ text = " ".join(line.strip() for line in raw.strip().splitlines())
105
+ if not text or text == "\b":
106
+ continue
107
+ for label in ("Example:", "Examples:"):
108
+ if text.startswith(label):
109
+ code = text[len(label) :].strip()
110
+ blocks.append(f"{label}\n\n```sh\n{code}\n```")
111
+ break
112
+ else:
113
+ blocks.append(_md(text))
114
+ return "\n\n".join(blocks)
115
+
116
+
117
+ # Typer >= 0.20 vendors its own click (typer._click), older ones use click: the
118
+ # command tree is read by duck typing, never isinstance against a click import.
119
+ Command = Any
120
+ Parameter = Any
121
+
122
+
123
+ def _is_argument(param: Parameter) -> bool:
124
+ return getattr(param, "param_type_name", "") == "argument"
125
+
126
+
127
+ def _subcommands(command: Command) -> dict[str, Command]:
128
+ commands = getattr(command, "commands", None)
129
+ return dict(commands) if isinstance(commands, dict) else {}
130
+
131
+
132
+ _TYPE_LABELS = {"str": "TEXT", "string": "TEXT", "text": "TEXT", "int": "INTEGER"}
133
+
134
+
135
+ def _metavar(param: Parameter) -> str:
136
+ """A stable value label (click and typer's vendored click name types differently)."""
137
+ name = str(getattr(param.type, "name", "text")).lower()
138
+ return _TYPE_LABELS.get(name, name.upper())
139
+
140
+
141
+ def _default(param: Parameter) -> str:
142
+ value: Any = param.default
143
+ if callable(value) or value in (None, False, "", (), []):
144
+ return ""
145
+ if value is True:
146
+ return "on"
147
+ if isinstance(value, PurePath):
148
+ value = value.as_posix()
149
+ return f"`{value}`"
150
+
151
+
152
+ def _param_row(param: Parameter) -> str | None:
153
+ if getattr(param, "hidden", False):
154
+ return None
155
+ help_text = _escape(getattr(param, "help", None) or "")
156
+ if _is_argument(param):
157
+ name = (param.name or "").rstrip("_").upper()
158
+ name += "..." if param.nargs == -1 else ""
159
+ label = f"`{name}`" + (" (required)" if param.required else "")
160
+ else:
161
+ flags = list(param.opts)
162
+ if param.secondary_opts:
163
+ flags = [f"{flags[0]}/{param.secondary_opts[0]}", *flags[1:]]
164
+ label = ", ".join(f"`{flag}`" for flag in flags)
165
+ if not param.is_flag:
166
+ label += f" {_metavar(param)}"
167
+ if param.multiple:
168
+ label += " (repeatable)"
169
+ if param.required:
170
+ label += " (required)"
171
+ if param.envvar:
172
+ envvars = [param.envvar] if isinstance(param.envvar, str) else list(param.envvar)
173
+ help_text = f"{help_text} Env: {', '.join(f'`{e}`' for e in envvars)}.".strip()
174
+ return f"| {label} | {_default(param)} | {help_text} |"
175
+
176
+
177
+ def _usage(path: str, command: Command) -> str:
178
+ pieces = [f"karni {path}"]
179
+ if any(not _is_argument(p) and not getattr(p, "hidden", False) for p in command.params):
180
+ pieces.append("[OPTIONS]")
181
+ for param in command.params:
182
+ if _is_argument(param):
183
+ name = (param.name or "").rstrip("_").upper()
184
+ name += "..." if param.nargs == -1 else ""
185
+ pieces.append(name if param.required else f"[{name}]")
186
+ if _subcommands(command):
187
+ pieces.append("COMMAND [ARGS]...")
188
+ return " ".join(pieces)
189
+
190
+
191
+ def _command(path: str, command: Command, level: int) -> list[str]:
192
+ out = [f"{'#' * level} `karni {path}`", ""]
193
+ body = _paragraphs(command.help)
194
+ if body:
195
+ out += [body, ""]
196
+ out += ["```", _usage(path, command), "```", ""]
197
+ rows = [row for row in (_param_row(p) for p in command.params) if row]
198
+ if rows:
199
+ out += ["| Argument / option | Default | Description |", "| --- | --- | --- |", *rows, ""]
200
+ if path == "make":
201
+ out += [
202
+ GENERATOR_RULES,
203
+ "`karni make --list`:",
204
+ "",
205
+ "```",
206
+ list_text(),
207
+ "```",
208
+ "",
209
+ "Field DSL for `karni make model NAME [FIELD...]`:",
210
+ "",
211
+ "| Field | Generates |",
212
+ "| --- | --- |",
213
+ *(f"| `{_escape(syntax)}` | {_escape(result)} |" for syntax, result in FIELD_DOCS),
214
+ "",
215
+ ]
216
+ for name, sub in _subcommands(command).items():
217
+ if not sub.hidden:
218
+ out += _command(f"{path} {name}", sub, level + 1)
219
+ return out
220
+
221
+
222
+ def render(app: typer.Typer | None = None) -> str:
223
+ """docs/cli.md for ``app`` (default: the ``karni`` app)."""
224
+ if app is None:
225
+ from karni_cli.main import app as karni_app
226
+
227
+ app = karni_app
228
+ root = typer.main.get_command(app)
229
+ visible = {name: cmd for name, cmd in _subcommands(root).items() if not cmd.hidden}
230
+ placed = {name for _, names in SECTIONS for name in names}
231
+ sections = [*SECTIONS, ("Other", tuple(n for n in visible if n not in placed))]
232
+ out = [HEADER, "## Contents", ""]
233
+ for title, names in sections:
234
+ present = [n for n in names if n in visible]
235
+ if present:
236
+ links = ", ".join(f"[{n}](#karni-{n})" for n in present)
237
+ out.append(f"- **{title}**: {links}")
238
+ out.append("")
239
+ for title, names in sections:
240
+ present = [n for n in names if n in visible]
241
+ if not present:
242
+ continue
243
+ out += [f"## {title}", ""]
244
+ for name in present:
245
+ out += _command(name, visible[name], 3)
246
+ return "\n".join(out).rstrip("\n") + "\n"
247
+
248
+
249
+ def main() -> None:
250
+ typer.echo(render(), nl=False)
251
+
252
+
253
+ if __name__ == "__main__":
254
+ main()
File without changes