From 2dcba7c45b56e788ec2696d36d3864b735582fd5 Mon Sep 17 00:00:00 2001 From: Pavel Rykov Date: Fri, 4 Sep 2026 13:08:07 +0300 Subject: [PATCH] chore: agent brief, Codex hook bridge and cross-agent rules sync PR #22 was produced with Codex, which never saw the project rules kept in .claude/rules and .cursor/rules: Codex reads only a root AGENTS.md and has no glob-based rule attachment. Contributors without an agent had no visible checklist either. - AGENTS.md as the canonical top-level brief, CLAUDE.md symlinked to it - .codex/hooks.json + .codex/hooks/attach_rules.py (verbatim from the rpa-gen-rules skill) deliver .cursor/rules/*.mdc to Codex sessions; .codex/rules.md is the index - .cursor/rules globs unquoted: the bridge parser reads them literally - workflow rule: README triggers cover auth/header handling and spec loading; Rules sync spans Cursor, Claude, Codex and AGENTS.md - architecture rule: spec download headers documented, stale note about a generated version.ts removed - .github/pull_request_template.md with the same checklist for humans Co-Authored-By: Claude Fable 5.1 --- .claude/rules/architecture.md | 9 +- .claude/rules/workflow.md | 15 +- .codex/hooks.json | 33 ++++ .codex/hooks/attach_rules.py | 257 +++++++++++++++++++++++++ .codex/rules.md | 49 +++++ .cursor/rules/architecture.mdc | 11 +- .cursor/rules/code-style.mdc | 2 +- .cursor/rules/implementation-order.mdc | 2 +- .cursor/rules/testing.mdc | 2 +- .cursor/rules/workflow.mdc | 15 +- .github/pull_request_template.md | 10 + .gitignore | 1 + AGENTS.md | 45 +++++ CLAUDE.md | 1 + 14 files changed, 430 insertions(+), 22 deletions(-) create mode 100644 .codex/hooks.json create mode 100755 .codex/hooks/attach_rules.py create mode 100644 .codex/rules.md create mode 100644 .github/pull_request_template.md create mode 100644 AGENTS.md create mode 120000 CLAUDE.md diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md index 613c0db..a17a099 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -19,7 +19,7 @@ ConfigLocator -> .ocli/ dir (global or local) ProfileStore -> profiles.ini (read/write/select) | v -OpenapiLoader -> fetches spec, caches under .ocli/specs/.json +OpenapiLoader -> fetches spec with the profile auth headers, caches under .ocli/specs/.json | v OpenapiToCommands -> parses spec, applies include/exclude filters, @@ -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/.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/.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 @@ -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 @@ -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. diff --git a/.claude/rules/workflow.md b/.claude/rules/workflow.md index caa43c5..42b940f 100644 --- a/.claude/rules/workflow.md +++ b/.claude/rules/workflow.md @@ -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) @@ -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: @@ -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. diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 0000000..1968602 --- /dev/null +++ b/.codex/hooks.json @@ -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 + } + ] + } + ] + } +} diff --git a/.codex/hooks/attach_rules.py b/.codex/hooks/attach_rules.py new file mode 100755 index 0000000..35f941f --- /dev/null +++ b/.codex/hooks/attach_rules.py @@ -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"\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) diff --git a/.codex/rules.md b/.codex/rules.md new file mode 100644 index 0000000..61381dd --- /dev/null +++ b/.codex/rules.md @@ -0,0 +1,49 @@ +# Codex bridge for Cursor rules + +This repository keeps detailed project rules in `.cursor/rules/*.mdc`, and that directory +is their single source of truth (mirrored by hand into `.claude/rules/*.md` for Claude Code). +Codex does not read `.mdc` files on its own, so `.codex/hooks/attach_rules.py` delivers them. + +## How delivery works + +| Trigger | What is attached | +|---------|------------------| +| `SessionStart` | every rule with `alwaysApply: true` | +| `PreToolUse` on `apply_patch` / `Edit` / `Write` | rules whose `globs` cover the files in the patch, once per rule per session | + +The hook parses `.mdc` frontmatter (`description`, `globs`, `alwaysApply`) directly, so a +new rule file is picked up with no wiring. It fails open: a malformed rule exits quietly +instead of blocking an edit. Configuration lives in `.codex/hooks.json`. + +Codex tracks hooks by content hash and skips untrusted ones without a hard error. Run +`/hooks` once per clone, and again after any edit to `attach_rules.py` or `hooks.json`. +Project-local hooks load only when the `.codex/` layer is trusted. + +To see what a given patch would pull in, no Codex session needed: + +```bash +echo '{"hook_event_name":"PreToolUse","session_id":"probe","tool_input":{"command":"*** Begin Patch\n*** Update File: src/openapi-loader.ts\n*** End Patch"}}' | python3 .codex/hooks/attach_rules.py +``` + +## Rule index + +| Cursor rule | Applies to | Attachment | +|-------------|------------|------------| +| [workflow.mdc](../.cursor/rules/workflow.mdc) | TDD flow for features and bugs, README update triggers, Rules Sync | always | +| [code-style.mdc](../.cursor/rules/code-style.mdc) | TypeScript style, naming, error handling, constructor-injected I/O | always, `**/*.ts` | +| [architecture.mdc](../.cursor/rules/architecture.mdc) | Layers, modules, allowed dependencies, spec parser guidance | always, `src/**/*.ts` | +| [testing.mdc](../.cursor/rules/testing.mdc) | Jest layout, isolation (no module mocks), fixtures | always, `tests/**/*.ts` | +| [implementation-order.mdc](../.cursor/rules/implementation-order.mdc) | Layer-by-layer order for new modules | `src/**/*.ts` | + +Keep the always-on set small and let the rest attach by glob. Codex caps model-visible hook +output (roughly 2500 tokens by default; `additionalContextLimit` in `hooks.json` raises it), +and oversized always-on context degrades the model instead of helping it. + +## Operating rule + +Keep `.cursor/rules/` and `.claude/rules/` in lockstep (see the Rules sync section of +`workflow.mdc`). The hook needs no update when rules change, but this index and the rule +list in `AGENTS.md` do: refresh both in the same change that adds, renames, or removes a +rule file. A rule is only reachable from Codex if its frontmatter carries `globs` or +`alwaysApply: true`, and `globs` must stay unquoted (`globs: src/**/*.ts`): the parser reads +the value literally, so a quoted glob never matches. diff --git a/.cursor/rules/architecture.mdc b/.cursor/rules/architecture.mdc index 62844f6..a35f844 100644 --- a/.cursor/rules/architecture.mdc +++ b/.cursor/rules/architecture.mdc @@ -1,6 +1,6 @@ --- description: Architecture of openapi-to-cli (ocli) -globs: "src/**/*.ts" +globs: src/**/*.ts alwaysApply: true --- @@ -20,7 +20,7 @@ ConfigLocator -> .ocli/ dir (global or local) ProfileStore -> profiles.ini (read/write/select) | v -OpenapiLoader -> fetches spec, caches under .ocli/specs/.json +OpenapiLoader -> fetches spec with the profile auth headers, caches under .ocli/specs/.json | v OpenapiToCommands -> parses spec, applies include/exclude filters, @@ -40,13 +40,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/.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/.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 @@ -56,7 +56,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 @@ -77,7 +77,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. diff --git a/.cursor/rules/code-style.mdc b/.cursor/rules/code-style.mdc index 9387b72..1baa325 100644 --- a/.cursor/rules/code-style.mdc +++ b/.cursor/rules/code-style.mdc @@ -1,6 +1,6 @@ --- description: Code style for openapi-to-cli (TypeScript) -globs: "**/*.ts" +globs: **/*.ts alwaysApply: true --- diff --git a/.cursor/rules/implementation-order.mdc b/.cursor/rules/implementation-order.mdc index a20ffc5..7ebdb56 100644 --- a/.cursor/rules/implementation-order.mdc +++ b/.cursor/rules/implementation-order.mdc @@ -1,6 +1,6 @@ --- description: Implementation order for openapi-to-cli (one unit at a time) -globs: "src/**/*.ts" +globs: src/**/*.ts alwaysApply: false --- diff --git a/.cursor/rules/testing.mdc b/.cursor/rules/testing.mdc index 7424578..ff448b5 100644 --- a/.cursor/rules/testing.mdc +++ b/.cursor/rules/testing.mdc @@ -1,6 +1,6 @@ --- description: Test conventions for openapi-to-cli (Jest) -globs: "tests/**/*.ts" +globs: tests/**/*.ts alwaysApply: true --- diff --git a/.cursor/rules/workflow.mdc b/.cursor/rules/workflow.mdc index c339365..4c958ed 100644 --- a/.cursor/rules/workflow.mdc +++ b/.cursor/rules/workflow.mdc @@ -20,7 +20,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) @@ -44,9 +44,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: @@ -60,12 +60,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.mdc is loaded under `src/**` - use it when picking modules and dependency direction. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..dc11159 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,10 @@ +## What changed + + + +## Checklist + +- [ ] A test in `tests/.test.ts` reproduces the bug or describes the feature, and fails without the change +- [ ] `npm test` and `npm run build` are green +- [ ] `README.md` is updated when the change is visible to users: flags, command names, profile fields, `.ocli/` layout, search behavior, supported spec features, which requests carry credentials or custom headers, spec loading and caching +- [ ] `examples/skill-ocli-api.md` and `skills/ocli-api/SKILL.md` still match the documented agent workflow diff --git a/.gitignore b/.gitignore index 47b8e05..253f7a8 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ tests/**/*.d.ts .ocli/ tests/fixtures/*.json tests/fixtures/*.yaml +__pycache__/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..003b7a6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,45 @@ +# openapi-to-cli (ocli) - agent brief + +`ocli` is a TypeScript CLI that turns OpenAPI/Swagger specs into runtime commands. No code generation: every invocation loads a cached spec and builds the command tree on the fly. Profiles live in `.ocli/profiles.ini`, cached specs under `.ocli/specs/`. Tests are the specification of behavior; `README.md` is the public contract. + +This file is the canonical top-level brief for every coding agent (Codex, Claude Code, Cursor, others). `CLAUDE.md` is a symlink to it. Detailed rules live in `.cursor/rules/*.mdc` and their mirrors in `.claude/rules/*.md`; Codex receives the Cursor side through the hook bridge in `.codex/` (see the last section). + +## Commands + +```bash +npm ci # install +npm test # full Jest suite +npx jest tests/.test.ts -t "" # one test +npm run build # tsc, must be clean +bash tests/fixtures/download.sh # large real-spec fixtures (GitHub, Box); their suites skip when absent +``` + +## Workflow that must not be skipped + +1. **Failing test first.** A bug gets a reproduction test, a feature gets a behavior test, in `tests/<module>.test.ts`. Run it alone and confirm it fails for the right reason before touching `src/`. +2. **Minimal change at the lowest layer.** Pure (`bm25.ts`, `command-args.ts`) -> I/O wrappers (`config.ts`, `profile-store.ts`, `openapi-loader.ts`) -> transform (`openapi-to-commands.ts`, `command-search.ts`) -> entry (`cli.ts`, the only module that talks to yargs, axios, process). Lower layers never import upper ones. +3. **Green everywhere.** `npm test` passes in full and `npm run build` is clean before the work is called done. +4. **README for anything a user can observe.** Flags, command names, profile fields, `.ocli/` layout, search behavior, supported spec features, authentication and header handling (which requests carry credentials, and to which hosts), spec loading and caching, `--help` text, error messages, exit codes. A change counts even when it adds no flag. `examples/skill-ocli-api.md` and `skills/ocli-api/SKILL.md` stay aligned with the documented agent workflow. +5. **Rules Sync.** Any change to a rule file is mirrored between `.claude/rules/` and `.cursor/rules/` in the same commit, and the index in `.codex/rules.md` plus the rule list below are refreshed when a rule is added, renamed, or removed. + +## Conventions that are easy to miss + +- Inject I/O through constructor options (`fs`, `httpClient`, `stdout`); tests pass fakes. Never `jest.mock` the real `fs` or `axios` modules. +- `strict: true`; explicit types on exported APIs; `unknown` over `any` at module boundaries. +- English identifiers, comments, and docs. Straight double quotes in code, plain hyphens rather than em-dashes in prose. Comments only where the why is non-obvious. +- Errors are `Error` subclasses with informative messages; only `cli.ts` translates them for the user. +- `tests/fixtures/github-openapi.json` and `box-openapi.yaml` are real specs and contracts; never hand-edit them. New fixtures are minimal and named after the feature they cover. + +## Rule files + +| Topic | Cursor (source for Codex) | Claude Code | Scope | +|-------|---------------------------|-------------|-------| +| Workflow | `.cursor/rules/workflow.mdc` | `.claude/rules/workflow.md` | always | +| Code style | `.cursor/rules/code-style.mdc` | `.claude/rules/code-style.md` | `**/*.ts` | +| Architecture | `.cursor/rules/architecture.mdc` | `.claude/rules/architecture.md` | `src/**/*.ts` | +| Testing | `.cursor/rules/testing.mdc` | `.claude/rules/testing.md` | `tests/**/*.ts` | +| Implementation order | `.cursor/rules/implementation-order.mdc` | `.claude/rules/implementation-order.md` | `src/**/*.ts`, optional | + +## Codex + +Codex loads this file once per session and has no glob-based rule attachment of its own. The bridge in `.codex/hooks.json` and `.codex/hooks/attach_rules.py` fills the gap: it injects every `alwaysApply: true` rule from `.cursor/rules/` at `SessionStart` and the glob-matched rules before each `apply_patch`, `Edit`, or `Write`. Run `/hooks` in Codex once per clone to trust the bridge, and again after any edit to the two hook files. The human-readable index is `.codex/rules.md`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file