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,144 @@
1
+ """``karni add <module>`` — wire an optional Karni module into the project.
2
+
3
+ Wiring: dependency in ``pyproject.toml`` + app in the ``# karni:apps``
4
+ marker block + the module's ``AGENTS.fragment.md`` recorded as
5
+ ``.karni/modules/<module>.md`` (project-owned, committed), from which
6
+ ``karni rules`` and the generated adapters (``karni rules sync``: AGENTS.md
7
+ between ``<!-- karni:module:<module> -->`` markers) take the module's
8
+ guidance. The fragment is read **through the project's interpreter** — the
9
+ CLI never imports project code itself — so it is only recorded once the
10
+ module is actually installed; until then the command says to run again
11
+ after installing.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import subprocess
17
+ from pathlib import Path
18
+
19
+ import tomlkit
20
+ import typer
21
+ from packaging.requirements import InvalidRequirement, Requirement
22
+ from packaging.utils import canonicalize_name
23
+
24
+ from karni_cli import agent_data
25
+ from karni_cli.adapters import MODULES_DIR, sync
26
+ from karni_cli.project import (
27
+ CliError,
28
+ compose_service_running,
29
+ require_project,
30
+ venv_python,
31
+ )
32
+ from karni_cli.settings_edit import add_app
33
+
34
+
35
+ def _add_dependency(project_root: str, distribution: str) -> bool:
36
+ """Add the distribution to [project].dependencies. Returns False when present."""
37
+ pyproject = Path(project_root) / "pyproject.toml"
38
+ if not pyproject.is_file():
39
+ raise CliError("No pyproject.toml in the project root.", code="C207")
40
+ doc = tomlkit.parse(pyproject.read_text())
41
+ try:
42
+ deps = doc["project"]["dependencies"]
43
+ except (KeyError, TypeError):
44
+ raise CliError(
45
+ "pyproject.toml has no [project].dependencies table — "
46
+ "karni add only manages that table; add the dependency manually.",
47
+ code="C207",
48
+ ) from None
49
+ target = canonicalize_name(distribution)
50
+ for dep in deps:
51
+ try:
52
+ if canonicalize_name(Requirement(str(dep)).name) == target:
53
+ return False
54
+ except InvalidRequirement:
55
+ continue
56
+ deps.append(distribution)
57
+ pyproject.write_text(tomlkit.dumps(doc))
58
+ return True
59
+
60
+
61
+ def _read_agents_fragment(project: Path, app_name: str) -> str | None:
62
+ """Read the installed module's ``AGENTS.fragment.md`` via the project interpreter.
63
+
64
+ Resolution mirrors the CLI's execution order: the project ``.venv``
65
+ first, then a running compose ``django`` service. Returns ``None`` when
66
+ no interpreter is reachable or the subprocess fails (module not
67
+ installed yet, no fragment shipped) — the caller falls back to the
68
+ "run again after installing" message.
69
+ """
70
+ code = (
71
+ "import importlib.resources as resources; "
72
+ f"print(resources.files({app_name!r}).joinpath('AGENTS.fragment.md')"
73
+ ".read_text(encoding='utf-8'), end='')"
74
+ )
75
+ python = venv_python(project)
76
+ if python is not None:
77
+ command = [str(python), "-c", code]
78
+ elif compose_service_running(project, "django"):
79
+ command = ["docker", "compose", "exec", "-T", "django", "python", "-c", code]
80
+ else:
81
+ return None
82
+ try:
83
+ proc = subprocess.run(command, cwd=project, text=True, capture_output=True, check=False)
84
+ except OSError:
85
+ return None
86
+ if proc.returncode != 0 or not proc.stdout.strip():
87
+ return None
88
+ return proc.stdout
89
+
90
+
91
+ def record_module_fragment(project: Path, module: str, fragment: str) -> bool:
92
+ """Store the module's guidance under ``.karni/modules/``. Returns True when changed."""
93
+ target = project / MODULES_DIR / f"{module}.md"
94
+ content = fragment.strip() + "\n"
95
+ if target.is_file() and target.read_text(encoding="utf-8") == content:
96
+ return False
97
+ target.parent.mkdir(parents=True, exist_ok=True)
98
+ target.write_text(content, encoding="utf-8")
99
+ return True
100
+
101
+
102
+ def _refresh_adapters(project: Path) -> None:
103
+ try:
104
+ result = sync(project, agent_data.load(project))
105
+ except CliError as exc:
106
+ typer.echo(f"Agent adapters not refreshed ({exc}) — run `karni rules sync`.")
107
+ return
108
+ for rel in result.written:
109
+ typer.echo(f"{rel}: refreshed")
110
+ for rel in result.blocked:
111
+ typer.echo(
112
+ f"{rel}: not generated by karni, left untouched — run `karni upgrade` or "
113
+ "`karni rules sync --force`."
114
+ )
115
+
116
+
117
+ def add(module: str = typer.Argument(..., help='Module short name, e.g. "payments"')) -> None:
118
+ project = require_project()
119
+ app_name = f"karni_{module.replace('-', '_')}"
120
+ distribution = f"karni-{module.replace('_', '-')}"
121
+
122
+ dep_added = _add_dependency(str(project), distribution)
123
+ app_added = add_app(project / "config" / "settings.py", app_name)
124
+
125
+ typer.echo(f"{distribution}: {'added to' if dep_added else 'already in'} pyproject.toml")
126
+ typer.echo(f"{app_name}: {'wired into' if app_added else 'already in'} INSTALLED_APPS")
127
+
128
+ fragment = _read_agents_fragment(project, app_name)
129
+ if fragment is None:
130
+ typer.echo(
131
+ f"{app_name} is not importable yet — run `karni add {module}` again "
132
+ "after installing to record its agent guidance."
133
+ )
134
+ else:
135
+ if record_module_fragment(project, module, fragment):
136
+ typer.echo(f"{(MODULES_DIR / f'{module}.md').as_posix()}: recorded agent guidance")
137
+ _refresh_adapters(project)
138
+
139
+ typer.echo("If the module ships URLs, add the include to config/urls.py:")
140
+ typer.echo(f' path("{module}/", include("{app_name}.urls"))')
141
+ typer.echo("Install it, then migrate:")
142
+ typer.echo(" docker compose build # container workflow")
143
+ typer.echo(" uv pip install -r pyproject.toml # host venv workflow")
144
+ typer.echo(" make migrate")
@@ -0,0 +1,36 @@
1
+ """``karni beat`` — Celery beat (the scheduler) through the project interpreter.
2
+
3
+ Beat merges every ``<app>/schedules.py`` declaration into its schedule at
4
+ startup (``karni.schedule``); list them with ``karni schedule list``. With
5
+ the docker compose stack up, a ``beat`` service already runs — run only one.
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.commands.worker import celery_args
14
+ from karni_cli.project import require_project
15
+
16
+
17
+ def beat(
18
+ args: list[str] | None = typer.Argument(None, help="Passed to celery beat."),
19
+ celery_app: str = typer.Option(
20
+ "config", "--app", "-A", envvar="KARNI_CELERY_APP", help="Celery app module."
21
+ ),
22
+ loglevel: str = typer.Option("info", "--loglevel", "-l"),
23
+ docker: bool | None = typer.Option(None, "--docker/--no-docker"),
24
+ service: str = typer.Option("django", "--service", envvar="KARNI_COMPOSE_SERVICE"),
25
+ ) -> None:
26
+ """Run Celery beat — wraps `python -m celery -A config beat -l info [ARGS]`."""
27
+ project = require_project()
28
+ proc = project_mod.run_python(
29
+ project,
30
+ celery_args(celery_app, "beat", loglevel, args),
31
+ docker=docker,
32
+ service=service,
33
+ capture=False,
34
+ interactive=True,
35
+ )
36
+ raise typer.Exit(proc.returncode)
@@ -0,0 +1,101 @@
1
+ """``karni check`` — backend checks plus frontend duplicate detection.
2
+
3
+ Runs ``manage.py karni_check`` (system checks + ``karni.checks`` hooks,
4
+ including the architecture and duplication rules) and, when ``frontend/``
5
+ exists, jscpd via the project's own pinned binary driven solely by
6
+ ``frontend/.jscpd.json``. Both steps always run; the exit code aggregates
7
+ both and each step's output is shown.
8
+
9
+ Outside CI, a clean run may end with one tip on stderr (:mod:`karni_cli.tips`,
10
+ at most once per project); ``-q`` suppresses it.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ import subprocess
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ import typer
21
+
22
+ from karni_cli import project as project_mod
23
+ from karni_cli import tips
24
+ from karni_cli.project import require_project
25
+
26
+ _BARE_IGNORE = re.compile(r"jscpd:ignore-start\s*$")
27
+
28
+
29
+ def _frontend_duplication(project: Path) -> int:
30
+ frontend = project / "frontend"
31
+ if not frontend.is_dir():
32
+ return 0
33
+ jscpd = frontend / "node_modules" / ".bin" / "jscpd"
34
+ if not jscpd.exists():
35
+ typer.echo(
36
+ "karni check: jscpd not installed — run `npm install` in frontend/ "
37
+ "(or pass --no-frontend to skip frontend duplication checks).",
38
+ err=True,
39
+ )
40
+ return 1
41
+ # Reason-required opt-outs: a bare jscpd:ignore-start (no reason after it)
42
+ # is itself a failure; ignore-end legitimately carries nothing.
43
+ bare = [
44
+ f"{path.relative_to(project)}:{lineno}"
45
+ for path in (frontend / "src").rglob("*.ts*")
46
+ for lineno, line in enumerate(path.read_text().splitlines(), start=1)
47
+ if _BARE_IGNORE.search(line)
48
+ ]
49
+ if bare:
50
+ for location in bare:
51
+ typer.echo(f"[duplicate] {location}: jscpd:ignore-start requires a reason", err=True)
52
+ return 1
53
+ proc = subprocess.run(
54
+ [str(jscpd), "--config", ".jscpd.json"],
55
+ cwd=frontend,
56
+ text=True,
57
+ check=False,
58
+ )
59
+ return proc.returncode
60
+
61
+
62
+ def _frontend_lint(project: Path) -> int:
63
+ """``eslint .`` in frontend/ with the project's own binary (the @karni/ui rules live there)."""
64
+ frontend = project / "frontend"
65
+ if not frontend.is_dir():
66
+ return 0
67
+ eslint = frontend / "node_modules" / ".bin" / "eslint"
68
+ if not eslint.exists():
69
+ typer.echo(
70
+ "karni check: eslint not installed — run `npm install` in frontend/ "
71
+ "(or pass --no-frontend to skip the frontend checks).",
72
+ err=True,
73
+ )
74
+ return 1
75
+ proc = subprocess.run(
76
+ [str(eslint), ".", "--max-warnings", "0"], cwd=frontend, text=True, check=False
77
+ )
78
+ return proc.returncode
79
+
80
+
81
+ def check(
82
+ docker: bool | None = typer.Option(None, "--docker/--no-docker"),
83
+ service: str = typer.Option("django", "--service", envvar="KARNI_COMPOSE_SERVICE"),
84
+ frontend: bool = typer.Option(True, "--frontend/--no-frontend"),
85
+ quiet: bool = typer.Option(False, "--quiet", "-q", help="Do not print the optional tip."),
86
+ ) -> None:
87
+ project = require_project()
88
+ backend = project_mod.run_manage(
89
+ project, ["karni_check"], docker=docker, service=service, capture=False
90
+ ).returncode
91
+ dup = _frontend_duplication(project) if frontend else 0
92
+ lint = _frontend_lint(project) if frontend else 0
93
+ if backend or dup or lint:
94
+ sys.stderr.write("karni check: FAILED\n")
95
+ else:
96
+ # Only after a clean run (failures deserve the attention), never in CI,
97
+ # and on stderr so stdout stays exactly the checks' output.
98
+ tip = tips.history_tip(project, quiet=quiet)
99
+ if tip:
100
+ sys.stderr.write(f"\n{tip}\n")
101
+ raise typer.Exit(1 if (backend or dup) else 0)
@@ -0,0 +1,81 @@
1
+ """``karni config`` — read and write Karni CLI settings.
2
+
3
+ karni config list every setting, its value and where it comes from
4
+ karni config get suggest_tools the effective value (project > global > default)
5
+ karni config set suggest_tools false write it to .karni/config.toml (committed)
6
+ karni config set --global KEY VALUE write it to ~/.config/karni/config.toml
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ import typer
14
+
15
+ from karni_cli import config as config_mod
16
+ from karni_cli.project import CliError, find_project
17
+
18
+ config_app = typer.Typer(
19
+ help=(
20
+ "Read and write Karni settings: project .karni/config.toml (committed) or "
21
+ "--global ~/.config/karni/config.toml; the project wins."
22
+ ),
23
+ no_args_is_help=True,
24
+ add_completion=False,
25
+ )
26
+
27
+ _GLOBAL_HELP = "Use the global file ($XDG_CONFIG_HOME/karni/config.toml) only."
28
+
29
+
30
+ def _source(value: config_mod.Value, project: Path | None) -> str:
31
+ if value.path is None:
32
+ return value.source
33
+ shown: Path = value.path
34
+ if project is not None:
35
+ try:
36
+ shown = value.path.relative_to(project)
37
+ except ValueError:
38
+ pass
39
+ return f"{value.source}: {shown}"
40
+
41
+
42
+ @config_app.command("get")
43
+ def get_command(
44
+ key: str = typer.Argument(..., help="Setting name (see `karni config list`)."),
45
+ global_: bool = typer.Option(False, "--global", help=_GLOBAL_HELP),
46
+ ) -> None:
47
+ """Print the effective value of KEY."""
48
+ value = config_mod.resolve(key, find_project(), global_only=global_)
49
+ typer.echo(config_mod.render(value.value))
50
+
51
+
52
+ @config_app.command("set")
53
+ def set_command(
54
+ key: str = typer.Argument(..., help="Setting name (see `karni config list`)."),
55
+ value: str = typer.Argument(..., help="New value (true/false)."),
56
+ global_: bool = typer.Option(False, "--global", help=_GLOBAL_HELP),
57
+ ) -> None:
58
+ """Set KEY to VALUE in the project file (or --global)."""
59
+ parsed = config_mod.parse(key, value)
60
+ if global_:
61
+ path = config_mod.global_path()
62
+ else:
63
+ project = find_project()
64
+ if project is None:
65
+ raise CliError(
66
+ "Not inside a Karni project (no manage.py here or above) — "
67
+ "use --global to set it for every project.",
68
+ code="C001",
69
+ )
70
+ path = config_mod.project_path(project)
71
+ config_mod.write(key, parsed, path)
72
+ typer.echo(f"{key} = {config_mod.render(parsed)} ({path})")
73
+
74
+
75
+ @config_app.command("list")
76
+ def list_command() -> None:
77
+ """Every setting with its effective value and source."""
78
+ project = find_project()
79
+ for name in sorted(config_mod.KEYS):
80
+ value = config_mod.resolve(name, project)
81
+ typer.echo(f"{name} = {config_mod.render(value.value)} # {_source(value, project)}")
@@ -0,0 +1,171 @@
1
+ """``karni context`` — the token-efficiency mechanism.
2
+
3
+ Backend inventory comes from ``manage.py karni_context``; COMPONENTS/HOOKS
4
+ come from @karni/ui's build-generated dist/manifest.json. Absent manifest →
5
+ those lines are omitted entirely (never stale, never guessed). Two lines are
6
+ read statically from the project tree (no Django): APPS — the local apps in
7
+ the ``# karni:apps`` block that live in this project — and PAGES — the files
8
+ under ``frontend/src/pages/``. Together with ``karni rules`` this is enough
9
+ for an agent to start work without reading files.
10
+
11
+ ``--with-history [PATH]`` appends a ``HISTORY:`` block from any history
12
+ provider that detects a store (``karni.agent.history``), capped at ~300
13
+ tokens; with no store the output is byte-identical to plain ``karni context``.
14
+ stdout stays machine-clean in every mode: no tips, no warnings.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import re
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ import typer
25
+
26
+ from karni_cli import history
27
+ from karni_cli import project as project_mod
28
+ from karni_cli.project import CliError, require_project
29
+ from karni_cli.settings_edit import MARKER
30
+
31
+ #: Mirrors karni.agent.history.HISTORY_TOKEN_CAP (that module enforces it); a
32
+ #: constant here keeps `karni context --help` free of provider discovery.
33
+ HISTORY_TOKEN_CAP = 300
34
+
35
+ _PAGE_SUFFIXES = (".tsx", ".ts", ".jsx", ".js")
36
+ _QUOTED = re.compile(r"""["']([^"']+)["']""")
37
+
38
+
39
+ def find_manifest(project: Path, explicit: Path | None) -> Path | None:
40
+ if explicit is not None:
41
+ return explicit if explicit.is_file() else None
42
+ for base in (project / "frontend", project):
43
+ candidate = base / "node_modules" / "@karni" / "ui" / "dist" / "manifest.json"
44
+ if candidate.is_file():
45
+ return candidate
46
+ return None
47
+
48
+
49
+ def manifest_lines(manifest_path: Path) -> list[str]:
50
+ try:
51
+ data = json.loads(manifest_path.read_text())
52
+ except (OSError, json.JSONDecodeError):
53
+ return []
54
+ lines: list[str] = []
55
+ if data.get("components"):
56
+ lines.append("COMPONENTS: " + " ".join(sorted(data["components"])))
57
+ if data.get("hooks"):
58
+ lines.append("HOOKS: " + " ".join(sorted(data["hooks"])))
59
+ return lines
60
+
61
+
62
+ def apps_line(project: Path) -> str | None:
63
+ """Local apps wired in the ``# karni:apps`` block, as ``label(path/)``."""
64
+ settings = project / "config" / "settings.py"
65
+ if not settings.is_file():
66
+ return None
67
+ lines = settings.read_text().splitlines()
68
+ end = next((i for i, line in enumerate(lines) if MARKER in line), None)
69
+ if end is None:
70
+ return None
71
+ start = next(
72
+ (i for i in range(end, -1, -1) if "INSTALLED_APPS" in lines[i] and "[" in lines[i]), end
73
+ )
74
+ names: list[str] = []
75
+ for line in lines[start : end + 1]:
76
+ code = line.split("#", 1)[0]
77
+ names += _QUOTED.findall(code)
78
+ found = []
79
+ for name in names:
80
+ module = name.split(".apps.", 1)[0]
81
+ path = Path(*module.split("."))
82
+ if (project / path).is_dir():
83
+ found.append(f"{module.rsplit('.', 1)[-1]}({path.as_posix()}/)")
84
+ return "APPS: " + " ".join(found) if found else None
85
+
86
+
87
+ def pages_line(project: Path) -> str | None:
88
+ pages = project / "frontend" / "src" / "pages"
89
+ if not pages.is_dir():
90
+ return None
91
+ found = sorted(
92
+ path.relative_to(pages).with_suffix("").as_posix()
93
+ for path in pages.rglob("*")
94
+ if path.suffix in _PAGE_SUFFIXES and not path.name.endswith((".test.tsx", ".test.ts"))
95
+ )
96
+ return "PAGES: " + " ".join(found) if found else None
97
+
98
+
99
+ def assemble(backend_output: str, ui_lines: list[str]) -> str:
100
+ """Merge UI lines into the backend sections, keeping the documented order
101
+ (MODELS, VIEWSETS, COMPONENTS, HOOKS, MODULES, BASES)."""
102
+ sections = [line for line in backend_output.split("\n") if line.strip()]
103
+ insert_at = next(
104
+ (i for i, line in enumerate(sections) if line.startswith(("MODULES:", "BASES:"))),
105
+ len(sections),
106
+ )
107
+ sections[insert_at:insert_at] = ui_lines
108
+ return "\n".join(sections)
109
+
110
+
111
+ def build_context_text(
112
+ project: Path,
113
+ *,
114
+ docker: bool | None = None,
115
+ service: str = "django",
116
+ manifest: Path | None = None,
117
+ ) -> str:
118
+ """The full inventory: APPS, MODELS, VIEWSETS, COMPONENTS, HOOKS, PAGES, MODULES, BASES.
119
+
120
+ Raises :class:`CliError` when the backend inventory command fails.
121
+ """
122
+ proc = project_mod.run_manage(project, ["karni_context"], docker=docker, service=service)
123
+ if proc.stderr:
124
+ sys.stderr.write(proc.stderr)
125
+ if proc.returncode != 0:
126
+ raise CliError(f"manage.py karni_context failed (exit {proc.returncode}).", code="C009")
127
+ found = find_manifest(project, manifest)
128
+ ui_lines = manifest_lines(found) if found else []
129
+ pages = pages_line(project)
130
+ if pages:
131
+ ui_lines.append(pages)
132
+ text = assemble(proc.stdout, ui_lines)
133
+ apps = apps_line(project)
134
+ return f"{apps}\n{text}" if apps else text
135
+
136
+
137
+ _WITH_HISTORY_HELP = (
138
+ "Append what a history provider (e.g. Gigai Chronicle) recorded about PATH under a "
139
+ "HISTORY: heading — without PATH, the project's recorded decisions, from providers "
140
+ f"that offer them. Capped at ~{HISTORY_TOKEN_CAP} tokens (~{4 * HISTORY_TOKEN_CAP} "
141
+ "characters), cut at line boundaries with a last line naming the command for the full "
142
+ "brief. With no history store detected, prints the plain inventory and nothing else."
143
+ )
144
+
145
+
146
+ def context(
147
+ path: str | None = typer.Argument(
148
+ None,
149
+ metavar="PATH",
150
+ help="File to brief, relative to the cwd (needs --with-history).",
151
+ show_default=False,
152
+ ),
153
+ with_history: bool = typer.Option(False, "--with-history", help=_WITH_HISTORY_HELP),
154
+ docker: bool | None = typer.Option(None, "--docker/--no-docker"),
155
+ service: str = typer.Option("django", "--service", envvar="KARNI_COMPOSE_SERVICE"),
156
+ manifest: Path | None = typer.Option(None, "--manifest", help="Explicit manifest.json path"),
157
+ ) -> None:
158
+ if path is not None and not with_history:
159
+ raise typer.BadParameter("PATH is only used with --with-history.", param_hint="PATH")
160
+ project = require_project()
161
+ try:
162
+ target = history.target(project, path) if path is not None else None
163
+ text = build_context_text(project, docker=docker, service=service, manifest=manifest)
164
+ except CliError as exc:
165
+ sys.stderr.write(f"{exc}\n")
166
+ raise typer.Exit(1) from None
167
+ if with_history:
168
+ block = history.block(project, target)
169
+ if block:
170
+ text = f"{text}\n{block}"
171
+ typer.echo(text)
@@ -0,0 +1,28 @@
1
+ """``karni createsuperuser`` — create an admin user (prompts unless --noinput)."""
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 createsuperuser(
12
+ args: list[str] | None = typer.Argument(
13
+ None, help="Passed to manage.py createsuperuser: --email E, --noinput …"
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
+ """Create an admin user — wraps `manage.py createsuperuser [ARGS]`."""
19
+ project = require_project()
20
+ proc = project_mod.run_manage(
21
+ project,
22
+ ["createsuperuser", *(args or [])],
23
+ docker=docker,
24
+ service=service,
25
+ capture=False,
26
+ interactive=True,
27
+ )
28
+ raise typer.Exit(proc.returncode)