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,136 @@
1
+ """``karni doctor`` — a human-readable report on the environment karni sees.
2
+
3
+ One line per fact: the project, how project commands would run (``.venv`` or
4
+ a running compose service), Node/npm, the ``@karni/ui`` manifest behind
5
+ ``karni context``'s COMPONENTS/HOOKS, whether the agent adapters are fresh,
6
+ and which history providers are installed / detect a store. It reports and
7
+ never changes anything; the exit status is 0 unless the CLI itself fails.
8
+
9
+ It may end with ONE tip (see :mod:`karni_cli.tips`); ``-q`` suppresses it.
10
+ This is human output — never parse it; agents use ``karni context``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import shutil
16
+ import subprocess
17
+ from pathlib import Path
18
+
19
+ import typer
20
+
21
+ from karni_cli import agent_data, tips
22
+ from karni_cli import history as history_mod
23
+ from karni_cli import project as project_mod
24
+ from karni_cli.adapters import sync
25
+ from karni_cli.commands.context import find_manifest
26
+ from karni_cli.project import CliError, find_project
27
+
28
+ _WIDTH = 12
29
+
30
+
31
+ def _line(label: str, text: str) -> str:
32
+ return f"{label:<{_WIDTH}}{text}"
33
+
34
+
35
+ def tool_version(name: str) -> str | None:
36
+ """``<name> --version`` output, or ``None`` when absent or failing."""
37
+ executable = shutil.which(name)
38
+ if executable is None:
39
+ return None
40
+ try:
41
+ proc = subprocess.run(
42
+ [executable, "--version"],
43
+ capture_output=True,
44
+ text=True,
45
+ timeout=10,
46
+ check=False,
47
+ stdin=subprocess.DEVNULL,
48
+ )
49
+ except (OSError, subprocess.SubprocessError):
50
+ return None
51
+ out = proc.stdout.strip().splitlines()
52
+ return out[0] if proc.returncode == 0 and out else None
53
+
54
+
55
+ def _display(path: Path, project: Path) -> str:
56
+ """``path`` relative to the project when it lives inside it, else as given.
57
+
58
+ The quickstart's layout keeps the venv one level above the project.
59
+ """
60
+ try:
61
+ return str(path.relative_to(project))
62
+ except ValueError:
63
+ return str(path)
64
+
65
+
66
+ def _interpreter(project: Path, service: str) -> str:
67
+ python = project_mod.venv_python(project)
68
+ if python is not None:
69
+ return f"{_display(python, project)} (project commands run here)"
70
+ if project_mod.compose_service_running(project, service):
71
+ return f'docker compose service "{service}" (running)'
72
+ return (
73
+ "none — create .venv (python -m venv .venv && .venv/bin/pip install -e .), activate a "
74
+ "virtualenv with the project's dependencies, or start the stack (docker compose up -d)"
75
+ )
76
+
77
+
78
+ def _node() -> str:
79
+ node = tool_version("node")
80
+ if node is None:
81
+ return "not found — needed for the frontend and `karni types`"
82
+ npm = tool_version("npm")
83
+ return f"{node} (npm {npm})" if npm else f"{node} (npm not found)"
84
+
85
+
86
+ def _manifest(project: Path) -> str:
87
+ found = find_manifest(project, None)
88
+ if found is None:
89
+ return "@karni/ui manifest absent — `npm install` in frontend/ (COMPONENTS/HOOKS hidden)"
90
+ return f"@karni/ui manifest: {_display(found, project)}"
91
+
92
+
93
+ def _adapters(project: Path) -> str:
94
+ try:
95
+ result = sync(project, agent_data.load(project), check=True)
96
+ except CliError as exc:
97
+ return f"cannot check: {exc}"
98
+ if result.stale:
99
+ return f"stale ({len(result.stale)} files) — run `karni rules sync`"
100
+ return f"up to date ({len(result.unchanged)} files)"
101
+
102
+
103
+ def _history(project: Path) -> list[str]:
104
+ found = history_mod.discovered()
105
+ if found is None:
106
+ return ["unavailable (karni.agent.history not found)"]
107
+ detected = history_mod.detected_names(project)
108
+ lines = (
109
+ [f"{name}: store detected — `karni context --with-history FILE`" for name in detected]
110
+ if detected
111
+ else [f"no store detected (installed: {', '.join(found.names) or 'none'})"]
112
+ )
113
+ lines += [f"skipped provider {error}" for error in found.errors]
114
+ return lines
115
+
116
+
117
+ def doctor(
118
+ service: str = typer.Option("django", "--service", envvar="KARNI_COMPOSE_SERVICE"),
119
+ quiet: bool = typer.Option(False, "--quiet", "-q", help="Do not print the optional tip."),
120
+ ) -> None:
121
+ project = find_project()
122
+ if project is None:
123
+ typer.echo(_line("project", "not found (no manage.py here or above)"))
124
+ typer.echo(_line("node", _node()))
125
+ return
126
+ typer.echo(_line("project", str(project)))
127
+ typer.echo(_line("interpreter", _interpreter(project, service)))
128
+ typer.echo(_line("node", _node()))
129
+ typer.echo(_line("ui", _manifest(project)))
130
+ typer.echo(_line("adapters", _adapters(project)))
131
+ for i, text in enumerate(_history(project)):
132
+ typer.echo(_line("history" if i == 0 else "", text))
133
+ tip = tips.history_tip(project, quiet=quiet)
134
+ if tip:
135
+ typer.echo("")
136
+ typer.echo(tip)
@@ -0,0 +1,60 @@
1
+ """``karni explain CODE`` — what an error code means and how to fix it, offline.
2
+
3
+ karni explain E104 a Karni error or system check (E/W codes)
4
+ karni explain C101 a karni command error (C codes)
5
+ karni explain PAY004 an installed module's code (asks the project interpreter)
6
+ karni explain K106 an agent rule (same as karni rules --explain K106)
7
+
8
+ Works without Django and without a project; every error Karni prints starts
9
+ with its code, so an agent can run this instead of searching the docs.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import re
16
+
17
+ import typer
18
+
19
+ from karni_cli import agent_data
20
+ from karni_cli.errorcodes import lookup
21
+ from karni_cli.project import CliError, find_project
22
+ from karni_cli.rules_render import render_explain
23
+
24
+ _RULE = re.compile(r"^[KP]\d{3}$")
25
+
26
+
27
+ def render_entry(entry: dict[str, str]) -> str:
28
+ return "\n".join(
29
+ [
30
+ f"{entry['code']} {entry['title']} ({entry['kind']})",
31
+ "",
32
+ f"Why: {entry['why']}",
33
+ f"Fix: {entry['fix']}",
34
+ f"From: {entry['source']}",
35
+ f"Docs: {entry['url']}",
36
+ ]
37
+ )
38
+
39
+
40
+ def explain(
41
+ code: str = typer.Argument(..., help="An error code such as E104, W101, C101, PAY004 or K106."),
42
+ as_json: bool = typer.Option(False, "--json", help="Machine-readable output."),
43
+ ) -> None:
44
+ """Explain an error code: why Karni objects and the exact fix."""
45
+ wanted = code.strip().upper()
46
+ if _RULE.match(wanted):
47
+ rule = agent_data.load(find_project()).rule(wanted)
48
+ if rule is None:
49
+ raise CliError(f"No rule {wanted!r} — `karni rules` lists the codes.", code="C204")
50
+ typer.echo(json.dumps(rule, indent=2) if as_json else render_explain(rule))
51
+ return
52
+ entry = lookup(wanted)
53
+ if entry is None:
54
+ raise CliError(
55
+ f"Unknown code {code!r}. Codes look like E104, W101, C101, PAY004 or K106 and "
56
+ "start every Karni error message; a module's codes are found inside a project "
57
+ "that installs it. docs/errors.md lists every code.",
58
+ code="C204",
59
+ )
60
+ typer.echo(json.dumps(entry, indent=2) if as_json else render_entry(entry))
@@ -0,0 +1,55 @@
1
+ """``karni make <type>`` — artisan-style generators (and the ``make:<type>`` aliases).
2
+
3
+ Every generator writes project code only, never overwrites without
4
+ ``--force``, previews with ``--dry-run``, prints each created/modified file,
5
+ and edits existing files only through marker comments or AST-located
6
+ appends. ``karni make --list`` prints them all; the generators themselves
7
+ live in :mod:`karni_cli.generators`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import typer
13
+
14
+ from karni_cli.generators import GENERATORS
15
+
16
+ make_app = typer.Typer(
17
+ help=(
18
+ "Generate project code: apps, models, viewsets, tasks, schedules, listeners, pages "
19
+ "... (`karni make --list`). Never overwrites without --force; --dry-run previews."
20
+ ),
21
+ no_args_is_help=False,
22
+ add_completion=False,
23
+ )
24
+
25
+
26
+ def list_text() -> str:
27
+ width = max(len(g.name) for g in GENERATORS) + 2
28
+ return "\n".join(f"{g.name:<{width}}{g.summary}" for g in GENERATORS)
29
+
30
+
31
+ @make_app.callback(invoke_without_command=True)
32
+ def make(
33
+ ctx: typer.Context,
34
+ list_: bool = typer.Option(
35
+ False, "--list", help="Print every generator with a one-line summary."
36
+ ),
37
+ ) -> None:
38
+ if ctx.invoked_subcommand is not None:
39
+ return
40
+ if list_:
41
+ typer.echo(list_text())
42
+ return
43
+ typer.echo(ctx.get_help())
44
+
45
+
46
+ for _generator in GENERATORS:
47
+ make_app.command(_generator.name, short_help=_generator.summary)(_generator.command)
48
+
49
+
50
+ def register_aliases(app: typer.Typer) -> None:
51
+ """``karni make:<type>`` — hidden Laravel-style aliases for every generator."""
52
+ for generator in GENERATORS:
53
+ app.command(f"make:{generator.name}", hidden=True, short_help=generator.summary)(
54
+ generator.command
55
+ )
@@ -0,0 +1,28 @@
1
+ """``karni manage`` — raw ``manage.py`` passthrough in the project environment.
2
+
3
+ The escape hatch for any Django/third-party management command without a
4
+ dedicated runner. Put ``--`` before arguments that clash with this command's
5
+ own options (``--docker``, ``--service``, ``--help``).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import typer
11
+
12
+ from karni_cli import project as project_mod
13
+ from karni_cli.project import require_project
14
+
15
+
16
+ def manage(
17
+ args: list[str] | None = typer.Argument(
18
+ None, help="The manage.py command and its arguments, e.g. `showmigrations events`."
19
+ ),
20
+ docker: bool | None = typer.Option(None, "--docker/--no-docker"),
21
+ service: str = typer.Option("django", "--service", envvar="KARNI_COMPOSE_SERVICE"),
22
+ ) -> None:
23
+ """Run any management command — wraps `manage.py ARGS` (raw passthrough)."""
24
+ project = require_project()
25
+ proc = project_mod.run_manage(
26
+ project, list(args or []), docker=docker, service=service, capture=False, interactive=True
27
+ )
28
+ raise typer.Exit(proc.returncode)
@@ -0,0 +1,20 @@
1
+ """``karni mcp`` — run the Karni MCP server on stdio (``pip install 'karni-cli[mcp]'``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from karni_cli.project import find_project
10
+
11
+
12
+ def mcp(
13
+ project: Path | None = typer.Option(
14
+ None, "--project", help="Project root (default: found from the working directory)."
15
+ ),
16
+ ) -> None:
17
+ from karni_cli.mcp_server import build_server # lazy: the MCP SDK is an extra
18
+
19
+ root = project.resolve() if project is not None else find_project()
20
+ build_server(root).run()
@@ -0,0 +1,23 @@
1
+ """``karni migrate`` — apply database migrations through the project interpreter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from karni_cli import project as project_mod
8
+ from karni_cli.project import require_project
9
+
10
+
11
+ def migrate(
12
+ args: list[str] | None = typer.Argument(
13
+ None, help="Passed to manage.py migrate: [app_label] [migration_name] --plan --fake …"
14
+ ),
15
+ docker: bool | None = typer.Option(None, "--docker/--no-docker"),
16
+ service: str = typer.Option("django", "--service", envvar="KARNI_COMPOSE_SERVICE"),
17
+ ) -> None:
18
+ """Apply migrations — wraps `manage.py migrate [ARGS]`."""
19
+ project = require_project()
20
+ proc = project_mod.run_manage(
21
+ project, ["migrate", *(args or [])], docker=docker, service=service, capture=False
22
+ )
23
+ raise typer.Exit(proc.returncode)
@@ -0,0 +1,106 @@
1
+ """``karni new <name>`` — scaffold a project from templates/project.
2
+
3
+ The template carries no agent guidance files: they are generated afterwards
4
+ by the same code as ``karni rules sync`` (AGENTS.md, CLAUDE.md, Cursor,
5
+ Windsurf, Copilot, Cline, Aider, Junie adapters + slash commands).
6
+
7
+ @karni/ui is resolved like the template: ``KARNI_UI_SRC`` > the monorepo
8
+ checkout's ``packages/karni-ui`` > the npm release. A local source becomes
9
+ ``"@karni/ui": "file:<abs path>"`` in frontend/package.json (installed as a
10
+ packed copy via the scaffold's ``.npmrc``), and its ``dist/`` is built here
11
+ when missing — package.json ``exports`` point into dist/.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+
18
+ import typer
19
+
20
+ from karni_cli import agent_data, scaffold
21
+ from karni_cli.adapters import sync
22
+ from karni_cli.brand import print_mark
23
+ from karni_cli.scaffold import resolve_template, validate_name
24
+
25
+
26
+ def write_agent_adapters(project: Path) -> int:
27
+ """Generate the agent adapter files into a freshly rendered project."""
28
+ result = sync(project, agent_data.load(project))
29
+ return len(result.written)
30
+
31
+
32
+ def prepare_ui_source(source: Path) -> None:
33
+ """Make sure a local @karni/ui has the dist/ a ``file:`` install ships."""
34
+ rebuild = f"(cd {source} && npm install && npm run build)"
35
+ if not scaffold.ui_dist_missing(source):
36
+ if scaffold.ui_dist_stale(source):
37
+ typer.echo(
38
+ f"warning: {source}/dist is older than its sources — rebuild it with "
39
+ f"{rebuild} before `npm install` in frontend/.",
40
+ err=True,
41
+ )
42
+ return
43
+ typer.echo(f"Building @karni/ui in {source} (dist/ is missing; file: installs ship dist/)...")
44
+ failure = scaffold.build_ui_dist(source)
45
+ if failure is not None:
46
+ typer.echo(
47
+ f"warning: could not build @karni/ui: {failure}\n"
48
+ f"Build it yourself before `npm install` in frontend/: {rebuild}",
49
+ err=True,
50
+ )
51
+
52
+
53
+ def _baseline_release() -> str:
54
+ """The newest release this karni-cli's changelog knows: what the scaffold is written for."""
55
+ from karni_cli.upgrades import changelog
56
+
57
+ try:
58
+ return changelog.newest_release(changelog.load())
59
+ except Exception: # noqa: BLE001 - a missing changelog never blocks `karni new`
60
+ from karni_cli import __version__
61
+
62
+ return __version__
63
+
64
+
65
+ def new(
66
+ name: str = typer.Argument(..., help="Project name (slug is derived from it)"),
67
+ output_dir: Path = typer.Option(Path("."), "--output-dir", "-o"),
68
+ domain: str = typer.Option("example.com", "--domain", help="Production domain for Caddy"),
69
+ ) -> None:
70
+ from cookiecutter.main import cookiecutter # lazy: heavy import
71
+
72
+ slug = validate_name(name)
73
+ print_mark()
74
+ template = resolve_template()
75
+ ui_source = scaffold.resolve_ui_source()
76
+ if ui_source is not None:
77
+ prepare_ui_source(ui_source)
78
+ cookiecutter(
79
+ str(template),
80
+ no_input=True,
81
+ extra_context={
82
+ "project_name": name,
83
+ "domain": domain,
84
+ "karni_ui": (ui_spec := scaffold.ui_spec(ui_source, output_dir / slug)),
85
+ "karni_ui_mount": scaffold.ui_mount_target(ui_spec),
86
+ "karni_baseline": _baseline_release(),
87
+ },
88
+ output_dir=str(output_dir),
89
+ )
90
+ adapters = write_agent_adapters(output_dir / slug)
91
+ typer.echo(f"Created {output_dir / slug} ({adapters} agent adapter files)")
92
+ if ui_source is not None:
93
+ typer.echo(f"@karni/ui: {ui_spec} (local checkout)")
94
+ else:
95
+ typer.echo(
96
+ f"@karni/ui: {scaffold.UI_REGISTRY_SPEC} from npm — until it is published, "
97
+ "set KARNI_UI_SRC=/path/to/Karni/packages/karni-ui and re-run karni new."
98
+ )
99
+ typer.echo("Next steps:")
100
+ typer.echo(f" cd {slug}")
101
+ typer.echo(" cp .env.example .env")
102
+ typer.echo(" docker compose up -d")
103
+ typer.echo(
104
+ " (cd frontend && npm install) # editor, lint, type-check and `karni check` on the host"
105
+ )
106
+ typer.echo(" karni check")
@@ -0,0 +1,20 @@
1
+ """``karni routes`` — every URL the project serves (``manage.py karni_routes``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from karni_cli import project as project_mod
8
+ from karni_cli.project import require_project
9
+
10
+
11
+ def routes(
12
+ as_json: bool = typer.Option(False, "--json", help="Print the routes as a JSON list."),
13
+ docker: bool | None = typer.Option(None, "--docker/--no-docker"),
14
+ service: str = typer.Option("django", "--service", envvar="KARNI_COMPOSE_SERVICE"),
15
+ ) -> None:
16
+ """List every URL: methods, name, view, model, access — wraps `manage.py karni_routes`."""
17
+ project = require_project()
18
+ args = ["karni_routes", *(["--json"] if as_json else [])]
19
+ proc = project_mod.run_manage(project, args, docker=docker, service=service, capture=False)
20
+ raise typer.Exit(proc.returncode)
@@ -0,0 +1,139 @@
1
+ """``karni rules`` — the conventions, file checks, and adapter sync.
2
+
3
+ karni rules conventions (code + one line each)
4
+ karni rules --explain K101 full statement, rationale, fix, enforcement
5
+ karni rules --procedure new-model the steps of a standard procedure
6
+ karni rules --check FILE... static violations with fixes (exit 1 if any)
7
+ karni rules --json the raw data (rules, procedures)
8
+ karni rules sync [--check] [--force]
9
+ regenerate AGENTS.md, CLAUDE.md, ... (CI: --check)
10
+
11
+ Works without Django and, except ``sync``, without a project; inside one,
12
+ ``karni.rules.py`` overrides and ``.karni/modules/`` guidance apply.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from pathlib import Path
19
+
20
+ import typer
21
+
22
+ from karni_cli import agent_data
23
+ from karni_cli.adapters import module_fragments, sync
24
+ from karni_cli.project import CliError, find_project, require_project
25
+ from karni_cli.rules_render import (
26
+ render_explain,
27
+ render_findings,
28
+ render_procedure,
29
+ render_prompts_doc,
30
+ render_rules,
31
+ )
32
+
33
+ rules_app = typer.Typer(
34
+ help="Karni's conventions for humans and AI agents (no Django needed).",
35
+ no_args_is_help=False,
36
+ add_completion=False,
37
+ )
38
+
39
+
40
+ def rules_text(project: Path | None) -> str:
41
+ data = agent_data.load(project)
42
+ return render_rules(data, module_fragments(project) if project else {})
43
+
44
+
45
+ def check_text(paths: list[Path], project: Path | None) -> tuple[str, int]:
46
+ """Rendered findings and their count for ``paths``."""
47
+ missing = [str(p) for p in paths if not p.is_file()]
48
+ if missing:
49
+ raise CliError(f"No such file: {', '.join(missing)}", code="C010")
50
+ findings = agent_data.check(paths, project)
51
+ if not findings:
52
+ return f"ok: no violations in {len(paths)} file(s)", 0
53
+ return render_findings(findings), len(findings)
54
+
55
+
56
+ @rules_app.callback(invoke_without_command=True)
57
+ def rules(
58
+ ctx: typer.Context,
59
+ check: list[Path] | None = typer.Option(
60
+ None, "--check", help="Statically check FILE (repeatable); exit 1 on violations."
61
+ ),
62
+ explain: str | None = typer.Option(None, "--explain", help="Show one rule in full."),
63
+ procedure: str | None = typer.Option(None, "--procedure", help="Show a procedure's steps."),
64
+ as_json: bool = typer.Option(False, "--json", help="Machine-readable output."),
65
+ prompts_doc: bool = typer.Option(False, "--prompts-doc", hidden=True),
66
+ ) -> None:
67
+ if ctx.invoked_subcommand is not None:
68
+ return
69
+ project = find_project()
70
+ if check:
71
+ if as_json:
72
+ typer.echo(json.dumps(agent_data.check(list(check), project), indent=2))
73
+ return
74
+ text, count = check_text(list(check), project)
75
+ typer.echo(text)
76
+ raise typer.Exit(1 if count else 0)
77
+
78
+ data = agent_data.load(project)
79
+ if explain:
80
+ rule = data.rule(explain)
81
+ if rule is None:
82
+ raise CliError(f"No rule {explain!r} — `karni rules` lists the codes.", code="C204")
83
+ typer.echo(json.dumps(rule, indent=2) if as_json else render_explain(rule))
84
+ return
85
+ if procedure:
86
+ found = data.procedure(procedure)
87
+ if found is None:
88
+ names = ", ".join(p["name"] for p in data.procedures)
89
+ raise CliError(f"No procedure {procedure!r} (available: {names}).", code="C204")
90
+ typer.echo(json.dumps(found, indent=2) if as_json else render_procedure(found))
91
+ return
92
+ if prompts_doc:
93
+ typer.echo(render_prompts_doc(data))
94
+ return
95
+ if as_json:
96
+ typer.echo(json.dumps(data.raw, indent=2))
97
+ return
98
+ typer.echo(render_rules(data, module_fragments(project) if project else {}))
99
+
100
+
101
+ @rules_app.command("sync")
102
+ def sync_command(
103
+ check: bool = typer.Option(
104
+ False, "--check", help="Only compare; exit 1 if any adapter is stale (for CI)."
105
+ ),
106
+ force: bool = typer.Option(
107
+ False, "--force", help="Overwrite hand-written files (backed up under .karni/backup/)."
108
+ ),
109
+ ) -> None:
110
+ """Regenerate the agent adapter files from Karni's rule registry."""
111
+ project = require_project()
112
+ data = agent_data.load(project)
113
+ result = sync(project, data, check=check, force=force)
114
+ if check:
115
+ if result.stale:
116
+ for rel in result.stale:
117
+ typer.echo(f"stale: {rel}", err=True)
118
+ typer.echo("Agent adapters are stale — run `karni rules sync`.", err=True)
119
+ raise typer.Exit(1)
120
+ typer.echo(f"Agent adapters up to date ({len(result.unchanged)} files).")
121
+ return
122
+ for rel in result.backed_up:
123
+ typer.echo(f"backed up: {rel}")
124
+ for rel in result.written:
125
+ typer.echo(f"wrote {rel}")
126
+ for rel in result.removed:
127
+ typer.echo(f"removed {rel}")
128
+ if result.blocked:
129
+ for rel in result.blocked:
130
+ typer.echo(f"skipped {rel}: not generated by karni (hand-written?)", err=True)
131
+ typer.echo(
132
+ "Move project-specific guidance into karni.rules.py (`karni upgrade` does this "
133
+ "for Karni's old scaffold files), then re-run; or `--force` to overwrite with a "
134
+ "backup.",
135
+ err=True,
136
+ )
137
+ raise typer.Exit(1)
138
+ if not result.written and not result.removed:
139
+ typer.echo(f"Agent adapters up to date ({len(result.unchanged)} files).")
@@ -0,0 +1,38 @@
1
+ """``karni test`` — the project's test suite (pytest) through the project interpreter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from karni_cli import project as project_mod
8
+ from karni_cli.project import CliError, require_project
9
+
10
+
11
+ def test(
12
+ args: list[str] | None = typer.Argument(
13
+ None, help="Passed to pytest: paths, -k EXPR, -x, --lf … (use -- before --help)."
14
+ ),
15
+ docker: bool | None = typer.Option(None, "--docker/--no-docker"),
16
+ service: str = typer.Option("django", "--service", envvar="KARNI_COMPOSE_SERVICE"),
17
+ ) -> None:
18
+ """Run the test suite — wraps `python -m pytest [ARGS]`."""
19
+ project = require_project()
20
+ probe = project_mod.run_python(
21
+ project, ["-c", "import pytest"], docker=docker, service=service, capture=True
22
+ )
23
+ if probe.returncode != 0 and "No module named" in (probe.stderr or ""):
24
+ raise CliError(
25
+ "pytest is not installed in the project interpreter, so `karni test` cannot run.\n"
26
+ "Install the project's dev group there: pip install pytest pytest-django "
27
+ "(or `uv sync`), then run karni test again.",
28
+ code="C011",
29
+ )
30
+ proc = project_mod.run_python(
31
+ project,
32
+ ["-m", "pytest", *(args or [])],
33
+ docker=docker,
34
+ service=service,
35
+ capture=False,
36
+ interactive=True,
37
+ )
38
+ raise typer.Exit(proc.returncode)
@@ -0,0 +1,40 @@
1
+ """``karni schedule list|run`` — the merged beat schedules (``manage.py karni_schedules``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from karni_cli import project as project_mod
8
+ from karni_cli.project import require_project
9
+
10
+ schedule_app = typer.Typer(
11
+ help="Celery beat schedules — wraps `manage.py karni_schedules`.",
12
+ no_args_is_help=True,
13
+ )
14
+
15
+
16
+ @schedule_app.command("list")
17
+ def list_schedules(
18
+ as_json: bool = typer.Option(False, "--json", help="Print the schedules as a JSON list."),
19
+ docker: bool | None = typer.Option(None, "--docker/--no-docker"),
20
+ service: str = typer.Option("django", "--service", envvar="KARNI_COMPOSE_SERVICE"),
21
+ ) -> None:
22
+ """List every schedule: name, task, timing, source — wraps `manage.py karni_schedules`."""
23
+ project = require_project()
24
+ args = ["karni_schedules", *(["--json"] if as_json else [])]
25
+ proc = project_mod.run_manage(project, args, docker=docker, service=service, capture=False)
26
+ raise typer.Exit(proc.returncode)
27
+
28
+
29
+ @schedule_app.command("run")
30
+ def run_schedule(
31
+ name: str = typer.Argument(..., help="The schedule name (see `karni schedule list`)."),
32
+ docker: bool | None = typer.Option(None, "--docker/--no-docker"),
33
+ service: str = typer.Option("django", "--service", envvar="KARNI_COMPOSE_SERVICE"),
34
+ ) -> None:
35
+ """Run one schedule's task now, synchronously — wraps `manage.py karni_schedules --run NAME`."""
36
+ project = require_project()
37
+ proc = project_mod.run_manage(
38
+ project, ["karni_schedules", "--run", name], docker=docker, service=service, capture=False
39
+ )
40
+ raise typer.Exit(proc.returncode)