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,31 @@
1
+ [project]
2
+ name = "{{ cookiecutter.project_slug }}"
3
+ version = "0.1.0"
4
+ description = "{{ cookiecutter.project_name }} — a Karni project."
5
+ requires-python = ">=3.12"
6
+ dependencies = [
7
+ "karni",
8
+ "gunicorn",
9
+ "uvicorn-worker",
10
+ "psycopg[binary]",
11
+ ]
12
+
13
+ [dependency-groups]
14
+ # factory-boy and Faker come with karni (karni.testing factories, `karni seed`).
15
+ dev = [
16
+ "pytest",
17
+ "pytest-django",
18
+ ]
19
+
20
+ [tool.pytest.ini_options]
21
+ DJANGO_SETTINGS_MODULE = "config.settings"
22
+ # tests/ plus every app's own tests/ (`karni make test <Model>` writes there).
23
+ # modules/ is left out: each module runs its own suite with its own settings.
24
+ testpaths = ["tests", "*/tests"]
25
+ # Karni's architecture rules run as a SEPARATE invocation (never via addopts):
26
+ # pytest --pyargs karni.testing.architecture
27
+
28
+ [tool.karni]
29
+ # The Karni release this project was generated for. `karni upgrade` starts
30
+ # from here (or from the installed karni, whichever is newer).
31
+ baseline = "{{ cookiecutter.karni_baseline }}"
@@ -0,0 +1,17 @@
1
+ """The API contract: the OpenAPI schema must match tests/openapi.snapshot.json.
2
+
3
+ The first local run writes the snapshot — commit it. From then on any change
4
+ to the API (a removed field, a changed type, a new endpoint) fails here with
5
+ the changes listed. Accept an intended change with
6
+ `KARNI_UPDATE_SNAPSHOTS=1 karni test` and commit the updated file. CI never
7
+ writes a snapshot.
8
+ """
9
+
10
+ import pytest
11
+
12
+ from karni.testing import assert_schema_snapshot
13
+
14
+
15
+ @pytest.mark.django_db
16
+ def test_the_api_contract_is_unchanged():
17
+ assert_schema_snapshot()
@@ -0,0 +1,11 @@
1
+ """Project test suite. Karni's architecture rules run separately in CI:
2
+
3
+ pytest --pyargs karni.testing.architecture
4
+ """
5
+
6
+ import pytest
7
+
8
+
9
+ @pytest.mark.django_db
10
+ def test_health_liveness(client):
11
+ assert client.get("/health/").status_code == 200
karni_cli/adapters.py ADDED
@@ -0,0 +1,373 @@
1
+ """Thin per-tool agent adapters, generated from data (``karni rules sync``).
2
+
3
+ Inputs, and nothing else: the adapter registry (``agent_adapters.toml``), the
4
+ rule/procedure data (:mod:`karni_cli.agent_data`), the project's
5
+ ``karni.rules.py`` adapter overrides, the module guidance recorded by
6
+ ``karni add`` under ``.karni/modules/`` and the project name in
7
+ ``pyproject.toml``. Same inputs, same bytes — which is what makes
8
+ ``karni rules sync --check`` a reliable CI staleness gate.
9
+
10
+ Safety: sync only overwrites files that carry the generated marker. A
11
+ hand-written file in an adapter's place is reported, never clobbered, unless
12
+ ``--force`` (which backs it up under ``.karni/backup/`` first).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import re
19
+ import shutil
20
+ import tomllib
21
+ from dataclasses import dataclass, field
22
+ from importlib import resources
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ from karni_cli.agent_data import AgentData
27
+ from karni_cli.project import CliError
28
+
29
+ GENERATED_MARK = "Generated by `karni rules sync`"
30
+ MARKER_LINE = (
31
+ f"<!-- {GENERATED_MARK} from Karni's rule registry. Do not edit: "
32
+ "project rules go in karni.rules.py. -->"
33
+ )
34
+ MODULES_DIR = Path(".karni") / "modules"
35
+ BACKUP_DIR = Path(".karni") / "backup"
36
+ _TEMPLATES = ("instructions", "import", "command")
37
+ _YAML_PLAIN = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9 _./()-]*$")
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Adapter:
42
+ id: str
43
+ tool: str
44
+ path: str
45
+ template: str
46
+ modules: bool = False
47
+ frontmatter: dict[str, Any] = field(default_factory=dict)
48
+ imports: str | None = None
49
+ note: str = ""
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class CommandTarget:
54
+ id: str
55
+ tool: str
56
+ dir: str
57
+ suffix: str
58
+ arguments: str
59
+ frontmatter: tuple[str, ...] = ()
60
+ agent: str | None = None
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class Registry:
65
+ adapters: tuple[Adapter, ...]
66
+ commands: tuple[CommandTarget, ...]
67
+ max_lines: int
68
+
69
+ def ids(self) -> set[str]:
70
+ return {a.id for a in self.adapters} | {c.id for c in self.commands}
71
+
72
+
73
+ def load_registry(path: Path | None = None) -> Registry:
74
+ if path is None:
75
+ text = (resources.files("karni_cli") / "agent_adapters.toml").read_text(encoding="utf-8")
76
+ else:
77
+ text = path.read_text(encoding="utf-8")
78
+ data = tomllib.loads(text)
79
+ adapters = tuple(
80
+ Adapter(
81
+ id=entry["id"],
82
+ tool=entry.get("tool", entry["id"]),
83
+ path=entry["path"],
84
+ template=entry["template"],
85
+ modules=bool(entry.get("modules", False)),
86
+ frontmatter=dict(entry.get("frontmatter", {})),
87
+ imports=entry.get("import"),
88
+ note=entry.get("note", ""),
89
+ )
90
+ for entry in data.get("adapter", [])
91
+ )
92
+ commands = tuple(
93
+ CommandTarget(
94
+ id=entry["id"],
95
+ tool=entry.get("tool", entry["id"]),
96
+ dir=entry["dir"],
97
+ suffix=entry["suffix"],
98
+ arguments=entry["arguments"],
99
+ frontmatter=tuple(entry.get("frontmatter", [])),
100
+ agent=entry.get("agent"),
101
+ )
102
+ for entry in data.get("commands", [])
103
+ )
104
+ for adapter in adapters:
105
+ if adapter.template not in _TEMPLATES:
106
+ raise CliError(
107
+ f"agent adapter {adapter.id}: unknown template {adapter.template!r}", code="C205"
108
+ )
109
+ return Registry(adapters, commands, int(data.get("defaults", {}).get("max_lines", 100)))
110
+
111
+
112
+ def apply_overrides(registry: Registry, overrides: dict[str, Any]) -> Registry:
113
+ """Honour ``ADAPTERS = {"disable": [...], "paths": {...}}`` from karni.rules.py."""
114
+ disable = set(overrides.get("disable", []))
115
+ paths: dict[str, str] = dict(overrides.get("paths", {}))
116
+ unknown = (disable | set(paths)) - registry.ids()
117
+ if unknown:
118
+ raise CliError(
119
+ f"karni.rules.py ADAPTERS names unknown adapter id(s): {', '.join(sorted(unknown))} "
120
+ f"(known: {', '.join(sorted(registry.ids()))}).",
121
+ code="C205",
122
+ )
123
+ adapters = tuple(
124
+ Adapter(**{**a.__dict__, "path": paths.get(a.id, a.path)})
125
+ for a in registry.adapters
126
+ if a.id not in disable
127
+ )
128
+ commands = tuple(
129
+ CommandTarget(**{**c.__dict__, "dir": paths.get(c.id, c.dir)})
130
+ for c in registry.commands
131
+ if c.id not in disable
132
+ )
133
+ return Registry(adapters, commands, registry.max_lines)
134
+
135
+
136
+ # inputs from the project
137
+
138
+
139
+ def module_fragments(project: Path) -> dict[str, str]:
140
+ """Module guidance recorded by ``karni add``: ``{module: fragment}``, sorted."""
141
+ directory = project / MODULES_DIR
142
+ if not directory.is_dir():
143
+ return {}
144
+ return {
145
+ path.stem: path.read_text(encoding="utf-8").strip()
146
+ for path in sorted(directory.glob("*.md"))
147
+ }
148
+
149
+
150
+ def project_title(project: Path) -> str:
151
+ pyproject = project / "pyproject.toml"
152
+ if pyproject.is_file():
153
+ try:
154
+ name = tomllib.loads(pyproject.read_text(encoding="utf-8"))["project"]["name"]
155
+ if isinstance(name, str) and name.strip():
156
+ return name.strip()
157
+ except (tomllib.TOMLDecodeError, KeyError, TypeError):
158
+ pass
159
+ return project.resolve().name
160
+
161
+
162
+ # rendering
163
+
164
+
165
+ def _yaml_value(value: Any) -> str:
166
+ if isinstance(value, bool):
167
+ return "true" if value else "false"
168
+ if value is None or value == "":
169
+ return ""
170
+ text = str(value)
171
+ return text if _YAML_PLAIN.match(text) else json.dumps(text, ensure_ascii=False)
172
+
173
+
174
+ def _frontmatter(values: dict[str, Any]) -> list[str]:
175
+ if not values:
176
+ return []
177
+ lines = ["---"]
178
+ for key, value in values.items():
179
+ rendered = _yaml_value(value)
180
+ lines.append(f"{key}: {rendered}" if rendered else f"{key}:")
181
+ lines.append("---")
182
+ return lines
183
+
184
+
185
+ def _procedure_names(data: AgentData) -> str:
186
+ return ", ".join(p["name"] for p in data.procedures)
187
+
188
+
189
+ def instructions_body(title: str, data: AgentData, note: str = "") -> list[str]:
190
+ lines = [
191
+ MARKER_LINE,
192
+ f"# {title} — agent instructions",
193
+ "",
194
+ "This is a Karni project. Karni already implements everything generic; you write",
195
+ "only domain code.",
196
+ "",
197
+ "Before any work, run these two commands and follow what they return:",
198
+ "",
199
+ "1. `karni context` — live inventory: apps, models, viewsets, UI components, hooks,",
200
+ " pages and installed modules.",
201
+ "2. `karni rules` — the conventions, each with a stable code (K###; project P###).",
202
+ "",
203
+ "While you work:",
204
+ "",
205
+ "- `karni rules --check <file>` on every file you write; fix each violation it prints.",
206
+ f"- `karni rules --procedure <name>` for routine tasks: {_procedure_names(data)}.",
207
+ "- `karni check` before committing; every enforced rule fails the build.",
208
+ "",
209
+ "If the `karni` command is unavailable, say so and stop; do not guess the conventions.",
210
+ ]
211
+ if note:
212
+ lines += ["", note]
213
+ return lines
214
+
215
+
216
+ def _module_section(fragments: dict[str, str], inline: bool) -> list[str]:
217
+ if not fragments:
218
+ return []
219
+ lines = ["", "## Module guidance", ""]
220
+ if not inline:
221
+ lines += [f"- {name}: run `karni rules` (includes its guidance)" for name in fragments]
222
+ return lines
223
+ for name, fragment in fragments.items():
224
+ lines += [f"<!-- karni:module:{name} -->", fragment, f"<!-- /karni:module:{name} -->", ""]
225
+ return lines[:-1]
226
+
227
+
228
+ def _join(lines: list[str]) -> str:
229
+ return "\n".join(lines).rstrip("\n") + "\n"
230
+
231
+
232
+ def render_adapter(
233
+ adapter: Adapter,
234
+ registry: Registry,
235
+ data: AgentData,
236
+ title: str,
237
+ fragments: dict[str, str],
238
+ ) -> str:
239
+ if adapter.template == "import":
240
+ target = next((a for a in registry.adapters if a.id == adapter.imports), None)
241
+ if target is not None:
242
+ rel = Path(target.path)
243
+ return _join([MARKER_LINE, f"@{rel.as_posix()}"])
244
+ # The imported adapter is disabled: fall back to the full body.
245
+ head = _frontmatter(adapter.frontmatter)
246
+ body = head + instructions_body(title, data, adapter.note)
247
+ if not adapter.modules:
248
+ return _join(body)
249
+ full = _join(body + _module_section(fragments, inline=True))
250
+ if full.count("\n") <= registry.max_lines:
251
+ return full
252
+ return _join(body + _module_section(fragments, inline=False))
253
+
254
+
255
+ def render_command(target: CommandTarget, procedure: dict[str, Any]) -> str:
256
+ values: dict[str, Any] = {}
257
+ for key in target.frontmatter:
258
+ if key == "description":
259
+ values[key] = procedure["description"]
260
+ elif key == "argument-hint":
261
+ values[key] = procedure["argument_hint"]
262
+ elif key == "agent":
263
+ values[key] = target.agent
264
+ lines = _frontmatter(values)
265
+ lines.append(procedure["intro"].replace("{arguments}", target.arguments))
266
+ lines.append("")
267
+ lines += [f"{i}. {step}" for i, step in enumerate(procedure["steps"], start=1)]
268
+ if procedure.get("outro"):
269
+ lines += ["", procedure["outro"]]
270
+ if procedure.get("rules"):
271
+ codes = ", ".join(procedure["rules"])
272
+ lines += ["", f"Rules that apply: {codes} (`karni rules --explain <code>`)."]
273
+ lines += ["", MARKER_LINE]
274
+ return _join(lines)
275
+
276
+
277
+ def render_all(project: Path, data: AgentData, registry: Registry | None = None) -> dict[str, str]:
278
+ """Every generated file for ``project``: ``{posix relative path: content}``."""
279
+ registry = apply_overrides(registry or load_registry(), data.adapter_overrides)
280
+ title = project_title(project)
281
+ fragments = module_fragments(project)
282
+ files: dict[str, str] = {}
283
+ for adapter in registry.adapters:
284
+ files[Path(adapter.path).as_posix()] = render_adapter(
285
+ adapter, registry, data, title, fragments
286
+ )
287
+ for target in registry.commands:
288
+ for procedure in data.procedures:
289
+ command = Path(target.dir) / f"{procedure['name']}{target.suffix}"
290
+ files[command.as_posix()] = render_command(target, procedure)
291
+ for rel, content in files.items():
292
+ count = content.count("\n")
293
+ if count > registry.max_lines:
294
+ raise CliError(
295
+ f"{rel} would be {count} lines (cap {registry.max_lines}); shorten the "
296
+ "project rules or module guidance feeding it.",
297
+ code="C205",
298
+ )
299
+ return files
300
+
301
+
302
+ # sync
303
+
304
+
305
+ @dataclass
306
+ class SyncResult:
307
+ written: list[str] = field(default_factory=list)
308
+ unchanged: list[str] = field(default_factory=list)
309
+ removed: list[str] = field(default_factory=list)
310
+ blocked: list[str] = field(default_factory=list)
311
+ backed_up: list[str] = field(default_factory=list)
312
+
313
+ @property
314
+ def stale(self) -> list[str]:
315
+ return sorted({*self.written, *self.removed, *self.blocked})
316
+
317
+
318
+ def is_generated(path: Path) -> bool:
319
+ try:
320
+ return GENERATED_MARK in path.read_text(encoding="utf-8")
321
+ except (OSError, UnicodeDecodeError):
322
+ return False
323
+
324
+
325
+ def _candidates(project: Path, registry: Registry, overrides: dict[str, Any]) -> set[str]:
326
+ """Every place a generated file may have been written (for obsolete cleanup)."""
327
+ paths: dict[str, str] = dict(overrides.get("paths", {}))
328
+ found = {Path(a.path).as_posix() for a in registry.adapters}
329
+ found |= {Path(p).as_posix() for p in paths.values()}
330
+ dirs = {c.dir for c in registry.commands} | set(paths.values())
331
+ for directory in dirs:
332
+ base = project / directory
333
+ if base.is_dir():
334
+ found |= {p.relative_to(project).as_posix() for p in base.iterdir() if p.is_file()}
335
+ return found
336
+
337
+
338
+ def backup(project: Path, rel: str) -> str:
339
+ source = project / rel
340
+ target = project / BACKUP_DIR / rel
341
+ target.parent.mkdir(parents=True, exist_ok=True)
342
+ shutil.copy2(source, target)
343
+ return (BACKUP_DIR / rel).as_posix()
344
+
345
+
346
+ def sync(project: Path, data: AgentData, *, check: bool = False, force: bool = False) -> SyncResult:
347
+ """Write (or, with ``check``, only compare) every adapter and command file."""
348
+ registry = load_registry()
349
+ files = render_all(project, data, registry)
350
+ result = SyncResult()
351
+ for rel, content in sorted(files.items()):
352
+ target = project / rel
353
+ if target.is_file():
354
+ current = target.read_text(encoding="utf-8")
355
+ if current == content:
356
+ result.unchanged.append(rel)
357
+ continue
358
+ if GENERATED_MARK not in current and not force:
359
+ result.blocked.append(rel)
360
+ continue
361
+ if GENERATED_MARK not in current and not check:
362
+ result.backed_up.append(backup(project, rel))
363
+ result.written.append(rel)
364
+ if not check:
365
+ target.parent.mkdir(parents=True, exist_ok=True)
366
+ target.write_text(content, encoding="utf-8")
367
+ for rel in sorted(_candidates(project, registry, data.adapter_overrides) - set(files)):
368
+ path = project / rel
369
+ if path.is_file() and is_generated(path):
370
+ result.removed.append(rel)
371
+ if not check:
372
+ path.unlink()
373
+ return result
@@ -0,0 +1,160 @@
1
+ # Agent adapter registry for `karni rules sync`.
2
+ #
3
+ # One entry per tool convention. Renaming a file or changing a frontmatter key
4
+ # is an edit here, not a code change. Projects can disable entries or move
5
+ # paths from karni.rules.py: ADAPTERS = {"disable": [...], "paths": {...}}.
6
+ #
7
+ # Every adapter says the same thing: run `karni context` and `karni rules`
8
+ # first, then follow what they return. The content lives in karni.agent.rules
9
+ # (packages/karni/karni/agent/rules.py); these files are thin pointers.
10
+ #
11
+ # Templates (implemented in karni_cli/adapters.py):
12
+ # instructions - the shared ~15-line body (+ optional YAML frontmatter)
13
+ # import - an `@<file>` import of another adapter (Claude Code)
14
+ # command - one file per procedure (slash commands / prompt files)
15
+ #
16
+ # Each entry records where its convention was verified, and when. Re-verify
17
+ # before a release: these conventions change.
18
+
19
+ [defaults]
20
+ max_lines = 100
21
+
22
+ # --- instruction adapters -----------------------------------------------------
23
+
24
+ [[adapter]]
25
+ id = "agents-md"
26
+ tool = "AGENTS.md (canonical: Codex, Jules, Gemini CLI, Zed, Warp, Devin; also read by Cursor, Copilot, Windsurf, Cline, Junie)"
27
+ path = "AGENTS.md"
28
+ template = "instructions"
29
+ modules = true
30
+ # Source: https://agents.md/ (verified 2026-10-02). Plain Markdown at the
31
+ # repository root, no required fields or frontmatter; the nearest AGENTS.md in
32
+ # the directory tree wins.
33
+
34
+ [[adapter]]
35
+ id = "claude"
36
+ tool = "Claude Code"
37
+ path = "CLAUDE.md"
38
+ template = "import"
39
+ import = "agents-md"
40
+ # Source: https://code.claude.com/docs/en/memory (verified 2026-10-02). When a
41
+ # CLAUDE.md exists Claude Code reads it INSTEAD of AGENTS.md by default; the
42
+ # documented way to share one file is a CLAUDE.md containing `@AGENTS.md`
43
+ # (imports expand at launch; block-level HTML comments are stripped).
44
+
45
+ [[adapter]]
46
+ id = "cursor"
47
+ tool = "Cursor"
48
+ path = ".cursor/rules/karni.mdc"
49
+ template = "instructions"
50
+ frontmatter = { description = "Karni project conventions: run karni context and karni rules before any work", globs = "", alwaysApply = true }
51
+ # Source: https://cursor.com/docs/context/rules (verified 2026-10-02). Project
52
+ # rules are .mdc files in .cursor/rules with frontmatter keys description,
53
+ # globs, alwaysApply (alwaysApply: true = every session). The legacy
54
+ # .cursorrules file is no longer documented. Cursor also reads AGENTS.md.
55
+
56
+ [[adapter]]
57
+ id = "windsurf"
58
+ tool = "Windsurf (Cascade; now documented as Devin Desktop)"
59
+ path = ".windsurf/rules/karni.md"
60
+ template = "instructions"
61
+ frontmatter = { trigger = "always_on" }
62
+ # Source: https://docs.devin.ai/desktop/cascade/memories (docs.windsurf.com
63
+ # redirects there; verified 2026-10-02). Workspace rules are one Markdown file
64
+ # per rule in .devin/rules/ (preferred) or .windsurf/rules/, frontmatter
65
+ # `trigger: always_on|model_decision|glob|manual`, 12,000 chars per file. The
66
+ # single-file .windsurfrules is legacy ("also still read"). .windsurf/rules/
67
+ # is used because both Windsurf and Devin Desktop read it.
68
+
69
+ [[adapter]]
70
+ id = "copilot"
71
+ tool = "GitHub Copilot"
72
+ path = ".github/copilot-instructions.md"
73
+ template = "instructions"
74
+ # Source: https://docs.github.com/en/copilot/how-tos/configure-custom-instructions/add-repository-instructions
75
+ # (verified 2026-10-02). Repository-wide instructions: .github/copilot-instructions.md,
76
+ # natural-language Markdown, no frontmatter. Copilot also reads AGENTS.md.
77
+
78
+ [[adapter]]
79
+ id = "cline"
80
+ tool = "Cline"
81
+ path = ".clinerules/karni.md"
82
+ template = "instructions"
83
+ # Source: https://docs.cline.bot/features/cline-rules (verified 2026-10-02).
84
+ # Workspace rules are Markdown files inside the .clinerules/ (or .cline/rules/)
85
+ # DIRECTORY; optional `paths` frontmatter scopes a rule (none = always on).
86
+ # Cline also reads AGENTS.md.
87
+
88
+ [[adapter]]
89
+ id = "aider"
90
+ tool = "Aider"
91
+ path = "CONVENTIONS.md"
92
+ template = "instructions"
93
+ note = "Aider does not load this file by itself: run `aider --read CONVENTIONS.md`, or add `read: CONVENTIONS.md` to .aider.conf.yml."
94
+ # Source: https://aider.chat/docs/usage/conventions.html (verified 2026-10-02).
95
+ # Not auto-loaded: `aider --read CONVENTIONS.md`, or `read: CONVENTIONS.md` in
96
+ # .aider.conf.yml (the body says so; karni never writes your aider config).
97
+
98
+ [[adapter]]
99
+ id = "junie"
100
+ tool = "JetBrains Junie"
101
+ path = ".junie/AGENTS.md"
102
+ template = "instructions"
103
+ modules = true
104
+ # Source: https://junie.jetbrains.com/docs/guidelines-and-memory.html
105
+ # (verified 2026-10-02). Lookup order: .junie/AGENTS.md, then root AGENTS.md
106
+ # (+ .junie/rules/*.md), then the LEGACY .junie/guidelines.md. A file at
107
+ # .junie/guidelines.md would never be read while AGENTS.md exists, so the
108
+ # preferred .junie/AGENTS.md is generated; it shadows the root file for Junie,
109
+ # hence `modules = true` (same module guidance as AGENTS.md).
110
+
111
+ # --- command targets (one file per procedure) ---------------------------------
112
+ # `arguments` replaces {arguments} in the procedure's first line.
113
+
114
+ [[commands]]
115
+ id = "claude-commands"
116
+ tool = "Claude Code"
117
+ dir = ".claude/commands"
118
+ suffix = ".md"
119
+ arguments = "$ARGUMENTS"
120
+ frontmatter = ["description", "argument-hint"]
121
+ # Source: https://code.claude.com/docs/en/slash-commands (verified 2026-10-02).
122
+ # .claude/commands/<name>.md creates /<name> (merged into skills; still
123
+ # supported). Frontmatter description, argument-hint; $ARGUMENTS substitution.
124
+
125
+ [[commands]]
126
+ id = "cursor-commands"
127
+ tool = "Cursor"
128
+ dir = ".cursor/commands"
129
+ suffix = ".md"
130
+ arguments = "the request typed after the command"
131
+ frontmatter = []
132
+ # Source: Cursor commands docs (docs.cursor.com/en/agent/chat/commands now
133
+ # redirects to the docs index; corroborated by forum.cursor.com threads,
134
+ # verified 2026-10-02). .cursor/commands/<name>.md, plain Markdown, NO
135
+ # frontmatter, invoked as /<name>.
136
+
137
+ [[commands]]
138
+ id = "windsurf-workflows"
139
+ tool = "Windsurf"
140
+ dir = ".windsurf/workflows"
141
+ suffix = ".md"
142
+ arguments = "the request typed after the command"
143
+ frontmatter = ["description"]
144
+ # Source: https://docs.devin.ai/desktop/cascade/workflows
145
+ # (verified 2026-10-02). Markdown workflows in .devin/workflows/ (preferred) or
146
+ # .windsurf/workflows/ (legacy, still read), invoked as /<name>; title,
147
+ # description and steps; 12,000 chars per file.
148
+
149
+ [[commands]]
150
+ id = "copilot-prompts"
151
+ tool = "GitHub Copilot (VS Code prompt files)"
152
+ dir = ".github/prompts"
153
+ suffix = ".prompt.md"
154
+ arguments = "${input:request:what to add}"
155
+ frontmatter = ["description", "argument-hint", "agent"]
156
+ agent = "agent"
157
+ # Source: https://code.visualstudio.com/docs/copilot/customization/prompt-files
158
+ # (verified 2026-10-02). .github/prompts/<name>.prompt.md, frontmatter
159
+ # description, name, argument-hint, agent, model, tools; invoked as /<name>;
160
+ # ${input:var:placeholder} collects input.