Skip to content
Merged
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
9 changes: 4 additions & 5 deletions .claude/rules/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ ConfigLocator -> .ocli/ dir (global or local)
ProfileStore -> profiles.ini (read/write/select)
|
v
OpenapiLoader -> fetches spec, caches under .ocli/specs/<profile>.json
OpenapiLoader -> fetches spec with the profile auth headers, caches under .ocli/specs/<profile>.json
|
v
OpenapiToCommands -> parses spec, applies include/exclude filters,
Expand All @@ -39,13 +39,13 @@ HttpClient (axios) -> performs the real HTTP request to API_BASE_URL

- `config.ts` - `ConfigLocator`. Finds `.ocli/` (global `~/.ocli/`, local in CWD), resolves `profiles.ini` paths.
- `profile-store.ts` - `ProfileStore`, `Profile`. Reads/writes `profiles.ini`, tracks current profile, validates fields.
- `openapi-loader.ts` - `OpenapiLoader`. Loads spec from URL or local file, caches it to `.ocli/specs/<profile>.json`, refreshes on demand. Resolves external `$ref` across multi-file specs.
- `openapi-loader.ts` - `OpenapiLoader`. Loads spec from URL or local file, caches it to `.ocli/specs/<profile>.json`, refreshes on demand. Resolves external `$ref` across multi-file specs. Remote fetches (the spec and every external `$ref` document) carry the headers passed by `cli.ts` through `loadSpec(profile, { headers })`: the profile custom headers plus the Basic/Bearer `Authorization` built by `buildProfileAuthHeaders`.
- `openapi-to-commands.ts` - `OpenapiToCommands`, `CliCommand`, `CliCommandOption`. Walks the spec, applies include/exclude filters, expands path-level params, resolves local `$ref`, builds command names with optional prefix, expands `enum`/`default`/`nullable`/`oneOf` schema hints for `--help`.
- `command-search.ts` - `CommandSearch`. BM25 over `(name, method, path, description, options[].name)`, plus regex fallback. Same engine used by both `ocli commands` and any future agent skill.
- `command-args.ts` - `findUnknownFlags`, `acceptsFreeFormBody`, `formatUnknownFlagsError`. Validates parsed flags of a dynamic command against `CliCommand.options`, suggests the closest declared name, and keeps the free-form body passthrough for body-capable operations whose spec declares no body.
- `bm25.ts` - tokenizer + BM25 scorer, no I/O.
- `cli.ts` - `ocli` entry point. yargs command tree: `profiles add|remove|list`, `use`, `commands`, and dynamic per-spec commands. Builds the `axios` request from a `CliCommand` + parsed args; injects auth, custom headers, server URL overrides.
- `version.ts` - generated by `scripts/generate-version.js` during `prebuild`. Do not edit by hand.
- `version.ts` - resolves `VERSION` at runtime from `OCLI_VERSION` or `package.json`; there is no generation step.

## Design principles

Expand All @@ -55,7 +55,7 @@ HttpClient (axios) -> performs the real HTTP request to API_BASE_URL
4. **Pure transform layer**: `bm25.ts`, `openapi-to-commands.ts`, `command-search.ts`, and `command-args.ts` perform no I/O; they take inputs and return outputs. This keeps them trivially unit-testable.
5. **Side effects at the edges**: filesystem in `config.ts`/`profile-store.ts`/`openapi-loader.ts`, network in `cli.ts` via `HttpClient`. Inject these via constructors (`fs`, `httpClient`) so tests can swap them.
6. **TypeScript strict**: `strict: true` in `tsconfig.json`. Explicit types for exported functions and public interfaces.
7. **No surprise breaking changes**: every CLI-visible change must be reflected in `README.md`.
7. **No surprise breaking changes**: every CLI-visible change must be reflected in `README.md`. This includes changes that add no flag, such as which requests carry profile credentials.

## Layers and allowed dependencies

Expand All @@ -76,7 +76,6 @@ Lower layers must not import from higher layers. New behavior should live in the
- `examples/skill-ocli-api.md` - example Claude Code skill describing the agent workflow.
- `skills/ocli-api/SKILL.md` - portable OpenClaw skill.
- `benchmarks/benchmark.ts` - token-overhead comparison (MCP variants vs CLI).
- `scripts/generate-version.js` - writes `src/version.ts` before build.
- `.ocli/` - working dir at runtime (not part of source). Never committed.
- `dist/` - `tsc` build output.

Expand Down
15 changes: 11 additions & 4 deletions .claude/rules/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ When the user asks for a new feature (words like "feature", "add", "implement",
5. **Confirm green**: re-run the same test; it must pass.
6. **Full suite**: `npm test`. All tests must be green. Fix regressions before moving on.
7. **Build check**: `npm run build` to confirm `tsc` is clean (no type errors).
8. **Docs**: update `README.md` whenever any of the following change: CLI flags, command names, profile fields, `.ocli/` layout, BM25 search behavior, supported OpenAPI/Swagger features, or the benchmark numbers. If you changed observable CLI output (`--help`, error messages, exit codes), update the relevant section of the README. The `examples/skill-ocli-api.md` and `skills/ocli-api/SKILL.md` must stay aligned with the documented agent workflow.
8. **Docs**: update `README.md` whenever any of the following change: CLI flags, command names, profile fields, `.ocli/` layout, BM25 search behavior, supported OpenAPI/Swagger features, authentication and header handling (which requests carry profile credentials and custom headers, and to which hosts), spec loading and caching (accepted sources, `$ref` resolution, when the cache is refreshed), or the benchmark numbers. A change counts even when it adds no flag: if a user can observe the difference (a spec behind auth now loads, credentials reach a host they did not reach before), document it. If you changed observable CLI output (`--help`, error messages, exit codes), update the relevant section of the README. The `examples/skill-ocli-api.md` and `skills/ocli-api/SKILL.md` must stay aligned with the documented agent workflow.
9. **Report**: brief summary of files touched, tests added, suite result.

## Bug fixes (TDD)
Expand All @@ -39,9 +39,9 @@ When the user reports a bug (words like "bug", "fix", "芯褕懈斜泻邪", "斜邪谐",
- `README.md` reflects any user-visible change.
- Report what changed, which tests were added, and the suite result.

## Rules sync (Cursor <-> Claude)
## Rules sync (Cursor <-> Claude <-> Codex)

`.claude/rules/*.md` and `.cursor/rules/*.mdc` cover the same topics and must stay aligned. **Any change to a rule in one location must be mirrored to the other in the same change**, no exceptions.
`.claude/rules/*.md` and `.cursor/rules/*.mdc` cover the same topics and must stay aligned. **Any change to a rule in one location must be mirrored to the other in the same change**, no exceptions. Codex reads the Cursor side through the hook bridge in `.codex/` (`hooks.json`, `hooks/attach_rules.py`), so there is no third copy of the rule bodies; what must be kept in step for Codex is the index `.codex/rules.md` and the top-level brief `AGENTS.md`, to which `CLAUDE.md` is a symlink.

Mapping:

Expand All @@ -55,12 +55,19 @@ Mapping:

When propagating, translate the frontmatter:

- Claude `paths: ["src/**/*.ts"]` -> Cursor `globs: "src/**/*.ts"` + `alwaysApply: true` (or `false` for optional topics like `implementation-order`).
- Claude `paths: ["src/**/*.ts"]` -> Cursor `globs: src/**/*.ts` + `alwaysApply: true` (or `false` for optional topics like `implementation-order`). Keep `globs` unquoted: the Codex bridge parser reads the value literally, and a quoted glob never matches.
- Claude rule without frontmatter (loaded every session, e.g. `workflow.md`) -> Cursor `alwaysApply: true` with no `globs`.
- Replace cross-rule links: Claude `[architecture.md](architecture.md)` -> Cursor `@architecture.mdc`.

Body content stays identical. If the change is Cursor-only or Claude-only (very rare - e.g. tool-specific quirk), state that explicitly in the file as "Tool-specific:" and skip mirroring for that section only.

Checklist for every rule change:

1. Edit the Claude file and its Cursor mirror in the same commit.
2. If a rule file was added, renamed, or removed, refresh the table in `.codex/rules.md` and the rule list in `AGENTS.md`.
3. If `AGENTS.md` changed, confirm `CLAUDE.md` still resolves to it (`ls -la CLAUDE.md`).
4. A rule reaches Codex only if its Cursor frontmatter carries `globs` or `alwaysApply: true`. The hook script and `hooks.json` are never edited per project; after any edit to them, Codex users must re-run `/hooks` to trust the new hash.

## Relationship to other rules

- [architecture.md](architecture.md) is loaded under `src/**` - use it when picking modules and dependency direction.
Expand Down
33 changes: 33 additions & 0 deletions .codex/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"description": "Deterministic delivery of .cursor/rules/*.mdc to Codex sessions.",
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume|clear|compact",
"hooks": [
{
"type": "command",
"command": "python3 \"$(git rev-parse --show-toplevel)/.codex/hooks/attach_rules.py\"",
"statusMessage": "Loading always-on project rules",
"timeout": 10,
"additionalContextLimit": 8000
}
]
}
],
"PreToolUse": [
{
"matcher": "^(apply_patch|Edit|Write)$",
"hooks": [
{
"type": "command",
"command": "python3 \"$(git rev-parse --show-toplevel)/.codex/hooks/attach_rules.py\"",
"statusMessage": "Attaching rules for edited files",
"timeout": 10,
"additionalContextLimit": 6000
}
]
}
]
}
}
257 changes: 257 additions & 0 deletions .codex/hooks/attach_rules.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
#!/usr/bin/env python3
"""Attach `.cursor/rules/*.mdc` to Codex sessions deterministically.

Codex has no glob-based rule attachment. Its `AGENTS.md` chain is resolved once
per session, walking from the repository root down to the launch directory, so
nested instruction files never load when Codex starts at the root. Telling the
model to "read the relevant rule file" is advisory and gets skipped.

This hook restores Cursor's behaviour by reading the frontmatter of the rule
files directly:

SessionStart -> inject every rule with `alwaysApply: true`
PreToolUse -> inject rules whose `globs` match the paths an `apply_patch`
call is about to touch, at most once per rule per session

`.cursor/rules/` stays the single source of truth; nothing is duplicated here.
The hook fails open: any unexpected input exits 0 with no output, so a broken
rule file can never block an edit.

Wired up in `.codex/hooks.json`. Requires `/hooks` approval in Codex once, and
again after every change to this file (Codex tracks hooks by content hash).
"""

from __future__ import annotations

import hashlib
import json
import os
import re
import sys
import tempfile
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parents[2]
RULES_DIR = REPO_ROOT / ".cursor" / "rules"
# State is keyed by repository path so two clones never share dedup state.
STATE_DIR = Path(tempfile.gettempdir()) / (
"codex-attach-rules-" + hashlib.sha1(str(REPO_ROOT).encode("utf-8")).hexdigest()[:12]
)

# `*** Add File: path`, `*** Update File: path`, `*** Delete File: path`
PATCH_FILE_RE = re.compile(r"^\*\*\*\s+(?:Add|Update|Delete)\s+File:\s*(.+?)\s*$", re.M)
# `*** Move to: path` carries the destination of a rename
PATCH_MOVE_RE = re.compile(r"^\*\*\*\s+Move to:\s*(.+?)\s*$", re.M)


class Rule:
def __init__(self, path: Path, description: str, globs: list[str], always: bool, body: str):
self.path = path
self.description = description
self.globs = globs
self.always = always
self.body = body

@property
def rel(self) -> str:
return self.path.relative_to(REPO_ROOT).as_posix()


def glob_to_regex(pattern: str) -> re.Pattern[str]:
"""Translate a Cursor glob into a regex.

`fnmatch` is unusable here because its `*` also crosses `/`, which makes
`src/api/**/*.py` miss `src/api/server.py`.
"""
out: list[str] = []
i, n = 0, len(pattern)
while i < n:
if pattern.startswith("**/", i):
out.append("(?:.*/)?")
i += 3
elif pattern.startswith("**", i):
out.append(".*")
i += 2
elif pattern[i] == "*":
out.append("[^/]*")
i += 1
elif pattern[i] == "?":
out.append("[^/]")
i += 1
else:
out.append(re.escape(pattern[i]))
i += 1
return re.compile("^" + "".join(out) + "$")


def parse_rule(path: Path) -> Rule | None:
"""Read one `.mdc` file. Frontmatter is flat, so no YAML dependency."""
text = path.read_text(encoding="utf-8")
if not text.startswith("---"):
return None
end = text.find("\n---", 3)
if end < 0:
return None
head = text[3:end]
body = text[end + 4 :].strip()

description, globs, always = "", [], False
for line in head.splitlines():
key, sep, value = line.partition(":")
if not sep:
continue
key, value = key.strip(), value.strip()
if key == "description":
description = value
elif key == "globs":
globs = [g.strip() for g in value.split(",") if g.strip()]
elif key == "alwaysApply":
always = value.lower() == "true"
return Rule(path, description, globs, always, body)


def load_rules() -> list[Rule]:
rules = []
for path in sorted(RULES_DIR.glob("*.mdc")):
rule = parse_rule(path)
if rule and rule.body:
rules.append(rule)
return rules


def state_file(session_id: str) -> Path:
safe = re.sub(r"[^A-Za-z0-9._-]", "_", session_id or "unknown")[:120]
return STATE_DIR / f"{safe}.json"


def load_sent(session_id: str) -> set[str]:
try:
return set(json.loads(state_file(session_id).read_text(encoding="utf-8")))
except Exception:
return set()


def save_sent(session_id: str, sent: set[str]) -> None:
try:
STATE_DIR.mkdir(parents=True, exist_ok=True)
state_file(session_id).write_text(json.dumps(sorted(sent)), encoding="utf-8")
except Exception:
pass


def patched_paths(tool_input: object) -> list[str]:
"""Collect repo-relative paths from an apply_patch payload."""
blobs: list[str] = []

def walk(node: object) -> None:
if isinstance(node, str):
blobs.append(node)
elif isinstance(node, list):
for item in node:
walk(item)
elif isinstance(node, dict):
for item in node.values():
walk(item)

walk(tool_input)

found: list[str] = []
for blob in blobs:
for raw in PATCH_FILE_RE.findall(blob) + PATCH_MOVE_RE.findall(blob):
path = Path(raw)
if path.is_absolute():
try:
path = path.relative_to(REPO_ROOT)
except ValueError:
continue
found.append(path.as_posix())
return found


def render(rules: list[Rule], preamble: str) -> str:
chunks = [preamble]
for rule in rules:
chunks.append(f"<!-- from {rule.rel} -->\n\n{rule.body}")
return "\n\n".join(chunks)


def emit(event: str, context: str) -> None:
json.dump(
{"hookSpecificOutput": {"hookEventName": event, "additionalContext": context}},
sys.stdout,
)


def main() -> int:
try:
payload = json.load(sys.stdin)
except Exception:
return 0

event = payload.get("hook_event_name") or os.environ.get("CODEX_HOOK_EVENT", "")
session_id = payload.get("session_id", "")

try:
rules = load_rules()
except Exception:
return 0
if not rules:
return 0

if event == "SessionStart":
if payload.get("source") == "clear":
state_file(session_id).unlink(missing_ok=True)
always = [r for r in rules if r.always]
if not always:
return 0
save_sent(session_id, {r.rel for r in always})
emit(
event,
render(
always,
"Project rules for this repository, always in force. They are"
" authoritative; `.cursor/rules/` is their single source of truth."
" More rules are attached automatically when you edit files they"
" cover.",
),
)
return 0

if event != "PreToolUse":
return 0

paths = patched_paths(payload.get("tool_input"))
if not paths:
return 0

sent = load_sent(session_id)
matched: list[Rule] = []
for rule in rules:
if rule.always or rule.rel in sent or not rule.globs:
continue
patterns = [glob_to_regex(g) for g in rule.globs]
if any(p.match(path) for path in paths for p in patterns):
matched.append(rule)

if not matched:
return 0

save_sent(session_id, sent | {r.rel for r in matched})
touched = ", ".join(sorted(set(paths))[:8])
emit(
event,
render(
matched,
f"Project rules that cover the files you are editing ({touched})."
" Apply them to this change before continuing.",
),
)
return 0


if __name__ == "__main__":
try:
sys.exit(main())
except Exception:
# Fail open: never block an edit because of this hook.
sys.exit(0)
Loading
Loading