Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agents/rules/codex-claude-sync.md
10 changes: 8 additions & 2 deletions .agents/skills/manage-agent-config/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: manage-agent-config
description: Use whenever creating, editing, renaming, or deleting any file under .claude/skills/, .claude/agents/, .agents/skills/, or .codex/agents/. Teaches the dual-tool Claude/Codex layout and reminds to run `make sync-agent-config`.
description: Manage files under .claude/{skills,agents,rules}/, .agents/{skills,rules}/, or .codex/agents/. Covers dual-tool Claude/Codex layout and reminds to run make sync-agent-config.
---

# Managing Claude ↔ Codex skills and subagents in this repo
Expand All @@ -21,9 +21,15 @@ This repo is dual-tool. Before you create or edit anything under `.claude/`, `.a
- `.codex/agents/<name>.toml` is **generated** - never hand-edit.
- Run `make sync-agent-config` - the TOML appears.

**Creating a new path-scoped rule?**

- `.claude/rules/<name>.md` is the **source of truth**. Use `globs:` frontmatter (not `paths:`).
- `.agents/rules/<name>.md` is a symlink mirror, created by `make sync-agent-config`.
- Read the `new-agent-rule` skill before writing your first rule.

**Renaming or deleting?**

- Rename or delete the source file (under `.agents/skills/` or `.claude/agents/`).
- Rename or delete the source file (under `.agents/skills/`, `.claude/agents/`, or `.claude/rules/`).
- Run `make sync-agent-config` - stale symlinks and orphaned TOMLs are pruned automatically.

## Frontmatter rules for shared skills
Expand Down
37 changes: 30 additions & 7 deletions .claude/rules/codex-claude-sync.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
---
paths:
- ".claude/skills/**"
- ".claude/agents/**"
- ".agents/skills/**"
- ".codex/agents/**"
description: Dual-tool (Claude Code + Codex CLI) skill, subagent, and rule sync layout
globs:
- ".claude/**"
- ".agents/**"
- ".codex/**"
---

# Codex ↔ Claude skill & subagent sync
Expand All @@ -18,6 +18,8 @@ This repo is dual-tool: both Claude Code and Codex CLI are expected to work. Ski
.claude/agents/<name>.md # source of truth (markdown + YAML frontmatter)
.codex/agents/<name>.toml # GENERATED from the .md; commit it
scripts/sync_agent_config.py # converter (uv run)
.claude/rules/<name>.md # source of truth (prose + globs frontmatter)
.agents/rules/<name>.md # symlink → ../../.claude/rules/<name>.md
```

Codex auto-scans `.agents/skills/` walking up from cwd to repo root. Claude auto-scans `.claude/skills/`. The symlink is the only reason both find the same file.
Expand Down Expand Up @@ -55,15 +57,35 @@ Rules:
- Claude-only frontmatter keys (`tools`, `model`) don't translate - document tool expectations in the prose body instead so both sides pick them up.
- Inside the body, avoid literal `"""` sequences (they'd close the TOML string); the converter escapes them but it's easier to just not use them.

## Rules: symlink, don't convert

Rules sync in the **opposite direction** from skills:
- `.claude/rules/<name>.md` is the **source of truth**. Claude auto-discovers rules here.
- `.agents/rules/<name>.md` is a symlink mirror, created by `make sync-agent-config`.

The inversion is necessary because Claude is the primary consumer of rules today. `.agents/rules/` is a forward-looking mirror in case a cross-tool standard emerges.

Rule frontmatter uses `globs:` (not `paths:`) to scope when the rule attaches. Example:

```yaml
---
globs:
- "src/api/**"
description: API route conventions
---
```

Read the `new-agent-rule` skill before creating a rule.

## Do not try to sync these

- `.claude/rules/*.md` vs `.codex/rules/*.rules` - different languages (prose vs permission DSL). Maintain separately.
- `.codex/rules/*.rules` - Codex permission DSL (Starlark). Separate from `.claude/rules/` prose rules; maintain independently.
- `.claude/commands/*.md` - Claude-only; Codex has no slash-command runtime.
- `CLAUDE.md` vs `AGENTS.md` - both auto-read by their respective tool; keep them as separate documents, though content may overlap.

## Tooling

- `make sync-agent-config` - idempotent. Creates missing `.claude/skills/` symlinks for every shared skill under `.agents/skills/`, regenerates `.codex/agents/*.toml` from `.claude/agents/*.md`, auto-prunes dangling symlinks and orphan TOMLs silently.
- `make sync-agent-config` - idempotent. Creates missing `.claude/skills/` symlinks for every shared skill under `.agents/skills/`, creates `.agents/rules/` symlinks for every rule under `.claude/rules/`, regenerates `.codex/agents/*.toml` from `.claude/agents/*.md`, auto-prunes dangling symlinks and orphan TOMLs silently.
- Pre-commit: [`prek`](https://prek.j178.dev/installation/), configured in `prek.toml` at repo root. Register once per clone with `prek install`. Runs `make sync-agent-config` then fails the commit if it produced drift.
- Python scripts in `scripts/` use `uv` and PEP 723 inline metadata for standalones.

Expand All @@ -75,3 +97,4 @@ The `manage-agent-config` skill (at `.agents/skills/manage-agent-config/`) has t
2. Claude-only skill (uses `$ARGUMENTS`, `allowed-tools`, etc.) → `.claude/skills/<name>/SKILL.md` as a real directory. No symlink.
3. Subagent → edit `.claude/agents/<name>.md`. Never hand-edit `.codex/agents/*.toml`. Run `make sync-agent-config`. Commit both files.
4. Delete or rename → edit/remove the source, then `make sync-agent-config` cleans up the mirror.
5. New rule → write `.claude/rules/<name>.md` with `globs:` frontmatter. Run `make sync-agent-config`. The `.agents/rules/` symlink appears.
47 changes: 47 additions & 0 deletions .claude/skills/new-agent-rule/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
name: new-agent-rule
description: Guide for creating a new path-scoped .claude/rules/ file with proper globs frontmatter and structure.
---

# Creating a new path-scoped rule

Rules keep CLAUDE.md lean by scoping guidance to specific file paths. A rule only loads when the agent touches files matching its `globs:` pattern.

## When to create a rule

- Guidance applies to a specific directory or file pattern, not the whole repo
- The guidance is long enough that embedding it in CLAUDE.md would bloat the file
- Multiple agents (or future tools) should pick up the same guidance automatically

## Naming and location

- Source of truth: `.claude/rules/<name>.md`
- Name: `snake_case.md` describing the area (e.g., `api_routes.md`, `test_conventions.md`)
- Mirror: `.agents/rules/<name>.md` (symlink, created by `make sync-agent-config`)

## Frontmatter format

Use `globs:` (NOT `paths:` -- it has known silent-failure bugs):

```yaml
---
description: One-line summary of what this rule covers
globs:
- "src/api/**"
- "tests/api/**"
---
```

Pick the narrowest set of globs that covers the area. Overly broad globs waste context on unrelated tasks.

## Workflow

1. Check if CLAUDE.md already covers this guidance (if so, migrate it to a rule)
2. Write `.claude/rules/<name>.md` with `globs:` frontmatter
3. Run `make sync-agent-config` then `make ci`

## Anti-patterns

- Forgetting `globs:` frontmatter (rule loads on every task, defeating the purpose)
- Restating what CLAUDE.md already says (keep rules additive, not duplicative)
- Using `paths:` instead of `globs:` (known silent-failure bugs in Claude)
97 changes: 86 additions & 11 deletions scripts/sync_agent_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@
# requires-python = ">=3.12"
# dependencies = ["pyyaml>=6.0", "tomli_w>=1.0"]
# ///
"""Sync Claude ↔ Codex skills and subagents.
"""Sync Claude ↔ Codex skills, rules, and subagents.

- Symlinks `.claude/skills/<name>` → `../../.agents/skills/<name>` for every
directory under `.agents/skills/`.
- Symlinks `.agents/rules/<name>.md` → `../../.claude/rules/<name>.md` for every
non-symlink `.md` file under `.claude/rules/`.
- Regenerates `.codex/agents/<name>.toml` from each `.claude/agents/<name>.md`.
- Auto-prunes dangling symlinks and orphaned TOMLs silently.
"""

from __future__ import annotations

import argparse
Expand All @@ -25,13 +28,29 @@
CLAUDE_SKILLS = REPO / ".claude" / "skills"
CLAUDE_AGENTS = REPO / ".claude" / "agents"
CODEX_AGENTS = REPO / ".codex" / "agents"
SHARED_RULES = REPO / ".agents" / "rules"
CLAUDE_RULES = REPO / ".claude" / "rules"

FRONTMATTER_RE = re.compile(r"^---\r?\n(.*?)\r?\n---\r?\n?(.*)$", re.DOTALL)
CLAUDE_ONLY_KEYS = {"tools", "model", "color", "allowed-tools", "disable-model-invocation"}
CLAUDE_ONLY_KEYS = {
"tools",
"model",
"color",
"allowed-tools",
"disable-model-invocation",
}

SHARED_SKILL_FORBIDDEN_KEYS = {
"allowed-tools", "disable-model-invocation", "user-invocable",
"context", "agent", "model", "effort", "hooks", "paths", "shell",
"allowed-tools",
"disable-model-invocation",
"user-invocable",
"context",
"agent",
"model",
"effort",
"hooks",
"paths",
"shell",
"argument-hint",
}
SHARED_SKILL_FORBIDDEN_BODY_PATTERNS = [
Expand Down Expand Up @@ -77,7 +96,9 @@ def render_toml(meta: dict, body: str, source: Path | None = None) -> str:


def _strip_code(text: str) -> str:
text = re.sub(r"^[ ]{0,3}(`{3,}).*?^[ ]{0,3}\1`*", "", text, flags=re.DOTALL | re.MULTILINE)
text = re.sub(
r"^[ ]{0,3}(`{3,}).*?^[ ]{0,3}\1`*", "", text, flags=re.DOTALL | re.MULTILINE
)
out: list[str] = []
i, n = 0, len(text)
while i < n:
Expand All @@ -87,7 +108,9 @@ def _strip_code(text: str) -> str:
while i + run < n and text[i + run] == "`":
run += 1
close = text.find("`" * run, i + run)
if close == -1 or any(text[i + run + k] == "\n" for k in range(close - i - run)):
if close == -1 or any(
text[i + run + k] == "\n" for k in range(close - i - run)
):
out.append(text[i : i + run])
i += run
elif preceded_by_bang and run == 1:
Expand All @@ -114,18 +137,26 @@ def validate_shared_skill(skill_dir: Path) -> list[str]:
errs: list[str] = []
bad_keys = SHARED_SKILL_FORBIDDEN_KEYS & set(meta.keys())
if bad_keys:
errs.append(f"{skill_md.relative_to(REPO)}: Claude-only frontmatter keys in shared skill: {sorted(bad_keys)}")
errs.append(
f"{skill_md.relative_to(REPO)}: Claude-only frontmatter keys in shared skill: {sorted(bad_keys)}"
)
for pat, label in SHARED_SKILL_RAW_BODY_PATTERNS:
if pat.search(body):
errs.append(f"{skill_md.relative_to(REPO)}: body uses Claude-only feature: {label}")
errs.append(
f"{skill_md.relative_to(REPO)}: body uses Claude-only feature: {label}"
)
scan_body = _strip_code(body)
for pat, label in SHARED_SKILL_FORBIDDEN_BODY_PATTERNS:
if pat.search(scan_body):
errs.append(f"{skill_md.relative_to(REPO)}: body uses Claude-only feature: {label}")
errs.append(
f"{skill_md.relative_to(REPO)}: body uses Claude-only feature: {label}"
)
if not meta.get("name"):
errs.append(f"{skill_md.relative_to(REPO)}: missing `name` in frontmatter")
if not meta.get("description"):
errs.append(f"{skill_md.relative_to(REPO)}: missing `description` in frontmatter")
errs.append(
f"{skill_md.relative_to(REPO)}: missing `description` in frontmatter"
)
return errs


Expand Down Expand Up @@ -190,6 +221,50 @@ def sync_skill_symlinks() -> list[str]:
return changes


def sync_rule_symlinks() -> list[str]:
"""Create symlinks from .agents/rules/<name>.md → ../../.claude/rules/<name>.md."""
changes: list[str] = []
rules_existed = CLAUDE_RULES.exists()
SHARED_RULES.mkdir(parents=True, exist_ok=True)
CLAUDE_RULES.mkdir(parents=True, exist_ok=True)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

wanted: set[str] = set()
for rule in CLAUDE_RULES.iterdir():
if rule.is_symlink() or not rule.is_file() or rule.suffix != ".md":
continue
wanted.add(rule.name)
link = SHARED_RULES / rule.name
target = Path("..") / ".." / ".claude" / "rules" / rule.name
if link.is_symlink():
if os.path.normpath(os.readlink(link)) == os.path.normpath(str(target)):
continue
link.unlink()
elif link.exists():
raise SystemExit(
f"ERROR: name collision - .agents/rules/{rule.name} is a real file "
f"but .claude/rules/{rule.name} also exists. The .claude/rules/ version is the "
"source of truth; remove the .agents/rules/ copy."
)
try:
link.symlink_to(target)
except (OSError, NotImplementedError) as e:
raise SystemExit(
f"ERROR: could not create symlink {link.relative_to(REPO)} -> {target}: {e}. "
"If you're on Windows, enable Developer Mode or run your shell as Administrator "
"so Python can create symlinks."
) from e
changes.append(f"symlinked {link.relative_to(REPO)}")
Comment thread
Miyamura80 marked this conversation as resolved.

if not rules_existed and not wanted:
return changes

for link in SHARED_RULES.iterdir():
if link.is_symlink() and link.name not in wanted:
link.unlink()
changes.append(f"pruned dangling {link.relative_to(REPO)}")
return changes


def sync_agents() -> list[str]:
changes: list[str] = []
CODEX_AGENTS.mkdir(parents=True, exist_ok=True)
Expand Down Expand Up @@ -221,7 +296,7 @@ def main() -> int:
)
args = parser.parse_args()

changes = sync_skill_symlinks() + sync_agents()
changes = sync_skill_symlinks() + sync_rule_symlinks() + sync_agents()
for c in changes:
print(c)
if args.check and changes:
Expand Down
Loading