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.
- karni_cli/__init__.py +3 -0
- karni_cli/_agent/__init__.py +82 -0
- karni_cli/_agent/__main__.py +44 -0
- karni_cli/_agent/check.py +658 -0
- karni_cli/_agent/history.py +328 -0
- karni_cli/_agent/history_chronicle.py +246 -0
- karni_cli/_agent/overrides.py +194 -0
- karni_cli/_agent/rules.py +673 -0
- karni_cli/_changelog/0.2.0.toml +304 -0
- karni_cli/_changelog/0.3.0.toml +25 -0
- karni_cli/_changelog/__init__.py +248 -0
- karni_cli/_errorcodes/__init__.py +1171 -0
- karni_cli/_errorcodes/__main__.py +34 -0
- karni_cli/_errorcodes/links.py +217 -0
- karni_cli/_templates/project/cookiecutter.json +8 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/.dockerignore +7 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/.env.example +85 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/.gitignore +10 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/Caddyfile +22 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/Dockerfile +53 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/Makefile +52 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/README.md +169 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/__init__.py +16 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/asgi.py +14 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/celery.py +17 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/settings.py +28 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/urls.py +31 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/wsgi.py +14 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/docker-compose.yml +126 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.dockerignore +2 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.jscpd.json +9 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.npmrc +7 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/Dockerfile.dev +17 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/eslint.config.js +32 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/index.html +14 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/package.json +43 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/postcss.config.js +6 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/public/favicon.svg +6 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/App.tsx +18 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/api/health.ts +21 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/index.css +11 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/layout.tsx +51 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/main.tsx +11 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/pages/home.tsx +28 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/routes.tsx +24 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/vite-env.d.ts +1 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/tailwind.config.ts +13 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/tsconfig.json +19 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/vite.config.ts +18 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/manage.py +23 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/pyproject.toml +31 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/__init__.py +0 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/test_api_schema.py +17 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/test_smoke.py +11 -0
- karni_cli/adapters.py +373 -0
- karni_cli/agent_adapters.toml +160 -0
- karni_cli/agent_data.py +260 -0
- karni_cli/brand.py +55 -0
- karni_cli/clidoc.py +254 -0
- karni_cli/commands/__init__.py +0 -0
- karni_cli/commands/add.py +144 -0
- karni_cli/commands/beat.py +36 -0
- karni_cli/commands/check.py +101 -0
- karni_cli/commands/config.py +81 -0
- karni_cli/commands/context.py +171 -0
- karni_cli/commands/createsuperuser.py +28 -0
- karni_cli/commands/doctor.py +136 -0
- karni_cli/commands/explain.py +60 -0
- karni_cli/commands/make.py +55 -0
- karni_cli/commands/manage_passthrough.py +28 -0
- karni_cli/commands/mcp.py +20 -0
- karni_cli/commands/migrate.py +23 -0
- karni_cli/commands/new.py +106 -0
- karni_cli/commands/routes.py +20 -0
- karni_cli/commands/rules.py +139 -0
- karni_cli/commands/run_tests.py +38 -0
- karni_cli/commands/schedule.py +40 -0
- karni_cli/commands/seed.py +34 -0
- karni_cli/commands/serve.py +50 -0
- karni_cli/commands/shell.py +28 -0
- karni_cli/commands/sync_roles.py +32 -0
- karni_cli/commands/types.py +72 -0
- karni_cli/commands/upgrade.py +231 -0
- karni_cli/commands/worker.py +63 -0
- karni_cli/config.py +142 -0
- karni_cli/environment.py +29 -0
- karni_cli/errorcodes.py +377 -0
- karni_cli/errordoc.py +227 -0
- karni_cli/generators/__init__.py +67 -0
- karni_cli/generators/apitest.py +206 -0
- karni_cli/generators/app.py +103 -0
- karni_cli/generators/command.py +98 -0
- karni_cli/generators/component.py +115 -0
- karni_cli/generators/core.py +319 -0
- karni_cli/generators/event.py +57 -0
- karni_cli/generators/factory.py +88 -0
- karni_cli/generators/fields.py +343 -0
- karni_cli/generators/introspect.py +263 -0
- karni_cli/generators/listener.py +168 -0
- karni_cli/generators/middleware.py +95 -0
- karni_cli/generators/migration.py +82 -0
- karni_cli/generators/model.py +235 -0
- karni_cli/generators/module.py +255 -0
- karni_cli/generators/naming.py +100 -0
- karni_cli/generators/page.py +373 -0
- karni_cli/generators/pysource.py +304 -0
- karni_cli/generators/schedule.py +174 -0
- karni_cli/generators/seeder.py +86 -0
- karni_cli/generators/serializer.py +165 -0
- karni_cli/generators/service.py +68 -0
- karni_cli/generators/task.py +84 -0
- karni_cli/generators/viewset.py +179 -0
- karni_cli/history.py +59 -0
- karni_cli/local_state.py +73 -0
- karni_cli/main.py +104 -0
- karni_cli/mcp_server.py +103 -0
- karni_cli/project.py +248 -0
- karni_cli/rules_render.py +86 -0
- karni_cli/scaffold.py +197 -0
- karni_cli/settings_edit.py +128 -0
- karni_cli/tips.py +53 -0
- karni_cli/typegen.py +101 -0
- karni_cli/upgrades/__init__.py +28 -0
- karni_cli/upgrades/changelog.py +123 -0
- karni_cli/upgrades/codemod.py +125 -0
- karni_cli/upgrades/engine.py +224 -0
- karni_cli/upgrades/legacy_agent_files.py +138 -0
- karni_cli/upgrades/python.py +670 -0
- karni_cli/upgrades/transforms/rename-export.cjs +96 -0
- karni_cli/upgrades/typescript.py +160 -0
- karni_cli/upgrades/v0_2/__init__.py +1 -0
- karni_cli/upgrades/v0_2/agent_guidance.py +269 -0
- karni_cli/upgrades/v1_0/__init__.py +5 -0
- karni_cli/upgrades/v1_0/all_objects_to_without.py +18 -0
- karni_cli/upgrades/v1_0/all_with_deleted_to_without.py +13 -0
- karni_cli/upgrades/v1_0/deleted_only_to_without_dead.py +13 -0
- karni_cli/upgrades/v1_0/object_permission_classes_to_access.py +16 -0
- karni_cli/upgrades/v1_0/related_lookups_to_optimize.py +15 -0
- karni_cli/upgrades/v1_0/scoped_related_lookups_to_optimize.py +15 -0
- karni_cli/upgrades/v1_0/soft_delete_manager_mixin_to_scoped.py +14 -0
- karni_cli-0.3.1.dist-info/METADATA +191 -0
- karni_cli-0.3.1.dist-info/RECORD +145 -0
- karni_cli-0.3.1.dist-info/WHEEL +4 -0
- karni_cli-0.3.1.dist-info/entry_points.txt +2 -0
- 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 }}"
|
|
File without changes
|
|
@@ -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.
|