commit 591b249c37afd7d9192e5509163322c10fc04550 equwal <13551856+equwal@users.noreply.github.com> 2026-10-03 22:43:18 -0700 Render one canonical AI-agent config into many agents' formats Targets are data: built-in definitions for Claude Code, Codex, Copilot CLI, Gemini CLI, Antigravity, Cursor and Windsurf, plus any target defined in aicr.toml. aicr owns only its marked instruction blocks, its MCP entries and the skills it wrote, and backs up each file it changes. Commands: render, diff, check, list-targets.
.gitignore | 11 + LICENSE | 21 ++ README.md | 60 ++++ examples/basic/aicr.toml | 29 ++ examples/basic/rules/style.md | 4 + examples/basic/rules/testing.md | 4 + examples/basic/skills/commit-message/SKILL.md | 5 + examples/machine.example.toml | 6 + pyproject.toml | 31 ++ src/ai_config_render/__init__.py | 397 ++++++++++++++++++++++++++ src/ai_config_render/__main__.py | 5 + src/ai_config_render/py.typed | 0 tests/test_render.py | 129 +++++++++ 13 files changed, 702 insertions(+)
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4095def --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.venv/ +__pycache__/ +*.egg-info/ +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ +.hypothesis/ +dist/ +build/ +.env +machine.toml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..160e1db --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 equwal + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..52eb592 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# ai-config-render (`aicr`) + +Write your AI-agent rules, skills, and MCP servers once. `aicr` renders them into the native files of Claude Code, Codex, Copilot CLI, Gemini CLI, Antigravity, Cursor, Windsurf, or any agent you describe in five lines of TOML. + +It never takes over a file. It owns only: + +- **Instruction files:** the blocks between `<!-- aicr:NAME -->` and `<!-- /aicr:NAME -->`. Your own text stays. +- **MCP configs:** the server entries it wrote. Your other servers and settings stay. +- **Skills and plain files:** the paths it wrote last run (tracked in a state file). + +It backs up every file before it changes it (`<file>.aicr-bak-<timestamp>`). Python 3.11+ standard library only. + +## Quickstart + +```bash +pipx install git+https://github.com/equwal/ai-config-render +aicr list-targets --source examples/basic # built-in targets; * = enabled +aicr diff --source examples/basic # dry run: unified diff of every change +aicr render --source examples/basic # write, with backups +aicr check --source examples/basic # exit 1 if any target drifted (CI, cron) +``` + +## Source layout + +Any folder works, git or not: + +``` +aicr.toml targets, rules, MCP servers, skills, files, vars +rules/<name>.md one block per rule; rules/<name>.<target>.md overrides for one target +skills/<name>/ copied whole into each target's skills folders +``` + +See [`examples/basic/aicr.toml`](examples/basic/aicr.toml). Enable a built-in target with `[targets.claude]`; override any field, or define a new target: + +```toml +[targets.myagent] +instructions = "${home}/.myagent/RULES.md" +skills = ["${home}/.myagent/skills"] +mcp = { format = "json", path = "${home}/.myagent/mcp.json", key = "servers" } # or format = "toml" +``` + +Any rule, server, skill, or file takes `targets = [...]` and `machines = [...]` filters. + +## Per-machine values + +`~/.config/aicr/machine.toml` (or `--machine FILE`) holds what differs per computer: `name`, `[vars]`, `skip_targets`, `state`, and `[mcp.<server>]` overrides. `${var}` works in every path and string. Built-in vars: `home`, `home_posix`, `os`, `machine`. See [`examples/machine.example.toml`](examples/machine.example.toml). + +## Why not ruler, rulesync, or chezmoi? + +- **ruler / rulesync** generate project-level agent files and mostly overwrite them. `aicr` targets user-level config, edits only its own blocks and entries, and backs up first, so it can share a file with your hand edits and with other tools. +- **chezmoi** manages whole dotfiles. A single `~/.claude.json` mixes your MCP servers with app state that changes every minute; `aicr` merges one key instead of owning the file. + +## Development + +```bash +pip install -e .[dev] +pytest && ruff check . && ruff format --check . && mypy +``` + +MIT license. diff --git a/examples/basic/aicr.toml b/examples/basic/aicr.toml new file mode 100644 index 0000000..54f1732 --- /dev/null +++ b/examples/basic/aicr.toml @@ -0,0 +1,29 @@ +# Canonical config for aicr. Run: aicr diff --source examples/basic +# ${var} comes from [vars] here or from the per-machine file (~/.config/aicr/machine.toml). +# Built-in vars: home, home_posix, os (windows|macos|linux), machine. + +# Enable built-in targets (aicr list-targets shows them). Override any field here. +[targets.claude] +[targets.codex] +[targets.copilot] +[targets.gemini] + +# A new target is just data: +# [targets.myagent] +# instructions = "${home}/.myagent/RULES.md" +# skills = ["${home}/.myagent/skills"] +# mcp = { format = "json", path = "${home}/.myagent/mcp.json", key = "servers" } + +# Rules: rules/<name>.md, or rules/<name>.<target>.md for one target. +# Filters on any rule, server, skill or file: targets = [...], machines = [...]. +[rules.style] +[rules.testing] + +[mcp.filesystem] +command = "npx" +args = ["-y", "@modelcontextprotocol/server-filesystem", "${projects}"] + +[skills.commit-message] + +[vars] +projects = "${home}/projects" diff --git a/examples/basic/rules/style.md b/examples/basic/rules/style.md new file mode 100644 index 0000000..ed6b03c --- /dev/null +++ b/examples/basic/rules/style.md @@ -0,0 +1,4 @@ +## Style + +- Lead with the key point. Keep answers short. +- Change only what the task requires. diff --git a/examples/basic/rules/testing.md b/examples/basic/rules/testing.md new file mode 100644 index 0000000..41bd2b9 --- /dev/null +++ b/examples/basic/rules/testing.md @@ -0,0 +1,4 @@ +## Testing + +- Run the test suite before you say a change works. +- Add a regression test for each bug fix. diff --git a/examples/basic/skills/commit-message/SKILL.md b/examples/basic/skills/commit-message/SKILL.md new file mode 100644 index 0000000..936b5f0 --- /dev/null +++ b/examples/basic/skills/commit-message/SKILL.md @@ -0,0 +1,5 @@ +--- +name: commit-message +description: Write a short commit message in the imperative mood. +--- +Summarize the change in one line of 50 characters or fewer, imperative mood. diff --git a/examples/machine.example.toml b/examples/machine.example.toml new file mode 100644 index 0000000..6e4753b --- /dev/null +++ b/examples/machine.example.toml @@ -0,0 +1,6 @@ +# Per-machine file. Not committed. Default path: ~/.config/aicr/machine.toml +name = "laptop" +skip_targets = ["gemini"] + +[vars] +projects = "${home}/code" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4a641a2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,31 @@ +[build-system] +requires = ["setuptools>=69"] +build-backend = "setuptools.build_meta" + +[project] +name = "ai-config-render" +version = "0.1.0" +description = "Render one canonical AI-agent config (rules, skills, MCP servers) into Claude Code, Codex, Copilot CLI, Gemini and more." +readme = "README.md" +license = "MIT" +requires-python = ">=3.11" +dependencies = [] + +[project.optional-dependencies] +dev = ["pytest", "hypothesis", "ruff", "mypy"] + +[project.scripts] +aicr = "ai_config_render:main" + +[tool.setuptools.package-data] +ai_config_render = ["py.typed"] + +[tool.ruff] +line-length = 130 + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "B", "UP", "SIM"] + +[tool.mypy] +strict = true +files = ["src", "tests"] diff --git a/src/ai_config_render/__init__.py b/src/ai_config_render/__init__.py new file mode 100644 index 0000000..9125f09 --- /dev/null +++ b/src/ai_config_render/__init__.py @@ -0,0 +1,397 @@ +"""Render one canonical AI-agent config into many agents' native formats. + +It touches only what it owns: + - instruction files: blocks between <!-- aicr:NAME --> and <!-- /aicr:NAME --> + - MCP configs: the server names it renders now, and the ones it rendered last run + - skills and plain files: paths it wrote last run (tracked in a state file) +Everything it overwrites is backed up first. +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import difflib +import json +import os +import re +import shutil +import sys +import tomllib +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +__version__ = "0.1.0" + +CONFIG_NAME = "aicr.toml" +DEFAULT_MACHINE = Path("~/.config/aicr/machine.toml") +DEFAULT_STATE = "~/.config/aicr/state.json" +VAR = re.compile(r"\$\{(\w+)\}") + +# A target is data. `mcp.format` is "json" (servers under `mcp.key`, default +# "mcpServers") or "toml" (servers under [mcp_servers.<name>]). Any field can be +# overridden, and new targets added, under [targets.<name>] in aicr.toml. +BUILTIN_TARGETS: dict[str, dict[str, Any]] = { + "claude": { + "instructions": "${home}/.claude/CLAUDE.md", + "skills": ["${home}/.claude/skills"], + "mcp": {"format": "json", "path": "${home}/.claude.json"}, + }, + "codex": { + "instructions": "${home}/.codex/AGENTS.md", + "skills": ["${home}/.codex/skills"], + "mcp": {"format": "toml", "path": "${home}/.codex/config.toml"}, + }, + "copilot": { + "instructions": "${home}/.copilot/copilot-instructions.md", + "skills": ["${home}/.copilot/skills"], + "mcp": {"format": "json", "path": "${home}/.copilot/mcp-config.json", "defaults": {"tools": ["*"]}}, + }, + "gemini": { + "instructions": "${home}/.gemini/GEMINI.md", + "skills": ["${home}/.gemini/skills"], + "mcp": {"format": "json", "path": "${home}/.gemini/settings.json"}, + }, + "antigravity": { + "instructions": "${home}/.gemini/GEMINI.md", + "skills": ["${home}/.gemini/antigravity/skills"], + "mcp": {"format": "json", "path": "${home}/.gemini/antigravity/mcp_config.json"}, + }, + "cursor": { + "mcp": {"format": "json", "path": "${home}/.cursor/mcp.json"}, + }, + "windsurf": { + "instructions": "${home}/.codeium/windsurf/memories/global_rules.md", + "mcp": {"format": "json", "path": "${home}/.codeium/windsurf/mcp_config.json"}, + }, +} + + +class Missing(Exception): + """A ${var} has no value.""" + + +def sub(value: Any, env: dict[str, str]) -> Any: + """Expand ${var} in every string of a nested value. An unknown var raises Missing.""" + if isinstance(value, str): + + def rep(m: re.Match[str]) -> str: + if m.group(1) not in env: + raise Missing(m.group(1)) + return env[m.group(1)] + + return VAR.sub(rep, value) + if isinstance(value, list): + return [sub(v, env) for v in value] + if isinstance(value, dict): + return {k: sub(v, env) for k, v in value.items()} + return value + + +def merge(a: dict[str, Any], b: dict[str, Any]) -> dict[str, Any]: + out = dict(a) + for k, v in b.items(): + out[k] = merge(out[k], v) if isinstance(v, dict) and isinstance(out.get(k), dict) else v + return out + + +def applies(entry: dict[str, Any], target: str, machine: str) -> bool: + """Filters: `targets` (default all), `machines` (default all).""" + if target and "targets" in entry and target not in entry["targets"]: + return False + return "machines" not in entry or machine in entry["machines"] + + +@dataclass +class Writer: + dry: bool + stamp: str = field(default_factory=lambda: dt.datetime.now().astimezone().strftime("%Y%m%d%H%M%S")) + changes: list[tuple[Path, str | None, str | None]] = field(default_factory=list) + pending: dict[Path, str] = field(default_factory=dict) # what a dry run would have written + + def read(self, p: Path) -> str | None: + return self.pending.get(p, p.read_text(encoding="utf-8") if p.exists() else None) + + def backup(self, p: Path) -> None: + if p.exists() and not self.dry: + dst = p.with_name(f"{p.name}.aicr-bak-{self.stamp}") + if p.is_dir(): + shutil.copytree(p, dst) + else: + shutil.copy2(p, dst) + + def text(self, p: Path, new: str) -> None: + old = self.read(p) + if old == new: + return + self.changes.append((p, old, new)) + self.pending[p] = new + if self.dry: + return + self.backup(p) + p.parent.mkdir(parents=True, exist_ok=True) + tmp = p.with_name(p.name + ".aicr-tmp") + tmp.write_text(new, encoding="utf-8", newline="") + os.replace(tmp, p) + + def copy(self, src: Path, dst: Path) -> None: + if dst.exists() and dst.read_bytes() == src.read_bytes(): + return + self.changes.append((dst, None, None)) + if not self.dry: + self.backup(dst) + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst) + + def remove(self, p: Path) -> None: + if not p.exists(): + return + self.changes.append((p, "", None)) + if not self.dry: + self.backup(p) + if p.is_dir(): + shutil.rmtree(p) + else: + p.unlink() + + +# ---------- instruction files ---------- + + +def block_re(name: str) -> re.Pattern[str]: + n = re.escape(name) + return re.compile(rf"<!-- aicr:{n} -->\n.*?<!-- /aicr:{n} -->\n?", re.DOTALL) + + +def render_instructions(text: str, blocks: dict[str, str], dropped: set[str]) -> str: + """Replace or append each owned block; remove dropped ones; keep all other text.""" + for name in dropped: + text = block_re(name).sub("", text) + for name, body in blocks.items(): + new = f"<!-- aicr:{name} -->\n{body.rstrip()}\n<!-- /aicr:{name} -->\n" + rx = block_re(name) + first = rx.search(text) + if first: + # Keep the first position, drop duplicates. + text = text[: first.start()] + new + rx.sub("", text[first.end() :]) + else: + text = text.rstrip("\n") + ("\n\n" if text.strip() else "") + new + return text + + +# ---------- MCP ---------- + + +def toml_key(k: str) -> str: + return k if re.fullmatch(r"[A-Za-z0-9_-]+", k) else json.dumps(k) + + +def toml_value(v: Any) -> str: + if isinstance(v, bool): + return "true" if v else "false" + if isinstance(v, (int, float)): + return repr(v) + if isinstance(v, str): + return json.dumps(v, ensure_ascii=False).replace(chr(0x7F), "\\u007f") # TOML forbids a raw DEL + if isinstance(v, list): + return "[" + ", ".join(toml_value(x) for x in v) + "]" + if isinstance(v, dict): + return "{ " + ", ".join(f"{toml_key(k)} = {toml_value(x)}" for k, x in v.items()) + " }" + raise TypeError(f"cannot write {type(v).__name__} to TOML") + + +def render_toml(text: str, servers: dict[str, dict[str, Any]], owned: set[str]) -> str: + """Drop every [mcp_servers.<owned>...] table, then append ours in one marked block.""" + text = re.sub(r"# aicr:mcp\n.*?# /aicr:mcp\n?", "", text, flags=re.DOTALL) + out: list[str] = [] + skip = False + for line in text.splitlines(keepends=True): + m = re.match(r"\s*\[\[?\s*([^\]]+?)\s*\]\]?\s*(#.*)?$", line) + if m: + parts = [p.strip().strip("\"'") for p in re.split(r"\.(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)", m.group(1))] + skip = len(parts) >= 2 and parts[0] == "mcp_servers" and parts[1] in owned + if not skip: + out.append(line) + text = "".join(out).rstrip("\n") + if servers: + body = ["# aicr:mcp"] + for name, cfg in servers.items(): + body.append(f"[mcp_servers.{toml_key(name)}]") + body += [f"{toml_key(k)} = {toml_value(v)}" for k, v in cfg.items()] + body.append("") + text += ("\n\n" if text else "") + "\n".join(body) + "# /aicr:mcp\n" + tomllib.loads(text) # refuse to write a broken config + return text if not text or text.endswith("\n") else text + "\n" + + +def render_json(text: str, servers: dict[str, dict[str, Any]], owned: set[str], key: str) -> str: + data = json.loads(text) if text.strip() else {} + cur = data.setdefault(key, {}) + for name in owned - servers.keys(): + cur.pop(name, None) + cur.update(servers) + return json.dumps(data, indent=2, ensure_ascii=False) + "\n" + + +# ---------- render ---------- + + +def load(source: Path, machine_file: Path) -> tuple[dict[str, Any], dict[str, Any], dict[str, str]]: + cfg = tomllib.loads((source / CONFIG_NAME).read_text(encoding="utf-8")) + mach = tomllib.loads(machine_file.read_text(encoding="utf-8")) if machine_file.exists() else {} + env = { + "home": str(Path.home()), + "home_posix": Path.home().as_posix(), + "os": "windows" if os.name == "nt" else "macos" if sys.platform == "darwin" else "linux", + **{k: str(v) for k, v in cfg.get("vars", {}).items()}, + **{k: str(v) for k, v in mach.get("vars", {}).items()}, + } + env["machine"] = str(mach.get("name", env["os"])) + for k, v in env.items(): # one level of vars-in-vars, e.g. projects = "${home}/code" + env[k] = VAR.sub(lambda m: env.get(m.group(1), m.group(0)), v) + return cfg, mach, env + + +def targets(cfg: dict[str, Any]) -> dict[str, dict[str, Any]]: + """Built-ins merged with [targets.*]. Without a [targets] table, no target is enabled.""" + out = {} + for name, t in cfg.get("targets", {}).items(): + spec = merge(BUILTIN_TARGETS.get(name, {}), t) + if spec.pop("enabled", True): + out[name] = spec + return out + + +def render(source: Path, machine_file: Path, dry: bool) -> tuple[Writer, list[str]]: + cfg, mach, env = load(source, machine_file) + machine = env["machine"] + state_file = Path(sub(mach.get("state", DEFAULT_STATE), env)).expanduser() + state = json.loads(state_file.read_text(encoding="utf-8")) if state_file.exists() else {} + new_state: dict[str, Any] = {} + w = Writer(dry) + notes: list[str] = [] + + for tname, raw in targets(cfg).items(): + if tname in mach.get("skip_targets", []): + continue + try: + t = sub(raw, env) + except Missing as e: + notes.append(f"{tname}: skipped, no var {e}") + continue + prev = state.get(tname, {}) + cur = new_state.setdefault(tname, {}) + + if "instructions" in t: + blocks: dict[str, str] = {} + for name, r in cfg.get("rules", {}).items(): + if not applies(r, tname, machine): + continue + src = source / "rules" / f"{name}.{tname}.md" + src = src if src.exists() else source / "rules" / f"{name}.md" + try: + blocks[name] = sub(src.read_text(encoding="utf-8"), env) + except Missing as e: + notes.append(f"{tname}: rule {name} skipped, no var {e}") + p = Path(t["instructions"]).expanduser() + # Two targets can share one file (gemini, antigravity): merge their blocks. + file_state = new_state.setdefault("_instructions", {}).setdefault(str(p), []) + dropped = set(prev.get("rules", [])) - blocks.keys() - set(file_state) + file_state.extend(sorted(blocks)) + if blocks or dropped: + w.text(p, render_instructions(w.read(p) or "", blocks, dropped)) + cur["rules"] = sorted(blocks) + + if "mcp" in t: + servers: dict[str, dict[str, Any]] = {} + for name, s in cfg.get("mcp", {}).items(): + if not applies(s, tname, machine): + continue + m = mach.get("mcp", {}).get(name, {}) + # Precedence, low to high: target defaults, server, server.target.<t>, machine, machine.target.<t> + spec: dict[str, Any] = t["mcp"].get("defaults", {}) + for layer in (s, s.get("target", {}).get(tname, {}), m, m.get("target", {}).get(tname, {})): + spec = merge(spec, layer) + spec = {k: v for k, v in spec.items() if k not in ("targets", "machines", "target")} + try: + servers[name] = sub(spec, env) + except Missing as e: + notes.append(f"{tname}: mcp {name} skipped, no var {e}") + p = Path(t["mcp"]["path"]).expanduser() + owned = set(servers) | set(prev.get("mcp", [])) + if servers or owned: + old = w.read(p) or "" + fmt = t["mcp"]["format"] + if fmt == "toml": + w.text(p, render_toml(old, servers, owned)) + elif fmt == "json": + w.text(p, render_json(old, servers, owned, t["mcp"].get("key", "mcpServers"))) + else: + raise SystemExit(f"{tname}: unknown mcp format {fmt!r} (use json or toml)") + cur["mcp"] = sorted(servers) + + names = [n for n, s in cfg.get("skills", {}).items() if applies(s, tname, machine)] + for d in t.get("skills", []): + root = Path(d).expanduser() + for n in names: + skill = source / "skills" / n + for f in sorted(skill.rglob("*")) if skill.is_dir() else []: + if f.is_file() and "__pycache__" not in f.parts: + w.copy(f, root / n / f.relative_to(skill)) + for n in set(prev.get("skills", [])) - set(names): + w.remove(root / n) + cur["skills"] = names + + owned_files: list[str] = [] + for dest, f in cfg.get("files", {}).items(): + if not applies(f, "", machine): + continue + try: + dest_p, src = Path(sub(dest, env)).expanduser(), source / sub(f["src"], env) + data = src.read_text(encoding="utf-8") + w.text(dest_p, sub(data, env) if f.get("template", False) else data) + except Missing as e: + notes.append(f"file {dest} skipped, no var {e}") + continue + owned_files.append(str(dest_p)) + for gone in set(state.get("_files", [])) - set(owned_files): + w.remove(Path(gone)) + new_state["_files"] = owned_files + new_state.pop("_instructions", None) + + if not dry: + state_file.parent.mkdir(parents=True, exist_ok=True) + state_file.write_text(json.dumps(new_state, indent=1), encoding="utf-8") + return w, notes + + +# ---------- CLI ---------- + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(prog="aicr", description=(__doc__ or "").splitlines()[0]) + ap.add_argument("command", choices=["render", "diff", "check", "list-targets"]) + ap.add_argument("--source", type=Path, default=Path("."), help=f"folder with {CONFIG_NAME} (default: .)") + ap.add_argument("--machine", type=Path, default=DEFAULT_MACHINE, help="per-machine vars file") + a = ap.parse_args(argv) + machine = a.machine.expanduser() + + if a.command == "list-targets": + cfg = tomllib.loads((a.source / CONFIG_NAME).read_text(encoding="utf-8")) if (a.source / CONFIG_NAME).exists() else {} + enabled = targets(cfg) + for name in sorted(BUILTIN_TARGETS.keys() | enabled.keys()): + spec = enabled.get(name, BUILTIN_TARGETS.get(name, {})) + mark = "*" if name in enabled else " " + print(f"{mark} {name:12} {spec.get('instructions', '-')} mcp={spec.get('mcp', {}).get('path', '-')}") + return 0 + + w, notes = render(a.source, machine, dry=a.command != "render") + for p, old, new in w.changes: + if a.command == "diff" and new is not None: + sys.stdout.writelines(difflib.unified_diff((old or "").splitlines(True), new.splitlines(True), f"a/{p}", f"b/{p}")) + else: + print(f"{'removed' if old == '' and new is None else 'changed'} {p}") + for n in notes: + print(f"note: {n}", file=sys.stderr) + return 1 if a.command == "check" and w.changes else 0 diff --git a/src/ai_config_render/__main__.py b/src/ai_config_render/__main__.py new file mode 100644 index 0000000..b176b08 --- /dev/null +++ b/src/ai_config_render/__main__.py @@ -0,0 +1,5 @@ +import sys + +from ai_config_render import main + +sys.exit(main()) diff --git a/src/ai_config_render/py.typed b/src/ai_config_render/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_render.py b/tests/test_render.py new file mode 100644 index 0000000..01d265b --- /dev/null +++ b/tests/test_render.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import json +import shutil +import tomllib +from pathlib import Path + +import pytest +from hypothesis import example, given +from hypothesis import strategies as st + +from ai_config_render import BUILTIN_TARGETS, main, render_instructions, render_json, render_toml + +EXAMPLE = Path(__file__).parent.parent / "examples" / "basic" + + +@pytest.fixture +def env(tmp_path: Path) -> tuple[Path, Path, Path]: + """A source copy, a fake home, and a machine file that points both into tmp_path.""" + home, source = tmp_path / "home", tmp_path / "src" + shutil.copytree(EXAMPLE, source) + cfg = (source / "aicr.toml").read_text(encoding="utf-8") + extra = "".join(f"[targets.{t}]\n" for t in BUILTIN_TARGETS if f"[targets.{t}]" not in cfg) + (source / "aicr.toml").write_text(cfg + extra, encoding="utf-8") + machine = tmp_path / "machine.toml" + machine.write_text( + f'name = "test"\nstate = {json.dumps(str(tmp_path / "state.json"))}\n[vars]\nhome = {json.dumps(home.as_posix())}\n', + encoding="utf-8", + ) + return source, home, machine + + +def run(cmd: str, source: Path, machine: Path) -> int: + return main([cmd, "--source", str(source), "--machine", str(machine)]) + + +def test_renders_every_builtin_format(env: tuple[Path, Path, Path]) -> None: + source, home, machine = env + assert run("render", source, machine) == 0 + for spec in BUILTIN_TARGETS.values(): + if "instructions" in spec: + text = Path(spec["instructions"].replace("${home}", str(home))).read_text(encoding="utf-8") + assert "<!-- aicr:style -->\n## Style" in text and "<!-- /aicr:testing -->" in text + p = Path(spec["mcp"]["path"].replace("${home}", str(home))) + if spec["mcp"]["format"] == "toml": + srv = tomllib.loads(p.read_text(encoding="utf-8"))["mcp_servers"]["filesystem"] + else: + srv = json.loads(p.read_text(encoding="utf-8"))["mcpServers"]["filesystem"] + assert srv["command"] == "npx" and srv["args"][-1] == f"{home.as_posix()}/projects" + for d in spec.get("skills", []): + assert (Path(d.replace("${home}", str(home))) / "commit-message" / "SKILL.md").is_file() + copilot = json.loads((home / ".copilot" / "mcp-config.json").read_text(encoding="utf-8")) + assert copilot["mcpServers"]["filesystem"]["tools"] == ["*"] + + +def test_idempotent_and_check(env: tuple[Path, Path, Path]) -> None: + source, _, machine = env + assert run("check", source, machine) == 1 + run("render", source, machine) + assert run("check", source, machine) == 0 + assert run("render", source, machine) == 0 + assert run("check", source, machine) == 0 + + +def test_preserves_user_content_and_drops_removed_entries(env: tuple[Path, Path, Path]) -> None: + source, home, machine = env + claude_md, codex = home / ".claude" / "CLAUDE.md", home / ".codex" / "config.toml" + claude_md.parent.mkdir(parents=True) + claude_md.write_text("# Mine\nkeep me\n", encoding="utf-8") + codex.parent.mkdir(parents=True) + codex.write_text('model = "x"\n\n[mcp_servers.mine]\ncommand = "a"\n', encoding="utf-8") + run("render", source, machine) + cfg = (source / "aicr.toml").read_text(encoding="utf-8") + (source / "aicr.toml").write_text( + cfg.replace("[rules.testing]\n", "").replace("[mcp.filesystem]", "[mcp.gone]"), encoding="utf-8" + ) + run("render", source, machine) + text = claude_md.read_text(encoding="utf-8") + assert text.startswith("# Mine\nkeep me\n") and "aicr:style" in text and "aicr:testing" not in text + data = tomllib.loads(codex.read_text(encoding="utf-8")) + assert data["model"] == "x" and set(data["mcp_servers"]) == {"mine", "gone"} + assert list(claude_md.parent.glob("CLAUDE.md.aicr-bak-*")) + + +def test_list_targets(capsys: pytest.CaptureFixture[str], env: tuple[Path, Path, Path]) -> None: + source, _, machine = env + assert run("list-targets", source, machine) == 0 + assert "* codex" in capsys.readouterr().out + + +text_st = st.text(st.characters(exclude_categories=["Cs"]), max_size=200).filter(lambda s: "<!--" not in s) +name_st = st.from_regex(r"[a-z][a-z0-9_-]{0,8}", fullmatch=True) + + +@given(text_st, st.dictionaries(name_st, text_st.filter(lambda s: s.strip() != ""), max_size=4), text_st) +def test_instruction_merge_round_trip(user: str, blocks: dict[str, str], new_body: str) -> None: + once = render_instructions(user, blocks, set()) + assert render_instructions(once, blocks, set()) == once # idempotent + for name, body in blocks.items(): + assert f"<!-- aicr:{name} -->\n{body.rstrip()}\n<!-- /aicr:{name} -->\n" in once + assert render_instructions(once, {}, set(blocks)).rstrip("\n") == user.rstrip("\n") # removing restores user text + + +json_val = st.recursive( + st.none() | st.booleans() | st.integers() | st.text(max_size=10), lambda c: st.lists(c, max_size=3), max_leaves=5 +) + + +@given(st.dictionaries(name_st, json_val, max_size=3), st.dictionaries(name_st, st.just({"command": "c"}), max_size=3)) +def test_json_merge_keeps_user_keys(user: dict[str, object], servers: dict[str, dict[str, object]]) -> None: + out = json.loads(render_json(json.dumps({"other": user}), servers, set(servers), "mcpServers")) + assert out["other"] == user and out["mcpServers"] == servers + assert json.loads(render_json(json.dumps(out), {}, set(servers), "mcpServers")) == {"other": user, "mcpServers": {}} + + +@given( + st.dictionaries( + name_st.filter(lambda n: n != "user"), + st.dictionaries(name_st, st.text(max_size=10) | st.integers(), max_size=3), + max_size=3, + ) +) +@example({"a": {"a": chr(0x7F)}}) # regression: a raw DEL made invalid TOML +def test_toml_round_trip(servers: dict[str, dict[str, object]]) -> None: + out = render_toml('top = 1\n\n[mcp_servers.user]\ncommand = "u"\n', servers, set(servers)) + data = tomllib.loads(out) + assert data["top"] == 1 and data["mcp_servers"].pop("user") == {"command": "u"} + assert data["mcp_servers"] == servers + assert render_toml(out, servers, set(servers)) == out